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>
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>