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