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>
--password puts the WiFi password in argv, where ps and shell history can
see it (#1754). This adds two ways to give it without that:
- --password-file PATH reads it from a file, dropping one trailing newline
(\n or \r\n). The file must exist and not be empty. If group or others can
read it, the script refuses and suggests chmod 600.
--allow-insecure-password-file accepts such a file with a warning, for
read-only mounts such as 0444 secrets.
- When --ssid is given with no password flag and stdin is a terminal, the
script asks with getpass. It reuses a saved password only if it was saved
for the same SSID. Without a terminal (scripts, CI) nothing is asked and
the existing missing-credentials check applies.
--password still works, but now prints a one-line warning on stderr that
points at --password-file. The two flags are mutually exclusive. The
firmware README examples now use --password-file.
Tests: 10 new tests in tests/test_provision_secrets.py, with placeholder
credentials and getpass and the flasher stubbed. 8 fail on the previous
commit; the other 2 check that nothing is asked without a terminal or when a
saved password matches the SSID. The suite goes from 25 to 35 passing.
Refs #1754
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>
provision.py saved each board's settings under the serial port path. Ports
get reused when boards are swapped (a second board on the same hub slot often
comes up as the same /dev/cu.usbmodem*), so the next board inherited the
previous board's node_id, target_port and channel without any sign (#1755).
- On a real flash the script reads the base MAC with `esptool read_mac` and
keeps state in mac-<aabbccddeeff>.json. The record also stores the MAC and
the port it was last written from.
- EUI-64 chips (C5, C6, H2) print an 8-byte "MAC:" line before "BASE MAC:";
the parser takes the base MAC on those and the single MAC line elsewhere.
- Migration: if the board has no record yet and a port-keyed file from an
earlier version exists, it is used once, with a notice that points at
--reset, then removed after the save so no other board can pick it up.
- --state and --dry-run still never open the port. --state shows the board
last provisioned from that port, and --mac selects a board by hand.
--dry-run without --mac keeps the port-keyed file, which the QEMU swarm test
relies on (--port /dev/null --dry-run).
- If the MAC can't be read, state stays keyed by port and the script says so.
Tests: tests/test_provision_mac_state.py adds 18 tests, using placeholder
MACs, with esptool, the NVS generator and the flasher stubbed. 15 of them
fail on the base; the other 3 guard behaviour the base already has (no serial
access on --dry-run, --reset, rejecting a bad --mac). The suite goes from 14
to 32 passing.
Not verified on hardware: the real esptool read_mac call. The output shapes
in the tests come from esptool 4.12 and 5.4.0 cmds.py.
Refs #1755
Co-Authored-By: claude-flow <ruv@ruv.net>
The per-port state file holds the WiFi password and seed token in clear, but
save_state() created the directory and file with the default umask, so they
landed 0755/0644 and any local user could read them (#1754). --state also
printed both values verbatim, and that output gets pasted into issues.
- The state dir is created 0700 and state files 0600. Staging uses mkstemp
(unique name, O_EXCL, 0600) plus fchmod, so a stale temp file or a planted
symlink can't receive the secret.
- harden_state_dir() tightens the dir and every .json/.tmp file in it,
including ones left by older versions. main() runs it before any read or
early exit, so --state repairs old files too. It skips symlinks and files
owned by other users, and only warns if chmod fails.
- The --dry-run NVS binary and the generator-failure CSV fallback are written
0600 even when the file already exists. The generator's CSV and binary now
live in a private mkdtemp() dir instead of the shared temp dir.
- --state shows password and seed_token as (set)/(empty). --show-secrets
prints them.
Windows is unchanged: chmod can't set owner-only ACLs there.
Tests: tests/test_provision_secrets.py adds 11 tests; all 11 fail on the
base and the suite goes from 14 to 25 passing.
Refs #1754
Co-Authored-By: claude-flow <ruv@ruv.net>
auto_label cuts a RuView .csi.jsonl recording at a radar log's window
boundaries and labels each cut empty/occupied with the radar's person
count and count interval, plus a small CSI summary (frame count, mean
RSSI, mean and variance of per-frame amplitude). The radar-to-recording
clock offset is a required, reported parameter. Windows are dropped with
a counted reason when the radar window is UNKNOWN, when too few CSI
frames fall inside it, or when an "empty" comes within empty_hold_ms of
an occupied window (a motion tracker can lose someone who sits still).
session_disjoint_split splits by session only and refuses a split where
either side would be empty or a named test session has no windows.
majority_baseline fits the no-CSI model on the training side (majority
occupancy class, mean count) and scores it on the test side, so any
occupancy accuracy built on these labels has a baseline next to it.
examples/radar_autolabel.rs is a small CLI over the three. Tests use
synthetic data only; nothing here has been run on a real capture yet.
Co-Authored-By: claude-flow <ruv@ruv.net>
ruview-groundtruth had an MmWave reference modality but no way to read a
radar log. RadarLabels reads one JSON summary per window (the shape a
UART radar reader writes: timestamp_ms, window_start_ms, health, source
flags, presence, target_count, max_target_count, distance_cm, targets
x/y) and turns each window into presence, person-count and nearest-range
labels.
Each label carries its basis and an uncertainty: a count interval from
the counts the window reports, the configured range sigma (a datasheet
figure, so CLAIMED), and the reader's quality. Windows from a simulated
or unverified source, a no_source reader, or with a presence flag that
disagrees with the count are UNKNOWN with a reason rather than guessed.
Labelled windows convert to ReferenceSeries for the existing alignment
and agreement code. Malformed lines are row-numbered InvalidRow errors.
serde_json moves from a dev-dependency to a dependency for the parser.
Co-Authored-By: claude-flow <ruv@ruv.net>
Proposed. Defines the RangeObservation record, a warn-only check mode
against --node-positions with tolerances from MEASURED ESP32-S3 FTM
bench data, per-boot calibration, and a fill mode gated off until a
source shows MEASURED p90 link error <= 0.3 m (expected from UWB).
Records the FTM bias between responder starts plainly and parks FTM
for check mode, coarse gating and time sync rather than dropping it.
Links ADR-144, ADR-281, rufield ADR-261, #2109 and #2133.
Co-Authored-By: claude-flow <ruv@ruv.net>
Adds ruview-survey, a host-side check that compares pairwise node ranges
with the configured --node-positions and warns, never writes. It reports
per-link residuals, a fitted scale with feet/inch/cm/mm unit detection,
a suggested id swap (exhaustive up to 8 nodes, fewest-changes rule,
explicit ambiguity for symmetric layouts), moved nodes, and unknown or
unranged ids.
RangeObservation is source-agnostic (FTM, UWB, BLE CS, manual). Per-boot
calibration keys offsets by (initiator, responder, responder_boot)
because the measured FTM bias moved with every responder start. Fill
mode is not implemented; fill_gate keeps it off until a source has
MEASURED p90 link error <= 0.3 m over >= 6 links.
FTM tolerances come from a two-board ESP32-S3 bench (20 MHz, ch 1):
spread within a start <= 0.31 m, bias between starts -0.5 to +1.9 m at
1.14 m, clamp to 0 below ~0.8 m. Tests are SYNTHETIC.
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 spatial.evidence.v1 format is specified in WeftOS ADR-107 section 7.
ADR-382 records the part RuView emits (envelope, room_enu frame,
rf_gaussian, rf_link_observation, validation ranges, versioning) so the
contract is readable from this repository, and names ADR-107 as its origin.
ADR-107 was amended on 2026-10-03: one yaw convention (degrees
counter-clockwise from room +x), new tof_depth and radar_range types, and
an optional sensor block on radar_track_point. The two RF bodies did not
change. The crate now documents the yaw sense of an rf_gaussian
orientation and tests it (+90 degrees about +z maps local x onto room +y),
and tests that the amended example lines are rejected as unknown types by
this RF-only mirror.
Co-Authored-By: claude-flow <ruv@ruv.net>
New leaf crate ruview-spatial-evidence converts ruview-unified output
into the spatial.evidence.v1 JSONL wire format (WeftOS ADR-107 section 7):
- RfGaussian / GaussianMap -> rf_gaussian records. A Gaussian with
synthetic provenance is always tagged SYNTHETIC; the caller can lower
the proof tag, never raise it.
- Link measurements (tx/rx, measured vs free-space amplitude) ->
rf_link_observation with excess_loss_db = 20 log10(free / measured).
The Friis reference comes from ruview-unified's channel model.
The v1 envelope and the two RF bodies are mirrored with local serde
structs, so there is no WeftOS dependency. Validation follows the v1
rules: schema version, id charset and length, finite values, coordinate,
frequency, scale, occupancy, confidence and excess-loss ranges, and the
16 KiB line limit. Golden tests compare against the ADR example lines.
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>
With RUVIEW_API_TOKEN set on the server and no token in the browser, the
sensing socket was refused, closed with 1006, and retried forever while the
header said "Connecting...". A browser cannot see the 401 on a refused
upgrade, so the service could not tell this from an outage.
- ws-ticket.js: resolveWsUrl() reports a stored token the server refuses
(401/403) as 'rejected'. probeWsAuthRequired() repeats the ticket request
with no bearer after an unticketed socket fails to open; a 401 means the
server wants a token this browser does not have ('missing'). The probe
only runs on the failure path, so auth-off servers see no extra request.
withWsTicket() keeps its string contract for the other callers.
- sensing.service.js: new 'auth-required' state and dataSource. Entering it
clears the retry timer; reconnect() leaves it.
- The header widget shows "Token required", the global banner explains it,
and both open QuickSettings on API Access (once automatically per page
load, then on click). Tab banners and the dashboard indicator get labels.
- Tests: ws-ticket.test.mjs (+6) and a new sensing.service.test.mjs (5),
added to the CI UI test step.
Fixes#2099
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>
Both capture scripts decoded a header layout the firmware does not send.
csi_collector.c and esp32_parser.rs use: magic u32 @0, node_id @4,
n_antennas @5, n_subcarriers u16 @6, freq_mhz u32 @8, sequence u32 @12,
rssi i8 @16, noise_floor i8 @17, ppdu_type @18, flags @19, then
n_antennas * n_subcarriers int8 I/Q pairs from @20.
- collect-training-data.py read a 16-byte header and int16 I/Q, so its
length check rejected every real single-antenna frame. It now reads the
20-byte header and int8 pairs, records freq/channel/seq/ppdu/flags, and
lets the recorder stamp receive time (the header has no timestamp).
- record-csi-udp.py read rssi from byte 6 and channel from byte 7 (the
n_subcarriers bytes) and treated bytes 8-19 as I/Q, so a 64-subcarrier
frame came out as 70 subcarriers with header bytes in the first six
amplitudes. It now checks the magic, reads the real fields, and writes
only the I/Q block to iq_hex.
- Add scripts/tests/test_capture_adr018.py, which builds frames with the
esp32_parser.rs test layout and checks the decoded fields.
Co-Authored-By: claude-flow <ruv@ruv.net>
prove.sh looked only for `python`, so on macOS (python3 only) it skipped
the deterministic proof and still printed RESULT: PASS with exit 0.
- Pick $PYTHON, then python3, then python.
- Check numpy/scipy import before running verify.py and print the
lockfile install command when they are missing; warn when installed
versions differ from archive/v1/requirements-lock.txt.
- Run verify.py single-threaded, as CI does.
- Hard gates that cannot run (no cargo, no Python, missing deps) are
reported as [NOT RUN] and the run ends INCOMPLETE with exit 2.
Data/hardware-gated claims are unchanged.
- Tell people to stop any local server on UDP 5005 first (#2086).
Fixes#2095
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>
home-assistant.md now describes current behaviour: the 15 announced
entities (13 with --privacy-mode) with their source and availability
rule, the entities that are not announced and why, publish rates and
retention, the persisted client id, and what privacy mode does and does
not cover. The guide no longer documents --semantic* flags that
sensing-server does not accept, and it drops the HIPAA/GDPR/CCPA
claims.
ADR-115 §11 records what P4.5 wired and what it did not, with a
before/after measurement against a real broker (MEASURED, one live
node).
Refs #2085, #2093
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>
Since ADR-296 the sensing server binds its UDP receiver to 127.0.0.1 by
default, and docker-entrypoint.sh never set a UDP bind. Inside a
container that address cannot receive from the published 5005/udp port,
so the default image never shows a node (#2089, follow-up to #1394).
The entrypoint now binds UDP on 0.0.0.0 only when the operator has set
an ADR-296 source guard (RUVIEW_UDP_ALLOW, RUVIEW_UDP_INSECURE_LAN, or
the matching CLI flags). Without a guard it keeps the loopback bind and
prints a note explaining why no node will appear and what to set,
including the macOS bridge-gateway allowlist. An explicit RUVIEW_UDP_BIND
or --udp-bind still wins.
I chose this over defaulting the bind to 0.0.0.0 unconditionally. With
no guard that would make the server refuse to start (exit 1) in auto
mode, which binds UDP, and the guard would still have to be supplied. The
change keeps ADR-296's fail-closed rule as is: a routable bind never
happens without an allowlist or the explicit insecure opt-in. Auth
defaults are unchanged.
The entrypoint also normalizes RUVIEW_UDP_INSECURE_LAN=1/yes/on to true,
since clap rejects 1 for that flag.
The compose service passes RUVIEW_API_TOKEN, the RUVIEW_UDP_* variables
and SENSING_ALLOWED_HOSTS through from the shell, with notes on the
exit-64 auth check, the macOS gateway allowlist and the 421 Host check.
The README and user-guide Docker quickstarts now set a token, and the
user guide gains a "Receiving ESP32 frames in Docker" section.
Co-Authored-By: claude-flow <ruv@ruv.net>
clap's default bool parser only takes true/false, so
RUVIEW_UDP_INSECURE_LAN=1 exited 2 with "invalid value '1'". Add a
shared parse_env_bool value parser (1/0, true/false, yes/no, on/off,
case-insensitive; anything else is still an error) and apply it to every
bool+env flag: RUVIEW_UDP_INSECURE_LAN, RUVIEW_NO_MDNS,
RUVIEW_NO_EDGE_REGISTRY, RUVIEW_MQTT, RUVIEW_MQTT_TLS,
RUVIEW_PRIVACY_MODE and RUVIEW_MATTER. The bare --flag form is
unchanged.
The Docker entrypoint now accepts RUVIEW_ALLOW_UNAUTHENTICATED=
true/yes/on (any case) as well as 1.
Fixes#2091
Co-Authored-By: claude-flow <ruv@ruv.net>
The multi_node_test UDP smoke tests sent synthetic ADR-018 CSI and
vitals frames to 127.0.0.1:5005, the sensing server's default port.
Because the tests are not ignored, a routine `cargo test` (and
scripts/prove.sh) could inject up to 255 fake node IDs into a locally
running server, and the hardcoded port could collide with other
processes.
Each test now binds its own receiver on 127.0.0.1:0 and sends to the
OS-assigned address, keeping the receiver alive for the test's
duration. The tests still only exercise the sender and frame-builder
path; no server is involved.
Ported from whitsentry, a RuView-based project, which carries the same fix.
Closes#2086
Co-Authored-By: claude-flow <ruv@ruv.net>
--dry-run persisted the merged state record, so the next real run merged
on top of values that never reached the device. It now skips save_state.
The NVS binary is still written to nvs_provision.bin because ADR-061 and
scripts/qemu-mesh-test.sh consume it.
--reset deleted the per-port state file before argument validation, so a
run that then failed (e.g. missing WiFi credentials) still lost the record.
It now only ignores the prior state; a successful flash overwrites the file
as before.
Also fix the module docstring's package name: the PyPI package is
esp-idf-nvs-partition-gen, matching the runtime hint.
Fixes#2096
Co-Authored-By: claude-flow <ruv@ruv.net>
Runtime classification is per node, from that node's own features and
amplitudes. The trainer instead built one sample per recorded line from the
fused frame-level features plus the first node's amplitudes, so a model
trained on a multi-node recording learned a different input distribution
from the one it is later asked to classify.
- load_recording now emits one sample per node per new frame, from that
node's node_features entry and amplitude vector. A node contributes only
when its amplitude vector changed since the previous line (every broadcast
snapshots all nodes), and stale nodes are skipped. Lines without
node_features (older single-node recordings) fall back to the frame-level
features.
- Runtime subcarrier statistics use the same 56-subcarrier window that
sensing_update, and therefore every recording, stores
(RECORDED_AMPLITUDE_LEN, now shared with the broadcast in main.rs).
- The trainer stamps model version 2 (TRAINED_MODEL_VERSION), so a freshly
trained model consumes the measured temporal dominant frequency (ADR-356)
instead of being switched to the legacy subcarrier proxy at runtime.
- The training accuracy log line is labelled in-sample and printed as a
percentage (it printed the 0..1 fraction with a % sign).
Tests: cargo test -p wifi-densepose-sensing-server --no-default-features:
bin 337 passed (5 new node_sample_tests), lib 539 passed,
integration tests pass. Clippy reports nothing on the changed lines.
Co-Authored-By: claude-flow <ruv@ruv.net>
Add --udp-tee / RUVIEW_UDP_TEE: every datagram the source allowlist admits
is copied unchanged to up to four loopback targets, so a fusion host or
Cognitum cog can read full I/Q, sequence numbers and sync packets without
binding the CSI port. Targets must be loopback and may not reuse the
listener port or port 0. Sends are non-blocking and drops are counted. Off
by default.
Tests: cargo test -p wifi-densepose-sensing-server --no-default-features
--lib udp_tee (6 passed) and --bin sensing-server (332 passed). Loopback
smoke test of the debug binary: a 288-byte datagram sent to the UDP port
arrived byte for byte at the tee target, and a non-loopback --udp-tee
target made the server exit 1 with "must be a loopback address".
Co-Authored-By: claude-flow <ruv@ruv.net>
Records the decision behind the two preceding firmware commits: why an
OTA'd image has to confirm itself, the health signals (STA IP plus one CSI
frame accepted by the stream sender), the 30 s minimum uptime and 120 s
deadline, the FORCE_FAIL test hook, ota_state in /ota/status, and the httpd
stack sizing.
MEASURED 2026-09-29:
- ESP32-S3 (16 MB, rollback bootloader): OTA push to ota_1, HTTP 200;
"httpd stack after POST /ota: 4900 of 8192 bytes never used";
pending_verify then valid; still valid on ota_1 after a hard reset.
A FORCE_FAIL build pushed to ota_0 rolled back to ota_1 after ~30 s.
- ESP32-C6 (4 MB layout, rollback bootloader): push to ota_1, HTTP 200;
pending_verify then valid ~28 s later; still valid after a hard reset.
Not yet verified: the 120 s no-IP timeout path, a true power cycle (an RTS
hard reset was used), and forced rollback on the C6.
Numbered 379: main has up to ADR-378 and open PRs claim nothing higher (2026-10-02).
Co-Authored-By: claude-flow <ruv@ruv.net>
With authentication enabled, collect-ground-truth.py could not start a CSI
recording: it sent no bearer token. It also treated any HTTP 200 as success,
but the recording endpoints answer 200 with {"success": false, "error": ...}
for refusals such as "recording already in progress". Either way it went on
capturing camera keypoints with no paired CSI and only printed a warning.
- Read the token from --token-file, else RUVIEW_API_TOKEN, and send it as
Authorization: Bearer on start and stop.
- post_json() returns False when the body says success: false.
- Name the CSI recording gt_<timestamp> so it pairs with
keypoints_<timestamp>.jsonl.
- Exit with an error when the recording cannot start, unless
--allow-no-csi is given.
Tests: py_compile; scripts/tests (15 passed); a local smoke test against a
stub HTTP server checked the bearer header, the request id, the
success:false refusal, and token precedence (file, then env).
Co-Authored-By: claude-flow <ruv@ruv.net>
On macOS 27, without Location Services, CoreWLAN redacts both the SSID and
the BSSID, while rssi, noise and channel stay real. resolve_bssid abstained
when both identities were blank, so every macOS scan was dropped and
`--source wifi` silently produced nothing.
- mac_wifi --scan-once marks its sample `"connected": true`. It is always the
single connected link, so a synthetic id cannot merge two networks.
- A redacted connected sample gets a stable locally administered synthetic
BSSID; the SSID stays empty. Unmarked redacted lines still abstain.
- `--source auto` that resolves to host WiFi now also binds UDP, and wifi_task
yields while the source is a live esp32, so ESP32 nodes that start after
the boot probe still promote the source (the #1004 rule). Without this, the
fix would make stock Macs ignore late-starting nodes.
- A missing helper is reported once, with the build command.
- ADR-025: Updated row + Amendment 1 recording the measured behaviour.
Measured on macOS 27: --source wifi gives source wifi:unnamed with sensing
ticks advancing; --source auto on a quiet port starts on wifi with
udp_receiver=true and promotes to esp32 (5 nodes) when real frames arrive.
Tests: wifiscan 158+1, sensing-server bin 332, all passing.
Co-Authored-By: claude-flow <ruv@ruv.net>
training_api loaded `{id}.csi.jsonl` in a flat {subcarriers, rssi} schema,
but the recorder writes `{id}.jsonl` sensing_update lines (nodes[] amplitude,
node_features[]). Training on a real recording therefore saw zero frames
(#1703).
- Accept sensing_update lines as well as legacy flat frames; try `{id}.jsonl`
first, then `{id}.csi.jsonl`.
- Emit one frame per node per update, skip repeated snapshots and nodes
without amplitudes, and keep frames grouped by node (stable sort).
- RecordedFrame gains an optional node_id; temporal features (window, prev)
only use frames from the same node.
- Malformed lines are counted, not fatal.
Tests: recording_format_tests (4 new);
cargo test -p wifi-densepose-sensing-server --no-default-features
--bin sensing-server: 336 passed, 0 failed.
Co-Authored-By: claude-flow <ruv@ruv.net>
Rollback-enabled nodes (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y) boot an
OTA'd image as PENDING_VERIFY, but nothing called
esp_ota_mark_app_valid_cancel_rollback(), so every OTA reverted at the next
reset, a second OTA was refused until then, and a bad image was never
deliberately rolled back.
- main/ota_health.{c,h}: at boot, if the running image is PENDING_VERIFY,
poll for STA got-IP and >=1 CSI frame accepted by the stream sender.
Confirm after CONFIG_OTA_HEALTH_MIN_UPTIME_S (30 s) once both are in;
roll back via esp_ota_mark_app_invalid_rollback_and_reboot() if they are
not in by CONFIG_OTA_HEALTH_TIMEOUT_S (120 s from boot). VALID, UNDEFINED,
NEW and no-otadata/factory images are left alone. The decision is a pure
header function, host-tested in test/test_ota_health.c (make host_tests).
- CONFIG_OTA_HEALTH_FORCE_FAIL (test only) rolls back where it would have
confirmed, for proving rollback on hardware.
- GET /ota/status reports ota_state so durability is visible remotely.
- RUNBOOK: replace the nonexistent ota_rollback_boot_check() claim and
document the behaviour and the hardware check (2.1).
Hardware-verified on an ESP32-S3 (push, confirm, hard reset, forced
rollback) and an ESP32-C6 (push, confirm, hard reset); see ADR-379, next in
this series. Host tests: make host_tests passes, including run_ota_health.
Co-Authored-By: claude-flow <ruv@ruv.net>
MEASURED 2026-09-29 on an ESP32-S3 (v0.8.12, 16 MB): every POST /ota of a
valid 1,228,672 B image died with "stack overflow in task httpd" and
RTC_SW_CPU_RST, so no OTA had ever completed on this firmware.
- ota_update.c: set config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE (new
Kconfig, default 8192; HTTPD_DEFAULT_CONFIG gave 4096). Move the 1 KB
receive buffer off the stack into a static (handlers are serialised on
the one httpd task). Integer progress percent instead of %f.
recv_wait_timeout stays 30 s.
- Log the httpd stack high-water mark after every POST /ota and
POST /wasm/upload so the size rests on device data.
- wasm_upload.c: GET /wasm/list builds its 2 KB JSON on the heap.
- RUNBOOK §3 (over the air) records the failure and the high-water-mark
log line.
Device check after the fix (same S3): "httpd stack after POST /ota: 4900 of
8192 bytes never used", i.e. about 3.3 KB peak. The ADR with the full
hardware evidence follows in this series (ADR-379).
This overlaps PR #1594, which raises the same stack. If #1594 lands first,
drop this commit; the rest of the series does not depend on it.
Co-Authored-By: claude-flow <ruv@ruv.net>