The per-node grid gate locked onto the densest CSI grid a node had ever
sent and rejected every other grid. Real ESP32-S3 nodes interleave
several grids: one node sent 192 bins on 88% of frames and 306 on 6%, so
the gate locked onto the 306 minority and dropped about 90% of that
node's frames. A node whose radio moved to a sparser grid for good was
rejected forever; when every node did this, `tick` froze while /health
still reported ok.
The gate now votes over each node's last 64 frames and locks onto the
grid the node sends most. A challenger must outnumber the active grid
3:2 to take over, so interleaved grids don't flap the lock. A switch
clears the window as before. On an ESP32-C6 the HE-SU majority still
wins and the HT minority is still rejected.
/health keeps `status: "ok"` (the UI's server probe keys on it) and adds
a `processing` block: a live/stalled/no_input/idle state, the age of the
last published update, and lock-free UDP counters (datagrams, age of the
last datagram, datagrams dropped by the source allowlist). A frozen
pipeline can now be told apart from input that stopped arriving.
Allowlist drops also log a warning on the 1st, 2nd, 4th, 8th... drop
instead of only at debug level.
Co-Authored-By: claude-flow <ruv@ruv.net>
Esp32CsiParser capped n_subcarriers at 256. Real ESP32-S3 nodes on
firmware 0.8.12 send 1-antenna frames with 306 subcarriers (612 I/Q
bytes), so the parser dropped about a third of live frames. Pointcloud
started hitting that cap once it switched to this parser.
The bound now comes from the firmware: frames are serialized into
CSI_MAX_FRAME_SIZE = 20 + 4 * 256 * 2 bytes, so a frame carries at most
1024 I/Q pairs across all antennas. The check is n_antennas *
n_subcarriers <= 1024.
HT/legacy bandwidth no longer maps subcarrier counts above 128 to 80 or
160 MHz. ESP32 radios use 20/40 MHz channels, and the larger counts
(192, 306) come from merged LLTF/HT-LTF buffers, so anything above 64
is reported as 40 MHz.
The pointcloud UDP receive buffer grows from 2048 to 4096 bytes so the
largest legal frame (2068 bytes) is not truncated.
Refs #1348
Co-Authored-By: claude-flow <ruv@ruv.net>
The sensing-server bridge builds every node frame with a zero phase
placeholder and the fuser combined those phases across nodes as if they
were measured against one reference. ESP32 nodes each run an independent
crystal with a random PLL phase after reset; mesh time sync aligns frames,
not RF phase, so there is nothing to combine.
- Add PhaseReference (Unavailable / NodeLocal / Shared{id}) to
MultiBandCsiFrame. The server bridge declares Unavailable.
- MultistaticFuser combines phase only when every frame declares the same
Shared id. Otherwise fused_phase is empty and FusedSensingFrame carries
PhaseFusion::AmplitudeOnly(reason). Amplitude fusion is unchanged.
- The ADR-134 CIR blend is skipped when the phase is a placeholder.
- TrustedOutput and /health/ready (trust.phase_fusion) report the mode
and reason.
- ADR-029 status note updated.
Refs #1752, #1710, #1750.
Co-Authored-By: claude-flow <ruv@ruv.net>
The pointcloud parser read channel from byte 8, RSSI from byte 9, noise
floor from byte 10 and a "timestamp" from bytes 16..19. The firmware
(csi_serialize_frame) writes freq_mhz as u32 at 8..11, sequence at
12..15, RSSI at 16, noise floor at 17 and PPDU type/flags at 18..19. So
RSSI came out as the high byte of the frequency (+9 for 2437 MHz), and
the tomography attenuation was built from it.
parse_adr018 now delegates to wifi_densepose_hardware::Esp32CsiParser,
the same parser the rest of the workspace uses, and only reshapes its
output. CsiFrame.channel/timestamp_us become freq_mhz/sequence, which
are what the header actually carries. ADR-081 feature-state packets
(0xC5110006) are no longer decoded as CSI frames.
build_test_frame (used by csi-test) now emits the real layout. Golden
byte-by-byte tests cover single and two-antenna frames, truncation and
the feature-state packet; a cross-check test compares against the
reference parser.
Refs #1348
Co-Authored-By: claude-flow <ruv@ruv.net>
decay(now) multiplied confidence by exp(-(now - timestamp_ns)/tau_eff)
on every call, but timestamp_ns is the last-seen time and does not move
when decay runs. Calling decay(t1) then decay(t2) applied
exp(-(t1-ts)/tau) * exp(-(t2-ts)/tau) instead of exp(-(t2-ts)/tau), so
confidence fell faster the more often the caller ticked.
Track how far each Gaussian has already been decayed (decayed_to_ns)
and only apply the interval since max(decayed_to_ns, timestamp_ns).
A fresh observation still restarts the clock, and the lifetime-stretched
tau_eff is unchanged. Fusion and the merge pass keep the later of the
two decayed_to_ns values. The new field is #[serde(default)], so maps
serialized before this change load with 0 and decay from timestamp_ns
as before.
Adds two tests: one-shot vs. per-second decay must agree to 1e-12, and
the wall-vs-transient persistence case must hold when decay runs every
10 s.
Co-Authored-By: claude-flow <ruv@ruv.net>
The UDP receiver already refuses a routable bind without a source
allowlist (ADR-296), and the Docker entrypoint refuses a routable bind
without RUVIEW_API_TOKEN (#864). Running the binary directly with
--bind-addr 0.0.0.0 and no auth only logged that auth was off.
The server now exits at startup when the HTTP/WebSocket bind is not
loopback and API auth (RUVIEW_API_TOKEN or RUVIEW_OAUTH_ISSUER) is off,
unless RUVIEW_ALLOW_UNAUTHENTICATED is set, in which case it starts and
logs a warning. Loopback binds are unaffected. The conditions match the
entrypoint's, so a container that starts today still starts.
Co-Authored-By: claude-flow <ruv@ruv.net>
Every person count is already gated on presence, but two presence
decisions disagreed with the motion level published next to them:
- smooth_and_classify[_node] set presence = smoothed_motion > 0.03 while
the debounced level only leaves "absent" above 0.04. A quiet room that
settles in (0.03, 0.04] read motion_level "absent" with presence true,
so the count stayed at 1.
- The raw-CSI per-node count was gated on the per-frame classification,
which an adaptive-model label overrides. /api/v1/nodes could show a
node as "absent" with person_count 1 while the ADR-297 room vote (which
reads the node's debounced level) already said the room was empty.
Presence now follows the debounced level, as the room vote already does,
and the per-node count is gated on the node's own published level.
score_to_person_count keeps its floor of 1: it only runs when presence is
true, and an empty room reports 0 through the gate.
Co-Authored-By: claude-flow <ruv@ruv.net>
/api/v1/stream/status returned "fps": 10 whenever tick > 1, whatever the
real rate, and both it and /health counted every broadcast subscriber
(recorder, MQTT bridge) as a client. The dashboard Streaming card read
"IDLE, 0 client(s)" for up to 30 s while the page was streaming, because
its first /health poll lands before the page's socket connects; with auth
on, the ticket round-trip widens that gap.
- New stream_stats module: UpdateRateMeter (sliding 5 s window, bounded at
1024 samples) and WsClientCounter with an RAII guard.
- AppStateInner::advance_tick() replaces the five `s.tick += 1` sites and
records each sensing update in the meter.
- /api/v1/stream/status now returns the measured update rate as `fps`
(0 when nothing flows), `active` from that rate, `clients` from the
WebSocket counter, `nodes` with each fresh node's measured csi_fps (null
until its EMA has 5 samples) and `csi_fps_total`.
- /health and the /health/health stream component count WebSocket clients
on /ws/sensing and /api/v1/stream/pose; the stream component also carries
the measured rate.
- DashboardTab re-reads health 500 ms after the sensing socket connects or
drops, so the card follows the socket instead of waiting for the next
30 s poll.
Fixes#2087
Co-Authored-By: claude-flow <ruv@ruv.net>
The previous commit limited --source to auto/esp32/wifi/simulated, which
rejected the vendor names the server already keys on: mediatek, qualcomm,
realtek (RTL8720F radar) and realtek_csi (RTL8721Dx CSI). They are the
labels the UDP receiver writes and effective_source() ages.
On 03a89793 those names fell into the catch-all plan, which bound no UDP
receiver, so vendor frames were never ingested. They now plan like esp32
(UDP receiver bound) without the ESP32-shaped simulator. Anything else,
including case variants and runtime-only labels like "mediatek:simulated",
is still rejected.
Refs #2097
Co-Authored-By: claude-flow <ruv@ruv.net>
--privacy-mode was declared in the MQTT arg group and read only by the
MQTT publisher, so /api/v1/sensing/latest, /api/v1/pose/current, the
/ws/sensing and /api/v1/stream/pose streams and recordings kept serving
pose keypoints (and heart/breathing rate when vitals are published).
- Move --privacy-mode (env RUVIEW_PRIVACY_MODE) out of MqttArgs to a
top-level server flag. Spelling and env var are unchanged. The MQTT
config takes its value from the server flag.
- Add privacy_filter: one key filter for vital_signs, HR/BR leaves,
enhanced_breathing, keypoints, pose_keypoints and joints_m.
- REST: middleware strips those keys from every JSON response on the
HTTP and WS ports. Unparseable JSON is withheld with a 500.
- WebSocket: /ws/sensing frames and /api/v1/stream/pose messages are
filtered; the pose physics assessment is withheld.
- /api/v1/pose/current?view=both|refined returns 403 privacy_mode.
- Recordings write filtered frames.
- Default (flag off) behaviour is unchanged; the middleware is a no-op.
Fixes#2094
Co-Authored-By: claude-flow <ruv@ruv.net>
/api/v1/status reported source_state "live_unverified" for a configured
live source before any frame had arrived, because source_state() fed a
fixed zero frame age into the ADR-295 resolver. It now uses the real age
of the last accepted frame for the current source, so the states are:
- disconnected (waiting_for_frames: true) until the first frame
- live_unverified while frames arrive within ESP32_OFFLINE_TIMEOUT (5 s)
- stale once they stop for longer than that
The response also carries waiting_for_frames and last_frame_age_ms.
ADR-270 vendor events get their own receive clock so they age the same
way.
An unknown --source value (for example "macos") used to start a server
with no data task. Clap now rejects it with the list of valid values
(auto, esp32, wifi, simulated, simulate), and plan_source returns an
error for anything else.
Docs: user guide lists the valid --source values and status states, the
macOS section uses --source wifi, and the Linux section says there is no
Linux RSSI source in the server yet.
Refs #2097
Co-Authored-By: claude-flow <ruv@ruv.net>
EngineBridge built its StreamingEngine without ever calling
set_node_positions, so the governed fuser placed every node at the
origin even when --node-positions was set. Startup now passes the same
keyed map the sibling multistatic_fuser gets, via
EngineBridge::with_node_positions. The fuser resolves positions by
node_id per frame, so nodes discovered later need no extra call.
/api/v1/status (and /health/ready) now reports the map under
trust.node_positions so the configuration can be checked at runtime.
Co-Authored-By: claude-flow <ruv@ruv.net>
Recordings, the recording list/delete routes, the trainer's dataset
loader and model export, the models routes and the adaptive classifier
all used paths relative to the process cwd (`data/recordings`,
`data/models`, `data/adaptive_model.json`), so `--data-dir` only covered
config and the session secret.
Add `recordings_dir(data_dir)` and `models_dir(data_dir)` and use them
everywhere. `MODELS_DIR` still overrides the models directory, and the
trainer now honours it too. With the default `--data-dir data` the paths
are unchanged.
Fixes#2088
Co-Authored-By: claude-flow <ruv@ruv.net>
latest_update is only rewritten when a source produces a new update. When
every source went quiet, /api/v1/sensing/latest kept returning the last
classification indefinitely, with no marker that it was old. A one-off
bogus frame could leave "absent 0.97" frozen there.
Past 10 s, the endpoint now returns
{"status":"stale","age_ms":...,"source":effective_source()} instead of
the old payload. Fresh payloads gain an age_ms field. "no data yet" is
unchanged. This is separate from #2060, which excludes stale nodes from
the room vote: that changes what a new update says, while this bounds how
long an old update is served.
Developed in whitsentry, a RuView-based project.
Co-Authored-By: claude-flow <ruv@ruv.net>
Stale node eviction (60 s) lived inside the ESP32 receive path and ran
every 100 ticks, so it only happened while ESP32 frames kept arriving.
After a burst of bogus node ids, or once every node went quiet, nothing
ever removed the entries, and they stayed listed in /api/v1/nodes for the
life of the process.
Move the existing rule unchanged into evict_stale_node_states (60 s,
nodes that never reported included). Call it from the receive path as
before, and from a new node_states_eviction_task that sweeps every 30 s
alongside the UDP receiver.
Developed in whitsentry, a RuView-based project.
Co-Authored-By: claude-flow <ruv@ruv.net>
parse_esp32_frame accepted any datagram that started with the 0xC511_0001
magic and had a 20-byte header. With n_antennas or n_subcarriers at zero,
the expected length collapses to the header itself, so the length check
passed and the frame minted a node carrying empty amplitude/phase arrays.
A non-ESP32 sender on the shared UDP port produced exactly that: bogus
nodes in /api/v1/nodes.
Return None when either count is zero. Well-formed frames parse as before.
The new tests cover zero antennas, zero subcarriers, and a bare header,
and check that a well-formed frame still parses.
Developed in whitsentry, a RuView-based project.
Co-Authored-By: claude-flow <ruv@ruv.net>
Discovery announced 20 entities per device, but only 6 ever received
state (#2085). presence/state was published on every CSI frame, and the
per-process default client id gave Home Assistant new devices on every
restart (#2093).
- mqtt::planner maps each snapshot to availability, state and events.
It runs one SemanticBus per node at 1 Hz (ADR-115 P4.5) and gates
binary states on change with a 60 s retained heartbeat. Sensors keep
their configured rates. Each entity is `online` only while the server
has a source for it; otherwise it is `offline` and gets no state.
- Entities with no source on the sensing broadcast are no longer
announced: zone_occupancy, bathroom_occupied, bed_exit,
multi_room_transition (no zones), meeting_in_progress (motion is
three-level) and pose (pose_keypoints is never set). 15 remain, 13
with --privacy-mode.
- mqtt::bridge, moved from main.rs, reads only sensing_update. That
removes the phantom aggregate device that edge_vitals frames created.
It takes fall events from the edge_vitals rising edge and
motion_energy from motion_band_power (previously always 0.0), and maps
present_still to 0 % motion.
- The default client id is wifi-densepose-<8 hex>, persisted in
<data-dir>/mqtt_client_id. --mqtt-client-id still overrides it.
Refs #2085, #2093
Co-Authored-By: claude-flow <ruv@ruv.net>
- firmware/openwrt-wn586x3/: the patch set that made a Wavlink WL-WN586X3
Rev A (MT7981B + MT7976C) emit CSI on OpenWrt 24.10.8. It contains:
MediaTek's mt7915 CSI driver patch rebased onto the mt76 revision 24.10.8
pins (BSD-3-Clause-Clear, MediaTek author line kept); the Rev A board
patch (GPL-2.0); a buffer-overrun fix for `mt76-vendor dump csi`; and
an attribute-enum fix for MtkCSIdump. Each patch keeps the licence of
the code it modifies. There are no images, firmware blobs, or captures.
- docs/mediatek-router-csi.md: an operator guide covering supported
silicon, the image build route that was tested, arming CSI, the
bridge, `--source mediatek`, endpoints and provenance labels, what the
data is not, evidence, and troubleshooting. Every claim is tagged
MEASURED, CODE-DERIVED, CLAIMED, or SYNTHETIC.
- scripts/mediatek-csi-phase-stability.py: a read-only reproducer for
the one MEASURED offline result. On one MT7976C radio, the cross-chain
phase arg(H0*conj(H1)) moves 0.014-0.039 rad (p50) between adjacent
frames for well-sampled transmitters, against about 1.56 rad for raw
per-chain phase, which is what random phase gives. It never prints a
transmitter address. The captures it was run on are not in the tree.
- ADR-266 Amendment 1: vendor CSI exists on MT7981 through MediaTek's
public feed patch, demonstrated on one board model, and still
`mediatek:physical-unvalidated`. The hardware gates remain open.
- user-guide.md and the bridge README link to the guide.
Developed in whitsentry, a RuView-based project.
Co-Authored-By: claude-flow <ruv@ruv.net>
Adds `wifi-densepose-mtk-bridge`. It converts MediaTek mt76 vendor CSI
into ADR-267 MTC1 frames for the sensing server's UDP ingest.
Inputs:
- `--replay`: an `mt76-vendor dump csi` JSON file, or a `.mtkcap`
recording. Records are grouped into frames by the driver's last-chain
bit, per transmitter address, so HT 2x2 and legacy 1x2 runs from one
radio each close with their real dimensions. Concatenated and duplicate
dumps (the vendor tool appends) are reported, not hidden.
- `--listen`: MtkCSIdump UDP datagrams. They carry no packet identity, so
each datagram becomes one 1x1 frame rather than a guessed pairing.
Missing RSSI/noise is the -128 dBm sentinel, and PPDU type is `Unknown`.
Provenance:
- The bridge never sets CALIBRATED.
- A replayed file must be either attested (`--captured-on
<model>/<firmware>`) or declared `--synthetic`, or the bridge refuses to
start.
- Synthetic is sticky. It can come from a sidecar, a `.synthetic.` file
name, or a capture header, and none of these can be overridden.
- `--listen` requires a `--listen-allow` CIDR list. Other sources are
dropped and counted, never decoded.
Fixtures are fabricated and use locally administered addresses. The
assembler tests that model the two live dump loops use stand-in addresses,
not the real clients'. Captures written under the bridge's earlier format
tag still load.
Tests: 51 unit and 39 integration tests, plus clippy -D warnings.
Developed in whitsentry, a RuView-based project.
Co-Authored-By: claude-flow <ruv@ruv.net>
Real MediaTek frames now produce a room update, but from a heuristic that
is labelled as one on every output. The validated ESP32 pipeline needs
per-subcarrier phase history this path does not feed it, so it is not
used here and is never impersonated.
- `mediatek-amplitude-heuristic-v0` (CLAIMED, no labelled evaluation):
presence and motion from per-device mean and peak amplitude over a 10 s
time window (not a sample count, because arrivals are bursty).
Hysteresis means one dip cannot flip the room to absent. Below a
confidence of 0.3, a device reports `unknown` and abstains from the
room vote rather than asserting "absent".
`SensingUpdate.classifier` names it; `nodes[].device_id` and
`nodes[].mediatek_diagnostics` say why each device reads as it does.
- `activity-index-v0` (CLAIMED): a fast/slow fluctuation index against
each device's own baseline, plus an absolute level against a learned
quiet floor. It is served in `SensingUpdate.activity` and
`GET /api/v1/csi/mediatek/activity`, with
`is_occupancy_estimate: false` on every block. `--activity-state <path>`
persists learned floors across restarts.
- The summary endpoint adds the heuristic's window statistics.
- The MQTT per-node topics key on `device_id` when it is present, so two
64-bit receiver ids cannot collide on `node_id` 0.
Developed in whitsentry, a RuView-based project.
Co-Authored-By: claude-flow <ruv@ruv.net>
The server kept one MediaTek snapshot in total, so two MT7981 receivers
overwrote each other, and `--source mediatek` fell through to the
catch-all source plan and bound no UDP socket at all.
- `--source mediatek` binds the UDP receiver and does not start the
ESP32-shaped simulator, which has no MediaTek branch and would otherwise
publish a simulated room classification next to real frames.
- Snapshots are kept per `device_id`.
`GET /api/v1/csi/mediatek/devices` lists them with `age_ms` and drops
any device silent for over 30 s.
- A bounded per-device ring (`--csi-ring`, default 256 frames, about
0.5 MiB for a 2x2 device at 64 subcarriers) keeps per-chain,
per-subcarrier amplitude and phase. It serves
`/devices/:device_id/frames` and `/devices/:device_id/summary`. Chains
are keyed by (tx, rx) identity, so a device that interleaves 1x2 legacy
and 2x2 HT frames is reported correctly.
- New provenance label `mediatek:physical-unvalidated` for real-silicon
frames that are not calibrated. `SYNTHETIC` still takes precedence over
every other flag. Plain `mediatek` stays reserved for calibrated frames.
No classifier changes in this commit.
Developed in whitsentry, a RuView-based project.
Co-Authored-By: claude-flow <ruv@ruv.net>
A real MT7981 (WL-WN586X3, MT7981B + MT7976C) CSI report arrived with
rx_mode = MT_PHY_TYPE_OFDM for a 2.4 GHz client. The MTC1 PpduType had no
pre-HT value, so a bridge had to drop the frame. MediaTek's CSI driver
treats legacy OFDM as its own tone-mask group, and association traffic is
legacy-rate, so these frames are routine rather than a corner case.
Add PpduType::Legacy = 6 and PpduType::Unknown = 7. Values 1-5 and the
72-byte header are unchanged. A decoder built before this change rejects
6 and 7 as UnknownPpduType (fails safe, drops the frame), so MTC1
consumers need a rebuild.
ADR-267 gains Amendment 1 recording the change, its compatibility, and the
observed 1x1x64 dimensions of the motivating capture. The capture is not
in the tree: it holds a client transmitter address and raw CSI.
Developed in whitsentry, a RuView-based project.
Co-Authored-By: claude-flow <ruv@ruv.net>
Nodes not named in --node-positions report [2.0, 0.0, 1.5], and the boot
line counts parsed entries whether or not any matches a live node id. A
config keyed by the wrong ids looked applied and did nothing.
- WARN once per node id when a fresh node has no configured position,
naming the configured ids (INFO when no positions are configured).
- /api/v1/nodes: add position_configured per node.
- /api/v1/status: add node_positions with configured_node_ids,
default_position and default_position_node_ids.
- At boot, WARN that entries without a node_id: prefix are keyed by list
index from 0, with the entry -> node mapping. Semantics are unchanged.
Co-Authored-By: claude-flow <ruv@ruv.net>