Compare commits

...
Author SHA1 Message Date
dependabot[bot] 42a22235c0 chore(deps): bump actions/github-script from 7.1.0 to 9.0.0
Bumps [actions/github-script](https://github.com/actions/github-script) from 7.1.0 to 9.0.0.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7.1.0...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-26 18:01:22 +00:00
rUv 6524712900 Merge pull request #1890: storage partition for 8MB, compile node_log out on 4MB
Verified on a physical ESP32-C6 (4 MB, esp32c6 defaults, ESP-IDF v5.4):
- App image: 1,085,424 B on main, 1,042,368 B with this change (43,056 B smaller).
- The 'Failed to find FATFS partition storage' error seen on every 4 MB C6 boot is gone.
- Wi-Fi join, CSI init and CSI UDP delivery to the host were confirmed.
CI: 35/35 green.
2026-09-26 12:36:57 -05:00
rUv dd02efe2fe Merge pull request #1903 from ruvnet/codex/calibration-process-identity
fix(calibration): bind server receipts to room identity
2026-09-16 16:30:28 +00:00
ruv 7d15e9fb4a fix(calibration): register calibrated-presence-evidence schema via field_bridge
The new schema constant was defined and asserted as a raw string literal
directly in main.rs, which the semconv registry test scans and requires
every "ruview.*" literal there to be a registered attribute/event. This
isn't a tracing event, though -- it's a wire-schema tag structurally
identical to CALIBRATION_MODEL_RECEIPT_SCHEMA, which already lives in
field_bridge.rs specifically to stay out of that scan. Move the new
constant there and reference it by identifier from main.rs, matching the
existing precedent instead of hand-editing the generated semconv registry.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-16 11:53:26 -04:00
ruv 69106970dd Merge branch 'main-ref-tmp' into codex-calib-work 2026-09-16 11:37:53 -04:00
ruv 686e4bad00 Merge branch 'fix-1936-work' into codex-calib-work 2026-09-16 11:37:42 -04:00
ruv b713a23cac fix(calibration): preserve expired status instead of collapsing to none
calibration_status unconditionally overwrote status with "none" whenever
active was false, which also erased the legitimate "expired" status for
a calibration that ran long ago and has since expired -- losing the
distinction between "never calibrated" and "was calibrated, now expired".

Fixes the pre-existing failure in
calibration_expiry_tests::long_running_expiry_is_not_reported_active_for_runtime_or_bootstrap,
called out as a known, unrelated gap in PR #1936.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-16 11:37:31 -04:00
Joe 6eb79cad26 chore: empty commit to re-trigger stale CI (row 35 sweep, main@45fc41ac) 2026-09-15 22:28:35 -04:00
rUv 45fc41ac4c Merge pull request #1944 from ruvnet/codex/realtek-csi-upstream-main
Add bounded RTL8721Dx RAC1 CSI ingest to current main
2026-09-15 19:14:52 +00:00
rUv 901cf16eb3 Merge pull request #1945 from ruvnet/codex/adr-348-collision-fix
Repair forecast ADR collision and RustSec lockfile gate
2026-09-15 18:56:09 +00:00
ruv af2d6981a3 ci: bound S3 8 MB image to 1200 KiB 2026-09-15 14:41:08 -04:00
ruv a04fbc4694 Resolve ADR 348 collision in upstream forecast CI 2026-09-15 14:41:08 -04:00
ruv dbaa3c7f52 ci: bound S3 8 MB image to 1200 KiB 2026-09-15 14:16:05 -04:00
ruv 6cf0356554 Update rustls lockfile for RUSTSEC-2026-0285 2026-09-15 14:07:11 -04:00
ruv b2ad3a65dc Resolve ADR 348 collision in upstream forecast CI 2026-09-15 14:06:31 -04:00
ruv 815384dcc4 Update rustls lockfile for RUSTSEC-2026-0285 2026-09-15 14:02:38 -04:00
ruv 8cd8742d2b Add bounded RTL8721Dx RAC1 CSI ingest to current RuView main 2026-09-15 13:57:01 -04:00
Dragan SpiridonovandRuflo & AQE dbf4788628 fix(calibration): bind the grid the radio keeps emitting, not the sparse minority
`accept_grid` locks each node onto the densest grid it has seen and rejects
sparser frames from the feature path -- on an ESP32-C6 the ~16% HT 64-bin
minority that arrives alongside HE-SU 256-bin. `select_calibration_grid`
ordered candidates by `max_gap_s` first, so it preferred whichever grid
looked temporally smoothest. A sparse trickle at a metronomic cadence has a
smaller worst-case gap than a busy stream with one scheduling hiccup, so
selection kept choosing the grid admission is designed to discard.

Measured on four ESP32-C6 nodes: a capture bound 64sc on node 11, every node
then locked onto 256sc, the bound grid went stale with no frame for five
minutes, frame_count froze at 10,997 and `calibration_grid_is_fresh` failed.
The capture could never finalize -- `collecting` for 858 s with both minimums
long since met, no error surfaced, and a flickering UI as the client retried.
The same rig bound 256sc on a different capture and finalized with five
baseline eigenvalues.

Order by subcarrier count first so selection agrees with admission, then fall
back to the previous gap/rate/ppdu ordering to choose among equally dense
candidates.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
2026-09-15 14:48:08 +02:00
Dragan SpiridonovandRuflo & AQE cdad032296 fix(calibration): only the grid-bound radio may write the single-link baseline
A room binding may name several radios, and every one of them was admitted
into the field model. That model is single-link: one baseline, one set of
amplitude offsets. Averaging two radios' offsets into it flattens the
eigenstructure and leaves only a scalar energy threshold behind.

Measured on three ESP32-C6 nodes:

  4-node binding  baseline_eigenvalue_count 0, residual threshold 18.90,
                  empty room reported an occupant in 1162/1162 frames
  1-node binding  baseline_eigenvalue_count 5, residual threshold 3.65,
                  empty room absent 604/604, occupied present 616/616

`person_count_at` already refused to score a non-bound radio, calling it
"deterministic false occupancy from hardware-specific amplitude offsets" --
but only when exactly one node was bound. With more it fell through to the
shared mixed history, so the guard never fired in the case it described.

Restrict the baseline write, not the admission. Every bound radio is still
admitted and recorded as a contributor: `calibration_source_nodes_missing`
refuses to finalize until each one proves it is live on the frozen grid, so
gating them out of the feed path entirely deadlocks the capture -- measured
on hardware as 43,120 frames over 51 minutes stuck in `collecting` with
missing_source_node_ids=[12,13,14]. Only `maybe_feed_calibration` is
restricted to the grid-bound radio, and scoring follows that same radio
whatever the size of the room's node set.

This is the narrowing `bootstrap_baseline::store` already applies when it
persists `vec![binding.source_node_id]` as the frozen model source.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
2026-09-15 14:09:33 +02:00
Dragan SpiridonovandRuflo & AQE 0decc5c3fa test(calibration): cover calibrated presence evidence identity and refusals
Assert the exact wire schema and every identity field the Mac app matches
against its model receipt, plus that evidence agrees with the server's own
person count. Pair the refusals (no receipt, bootstrap-only authority,
expired model) with the positive case so an absent value can never be read as
proof when the path is simply broken.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
2026-09-14 18:54:15 +02:00
Dragan SpiridonovandRuflo & AQE d4a3c7f17f feat(calibration): emit calibrated presence evidence v2 on the sensing stream
The Mac app gates its held-out empty-room check on
`calibrated_presence_evidence` carrying
`ruview.calibration.calibrated-presence-evidence.v2`. No shipped server has
ever emitted that key, so the check fails on its first iteration with "No
fresh calibrated presence result matched this room's current model receipt",
`bootstrap/promote` is never called, and `bootstrap_baseline::store` -- the
only path that persists a calibration -- never runs. A completed 62-minute
field model is therefore lost on the next restart.

Emit the evidence bound to the active model receipt. `None` unless an
explicit calibration is fresh and the field model scores the observation
without a heuristic fallback, so a present value is always calibrated.

Extract the field-model scoring history into `scoring_history()` and reuse it
for both `person_count_at` and the evidence, so the published evidence cannot
disagree with the count derived from the same model.

Co-Authored-By: Ruflo & AQE
Claude-Session: https://claude.ai/code/session_01Qh7eir6C2U5CSwD7GR43Zv
2026-09-14 18:49:50 +02:00
Dragan Spiridonov 2c0abcc802 fix(calibration): separate receipt schema from telemetry literals 2026-09-11 20:29:42 +02:00
Dragan Spiridonov f1dbaac452 Merge remote-tracking branch 'origin/main' into codex/calibration-process-identity 2026-09-11 20:27:02 +02:00
rUv 33a9e90896 Merge pull request #1902 from ruvnet/feat/firmware-usb-onboarding-c6
firmware: USB onboarding on main + C6 console RX fix (0.8.12)
2026-09-11 13:32:02 +00:00
Dragan Spiridonov bbbb14169d fix(calibration): bind server receipts to room identity 2026-09-11 15:21:39 +02:00
ruv 4fbe58f736 fix(firmware): keep onboarding digest compatible with ESP-IDF 6 2026-09-11 09:19:33 -04:00
ruvandClaude Fable 5.1 a64e9e98fb fix(firmware): keep the C6 console RX alive and ship USB onboarding (0.8.12)
- mmwave_sensor: the default probe pins (UART1 TX=17/RX=18) put UART1 TX on
  GPIO17, which is U0RXD on the ESP32-C6. Console input was dead on every C6
  node even when no sensor was found, because uart_driver_delete() leaves the
  GPIO matrix routing in place. C6 now defaults to GPIO4/5 and any pin pair
  overlapping the console UART is refused.
- serial_onboarding_protocol: _POSIX_C_SOURCE for strtok_r under -std=c11
  -pedantic (host tests on glibc).
- main/CMakeLists: explicit led_strip REQUIRES so builds work with the
  component manager disabled (vendored components, offline builds).
- release_bins/c6-onboarding: 0.8.12 C6 images built with ESP-IDF v5.4.

Hardware-verified on an ESP32-C6 over cu.usbserial-3120 from the RuView Mac
app: flash -> RUVIEW_HELLO_V1 receipt -> RUVIEW_CONFIG_V1 -> node 3 live at
-34 dBm on the sensing server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqz356q3Qz6sscTrpy5Jrv
2026-09-11 08:58:46 -04:00
ruv 130afabae8 feat(firmware): add secure macOS USB onboarding
(cherry picked from commit 3373e79f38afef3a1430feeb78ae034e78777c66)
2026-09-10 19:25:13 -04:00
Joeandclaude-flow 36e4df95b7 feat(firmware): give node_log an 8MB home, compile it out on 4MB
node_log currently only has a FAT volume on the 16MB table
(partitions_16mb.csv), so it silently does nothing on the two chips
most of the fleet's OTA/CI surface actually exercises.

partitions_display.csv (8MB) ends at 0x600000 with 2.00 MB of flash
never allocated. Add a storage/fat partition filling exactly that
tail (0x600000 + 0x200000 = 0x800000, the full 8 MB with nothing left
over), so node_log has somewhere to mount on the s3/8mb profile.

partitions_4mb.csv has no equivalent room -- two 1.81 MB app slots
leave only 256 KB free, not enough for a FAT volume of any useful
size. Guard node_log.c's whole implementation on
CONFIG_ESPTOOLPY_FLASHSIZE_4MB and provide no-op stubs for every
entry point, so main.c's call sites need no #ifdef of their own.
Deliberately did not touch main/CMakeLists.txt: fatfs/wear_levelling
stay in REQUIRES, and with nothing left calling into them on 4MB,
--gc-sections drops both archives the same way it already drops
libespcoredump.a when coredump-to-flash is unused (measured at 0
bytes contribution).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ
2026-09-10 13:21:59 -04:00
Faizanandgyrwb6vjbj-ctrl b370d4fcd4 fix: enable macOS RSSI sensing via CoreWLAN (#1845)
* fix: enable macOS RSSI sensing via CoreWLAN

* fix: abstain when macOS WiFi identity is unavailable

---------

Co-authored-by: gyrwb6vjbj-ctrl <253160963+gyrwb6vjbj-ctrl@users.noreply.github.com>
2026-09-10 00:15:02 +00:00
500cfe4787 feat(firmware): persistent on-node log and coredump-to-flash (#1878)
* feat(firmware): add the 16MB flash profile the C6 fleet runs on

The 4MB and display layouts have 1.875MB and 2MB OTA slots, sized for 4MB
and 8MB parts. A 16MB C6 (DevKitC-1 N16) has no layout in the tree at all,
so building for one meant hand-editing sdkconfig -- and a smaller table
written over a deployed 16MB node relocates NVS and destroys its
provisioning, which is why this belongs in the repo rather than in a
workstation's untracked sdkconfig.

Two 4MB OTA slots, plus a 64K coredump partition and an 8000K FAT volume
for on-node storage. Rollback is enabled so an OTA'd image that cannot
confirm itself is reverted by the bootloader.

Layered, not inherited: sdkconfig.defaults.esp32c6 keeps its 4MB layout so
the common 4MB devkit is never silently repartitioned.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

* feat(firmware): persistent on-node log and coredump-to-flash

A remote fault currently leaves no post-mortem. main.c decodes the reset
reason at boot and logs it to the CONSOLE -- which is exactly what a power
cycle destroys, and a power cycle is how a wedged node gets recovered.

The flash was already provisioned for this and the features were simply off:
partitions_16mb.csv has reserved a 64K `coredump` partition and an 8000K
`storage` FAT volume since the 16MB layout was written, neither had ever been
mounted or written, and the build defaulted to CONFIG_ESP_COREDUMP_ENABLE_TO_NONE.

- node_log.c/h: a 128 B-record ring on the storage volume. Boot records
  (reset reason + the previous session's final uptime + a health snapshot),
  periodic health samples, and event records. Ordering is boot_id + uptime_s
  because these boards have no RTC and nothing can be timestamped absolutely.
  Counters are stored RAW and differenced at read time, so a cadence change
  never invalidates old records. Retention, not wear, binds: 128 B every
  300 s is ~208 days in 8 MB, and the write rate is bounded explicitly.
- ap_rssi_dbm, the association link from esp_wifi_sta_get_ap_info. The
  adaptive controller's rssi_median_dbm is a CSI statistic, not the uplink,
  so a node with a bad uplink currently shows no signal for it anywhere.
- CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH, complementary rather than a
  substitute: a coredump catches a CRASH, the boot record catches a HANG
  followed by a watchdog reset.

node_log_init failing is non-fatal by design -- the module disables itself
and every entry point becomes a no-op. A logging volume that will not mount
must never take the fleet down with it.

csi_collector gains two gate accessors. This build selects its frame gate at
compile time, so both return CSI_GATE_NOT_CONFIGURABLE rather than a
plausible number: the log keeps a field of fixed width and meaning either
way, and an explicit "not applicable" is stored instead of something a
reader would later reason from as a real setting.

Built with ESP-IDF v5.4 for esp32c6 using
defaults + .esp32c6 + .16mb. Verified from the resulting artifacts: 16MB
table with coredump and storage present, COREDUMP_ENABLE_TO_FLASH set,
rollback enabled, image 0x10abd0 bytes in a 4M slot. NOT flashed to hardware
from this branch -- the equivalent code was exercised on a node-3 pilot, but
that is not evidence for this build.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

* docs(adr-362): reading the on-node log is on-demand, never streamed

The log engine lands with no consumer, so the access pattern was open. Records
the decision to pull a node's log only when a human asks -- after a hang or a
crash, or while troubleshooting that node -- and to keep nothing server-side.

The reasoning that decided it: the ring already survives a power cycle on the
node (~208 days of retention), so a server copy duplicates a durability
property that is already met; and airtime is this fleet's binding constraint,
measured at 91% consumed by two slow nodes, so a periodic drain across nine
boards would spend exactly the resource whose exhaustion you would open the
log to investigate. An instrument must not perturb what it measures.

Leaves the decode-where question open on purpose, with the argument for a raw
proxy stated: decoding server-side defines the record layout in both C and
Rust, and the pilot already paid once for a host/firmware disagreement about a
field's meaning.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

---------

Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
2026-09-09 23:50:34 +00:00
f7c2c6587e feat(firmware): add the 16MB flash profile the C6 fleet runs on (#1877)
The 4MB and display layouts have 1.875MB and 2MB OTA slots, sized for 4MB
and 8MB parts. A 16MB C6 (DevKitC-1 N16) has no layout in the tree at all,
so building for one meant hand-editing sdkconfig -- and a smaller table
written over a deployed 16MB node relocates NVS and destroys its
provisioning, which is why this belongs in the repo rather than in a
workstation's untracked sdkconfig.

Two 4MB OTA slots, plus a 64K coredump partition and an 8000K FAT volume
for on-node storage. Rollback is enabled so an OTA'd image that cannot
confirm itself is reverted by the bootloader.

Layered, not inherited: sdkconfig.defaults.esp32c6 keeps its 4MB layout so
the common 4MB devkit is never silently repartitioned.


Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
2026-09-09 23:31:03 +00:00
30391bfbd2 docs(adr-361): single-transmitter pairing -- hypothesis confirmed, fix not adopted (#1876)
A-B-A capture from the overnight run, ten 30 s windows per arm: pairing
fraction rises 0.5668 -> 0.7287 (Welch t=13.26, p<0.0001) when the fleet is
filtered to one transmitter, but paired observations/s falls 44% because
tx/s falls 56%. Fraction was the wrong figure of merit; not adopted.

Firmware changes (persistent on-node log, live filter_mac, and the node-3
pilot fixes) that produced this capture are held back pending a port against
main's restructured firmware/main/ tree (config_api.c and ota_update.c are
gone there).


Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
2026-09-09 23:31:00 +00:00
rUv 00a42d7cda fix(sensing-server): don't let edge-vitals sentinel RSSI poison mean_rssi (#1882)
The ESP32 edge-vitals packet (0xC511_0002) carries a raw i8 RSSI byte with
no "valid" flag. When the edge pipeline hasn't sampled a real reading yet,
it has been observed sending near-zero sentinel values (-1, -2 dBm) that
are physically implausible for a WiFi link. These were pushed straight
into the node's rssi_history and the room's fused mean_rssi, producing a
one-tick reading of ~-2 dBm on node 3 while its own CSI path simultaneously
showed a real ~-50 dBm signal.

Add an is_plausible_rssi() guard (mirroring the existing
update_csi_fps_ema() plausibility-filter pattern) so an implausible RSSI
falls back to the node's last known-good reading instead of overwriting it.
2026-09-09 20:10:06 +00:00
rUv bd6f38e2df fix(engine): wire node positions into StreamingEngine's governed fuser (#1880)
StreamingEngine owns its own MultistaticFuser instance -- a different one
from wifi-densepose-sensing-server's AppState, which was fixed for this
same class of bug in #1860. Nothing ever called set_node_positions /
set_node_positions_by_id on this instance, so every FusedSensingFrame
produced by the governed path (fuse_scored_calibrated, which feeds
provenance and the witness) carried every node's position as the origin
regardless of deployment configuration.

Adds StreamingEngine::set_node_positions(HashMap<u8, [f32;3]>), delegating
to the fuser's existing set_node_positions_by_id. Also fixes a related
hazard in set_multistatic_config: it rebuilt the fuser wholesale
(MultistaticFuser::with_config(cfg)), which silently discarded any
positions configured before it. It now preserves them across the rebuild,
so callers don't need to remember a specific ordering between the two
setters.

Two regression tests per the issue's own acceptance criteria: one calling
fuse_scored_calibrated directly and asserting each fused contribution
carries its own node's configured position, and one asserting positions
survive a later set_multistatic_config call.

Fixes #1870.
2026-09-09 17:28:06 +00:00
rUv 203bd66daa Merge pull request #1879 from ruvnet/fix/hf-csi-embedding-metrics
fix(training): export the real contrastive lossHistory/duration, not a no-op baseline pass
2026-09-09 16:27:40 +00:00
ruv 2c3a924231 fix(training): export the real contrastive lossHistory/duration, not a no-op baseline pass
scripts/train-ruvllm.js ran two separate computations during contrastive
pretraining: ContrastiveTrainer.train() (a baseline pass that computes loss
but never updates the encoder) and a hand-written loop that actually applies
gradient updates to encoder.w2. The exported training-metrics.json took
lossHistory and contrastive.durationMs from the first (no-op) pass instead
of the second (real) one, so the published curve was flat/meaningless
(~0.1352 for all 20 epochs) and the duration was ~30x too short.

This did not affect finalLoss/improvement or the held-out temporal-triplet
accuracy metric published for this model on HuggingFace
(ruvnet/wifi-densepose-pretrained) -- those already came from the real loop.
It only affected the diagnostic curve/duration anyone auditing convergence
from the published JSON would see.

Also: config.json's training.steps field (epochs * triplets, technically
consistent but reads like an optimizer-step count) is now reported as
separate epochs/tripletUpdatesPerEpoch fields, and the exported version
bumped 1.0.0 -> 2.0.1 to match the "v2" naming already used elsewhere for
this file set.

Verified by re-running against the real capture
(data/recordings/overnight-1775217646.csi.jsonl): lossHistory now shows a
genuine decreasing curve (0.1037 -> 0.0654 across 20 epochs, matching the
already-published finalLoss exactly), durationMs reports the real ~40s
gradient-loop time instead of ~12s.

Model card updated with the same disclosure (docs/huggingface/MODEL_CARD.md)
plus a real, previously-undisclosed EWC forgetting-rate figure (32.97%,
present in training-metrics.json but not surfaced in the human-readable
card) and a corrected repro-script pointer.
2026-09-09 11:26:17 -04:00
rUv e5422d52e4 Merge pull request #1858 from clonea1/contrib/gate-experiment-findings
docs: measured comparison of three CSI frame-selection gates, and the harness
2026-09-09 13:10:02 +00:00
ruv 26e8ca3f39 Merge upstream/main, resolve README.md table-row conflict 2026-09-09 08:40:14 -04:00
rUv c407e738a3 Merge pull request #1872 from clonea1/contrib/tdm-soft-guard
fix(signal): the derived soft guard flagged the schedule it was derived from
2026-09-09 12:38:26 +00:00
rUv 755e9bca05 Merge pull request #1859 from clonea1/contrib/mesh-aligned-rate-gate
feat(firmware): align the CSI rate gate to mesh time
2026-09-09 12:38:23 +00:00
rUv 73e15a7d29 Merge pull request #1857 from clonea1/contrib/csi-fps-delta-domain
fix(server): recover the true CSI frame rate through bursty UDP delivery
2026-09-09 12:38:18 +00:00
rUv f38beff441 Merge pull request #1867 from ruvnet/feat/c6-xiao-antenna-select
feat(firmware): add XIAO ESP32-C6 antenna selection
2026-09-09 12:35:50 +00:00
rUv 830abd7e18 Merge pull request #1860 from clonea1/contrib/server-node-positions
fix(sensing-server): key node positions by node id, not by list position
2026-09-09 12:35:27 +00:00
rUv b296ea26a4 Merge pull request #1862 from clonea1/contrib/adr-345-per-link-csi
docs(adr): ADR-345 — per-link CSI attribution and node-to-node ranging
2026-09-09 12:35:23 +00:00
rUv 0b29902d8b Merge pull request #1861 from clonea1/contrib/firmware-runbook
docs(firmware): add the ESP32-C6 operational runbook
2026-09-09 12:35:19 +00:00
rUv b18b34e9f5 Merge pull request #1855 from clonea1/contrib/ignore-host-test-binaries
chore: ignore host-test binaries by shape, not by a list that goes stale
2026-09-09 12:35:15 +00:00
Joeandclaude-flow 169597a7d2 fix(signal): the derived soft guard flagged the schedule it was derived from
`MultistaticConfig::for_tdm_schedule` set the soft guard to `cycle_us / 3`,
reasoned about in its own doc comment as "a normal adjacent-slot pair fuses
cleanly". But `fuse_scored` does not compare pairs -- it takes
`max(timestamp) - min(timestamp)` across the whole cohort, so the spread it
measures is always the first-to-last offset of `(N - 1)` slots.

Against that bound:

    soft < stagger  <=>  N*slot/3 < (N-1)*slot  <=>  N/3 < N-1  <=>  N > 1.5

which holds for every N >= 2. The derived soft guard never covered the schedule
it was derived from, at any fleet size, so a complete healthy cycle was recorded
as a `TimestampMismatch` contradiction -- which demotes the BFLD privacy class
one step (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs.

The constructor's own doc example is the smallest demonstration. It exists to
accommodate a MEASURED 18,194 us spread on a 2-slot mesh, and asserts the hard
guard clears it -- but the soft guard it derives for that same mesh is
36,000/3 = 12,000 us, below the measurement that motivated the constructor.

Soft is now the designed stagger, `(slots - 1) * slot_duration_us`, plus the
same 20% jitter headroom the hard guard already takes. The headroom is not
cosmetic: the nominal 2-slot stagger is 18,000 us while the observed spread is
18,194 us, so a bare nominal bound would still flag its own motivating cycle.
`stagger < cycle` always, so the result stays strictly below the hard guard and
a spread approaching a full cycle is still flagged as loose alignment.

Why this survived: every existing assertion on `soft_guard_us` is relational
(`soft < hard`, `soft >= 1`). None ever compared it to a spread the schedule
actually produces. Three tests are added -- the derived guard clears the stagger
across 2..13 slots, a full evenly-staggered cycle raises no contradiction, and a
spread beyond the stagger is still flagged, so the fix cannot be mistaken for
disabling the check. The first two fail against `cycle / 3`; the third passes
either way by design.

Scope: this corrects the DERIVED path only. The hand-set defaults (60 ms hard /
20 ms soft) are unchanged and remain correct for the 2-slot mesh they were sized
against.

Software-only scope. No hardware-accuracy claim is made.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-09 00:23:58 -04:00
Joeandclaude-flow 0d1a247bfe fix(sensing-server): a silent node no longer moves its neighbours' coordinates
`MultistaticFuser::fuse` addressed node positions by the frame's index in the
cohort it was handed. That index is a rank, not an identity: the cohort from
`node_frames_from_states_with_guard` is compacted -- stale, out-of-guard and
empty-history nodes are dropped before `fuse` ever sees them. So one board
missing a beacon re-ranked every node above it and handed each of them its
neighbour's coordinates, silently and fleet-wide.

`MultiBandCsiFrame` already carries its own `node_id`, so the identity was
present at the boundary and simply unused. Positions are now stored keyed by
node id and resolved per frame by that id.

- `set_node_positions_by_id` takes the keyed map; `main` passes the same
  `node_positions_by_id` map that already feeds `NodeInfo`, so one parsed
  identity now serves both the metadata and the effectful path (#1860 fixed
  only the former).
- `set_node_positions(Vec)` is kept as a documented compatibility mapping:
  entry `i` is node `i`, which is exactly what a bare `--node-positions` list
  has always meant. `node_positions()` returns that dense view unchanged.
- `FusedSensingFrame::node_positions` stays parallel to `node_frames`, but each
  entry is resolved from its own frame's id, so the pairing survives compaction.

Behaviour change: with a bare (unprefixed) `--node-positions` list on a fleet
whose ids are not dense and zero-based, a node now reads the entry declared for
its id rather than the entry at its rank. That is the defect being fixed; ids
given explicitly as `node_id:x,y,z` are unaffected, and so is any fleet whose
ids are dense from zero with every node reporting.

Tests: sparse ids, reversed configuration order, a configured-but-absent node,
an unconfigured node, join/leave churn, the preserved legacy positional
mapping, and a negative control pinning the compacted-cohort case. Six of the
eight fail against the pre-fix lookup; the legacy-mapping test is expected to
pass both ways, and order-independence became structural with keyed storage.

Software-only scope. No hardware-accuracy claim is made.

Closes #1866
Refs #1860

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-08 19:55:07 -04:00
Dragan Spiridonov a4d49f76b9 feat(firmware): add XIAO C6 antenna selection 2026-09-08 14:12:41 +02:00
Joeandclaude-flow 51102b3baf docs(adr): ADR-345 — per-link CSI attribution and node-to-node ranging
Records why device-free position kept failing here, measured rather than
argued, and the join key built in response. The implementing PRs land without
this and are harder to read for it.

**Phase is unusable on this hardware.** The bistatic tier needed a signed
per-link radial velocity, which requires the common-mode component of CSI
phase. Measured across three ESP32-C6 nodes over ~40,000 frames, the
frame-to-frame change in common-mode phase is statistically indistinguishable
from uniform random: resultant length R = 0.011-0.032 against a 0.000
reference, sd 1.78-1.81 against pi/sqrt(3) = 1.814, no lag-1 autocorrelation.
A second, unwrap-free estimator agreed.

The mechanism is packet-detection timing quantization. Common-mode phase is
-2*pi*f_c*tau, and one ADC sample of jitter at 20 MHz is about 758 rad -- some
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
is default-off as a result.

**Amplitude works but had no parallax.** The three AP->node links leave a single
distant transmitter inside a 36.5 degree fan; scalar per-node energy weighting
closed with a clean negative result -- magnitude responds to proximity,
direction does not.

What follows from both: a position tier needs several transmitters seen from
several receivers, and fusing those requires knowing that two nodes observed
the SAME transmission. Arrival timestamps cannot say so -- the sink sees network
jitter, not capture time -- and the per-node sequence counter is private to each
node. Hence (tx_mac, rx_seq): the 802.11 sequence control field is assigned by
the transmitter and is identical at every receiver, so it names one
transmission without any coordination between receivers, no shared clock and no
negotiation.

Status is Proposed, deliberately. The measurement layer is implemented and
validated on hardware; localization is NOT built, and the ADR says so along
with the rest of its known limitations -- peer links are ~10x weaker than data
frames, three links is below the threshold for tomographic localization
regardless of quality, and no position tier consumes the link table.

References only ADR-018, ADR-060 and ADR-110, all already present here.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:38:40 -04:00
Joeandclaude-flow 977fa26731 docs: list the firmware runbook in the documentation index
Same reason as the operational content itself: a procedure nobody can find is a
procedure nobody follows. The README's documentation table is where a reader
looks, and the runbook was reachable only by browsing the firmware directory.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:03:09 -04:00
Joeandclaude-flow 2841b22cc9 docs: list the frame-selection study in the documentation index
The study was reachable only by knowing the file existed. Nothing in the repo
pointed at it, so a reader following the README would never learn that the
question had been measured at all.

Adds a row to the documentation table with the headline result and the fact
that the harness ships with it. Deliberately states both numbers -- the +31.7 pp
that seq bought and the +0.5 pp that mesh alignment did not -- so the index
entry cannot be read as advocacy for one setting.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:02:52 -04:00
Joeandclaude-flow 3fd3851633 docs(firmware): say why seq needs no sync and mesh alignment does
The two options differ in kind. Mesh alignment is an agreement between
receivers and inherits whatever the sync topology does; selecting by rx_seq
needs no agreement, because the 802.11 sequence control field is assigned by
the transmitter and is identical at every receiver. Nodes that have never heard
of each other, sit in different sync domains, or run with no mesh at all still
keep the same subset.

The code shows it: the seq test runs before any sync check and reads only the
frame, while only the mesh branch calls c6_sync_espnow_is_valid(). That
asymmetry is why seq had nothing to degrade in our runs, and it is the thing to
weigh if a fleet's sync is fragile -- seq is not just the better performer
there, it is the option with no dependency that can quietly fail.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:55:38 -04:00
Joeandclaude-flow d6b940184e docs: state that the mesh arm was partly an elapsed arm
The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an
unsynced node falls back to the elapsed gate. Three of nine nodes never
synchronised during these runs, so for those three the mesh and mesh+seq arms
silently ran the elapsed gate.

+0.5 pp is therefore not a clean fleet-wide measurement of window alignment.
Reported as observed rather than reconstructed: the arms were assigned before
we understood the fallback, and a post-hoc per-node reanalysis would not be the
experiment we ran.

Names the cause as environmental rather than a defect. Sync is leader-elected
and hub-and-spoke, so whether every node hears the leader is a property of the
building -- and a house that fragments into sync domains degrades any
shared-time option quietly, for exactly the nodes whose data you would most
want.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:50:49 -04:00
Joeandclaude-flow 60adac05b7 docs(firmware): state that our mesh arm was partly an elapsed arm
The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an
unsynced node falls back to the elapsed gate rather than gating on a
meaningless epoch. Correct behaviour, but it contaminates the comparison we
published: three of nine nodes never synchronised, so for those three the
setting under test was silently not in effect. The +0.5 pp should be read as
"mesh where sync held, elapsed where it did not", not as a clean fleet-wide
measurement.

Records the house-dependent cause as well. Time sync is leader-elected and
hub-and-spoke: one node broadcasts and the rest follow, so whether every node
hears the leader is a property of the building. In a larger or more divided
house the fleet fragments into sync domains and any option depending on shared
time degrades quietly for the nodes that fell out -- which is exactly the case
where someone would most want to trust the measurement.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:50:28 -04:00
Joeandclaude-flow ec9da648b8 docs(firmware): add the gate comparison as a worked example of house-dependence
The tunables table says which options depend on the building. This shows what
that means with real numbers, and is explicit that they are ours.

Nine-node C6 fleet, 2x2 factorial, three Latin-square blocks: seq selection
doubled cross-node pairing (0.5605 vs 0.2434, +31.7 pp, ~70x the drift floor)
while mesh-time alignment bought +0.5 pp, inside noise.

The point is the reasoning, not the ranking. It records WHY that happened here
-- these nodes already overheard 72% of transmissions in common, so there was a
large shared pool for seq to select from and little left for alignment to
recover -- and says plainly that a house with fewer shared transmitters or
thicker walls has less for seq to work with and may find alignment matters
more. We did not measure such a house and do not claim to know.

Also keeps the trade (pairing bought with raw frame rate; edge processing
unaffected because separately rate-limited, so the cost lands on the uplink)
and the prediction we got wrong: seq is NOT a workaround for broken time sync,
which means repairing our mesh split would not have closed the gap.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:46:35 -04:00
Joeandclaude-flow 0fd1c63563 docs(firmware): document the tunables, and which ones depend on the house
The Kconfig options were discoverable only by opening menuconfig and reading
help text, which requires already knowing they exist. Two of them --
CSI_GATE_MESH_ALIGNED and the thermal throttle pair -- have no universally
right value: they depend on walls, transmitters, enclosure and placement.

Lists all eight with their defaults, marks which are house-dependent, and says
what to measure for each. Notes explicitly that in one measured fleet window
alignment bought +0.5 pp of cross-node pairing while rx_seq selection bought
+31.7 pp, so nobody inherits the assumption that the default is doing the work.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:43:35 -04:00
Joeandclaude-flow 521b29ea21 Merge origin/main into contrib/mesh-aligned-rate-gate
Both sides appended to the end of Kconfig.projbuild -- upstream added the
thermal monitoring menu, this branch adds the two CSI gate options. Kept both.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:29:56 -04:00
Joeandclaude-flow e8e4e5cf67 fix(firmware): define the Kconfig options this gate is guarded by
csi_collector.c guards the mesh-aligned gate on CONFIG_CSI_GATE_MESH_ALIGNED,
but nothing defined it. Upstream's Kconfig.projbuild has no such symbol and
this branch did not add one, so the #ifdef would never be true and the gate
would ship dead: present in the diff, absent from the binary, and silent about
it. The A/B this PR offers would have compared the old gate with the old gate.

Adds CSI_GATE_MESH_ALIGNED (default y, the gate) and CSI_SEQ_DIAG (default n,
the probe that measures its effect rather than assuming it).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:28:57 -04:00
Joeandclaude-flow 9af1cc6644 docs(firmware): add the ESP32-C6 operational runbook
README.md explains the project; there was nothing that said what to run, in
what order, and what to check. This is that, written from the procedures that
were actually used on a nine-node C6 fleet.

Everything in it was learned by getting it wrong at least once, and the
failures it prevents are the silent kind:

- The build must concatenate THREE defaults files. IDF discovers
  sdkconfig.defaults and sdkconfig.defaults.<target> on its own but NOT
  sdkconfig.defaults.16mb, which is where CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
  lives. Build without it and the image is correct in every visible respect and
  cannot roll back a bad OTA, while ota_rollback_boot_check() sits in the app
  expecting the capability.
- A five-key pre-flash gate, because a stale sdkconfig reading FLASHSIZE=4MB
  against a 16MB fleet produces an image that silently is not the configuration
  under test.
- What OTA can and cannot reach, per flash region. Bootloader and partition
  table are USB-only, and there is no bootloader version on the wire -- so
  "every node reports health" proves the app is current and proves nothing
  about the bootloader.
- A single-board diagnostic flash with a revert path: parse otadata first, back
  up the 8 KB, and reverting is writing it back rather than rebuilding.
- min_heap_kib is a minimum-since-boot watermark, not an instantaneous reading.
  Sampling it within a minute of a restart produced a wrong "no effect" verdict
  once; the real answer, once settled, was -56 KiB.
- /api/v1/nodes rssi_dbm is not the AP link and swings 20+ dB with no physical
  change.

No code changes. One file.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:26:55 -04:00
Joeandclaude-flow 56fcc49104 Merge origin/main into contrib/server-node-positions
Upstream added a `node_positions_config` of its own while this branch was
waiting, modelled as an ordered `Vec` with a `node_positions_by_active_id`
helper that gives the Nth-lowest ACTIVE node id the Nth entry.

That is the same class of bug this branch exists to fix, one level up: a
position now depends on which OTHER nodes happen to be alive. One node going
quiet re-ranks every node above it and silently moves their coordinates.

Resolved by keying the map by node id throughout -- the struct field, its
initialiser, the local in main() and the helper -- and keeping upstream's
active-node filter unchanged.

Upstream's three helper tests are ported to the keyed form, and one is added
that pins the difference: a stale node must not move its neighbours.

Verified: cargo test -p wifi-densepose-sensing-server --no-default-features
passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 10:42:33 -04:00
Joeandclaude-flow 11c014485e fix(server): recover the true CSI frame rate through bursty UDP delivery
`csi_fps_ema` currently under-reports whenever more than one frame arrives in a
burst, and the under-report is proportional to the burst size.

The present scheme rejects sub-5 ms deltas and, crucially, does NOT re-anchor
on them. Holding the anchor means the next accepted delta spans the whole
burst while contributing a single sample -- so a burst carrying N frames is
counted as one. That is invisible when a burst holds one frame, which is the
case the current comment describes, and an exact halving when it holds two.

Two changes, both in the same direction:

- Average in the DELTA domain and invert, rather than averaging 1/dt. The
  reciprocal is unbounded as dt approaches zero, so one 36 us arrival can
  outweigh hundreds of nominal ones; an average of deltas cannot be dominated
  that way. This is what makes it safe to stop discarding short deltas.
- Re-anchor on every arrival. One delta per frame means the deltas sum to the
  elapsed time, which is the condition that makes the estimator equal
  frames/elapsed.

Simulated against the same arrival stream, a node genuinely producing 40 fps
whose frames are delivered in pairs (four frames per 100 ms):

    clean 40 fps, evenly delivered   before 40.0   after 40.0
    bursty 40 fps, pairs per 50 ms   before 20.0   after 40.6

The clean-cadence control is unchanged; the bursty case stops halving.

MAX_PHYSICAL_CSI_FPS is retained and still enforced at the consumption
boundary in `measured_sample_rate_hz`, so restored or malformed state cannot
overclock the DSP math. The estimator itself is left unclamped so that a rate
above the ceiling remains visible as the anomaly it is rather than being
silently flattened to 50.

TRADE-OFF, stated plainly: alpha moves from 1/8 to 1/32, so the estimate is
about three times slower to settle after a genuine rate change. That is the
price of not letting a single short delta swing it, and the steady-state test
horizon is widened to match.

Tests: `observe_csi_frame_arrival_ignores_subms_bursts` is replaced. It fed
three arrivals per 25 ms group -- 120 frames per second, which no single node
can emit under the firmware's own 50 fps send ceiling -- and asserted the
result should read 40, which only holds if two of every three real frames are
discarded. The replacement uses a physically consistent stream and asserts the
same 40 fps ground truth.

Full suite passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 09:57:16 -04:00
Joeandclaude-flow 86e45980fe chore: ignore host-test binaries by shape, not by a list that goes stale
`make host_tests` writes its binaries beside their sources, inside a tracked
directory. .gitignore named five of them individually, and the list had fallen
behind the Makefile: test_csi_sanitize, test_mmwave_detect and test_thermal
were built by the default target, ignored by nothing, and got committed by
accident on two contribution branches.

Enumerating targets cannot hold -- every new host test needs someone to
remember this file. So ignore by shape instead: in that directory every source
carries an extension and no binary does.

    firmware/esp32-csi-node/test/test_*
    firmware/esp32-csi-node/test/fuzz_*
    !firmware/esp32-csi-node/test/*.*

Verified rather than reasoned about. After building every host test,
`git status --porcelain` reports only this file. `git check-ignore` confirms
all ten binaries are ignored -- including test_edge_grid_he and
test_edge_grid_pre_he, which are not on main yet -- while the .c sources, the
Makefile, the README and the corpus seeds stay tracked.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 08:15:00 -04:00
rUv d613a576ea Merge pull request #1849 from ruvnet/codex/calibration-grid-integrity-20260906
fix(calibration): harden empty room bootstrap integrity
2026-09-06 12:25:17 -04:00
ruv 97578ee401 fix(firmware): make host dead stripping portable 2026-09-06 11:51:09 -04:00
ruv e54b4171ca fix(calibration): bind field models to stable CSI grids 2026-09-06 11:38:31 -04:00
ruv 338c523217 fix(calibration): tolerate measured UDP reordering 2026-09-06 11:38:31 -04:00
ruv 224c9eb671 feat(calibration): expose CSI admission receipts 2026-09-06 11:38:31 -04:00
ruv 2deb6d2d64 fix(calibration): require unique raw CSI samples 2026-09-06 11:38:31 -04:00
ruv 82e05d0664 fix(calibration): expire live field models safely 2026-09-06 11:38:30 -04:00
ruv a2c662f9f7 fix: harden CSI timing and baseline reliability 2026-09-06 11:38:30 -04:00
ruv 48c6244d37 fix(calibration): learn held out empty residuals 2026-09-06 11:38:30 -04:00
ruv 30404dd9cf feat(calibration): select explicit baseline source 2026-09-06 11:38:30 -04:00
rUv c4021f9884 docs: add WiFlow reproduction protocol (#1787) 2026-09-05 09:42:43 -04:00
rUv 2227b42841 feat(calibration): learn empty room startup confidence (#1840) 2026-09-04 23:02:06 -04:00
Joeandclaude-flow 767c36ad12 docs: measured comparison of three CSI frame-selection gates, and the harness
How a node rate-limits CSI callbacks decides whether cross-node fusion works at
all, because it decides whether two nodes keep the SAME frames or merely the
same NUMBER of frames. We measured three gates on a nine-node ESP32-C6 fleet as
a 2x2 factorial, three Latin-square blocks, every arm verified live on every
node.

Selecting frames by 802.11 rx_seq roughly doubled cross-node pairing over the
elapsed-time gate: 0.5605 against 0.2434, +31.7 pp, reproduced in all three
blocks at ~70x the measured drift floor.

Two results we did not want and are reporting anyway:

- Mesh-time alignment -- a gate we built and expected to be the answer -- bought
  +0.5 pp, about one standard deviation of noise. Indistinguishable from doing
  nothing.
- We predicted rx_seq selection would win most on node pairs spanning a broken
  sync domain. Per-pair yield says the opposite: never-synced nodes gained
  LEAST. So it is not a workaround for broken time sync, and repairing our mesh
  split would probably not have closed the gap.

The document leads with the method rather than the numbers, because the numbers
are one house. It records the three mistakes that made an earlier attempt
unreadable -- comparing different node subsets, not verifying the gate was
actually live, and using frame rate as the check that a gate is working when a
bucket gate makes frame rate nearly insensitive to it -- and the null run that
establishes the noise floor without which none of the contrasts can be judged.

run_arms.py is included so a reader can measure their own environment instead
of trusting ours. That is the point of the contribution; our figures are the
worked example.

Stated as a trade, not a win: pairing is bought with raw frame rate. On-device
edge processing was unaffected here (separately rate-limited), so the cost fell
on the uplink -- but whether that is acceptable depends on what consumes the
stream.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 17:46:00 -04:00
rUv 2e9e87c65c feat(calibration): persist safe empty room bootstrap prior (#1826) 2026-09-04 16:31:38 -04:00
Joeandclaude-flow d6347fe12e fix(sensing-server): key node positions by node id, not by list position
Fixes the indexing mismatch found in review. Positions were inserted keyed by
the entry's index in the parsed list and read back by the node's actual
node_id:

    for (idx, p) in positions.iter().enumerate() {
        node_positions_config.insert(idx as u8, *p);   // key = index
    }
    ...
    s.node_positions_config.get(&id)                   // lookup = node_id

On any fleet not numbered 0,1,2,... every lookup misses and falls through to the
hardcoded [2.0, 0.0, 1.5] that --node-positions exists to replace. The config
reads as applied and does nothing, which is worse than not having wired it up.
It passed here only because these nine boards happen to be 0..8.

--node-positions now accepts an explicit form alongside the positional one:

    x,y,z;...           positional, identity is the index (unchanged)
    node_id:x,y,z;...   explicit, identity is the stated id

Both are accepted and may be mixed, so existing invocations keep working.
Coordinates never contain a colon, so the split is unambiguous. An unparseable
or out-of-range id is skipped rather than guessed at -- falling back to
positional there would reintroduce the ambiguity being removed, and 256 must
not wrap to node 0.

Explicit ids are the better form on a real fleet: with the positional one, the
binding of a position to a board is implied only by argument order, which is
exactly the fragility that produced this bug.

MultistaticFuser::set_node_positions() is deliberately left alone. It consumes
the list positionally, matched to per-frame iteration order rather than to node
id -- a real and separate concern, but this change is about the NodeInfo output
and quietly altering fusion semantics inside it would be the wrong scope. The
fuser receives the same list, in the order given, as before.

The map is now built by field_bridge::node_positions_by_id() rather than a loop
in main(). That is the point: the defect lived in a loop no test could reach.
Nineteen tests cover it, including non-sequential ids (11,12,13), a fleet
re-homed to 0,2,4,...,16 with two-digit ids, a fleet that does not start at
zero, and an assertion that the list indices are NOT keys -- which is the bug
stated directly. The four pre-existing parse tests are untouched and still pass,
so the positional path is unchanged.

265 server tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 11:53:40 -04:00
Joe 7cdf5714e8 feat(firmware): align the CSI rate gate to mesh time
HOW the 50 Hz rate is limited decides whether cross-node fusion is
possible at all, and the original gate quietly made it impossible.

The gate was "at least 20 ms since MY last accept". Each node therefore
has an independent phase: node A can accept at t=0,20,40 ms while node B
accepts at 7,27,47. Both are perfectly healthy, both hit 50 Hz, and they
accept disjoint frames -- so no two nodes ever hold a measurement of the
same transmission and there is nothing to pair.

MEASURED 2026-08-30 with two boards side by side, logging (tx_mac,
rx_seq) before and after the gate: they HEARD the same transmissions 72%
of the time but only 25% of ACCEPTED frames were common. The frames were
there; the gate was throwing away the pairing. (Also settles an open
question from ADR-345: rx_seq is populated -- 4099 distinct values across
~44,000 frames, both nodes reporting the identical range.)

Now gates on a bucket of the mesh-aligned epoch, so every node takes the
first frame it hears in the same absolute 20 ms window and frames both
nodes heard are frames both nodes keep. Mesh sync is good to roughly
490 us against a 20 ms bucket -- about 2.5% of a window -- so boundary
disagreements are rare.

Falls back to the original elapsed-time gate whenever
c6_sync_espnow_is_valid() is false, because an unsynced epoch would
otherwise gate on a meaningless number; a node that has not heard a leader
yet simply gets the old behaviour. The half-interval floor is deliberate:
bucket gating alone could accept at the end of one window and the start of
the next, back to back, and this gate exists to prevent an SPI flash cache
crash in the WiFi ISR, not for tidiness.

Behind CONFIG_CSI_GATE_MESH_ALIGNED (default y) so it can be A/B'd against
the old gate; CONFIG_CSI_SEQ_DIAG measures the difference directly.

NOT yet deployed to the fleet, and not yet enough on its own: the wire
format carries a per-node counter, not rx_seq, so the server still cannot
pair frames. Wire v3 has to carry rx_seq before any of this is usable off
a serial cable.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 4b54ef8ecb2a28d173d411a064809dc081c43f52)
2026-09-02 22:43:27 -04:00
84 changed files with 11149 additions and 405 deletions

No files matched your search

+2 -2
View File
@@ -293,7 +293,7 @@ jobs:
done
- name: Update deployment status
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
with:
script: |
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
@@ -344,7 +344,7 @@ jobs:
- name: Create deployment issue on failure
if: needs.deploy-production.result == 'failure'
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
with:
script: |
github.rest.issues.create({
+24 -4
View File
@@ -53,7 +53,9 @@ jobs:
sdkconfig: sdkconfig.defaults
partition_table_name: partitions_display.csv
size_warn_kb: 1100
size_limit_kb: 1152
# Current 8 MB image is 1163 KiB after USB onboarding. The OTA app
# partition is 2048 KiB; retain a 1200 KiB hard gate and warning.
size_limit_kb: 1200
artifact_app: esp32-csi-node.bin
artifact_pt: partition-table.bin
- variant: 4mb
@@ -92,12 +94,11 @@ jobs:
idf.py set-target ${{ matrix.target }}
idf.py build
- name: Build and run host-side ADR-110 unit tests
- name: Build and run host-side firmware unit tests
if: matrix.variant == 'c6-4mb'
working-directory: firmware/esp32-csi-node/test
run: |
make test_adr110
./test_adr110
make host_tests
- name: Verify binary size budget
working-directory: firmware/esp32-csi-node
@@ -184,3 +185,22 @@ jobs:
name: esp32-csi-node-firmware-${{ matrix.variant }}
path: firmware/esp32-csi-node/release-staging/
retention-days: 90
build-c6-idf6:
name: Build XIAO C6 external antenna (ESP-IDF 6.0.2)
runs-on: ubuntu-latest
container:
image: espressif/idf:v6.0.2
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
submodules: recursive
- name: Build the IDF 6 canary image
working-directory: firmware/esp32-csi-node
run: |
. $IDF_PATH/export.sh
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" set-target esp32c6
idf.py build
test "$(stat -c%s build/esp32-csi-node.bin)" -le $((1152 * 1024))
strings build/esp32-csi-node.bin | grep -F "external U.FL"
+1 -1
View File
@@ -460,7 +460,7 @@ jobs:
- name: Create security issue on critical findings
continue-on-error: true
if: needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure'
uses: actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325 # v6
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v6
with:
script: |
github.rest.issues.create({
+10 -5
View File
@@ -17,11 +17,16 @@ firmware/esp32-csi-node/sdkconfig.defaults.bak
firmware/esp32-hello-world/sdkconfig.old
# Host-built firmware test binaries (compiled from test/*.c, not source)
firmware/esp32-csi-node/test/test_adr110
firmware/esp32-csi-node/test/test_vitals
firmware/esp32-csi-node/test/fuzz_serialize
firmware/esp32-csi-node/test/fuzz_edge
firmware/esp32-csi-node/test/fuzz_nvs
# Compiled host-test and fuzz binaries. The Makefile writes them beside their
# sources, so they land in a tracked directory. Ignore them by SHAPE rather
# than by name: every source here carries an extension and no binary does.
#
# This replaced a hand-maintained list of five targets, which had gone stale --
# test_csi_sanitize, test_mmwave_detect and test_thermal were all built by
# `make host_tests`, ignored by nothing, and got committed by accident.
firmware/esp32-csi-node/test/test_*
firmware/esp32-csi-node/test/fuzz_*
!firmware/esp32-csi-node/test/*.*
firmware/esp32-csi-node/test/*.exe
firmware/esp32-csi-node/test/*.obj
+2
View File
@@ -719,6 +719,8 @@ Start with the user, build, and calibration guides; expand for the full referenc
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [CSI Frame Selection — Measured](docs/csi-frame-selection-measured.md) | Which rate gate actually buys cross-node pairing, measured on a nine-node ESP32-C6 fleet as a 2x2 factorial in three Latin-square blocks. Selecting frames by 802.11 `rx_seq` roughly doubled pairing (+31.7 pp) while mesh-time alignment bought +0.5 pp, inside noise. Includes the trade it makes, a prediction that turned out wrong, the limits of the measurement, and `run_arms.py` so the comparison can be re-run rather than taken on trust. |
| [ESP32-C6 Firmware Runbook](firmware/esp32-csi-node/RUNBOOK.md) | What to run, in what order, and what to check: the three-file build that keeps OTA rollback compiled in, a five-key pre-flash gate, which flash regions OTA can and cannot reach, a single-board diagnostic flash with a revert path, and the tunables whose right value depends on your house rather than on the chip. |
| [Architecture Decisions](docs/adr/README.md) | 205 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
+29
View File
@@ -10,6 +10,35 @@ func main() {
exit(1)
}
// One connected-link RSSI sample for the Rust adapter. Keep the legacy
// no-argument 10 Hz stream below unchanged for Python callers.
if CommandLine.arguments.contains("--scan-once") {
guard interface.powerOn(),
let channel = interface.wlanChannel(),
interface.rssiValue() < 0 else {
fputs("WiFi is not connected\n", stderr)
exit(1)
}
let sample: [String: Any] = [
"ssid": interface.ssid() ?? "",
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
"channel": channel.channelNumber,
"rssi": interface.rssiValue(),
"noise": interface.noiseMeasurement(),
"timestamp": Date().timeIntervalSince1970,
"tx_rate": interface.transmitRate()
]
do {
let data = try JSONSerialization.data(withJSONObject: sample, options: [.sortedKeys])
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.write(Data([0x0a]))
} catch {
fputs("Could not encode WiFi sample: \(error)\n", stderr)
exit(1)
}
return
}
// Flush stdout automatically to prevent buffering issues with Python subprocess
setbuf(stdout, nil)
@@ -0,0 +1,218 @@
# ADR-323: RTL8721Dx (Realtek AmebaDplus) CSI Wire Protocol
- **Status**: accepted
- **Date**: 2026-09-15
- **Deciders**: RuView maintainers
- **Tags**: realtek, ameba, amebadplus, rtl8721dx, csi, protocol, rust, udp
## Context
Unlike the MediaTek/Qualcomm CSI paths (ADR-266..269), which are simulator-first
because no supported public vendor interface exports complex CSI, Realtek's
official Ameba RTOS SDK (`Ameba-AIoT/ameba-rtos`, Apache-2.0) documents and
exposes a real, callback-driven CSI API for the RTL8721Dx ("AmebaDplus")
family: `wifi_csi_config()` plus the `RTW_EVENT_CSI_DONE` event delivering a
`struct rtw_event_csi_report_info` (per-tone signed I/Q, 8-bit or 16-bit
precision, selectable tone decimation, passive or active/triggered capture).
This has been confirmed directly against SDK source
(`component/wifi/api/wifi_api_event.h`) and against the official
`example/wifi/wifi_csi` example, and validated on real PKM8721DAF-C13-F10
hardware (build+flash+boot confirmed on branch `feat/rtl8721dx-csi-bringup`).
The RTL8721Dx is a 1x1 WiFi 4 part — single TX/RX chain, not MIMO — so the
MediaTek/Qualcomm wire format (Tx/Rx dimensioned matrices, chipset chain
counts) does not fit. The vendor SDK's raw struct is also not a safe host
wire format as-is: it has no CRC, a C flexible-array-member payload, and
fields sized for firmware convenience (e.g. a wrapping `u32` microsecond
timestamp) rather than network robustness.
Per the existing repo convention (ESP32/ADR-018, MediaTek/ADR-266-267,
Qualcomm/ADR-268-269, RTL8720F radar/ADR-263-264), each vendor CSI/radar
source gets its own self-delimiting envelope and its own Rust module pair
(`wifi-densepose-hardware` for framing+codec+simulator,
`wifi-densepose-sensing-server` for the bounded JSON-safe snapshot), never a
shared "generic vendor CSI" struct that would either lose vendor-specific
fields or invite magic-space collisions. `SourceKind::Realtek` (value 2) is
already assigned to the RTL8720F FMCW radar path (ADR-263/264) and must not
be reused for CSI.
## Decision
Define `RAC1` ("Realtek Ameba CSI", version 1) as a little-endian,
self-delimiting envelope, magic `0x3143_4152` (distinct from `MTC1`
`0x3143544d`, `QCS1` 0x31535143, `RTR1` 0x31525452, and all ADR-018
`0xC511000x` siblings):
Fixed 49-byte header (explicit field-by-field serialization, no `#[repr(C)]`
padding, matching the MediaTek/Qualcomm modules' style):
| Field | Type | Notes |
|---|---|---|
| magic | u32 LE | `0x3143_4152` |
| version | u8 | 1 |
| header_len | u16 LE | 49 |
| frame_len | u32 LE | header + payload + 4-byte CRC |
| node_id | u8 | disambiguates multi-board illuminator/receiver deployments; 0 = unset |
| csi_mode | u8 | 0=passive, 1=active, 2=board-to-board (`CsiMode`) |
| sequence | u32 LE | from firmware `csi_sequence` |
| timestamp_us | u32 LE | from firmware `hw_assigned_timestamp` (hardware-clock microseconds, wraps ~71 min — consumers must not assume monotonic across a capture longer than that) |
| peer_mac | [u8; 6] | firmware `mac_addr` |
| trig_mac | [u8; 6] | firmware `trig_addr` |
| channel | u8 | operating channel |
| bandwidth | u8 | 0=20MHz, 1=40MHz |
| rx_rate | u8 | firmware `rx_rate` |
| protocol_mode | u8 | 0=OFDM,1=HT,2=VHT,3=HE (`ProtocolMode`) |
| num_sub_carrier | u16 LE | tone count in payload |
| num_bit_per_tone | u8 | 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q) |
| decimation | u8 | tone group (1/2/4/8 — every Nth tone) |
| rssi_dbm | i8 | single RF chain (1x1 hardware) |
| rxsc | u8 | sub-20 MHz channel used |
| csi_valid | u8 | 0/1 from firmware |
| flags | u8 | bit 0 = SYNTHETIC (simulator provenance, never clearable downstream) |
| payload_len | u32 LE | bytes, == `num_sub_carrier * (num_bit_per_tone / 8)` |
Payload: `num_sub_carrier` consecutive (I, Q) tone pairs, each
`num_bit_per_tone / 16` bytes per component (1 byte/component when
`num_bit_per_tone == 16`, 2 bytes/component little-endian signed when `== 32`)
— i.e. exactly the byte layout the firmware already produces in
`csi_data[]`, just framed. CRC-32/IEEE over header+payload, final 4
little-endian bytes. One envelope per UDP datagram.
Parsers reject: unknown version, wrong `header_len`, `frame_len` exceeding
the IPv4 UDP payload cap (65,507 bytes), `num_bit_per_tone` outside
`{16, 32}`, `payload_len` inconsistent with `num_sub_carrier *
(num_bit_per_tone/8)`, trailing bytes, and bad CRC — mirroring the
MediaTek/Qualcomm parser contract exactly.
Host side: add `realtek_csi.rs` to both
`wifi-densepose-hardware` (frame + codec + simulator, mirroring
`mediatek_csi.rs`/`qualcomm_csi.rs`) and `wifi-densepose-sensing-server`
(bounded `RealtekCsiSnapshot`, mirroring `QualcommCsiSnapshot`), wired into
the same UDP listener loop and `/api/v1/csi/realtek/latest` route as the
existing vendor sources. The initial Mac compatibility port keeps Realtek CSI
as a separate bounded diagnostic stream. Fresh ESP32 frames remain the primary
sensing source. This change does not add `SourceKind::RealtekCsi` to a signed
ingest contract; that requires a separate reviewed identity-bound hardware
proof. RAC1 CRC checks corruption, not origin.
Firmware side (`example/wifi/wifi_csi` derivative, built against the
official Ameba RTOS SDK, tracked outside this repository per the existing
`CLAUDE.local.md` convention — the vendor SDK itself is never vendored into
this repo): join the board to the deployment's WiFi AP in STA mode
(passive CSI against the real AP as peer, since only one physical board is
available in this bring-up), read the AP's BSSID at runtime after
association (never hardcode a MAC into firmware source), pack each
`RTW_EVENT_CSI_DONE` report into one `RAC1` datagram, and `sendto()` it over
UDP to the host sensing-server's existing CSI port.
## Consequences
### Positive
- CSI is real, MEASURED hardware output from day one — no simulator-first
bridge phase is needed the way MediaTek/Qualcomm required, because the
vendor SDK genuinely exposes the capability and we have the board in hand.
- One board is sufficient for a first useful signal (passive CSI against the
home AP); active and board-to-board modes are additive later without a
wire-format break (`csi_mode` already reserves the values).
- Reuses the bounded UDP parser and status-route pattern without changing
existing ESP32 sensing. Unsigned RAC1 diagnostics are not authorized as
calibrated presence, person count, pose, or vital evidence.
### Negative
- 1x1 hardware means no spatial (multi-antenna) diversity from a single
board; multi-board deployment is required for the differential/localization
use cases described in the original RTL8721Dx capability analysis.
- The 71-minute microsecond timestamp wraparound (inherited from the
firmware's `u32` field) means long unattended captures need host-side
sequence-based reordering, not raw timestamp arithmetic, across a wrap.
### Neutral
- The RAC1 envelope intentionally does not carry the SDK's raw
`csi_signature` (0xABCD) marker — our own `magic`/CRC supersede it as the
framing integrity check.
## Hardware gates
- [x] Confirm SDK source exposes `RTW_EVENT_CSI_DONE` / `rtw_event_csi_report_info` (done — `component/wifi/api/wifi_api_event.h`).
- [x] Build and flash a baseline image on real PKM8721DAF-C13-F10 hardware, confirm boot over serial (done — `feat/rtl8721dx-csi-bringup`).
- [x] Flash the CSI-streaming firmware and confirm `RAC1` datagrams parse on the host from the real board (done, 2026-09-15 — see below).
- [x] Record MEASURED throughput/latency/jitter from the real running firmware (done, 2026-09-15 — see below).
- [ ] A human walk-test across the link (amplitude perturbation vs. static baseline) is deferred — requires the operator physically present; not claimed until performed.
### MEASURED: real RAC1 capture and sustained throughput (2026-09-15)
Passive CSI (`RTW_CSI_MODE_NORMAL`, `RTW_CSI_TRIG_BEACON | RTW_CSI_TRIG_DATA | RTW_CSI_TRIG_QOS_DATA`)
against the deployment AP. A single `RAC1` datagram was first
cross-checked field-by-field against the firmware's own serial log of the
same report — every byte matched: magic `0x31434152`, RSSI byte exact match,
channel/bandwidth/protocol/subcarrier-count exact match, 157-byte total frame
(49-byte header + 104-byte payload + 4-byte CRC, exactly as specified above).
`RTW_CSI_MODE_RX_RESP` (estimate CSI from ACKs to the STA's own transmissions)
never fired `RTW_EVENT_CSI_DONE` across two full test runs with confirmed
real traffic (not a hang) — this mode does not work for this hardware/AP
combination and `RTW_CSI_MODE_NORMAL` with beacon+data triggers is the
working configuration.
**An earlier note here claimed `NORMAL` mode fires only once per CSI enable
— that was wrong**, caused by a debug log line gated to print only every
50th frame (`frame_count % 50 == 1`), which was misread as "only one report
total." A follow-up capture with a UDP listener running concurrently with a
board reset measured **277 real RAC1 datagrams in 40 seconds (43,489 bytes)**,
with `csi_sequence` climbing continuously and monotonically in the firmware's
own serial log across six separate 50-frame log checkpoints in one 45s
session — CSI reporting is continuous, roughly 7-12 fps, not sparse.
### MEASURED: throughput optimization pass (2026-09-15)
Formalized as a repeatable tool: `scripts/benchmark-rtl8721dx-csi.py` (listens
on the sensing-server's CSI UDP port, reports fps/throughput/RSSI/inter-frame
interval/sequence-gap stats from real received `RAC1` datagrams).
**Baseline** (`trig_period=200` [64ms], `trig_frame_ctrl=0`,
`CSI_REPORT_BUF_NUM=1`), 30s capture:
| Metric | Value |
|---|---|
| RAC1 fps | 14.67 |
| Throughput | 2,302.7 bytes/s |
| Mean inter-frame interval | 68.3 ms |
| RSSI | mean -34.4 dBm |
`trig_period` (units of 320µs) was hypothesized as the rate-limiting
throttle — the observed 68.3ms mean interval tracks the 64ms configured
period almost exactly. Confirmed by tuning:
1. `trig_period`: 200 → 15 (the SDK doc's stated minimum recommended value,
~4.8ms)
2. `trig_frame_ctrl`: 0 → `RTW_CSI_TRIG_ACK` (second trigger source: every
link-layer ACK the AP sends for the heartbeat's own outgoing traffic, on
top of the existing beacon trigger)
3. `CSI_REPORT_BUF_NUM`: 1 → 4 (a "lack of csi buf!" drop was observed even
at the slower baseline rate; more headroom needed at a higher rate)
**Tuned**, 30s capture:
| Metric | Value | vs. baseline |
|---|---|---|
| RAC1 fps | 66.40 | **4.5x** |
| Throughput | 10,424.8 bytes/s | **4.5x** |
| Mean inter-frame interval | 14.9 ms | 4.6x lower |
| Sequence gaps | 78 / ~2036 (~3.8%) | new (0 observed at the slower baseline rate) |
| RSSI | mean -31.1 dBm | (different session/position, not a controlled variable here) |
The `trig_period` hypothesis is confirmed as the primary lever. The ~3.8%
frame-loss rate at 66 fps is a reasonable place to stop for now — it is
UDP-over-WiFi loss, not a firmware defect, and is small enough not to block
downstream sensing use; revisit only if a use case needs stronger delivery
guarantees (sequence-gap-triggered re-request, TCP fallback, etc.).
## Links
- [ADR-266](ADR-266-mediatek-filogic-csi-platform.md) / [ADR-267](ADR-267-mediatek-mimo-csi-wire-protocol.md) — sibling vendor CSI ADR pair (simulator-first precedent)
- [ADR-268](ADR-268-qualcomm-atheros-csi-platform.md) / [ADR-269](ADR-269-qualcomm-csi-wire-protocol.md) — sibling vendor CSI ADR pair
- [ADR-263](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md) / [ADR-264](ADR-264-rtl8720f-radar-wire-protocol.md) — existing Realtek (RTL8720F radar) path; `SourceKind::Realtek` owner
- `CLAUDE.local.md` — RTL8721Dx toolchain setup, board/COM port, first bring-up log
@@ -0,0 +1,193 @@
# ADR-345: Per-link CSI attribution and node-to-node ranging
- **Status**: Proposed — measurement layer implemented and validated on hardware; localization not built
- **Date**: 2026-08-28
- **Deciders**: Joe
- **Tags**: csi, wire-format, esp32-c6, localization, adr-018, adr-060, adr-110
## Context
Every attempt at device-free position estimation in this repository has failed
against the same wall, and the wall has now been measured twice from different
directions.
**Phase is unusable.** The bistatic tier needed a signed per-link radial
velocity, which requires the common-mode component of CSI phase. Measured
2026-08-28 across all three ESP32-C6 nodes over ~40,000 frames, the
frame-to-frame change in common-mode phase is statistically indistinguishable
from uniform random — resultant length `R` = 0.011–0.032 against a 0.000
reference, standard deviation 1.78–1.81 against `pi/sqrt(3)` = 1.814, and no
lag-1 autocorrelation. A second, unwrap-free estimator agreed. The mechanism is
packet-detection timing quantization: common-mode phase is `-2*pi*f_c*tau`, and
one ADC sample of jitter at 20 MHz is `2*pi * 2.412e9 * 50e-9` ~= 758 rad, about
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
is default-off as a result (`bistatic_tier_enabled`).
**Amplitude works but had no parallax.** The three AP -> node links leave a
single distant transmitter within a 36.5 degree fan and converge on the node
cluster. Bearings computed from `room_config.json`: 198.5, 221.2, 235.0 degrees.
Scalar per-node energy weighting was closed with a clean negative result on
2026-08-27 — magnitude responds to proximity, direction does not.
**The receivers were hearing far more than we recorded.** A node in promiscuous
MGMT+DATA mode with no `filter_mac` produces CSI for every transmitter on the
channel. Measured on node 2: the AP accounted for only **34%** of frames; the
two peer nodes supplied 39% and 27%. Those peer frames are valid measurements
of *node-to-node* channels — but ADR-018's wire format carries no transmitter
address, so the sink could not attribute them. They interleaved into one
`frame_history` with mixed geometry, and the only available remedy
(`filter_mac`, ADR-060) fixes the mixing by discarding ~75% of the data.
The node-to-node links between the same three boards cross the room from three
sides at **108.7 degrees** of angular spread (bearings 0.0, 73.2, 108.7) versus
36.5 for the AP links — roughly 3x the parallax, with both endpoints known,
fixed, and under our control rather than wherever the router happens to live.
Link count also grows as `N*(N-1)/2`: 3 nodes give 3 links, 6 give 15, 13 give
78.
## Decision
Carry the transmitter MAC on the CSI wire and model CSI per **link**
(receiver, transmitter) rather than per **node**, as a layer alongside the
existing per-node model.
### 1. Wire format v2
`CSI_MAGIC_V2 = 0xC5110008`, header 26 bytes: the v1 header unchanged through
byte 19, then the transmitter address (`info->mac`, 802.11 addr2) at bytes
20..25, with I/Q from byte 26.
`0xC5110002`..`0xC5110007` were already claimed by the vitals, feature and other
edge packets, so v2 takes the next free value rather than an adjacent one — see
`issue_928_magic_collision_tests` for why that matters. The parser accepts both
magics, so a mixed fleet keeps working and v1 nodes simply report no
transmitter.
### 2. Per-link state (`links.rs`)
`LinkTable` keyed by `LinkId { rx_node, tx_mac }`, each link holding its own
amplitude history, RSSI EMA, learned resting baseline and motion metric.
The metric is the mean over subcarriers of the temporal standard deviation of
**AGC-normalized** amplitude. Removing each frame's own mean across subcarriers
is essential rather than cosmetic: the ESP32 applies automatic gain control per
packet, so absolute amplitude jumps between packets for reasons unrelated to the
channel, and any statistic built on the raw level measures the radio's gain
control instead of the room.
Amplitude only, deliberately — nothing here can depend on phase coherence.
### 3. Beacon rate
`BEACON_PERIOD_MS` 100 -> 20. At 10 Hz a peer samples a node-to-node link with a
5 Hz Nyquist while motion Doppler lives at 1–20 Hz; it aliases before it starts.
20 ms matches both the receiver's own `CSI_MIN_PROCESS_INTERVAL_US` ceiling and
the gateway self-ping already used for the AP link.
### 4. `GET /api/v1/links` and a live UI panel
The link table was initially reachable only through a 30-second log line, which
is the wrong medium for a quantity whose value is watching it respond while you
walk across the room. `GET /api/v1/links` returns one row per link — `motion`,
`raw_motion`, `rssi_dbm`, `frames`, `kind` — and `LinkMeshPanel` renders them in
the Sensing tab at 1 Hz. `raw_motion` is shown beside `motion` deliberately: a
link whose baseline has not settled reports a healthy raw value and a near-zero
motion, and with only the one number that is indistinguishable from a dead link.
Node identity is **inferred, not measured**. Nothing in the system records a
node's own MAC — a node reports the addresses it hears, never its own. But a
radio does not receive its own transmissions, so given the set of receivers
currently reporting, a transmitter heard by all of them except exactly one is
that one receiver's own board; a transmitter heard by every receiver is external
infrastructure. `links::infer_transmitting_node` declines (returns `None`) with
fewer than three receivers, where "heard by all but one" and "heard by exactly
one" are the same observation, and whenever more than one receiver is missing,
which a peer out of range produces indistinguishably. The field is named
`tx_node_inferred` and the UI draws the peer id with a trailing `?`.
Checked against the known MAC map on 2026-08-28 the inference labelled all four
transmitters correctly — nodes 0, 1 and 2 by address, and the AP as external —
with no access to that map. A wrong answer here mislabels a display row; it
never reaches a metric.
## Consequences
### Validated on hardware, 2026-08-28
All three nodes on wire v2 at 20 ms produced the full mesh: **9 links** (3 AP +
6 directional node-to-node).
Reciprocity — each physical channel measured independently from both ends:
| channel | direction A | direction B | ratio |
|---|---|---|---|
| 0 <-> 1 | 2.229 | 1.930 | 1.15 |
| 0 <-> 2 | 1.353 | 1.000 | 1.35 |
| 1 <-> 2 | 1.398 | 1.486 | 1.06 |
Nothing in the implementation enforces this. Three independent radio pairs
agreeing within 6–35% is the strongest available evidence that the per-link
separation is real rather than an artifact of frame bucketing.
Link quality is also far more uniform than the AP links: node-to-node raw motion
spans 1.0–2.2 across all six, versus 3.8–17.8 for the three AP links (which
track RSSI: -67, -72, -80). Uniform link quality is desirable for tomographic
localization — no single link dominates the solution.
### Known limitations
- **Peer links are ~10x weaker.** A 16-byte ESP-NOW beacon offers far less
energy to estimate a channel response from than a full data frame. Whether
1.0–2.2 perturbation is sufficient to localize with is **unknown and untested**.
- **Three links is below threshold.** Tomographic localization needs many
crossing links; 3 will not localize regardless of quality. This ADR delivers
the measurement layer, not localization.
- **Localization is not implemented.** No position tier consumes `LinkTable`.
The live position estimate remains `doppler_centroid`.
- **Per-node path regression.** Removing `filter_mac` to admit peer frames
returned the *per-node* `frame_history` to a mixed-transmitter stream, since
it still consumes every frame regardless of source. Node 2's classification
signal is noisier as a result. The fix is to select a link to feed the
per-node path — software filtering that keeps the other links — and it is not
yet done.
### Scaling limits to settle before more nodes
- **Airtime.** Every node broadcasts, so N nodes put N/period frames per second
on the channel being sensed. 3 nodes at 20 ms is 150 pps; 13 would be 650 pps.
`BEACON_PERIOD_MS` should become an NVS setting scaled to fleet size.
- **`MAX_LINKS` is 64.** 13 nodes give 78 receiver-transmitter pairs plus the AP
plus neighbours, which overflows. Raise deliberately, with the memory implied.
- **Placement.** Links must *cross* the target area. Nodes spread through a
house give roughly one link per room, each attenuated by walls and perturbed
by anyone anywhere along it — whole-house coarse presence, not position.
Perimeter placement around a single room is the geometry that works.
## Related bugs found and fixed while implementing
- **ADR-060 MAC filtering cost ~75% of capture rate.** The 50 Hz rate gate ran
*before* the MAC filter and stamped `s_last_process_us` before the check, so a
frame from any other transmitter consumed the slot and was then discarded.
Enabling `filter_mac` dropped yield from ~42 pps to 6–13. Filtering first
restores it (measured 31–37 pps). A 6-byte `memcmp` is negligible ISR work next
to the CSI processing the gate guards.
- **The entire edge pipeline was dead on ESP32-C6.** `EDGE_MAX_SUBCARRIERS` was
128, an S3-era assumption; a C6 on an HE-capable AP delivers HE20 frames with
256 bins, and `process_frame` rejected every one. ADR-110 onboarded the C6 to
the capture path but not this one. Vitals, presence, fall detection and
per-slot counting had never run on these boards. Now target-conditional on
`CONFIG_SOC_WIFI_HE_SUPPORT`, with the four subcarrier-sized arrays hoisted to
static so peak stack is *lower* than it was at 128.
- **A target-conditional constant that failed silently.** `edge_processing.h`
had no `#include` at all, so `CONFIG_SOC_WIFI_HE_SUPPORT` was undefined and
`#if` evaluated it as 0 — selecting the wrong size with no warning. An
undefined identifier in `#if` is 0 in C. This is the same failure mode as the
bug it was fixing, one layer up.
## References
- `v2/crates/wifi-densepose-sensing-server/src/links.rs` — per-link state, 8 tests
- `v2/crates/wifi-densepose-sensing-server/src/phase_diag.rs` — the phase measurement
- `firmware/esp32-csi-node/main/csi_collector.c` — wire v2 serialization, filter ordering
- `firmware/esp32-csi-node/main/edge_processing.h` — `EDGE_MAX_SUBCARRIERS`
- ADR-018 (wire format), ADR-060 (MAC filtering), ADR-110 (C6 onboarding)
@@ -0,0 +1,134 @@
# ADR 355: Local empty room bootstrap prior
Status: Accepted
Date: 2026-09-04
## Context
Most installations are occupied when RuView first starts. An uncalibrated
signal heuristic can interpret HVAC, clock error, neighboring motion, or a
short covariance window as a person. It can also surface plausible numeric
vitals before a room model establishes occupant attribution.
One empty home is not representative training data for all homes and contains
no positive human examples. It is useful only as a recent negative prior for
the same installation. It cannot define a universal human signal model.
The field model was memory only. The server also used a relative `data/`
directory, which is unreliable for embedded application hosts.
## Decision
RuView may persist a completed empty room field model as a local bootstrap
prior only after two server controlled stages:
1. Normal calibration reaches both 1,000 accepted frames and 600 seconds.
The frame target represents twenty complete fifty frame runtime windows.
A caller may select one currently live ESP32 source. The server checks its
freshness before replacing an active bootstrap prior, binds collection to
that source, and rejects frames from other radios. When no source is
selected, the first accepted ESP32 source preserves legacy behavior.
2. Promotion observes 12 new samples spaced by at least one second. Each
sample has a four second bounded acquisition timeout. At least 10 must
resolve empty, all ticks must be fresh, and none may publish numeric vital
signs.
The stored image contains aggregate means, environmental modes, mode energies,
noise statistics, sorted scalar residual references, configuration, one source
node ID, and a server generated model ID. It excludes raw CSI, waveforms,
device addresses, room names, pose, identity, credentials, and positive human
labels. Runtime matching compares a fifty frame mean with the learned residual
distribution. Its score is empirical background conformance, not an absence
probability.
The image is bound to a SHA 256 digest of the stable installation ID, carries
an integrity digest over the exact stored payload bytes, is capped at 1 MiB,
and inherits the field model expiry.
Files with an invalid schema, dimensions, values, node set, installation
binding, digest, size, or lifetime fail closed.
Restoring an image creates a field model in `bootstrap_only` mode. It may
reduce an uncalibrated false person count, but it cannot authorize calibrated
evidence or numeric heart and breathing rates. Explicit calibration replaces
it with fresh statistics. Cancelling an unfinished capture restores a valid
prior. Administrator scoped reset removes it.
A restored image may receive one operator confirmed held out empty refinement.
The server measures 12 fresh, source bound runtime residuals at one second
spacing and requires zero stale samples and zero numeric vital samples. It
persists only the scalar residuals. The new boundary is the larger of the
existing threshold and 110 percent of the held out 95th percentile, capped at
125 percent of the prior threshold. The one use marker persists in the model,
so repeated requests cannot ratchet the threshold upward.
The status endpoint reports the learned runtime window, residual threshold,
reference count, current background conformance, and the negative only
authority boundary. A restored model never resumes collection and never
persists raw calibration frames.
The server accepts `--data-dir` or `RUVIEW_DATA_DIR`, allowing an application
host to provide a protected state directory.
## Consequences
Startup can use recent measured background structure instead of only generic
heuristics. No accuracy lift is claimed until occupied and empty held out data
from multiple installations is recorded.
The largest failure mode is overfitting to a temporarily quiet home and
suppressing a weak occupant. Controls are low authority, 24 hour expiry,
installation binding, vital abstention, explicit recalibration, and reset.
A human signal model still requires positive, consented, leakage separated
occupied sequences. Empty data is never relabeled as human training.
## Implementation
1. `wifi-densepose-signal::ruvsense::field_model` exports and validates
`FieldModelSnapshotV1`.
2. `wifi-densepose-sensing-server::bootstrap_baseline` owns schema, integrity,
storage, lifetime, and the held out gate.
3. `POST /api/v1/calibration/bootstrap/promote` measures the validation window
and stores the image only on pass.
4. `GET /api/v1/calibration/status` reports bootstrap authority and explicitly
denies calibrated evidence and numeric vital authority.
5. Numeric vitals fail closed unless an explicit fresh calibration resolves
exactly one occupant at sufficient signal quality and confidence.
6. The legacy `edge_vitals` WebSocket message applies the same publication
gate as `sensing_update`. When the bootstrap background matches, it emits
absent state, zero people, null numeric rates, and an explicit abstention
reason instead of forwarding raw edge candidates.
7. `POST /api/v1/calibration/start?source_node_id=N` selects a live source for
deterministic single link capture. A missing or stale selection fails
without disabling the active bootstrap prior.
8. `POST /api/v1/calibration/bootstrap/refine?confirmed_empty=true&source_node_id=N`
performs the bounded one time held out refinement. It requires administrator
authority when transport authentication is enabled.
## Measured installation evidence
One physical empty home run on source Node 5 collected 1,762 accepted frames
over 655 seconds and produced 35 privacy reduced reference windows. Promotion
observed 12 of 12 fresh empty samples, zero stale samples, and zero numeric
vital samples. After process restart, 12 of 12 fresh samples matched the empty
background and contained no numeric vital signs. Background conformance scores
ranged from 50.0 to 64.3 percent with a median of 54.3 percent.
This is measured negative evidence for one installation. No occupied positive
holdout was recorded, so it does not prove person recall, person counting, or
cross home accuracy improvement.
## Acceptance test
With a physically empty room, complete calibration on stable nodes, promote
the single source model, and restart the server with the same installation ID
and data directory. Status must report `binding_mode=bootstrap_only`, zero
collection frames, and false authority for calibrated evidence and numeric
vitals. Twelve fresh empty samples must contain no numeric vital signs.
Starting a new room calibration must replace the prior and begin at frame zero.
When several radios are live, selecting Node 5 must leave Nodes 3 and 7 out of
the source set for the entire capture.
After restart, a confirmed empty holdout may refine the restored boundary once.
The stored snapshot must reject a second refinement and must continue to deny
calibrated evidence and numeric vital authority.
@@ -0,0 +1,151 @@
# ADR 356: Rate truthful CSI and reliability gated room adaptation
Status: Accepted
Date: 2026-09-05
## Context
RuView mixed three different notions of time. ESP32 firmware requested a packet
rate, the server observed a delivered rate, and several server paths still used
fixed 2 Hz, 10 Hz, or 20 Hz assumptions. One feature named
`dominant_freq_hz` was not a temporal frequency at all. It was the index of the
strongest subcarrier multiplied by 0.05. A second defect compared a newly
inserted frame with itself, forcing that motion delta to zero.
These errors can move a spectral peak into the breathing or heartbeat band and
produce plausible but false values. They also make models sensitive to WiFi
traffic and processor scheduling rather than physical motion.
ESP-IDF documents that `wifi_csi_info_t.first_word_invalid` marks the first four
CSI bytes invalid because of a hardware limitation. It also documents that the
CSI receive callback runs in the WiFi task and should hand work to a lower
priority task rather than perform lengthy processing. RuView serialized the
invalid bytes unchanged and fed the original buffer to edge processing.
Recent work suggests four useful directions with different maturity:
1. OpenCSI represents each link as a dimensionless residual against its own
quiet period temporal standard deviation and exposes baseline maturity. Its
reported transfer results are limited to binary presence.
2. MORIC derives motion centered delay and Doppler views to reduce dependence
on static environment specific CSI structure.
3. Parameter efficient CSI crowd counting combines self supervised pretraining,
small room adapters, and a stateful counting machine.
4. WiFi JEPA masks complete radio links and predicts their latent embeddings,
using real and ray traced CSI for pose representation learning.
The papers report promising external results. None is measured RuView evidence,
and none authorizes deployment without leakage free occupied and empty held out
evaluation.
## Decision
### Firmware ingestion
1. When ESP-IDF marks the first CSI word invalid, zero the first four serialized
CSI bytes while retaining the fixed packet shape.
2. Set ADR-018 byte 19 bit 5 to record that sanitation occurred.
3. Feed edge DSP from the sanitized serialized payload, never the original
invalid driver buffer.
4. Keep callback work bounded to sanitation, serialization, and the existing
queue handoff. Do not add transforms to the WiFi task.
### Server time model
1. Estimate delivered CSI rate from accepted monotonic frame timestamps after
five samples. Use 20 Hz only during startup.
2. Cap the physical input rate at the firmware contract of 50 Hz so a burst or
timestamp artifact cannot retune downstream filters above their supported
range.
3. Reconfigure the vital detector only after a 20 percent rate change. Clear
temporal and smoothing state whenever its clock changes. Samples calculated
with different clocks must never be blended.
4. Compute temporal dominant frequency from the recent mean amplitude series
with demeaning, a Hann window, and bounded direct Fourier bins from 0.1 Hz to
the smaller of 4 Hz or 45 percent of sample rate.
5. Return zero for short, flat, nonfinite, or spectrally ambiguous windows.
6. Compare the current frame with the prior frame, not itself, for motion delta.
7. Export zero sample rate until the estimate is mature. Never present a startup
fallback as a measured node property.
### Model compatibility and room reliability
1. Preserve the historical fifth input feature for version 1 adaptive models at
the model boundary. Version 2 and later models consume the true temporal
frequency. This prevents an existing model from silently receiving a
different input distribution after a server update.
2. A background model is unreliable until it contains at least ten finite,
nonnegative residual references. An unreliable model cannot suppress a
runtime change and exposes no conformance score.
3. Expose background maturity, reliability, and a robust residual Z score based
on the median and median absolute deviation.
4. Keep the persisted empty home prior negative only. It cannot authorize
calibrated presence, person count, pose, or numeric vitals.
### Model roadmap
The following work remains proposal only:
1. Evaluate an OpenCSI style per link quiet residual normalization for binary
presence without replacing absolute magnitude features used for motion.
2. Evaluate delay and Doppler views after phase sanitation for activity.
3. Pretrain a frozen multilink encoder and adapt small room specific modules for
counting.
4. Evaluate link masked self supervision for pose only when at least three
spatially distinct links and synchronized positive pose labels exist.
Every candidate uses immutable manifests and splits by room, subject,
recording, and time. It is compared with the frozen baseline under identical
seeds. Counting includes confusion matrices and mean absolute error. Pose
includes PCK at 20 centimetres, MPJPE, and the mean pose baseline. A shuffled
label control must fail.
Promotion requires at least 10 percent held out lift, less than 10 milliseconds
added p95 inference latency, no more than 20 percent peak memory overhead, zero
leakage or secret findings, frozen anchor retention, and no regression in empty
rejection or occupied recall. Promotion remains an explicit maintainer action.
## Consequences
The firmware no longer preserves a known invalid prefix as sensor evidence. The
server uses an observed physical clock for spectral features and vital filters.
Existing version 1 adaptive models remain compatible. Sparse background samples
fail open to observation and fail closed for suppression.
The direct Fourier estimator is intentionally small and bounded to 128 temporal
samples. It is portable and deterministic, but it is not a replacement for a
phase sanitized delay and Doppler pipeline.
The largest remaining uncertainty is human recall. Empty home evidence can
measure false presence but cannot show whether weak occupied signals are being
suppressed. The fix path is a consented, leakage separated occupied holdout with
multiple spatial links and synchronized reference labels.
## Research basis
1. Espressif ESP-IDF WiFi CSI programming guide:
https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32/api-guides/wifi.html
2. Espressif ESP CSI reference implementation:
https://github.com/espressif/esp-csi
3. OpenCSI:
https://arxiv.org/abs/2607.26665
4. MORIC:
https://arxiv.org/abs/2506.12997
5. Parameter efficient CSI crowd counting:
https://arxiv.org/abs/2601.02203
6. WiFi JEPA:
https://arxiv.org/abs/2607.11064
## Acceptance test
Run the firmware serializer host tests and the focused hardware, signal, and
sensing server Rust suites. The deterministic serializer test must prove the
first four bytes are zero only when ESP-IDF marks them invalid and bit 5 must
round trip through the Rust parser. The temporal estimator must recover a
1.2 Hz synthetic signal within 0.21 Hz, abstain on short and flat inputs, and
remain below 10 milliseconds p95 on the target Mac. Existing version 1 model
input must retain the legacy proxy. A background model with nine references
must never report a match or suppress a runtime change. Physical firmware is
not qualified until an S3 and C6 boot and stream the sanitized flag without
watchdog, memory, or transport regressions during a five minute burn in.
@@ -0,0 +1,143 @@
# ADR 357: Raw CSI calibration sample integrity
Status: Accepted
Date: 2026-09-06
## Context
The empty room field model requires 1,000 accepted observations over at least
600 seconds. The sensing server previously invoked calibration from both the
raw CSI path and the one second edge vital path. Edge vital packets did not add
a CSI observation, so that path repeatedly fed the existing history tail. A
counter could therefore satisfy the frame gate without representing 1,000
independent CSI packets.
Node liveness also combined edge vital and CSI traffic. A source emitting only
edge vital summaries could appear live enough to start calibration even though
it had no fresh raw CSI. Finally, cloning the complete per node history before
each feed copied as much as 204,800 bytes for a 100 frame, 256 subcarrier
window even though calibration consumes only the current observation.
The earlier single installation result recorded in ADR 355 predates this
admission boundary. Its frame count is not accepted as evidence of independent
CSI coverage. It remains useful only as diagnosis of the failure mode and must
not be promoted as a production bootstrap prior.
## Decision
1. Only a packet on the frozen, model specific CSI grid may advance field
calibration. Edge vital packets update their own health and presentation
state but never feed the field model. The independent live inference grid
remains unchanged.
2. Each calibration session stores the last admitted wrapping sequence for
every contributing node. Duplicate sequences are ignored. Normal forward
progress and the `u32::MAX` to zero wrap are accepted.
3. A packet one through sixteen sequence values behind the current high water mark
is bounded UDP reordering. It is recorded and dropped without feeding the
model or moving the high water mark. A backward depth above sixteen, or an
ambiguous half range sequence, latches that node as faulted for the remainder
of the session. Status exposes the policy, reorder window, per node reorder
count, maximum observed depth, and `sequence_fault_node_ids`. Finalization
returns `calibration_sequence_discontinuity` after a fault. The operator must
cancel and restart the empty room capture.
4. A requested source may start calibration only after a header only, bounded
observation window identifies one stable grid. The selected grid needs at
least ten seconds of evidence, a measured rate of at least 2 Hz, a latest
sample newer than five seconds, and no observed gap of five seconds or more.
Calibration status reports the immutable grid, selection evidence, current
grid age, and `last_sequence_by_node` without exposing CSI values.
5. Start, cancel, reset, and process initialization clear sequence admission
state. A failed feed does not advance the sequence cursor, so the same
packet may be retried after a transient model input failure.
6. The field bridge receives the current amplitude slice rather than a cloned
history. A separate bounded runtime history retains only that same frozen
grid, so the model is never trained on one symbol layout and evaluated on a
different layout. Canonical grid normalization remains unchanged.
7. The exact source grid is stored inside the digest protected bootstrap v2
payload. A restored model rebuilds only its matching runtime history. An
unbound legacy image cannot become an active startup prior.
8. Successful finalization clears the selected grid runtime history. Bootstrap
promotion waits up to 35 seconds for a complete fresh runtime window before
scoring twelve additional samples. This covers a 50 frame window at the 2 Hz
admission floor plus ten seconds of bounded jitter. Calibration observations
therefore cannot enter the held out decision window.
9. Stop and promotion require the frozen grid to remain fresh and reject any
latched sequence fault immediately before persistence. Bootstrap v2 accepts
exactly one source node because the current runtime supports one grid bound
field model.
10. The legacy `--calibrate` startup switch fails closed before opening ports.
Grid evidence does not exist at process start, so operators start the server,
wait ten seconds, then call the explicit single source calibration API.
## Consequences
Calibration frame count now means unique, forward raw CSI observations within
one uninterrupted sequence epoch. A vitals only node cannot produce a
bootstrap model. Late packets never become calibration evidence.
One physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563,
4,119,564, and 4,119,565 in one receive batch. A second independent 90 second
capture observed bounded late packets at depths 9, 10, and 11. The window of
sixteen covers the measured maximum with five positions of margin. At the
firmware acceptance ceiling of 50 Hz, the window represents no more than 320
milliseconds. Late packets are always dropped. Larger regressions remain
failed closed.
The same 90 second capture measured three interleaved HT grids on Node 5. The
64 subcarrier stream delivered 436 frames at 4.84 Hz with a 1.56 second maximum
gap. The 128 stream delivered 100 frames at 1.11 Hz with a 5.05 second maximum
gap. The 192 stream delivered 49 frames at 0.54 Hz with an 11.53 second maximum
gap. Only the 64 grid exceeded the 2 Hz admission floor and stayed inside the
five second freshness boundary, so the final field model binds to that grid.
The current wire format still has no authenticated boot epoch or stable device
identity. A restart very early in a session or a same endpoint adversary cannot
be distinguished cryptographically. A future ADR 018 revision must carry an
authenticated device identifier and a random per boot identifier before the
server claims exact restart or identity collision detection.
No new raw CSI is persisted. Sequence cursors and fault markers remain bounded
in memory to active calibration sources. The bootstrap schema advances to v2
to protect the grid identity while its negative only authority remains
unchanged.
## Implementation
1. `NodeState` records header only recent grid evidence and a separate bounded
field model history.
2. `AppStateInner::maybe_feed_calibration_frame` owns source binding, sequence
ordering, fault latching, model feed, and cursor commit.
3. `field_bridge::maybe_feed_calibration` accepts exactly one current amplitude
slice and rejects an empty observation.
4. Calibration start, status, stop, cancel, reset, promotion, refinement, and
restart enforce the frozen source and grid contract.
5. `bootstrap_baseline` persists the source grid inside the verified v2 image.
6. Finalization clears training history, and promotion blocks until the model
has a complete fresh scoring window plus twelve separately spaced samples.
7. Startup calibration without measured grid evidence exits with a typed
operator instruction instead of creating a model that can never receive a
frame.
## Acceptance test
Unit tests must prove stable grid selection, rate and gap rejection, first
packet acceptance, forward progress, duplicate rejection, source and grid
isolation, maximum counter wrap, empty input retry, measured depth eleven
reordering without a model feed, depth sixteen acceptance, depth seventeen
latching, half range rejection, session clear, receipt visibility, protected
grid persistence, and typed stop refusal.
Tests must also prove startup flag refusal, missing and stale grid refusal,
complete holdout isolation, and sequence fault rejection during holdout.
On physical hardware, begin a Node 5 empty room capture and observe a fresh
sixty second interval. Calibration frame growth must match unique 64 subcarrier
Node 5 sequences while the independent global inference grid remains unchanged.
Edge vital packets and other grids must produce zero growth. The bound grid
must remain active, sequence faults must remain empty, and numeric vital output
must remain absent. Any bounded late packet must appear in the reorder receipts
while leaving the model count unchanged. Complete both the 1,000 frame and 600
second gates, then run the twelve sample server controlled bootstrap holdout.
Restart and prove that only the persisted grid rebuilds the runtime history.
Do not claim human detection improvement until a separate consented occupied
holdout demonstrates retained recall.
@@ -0,0 +1,110 @@
# ADR-361: Point the whole fleet at one transmitter, to remove selection ambiguity
**Status:** Proposed — built, deployed fleet-wide, A-B-A measured. **Hypothesis CONFIRMED, adoption NOT justified.** The pairing *fraction* rises sharply; paired observations per second fall.
**Date:** 2026-09-09
**Follows:** ADR-358 (falsified), ADR-359 (mechanism confirmed, not adopted), ADR-360 (falsified).
## The hypothesis, recorded long before it could be tested
From `docs/BACKLOG.md`, "Next: the actual pairing constraint":
> Two nodes can therefore fire in the same aligned window and still pick frames
> from *different* transmitters, which can never pair under the
> `(tx_mac, rx_seq)` key. Cheapest test and likely fix: **point the whole fleet
> at one strong transmitter with the existing ADR-060 MAC filter.**
It had never been run, and the reason was mechanical rather than scientific:
**`filter_mac` was writable only by `provision.py` over USB.** It is a 6-byte
blob, absent from the `cfg_key_t` scalar table the config API validates
against, so the test would have required nine physical visits. Exposing it over
the network (see the firmware change accompanying this ADR) reduced the whole
experiment to two config pushes.
## What was measured
The associated AP was chosen on measurement, not assumption. Five transmitters
reach all nine receivers; only one carries a usable rate:
```
transmitter n_rx total_fps mean_rssi
8c:30:66:86:a4:21 9 109.81 -66.6 <- the AP, 26x the next
96:30:66:86:a4:21 9 4.24 -65.9
08:36:c9:71:ff:52 9 0.18 -75.9
64:16:66:cb:17:07 9 0.08 -81.7
10:a5:62:24:bb:30 9 0.06 -78.2
```
A-B-A, ten 30 s windows per arm. `/api/v1/fusion` counters are cumulative since
sink start, so they are **differenced over each window** and never divided as
lifetime totals — the trap already recorded for `early_drop`/`csi_fps_samples`.
The filter applied live on all nine nodes with **no restart**, so no reboot
confounds any arm.
```
arm n paired_fraction tx/s paired/s
A1 control 10 0.5668 +/- 0.0107 73.0 41.4
B filtered 10 0.7287 +/- 0.0352 33.9 24.7
A2 control 10 0.5836 +/- 0.0120 81.5 47.6
```
**Pairing fraction +0.1535 against the pooled control (+26.7% relative),
Welch t = 13.26, df = 10.5, p < 0.0001.** Reversible: A2 returned to control,
+0.0168 against A1.
Per-node confirmation that the filter did what it claims, taken on node 3 while
eight nodes were still unfiltered:
```
node 3 (filtered) AP fps 13.56 non-AP fps 0.28 non-AP share 2.0%
node 8 (control) AP fps 12.18 non-AP fps 5.66 non-AP share 31.7%
```
The AP rate is untouched; peer traffic falls to residue.
## The hypothesis is confirmed. The fix is not.
Transmitter ambiguity **was** suppressing pairing, and removing it lifts the
fraction by a quarter. That settles a question that had been open since
2026-09-07 and had resisted three other experiments.
But the backlog called this the "likely fix", and that half does not survive.
It predicted yield would fall to ~13 fps and that "the frames that remain are
pairable". Both are true, and neither is sufficient:
- **tx/s fell 56%** (77.3 -> 33.9)
- **paired observations per second fell 44%** (44.4 -> 24.7)
A higher fraction of a much smaller pie. The quantity downstream sensing
actually consumes is paired observations per second, and that went **down**.
Fraction was the wrong figure of merit, and choosing it is what made this look
like a fix rather than a diagnosis.
## Decision
**Do not adopt. Keep the branch unmerged.** Production stays unfiltered,
`gate_mode=2`, `gate_seq_period=8`.
`filter_mac` **is** kept in the config API. It costs nothing when unset, it is
now the cheapest available probe for "is this node's pairing transmitter-
limited?", and the ADR-060 filter had been effectively unreachable for the
fleet's whole life.
## What this opens
A **partial** filter is the obvious next question and is NOT what was tested
here. Restricting to a handful of widely-heard transmitters rather than exactly
one would trade less yield for some of the gain, and there is no reason to think
the optimum is at either extreme. The five 9-receiver transmitters above are the
natural candidate set, though four of them carry almost no rate.
That is a new experiment with a different independent variable, not a variant
of this one. It should be sized on **paired observations per second**, which is
the lesson this ADR paid for.
## Honest scope
Software-only. One house, one AP, nine nodes, ~15 minutes per arm, at 05:00
local with the house quiet. The effect is far larger than the within-arm
scatter and it reversed cleanly, but it has not been repeated across times of
day, and pairing is known to move with the transmitter population (see
"Transmitter population instability", 2026-09-02).
@@ -0,0 +1,69 @@
# ADR-362: Reading the on-node log is on-demand, never streamed
**Status:** Accepted
**Date:** 2026-09-09
**Follows:** the on-node log engine (persistent 128 B-record ring + coredump-to-flash)
## Context
The firmware now keeps a persistent log on the node's `storage` volume and can
write a coredump to flash. Nothing reads either one. The log is a new concept
in this tree, so there is no existing consumer to extend and the access
pattern is genuinely open.
Two shapes were considered:
1. **Ingest** — the server polls every node on a schedule, drains new records,
and keeps its own copy that the UI reads.
2. **On-demand** — the server fetches a node's log only when a human asks for
it, and keeps nothing.
## Decision
**On-demand only. No streaming, no periodic polling, no server-side copy, no
alerting.**
The log is pulled after a board hangs or crashes, or while troubleshooting a
specific node. That is the whole access pattern.
## Why
- **The persistence already lives on the node.** The ring holds ~208 days at
128 B per 300 s, and surviving a power cycle is the entire reason the module
exists — a power cycle is how a wedged node gets recovered. A second copy on
the server duplicates a durability property that is already satisfied.
- **Airtime is this fleet's binding constraint, and it is the thing being
diagnosed.** Two nodes uplinking at 1 Mbps were measured consuming 91% of
fleet airtime. A periodic log drain across nine nodes would spend exactly the
resource whose exhaustion is the most common thing you would open the log to
investigate. An instrument must not perturb what it measures.
- **Coredumps are 64 KB.** They are only ever worth moving when something has
actually crashed.
- **The use is diagnostic, not observational.** Nobody watches nine logs. You
open one node's history when that node misbehaves.
## What this implies gets built
- A server route that proxies to the node on request — `GET
/api/v1/nodes/{id}/log`, and the same for `/coredump` — authenticating with
the OTA PSK the firmware endpoints already sit behind.
- A page under the existing Tools menu to view one node's records.
- **No** scheduler, **no** new storage, **no** retention policy on the server.
## Open question, deliberately not settled here
Whether the server decodes the 128 B records into JSON, or proxies raw bytes
and lets the decoder at the edge (browser, or `node_log_read.py`) interpret
them.
Decoding server-side means the record layout is defined in two places — C and
Rust — and can drift silently. The pilot already paid for a version of this:
a field whose sentinel the host had to learn to render as `n/a`, where a
plausible wrong value would have been worse than an obvious gap. That argues
for the raw proxy and a single definition of the format.
## Non-goals
Real-time monitoring, log streaming, fleet-wide log aggregation, and alerting
on log contents are all explicitly out of scope. If a future need genuinely
requires one of them, it should revisit this ADR rather than grow into it.
@@ -0,0 +1,93 @@
# ADR 363: Native macOS USB node onboarding
## Status
Accepted and physically qualified with one attached ESP32 S3 and one attached
ESP32 C6 through an unsigned Mac Catalyst development build. Signed
distribution remains a release gate.
## Context
RuView operators currently provision ESP32 nodes with command line tooling.
The macOS app can list server accepted node identities, but it cannot establish
that an attached serial device is RuView firmware, assign a collision free node
identity, preserve its WiFi configuration, or prove that it joined the sensing
server. A UI that merely stores a friendly name would create false success.
Generic shell access from the app would also expand authority beyond the task.
Serial logs can contain operational data, and WiFi credentials must not enter
application diagnostics or provisioning receipts.
## Decision
1. Firmware 0.8.12 provides a bounded line protocol over the physical USB serial
channel. A discovery request carries a random 128 bit nonce. The device
response binds that nonce to chip family, firmware version, current node
identity, private server target, configuration state, and a pseudonymous
64 bit device digest derived from the base MAC under a RuView domain.
2. A configuration request is accepted only for the most recently issued
nonce within 60 seconds. The nonce is single use. Node identity is limited
to 1 through 255, the target is limited to RFC 1918 IPv4, and the target port
is limited to 1 through 65535.
3. Existing WiFi configuration can be preserved. New credentials use strict
base64 framing, bounded lengths, and control character rejection. Firmware
commits NVS before acknowledging and rebooting. Credentials are never
returned, logged, or included in the receipt.
4. The macOS client uses a native serial bridge. It allowlists exact character
device prefixes, configures the port directly, and does not invoke a shell,
Python, or an external process. It rechecks the device digest immediately
before configuration to reduce port substitution risk.
5. The app suggests the lowest unused server node identity and the Mac private
LAN address. Success is shown only after the configured node appears in the
server inventory as active with evidence no older than three seconds.
6. Old firmware is reported as requiring an update. The app must not claim it
provisioned a device based only on generic RuView serial logs.
7. Automatic firmware flashing is outside this decision. Flashing requires an
exact chip and port check, a private backup, a matching image, and captured
boot evidence. Release publication remains a separate maintainer action.
8. The device digest uses the PSA Crypto SHA 256 interface on ESP IDF 5.4 and
6.0. This keeps the stable domain separated identity while avoiding the
legacy Mbed TLS hash API removed by ESP IDF 6.
9. ESP32 C6 mmWave UART defaults use GPIO 4 and GPIO 5. Startup rejects any
configured mmWave pin pair that overlaps the active console RX or TX pins.
This preserves the onboarding input path after a failed mmWave probe.
## Security and privacy consequences
The app gains Mac App Sandbox USB and serial device access, but not general file
or shell authority. The protocol does not authenticate a device with a hardware
root of trust; the digest is stable identification, not a cryptographic device
certificate. Physical USB possession is therefore the trust boundary for this
version. An authenticated fleet identity remains ADR 305 scope.
The largest failure mode is a successful NVS write followed by failure to join
the LAN. The user receives an explicit bounded verification failure instead of
a success screen, while the board retains its committed settings and can be
rescanned over USB.
## Acceptance test
Connect an ESP32 with firmware 0.8.12 to a Mac that is already receiving RuView
nodes. Use only the macOS Add Sensor workflow to preserve WiFi, assign the
lowest unused identity, and set the Mac private address. Pass when the UI
reports the expected chip and firmware, the reassigned node remains fresh for
five minutes, the stale collided identity expires, app restart preserves the
profile, no server health, send, watchdog, panic, or reboot evidence appears,
and no credential appears in app logs or the provisioning receipt.
The 2026-08-31 physical run passed on an ESP32 S3. The measured witness is in
`docs/validation/2026-08-31-macos-usb-node-onboarding.md`.
The 2026-09-11 physical run passed on an ESP32 C6 over
`cu.usbserial-3120`. The Mac app flashed the image, received the nonce bound
hello receipt, preserved node identity 3, committed the server route, and
verified fresh server evidence at minus 34 dBm. The run also exposed and fixed
the GPIO 17 console RX conflict described above.
+4
View File
@@ -92,10 +92,13 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed |
| [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed |
| [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed |
| [ADR-345](ADR-345-per-link-csi-and-node-to-node-ranging.md) | Per-link CSI attribution and node-to-node ranging — why phase-based position fails on this hardware (measured), and the coordination-free `(tx_mac, rx_seq)` join key that does not need it | Proposed — measurement layer implemented and validated on hardware; localization not built |
| [ADR-348](ADR-348-independent-rust-multivariate-forecasting.md) | Independent Rust multivariate forecasting for RuView | Proposed |
| [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed |
| [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed |
| [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted |
| [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted |
| [ADR-356](ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md) | Rate truthful CSI and reliability gated room adaptation | Accepted |
### Platform and UI
@@ -112,6 +115,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) |
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) |
| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) |
| [ADR-363](ADR-363-native-macos-usb-node-onboarding.md) | Native macOS USB node onboarding with nonce bound discovery and fresh server verification | Accepted and physically qualified on one ESP32 S3; C6 and signed distribution pending |
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
+165
View File
@@ -0,0 +1,165 @@
# Which CSI frames a node keeps decides whether cross-node fusion works
A measured comparison of three frame-selection gates on a nine-node ESP32-C6
fleet, and a method for repeating it in your own environment.
**This is one house.** Nine boards, one floor plan, one RF environment, one
transmitter population. The numbers below are what we measured here; the part
worth reusing is the method, not our figures. `run_arms.py` is included so you
can measure yours.
---
## The problem
Cross-node fusion pairs frames by transmission identity: two nodes are fusing
usefully only when they hold measurements of *the same packet in the air*. The
metric is `paired_fraction` — of the transmissions the fleet reported, what
share were captured by two or more nodes.
Every node also rate-limits which CSI callbacks it keeps, because processing
every callback at 100–500 Hz destabilises the WiFi ISR. **How that limiting is
done turns out to matter far more than we expected**, because it decides
whether two nodes keep the *same* frames or merely the *same number* of frames.
Three mechanisms, all valid rate limits:
| gate | rule |
|---|---|
| `elapsed` | at least 20 ms since **this node's** last accepted frame |
| `mesh` | first frame heard in each shared 20 ms window of a mesh-aligned clock |
| `seq` | keep frames where the 802.11 `rx_seq` is divisible by N |
`elapsed` gives every node an independent phase. `mesh` makes nodes agree on
the *window*. `seq` makes them agree on the *frame*, using a number the
transmitter assigns — so it needs no clock, no leader and no synchronisation.
## Method
Four arms — the three gates plus `mesh`+`seq` stacked — run as a 2x2 factorial.
Three deliberate choices, each of which we got wrong first:
**Whole fleet per arm, never a subset.** An earlier attempt compared four gated
nodes against four ungated ones. Nodes differ enormously in how much traffic
they hear, so that comparison could not be read at all.
**Verify the gate is actually running.** Each switch is read back from every
node before sampling. "Config was stored" is not "gate is live", and an earlier
run could not distinguish a null result from a change that never applied.
**Frame rate is NOT a valid check that a gate is working.** A bucket gate needs
only one survivor per window, so at ~14 candidate frames per bucket even a
perfect 1-in-4 gate moves frame rate about 2%. Use instead
R_window = (delta early_drop + delta accepted) / delta accepted
computed from **differenced** counters. Never divide lifetime totals: they
carry a large post-boot burst. One node read R = 1126 moments after a reboot,
26.5 an hour later, and 19.2 windowed.
**Establish the noise floor first.** A null run — three hours, no changes —
gave drift of sd **0.45 pp** across 20-minute windows, about 2x the ±0.2 pp
binomial counting error. Without that number none of the results below can be
called significant.
Arms are 20 minutes in Latin-square blocks (`U M S MS / S MS M U / MS U S M`),
so slow drift lands on every arm equally rather than on whichever ran first.
## Results
Three blocks, twelve arms, every arm verified live on all nine nodes.
| arm | block 0 | block 1 | block 2 | mean |
|---|---|---|---|---|
| `elapsed` | 0.2881 | 0.2409 | 0.2012 | **0.2434** |
| `mesh` | 0.2692 | 0.2580 | 0.2167 | **0.2480** |
| `seq` (period 8) | 0.5819 | 0.5573 | 0.5422 | **0.5605** |
| `mesh`+`seq` | 0.5639 | 0.5623 | 0.5093 | 0.5452 |
**`seq` roughly doubles pairing: +31.7 pp, 2.30x.** That gap is ~70x the
measured drift floor and reproduces in all three blocks; within-block contrasts
sit at +0.153 to +0.175 every time.
**Mesh-time alignment bought essentially nothing here: +0.5 pp**, about one
standard deviation of the noise floor. We built that gate and expected it to be
the answer; it is indistinguishable from doing nothing. Stacking it on top of
`seq` is marginally *worse* than `seq` alone.
### It is a trade, not a free win
At period 8 the seq arms carried ~75–95k transmissions per 20 minutes against
~120–160k ungated. You are buying pairing with raw frame rate. Absolute paired
transmissions per minute still went **up** (2629 vs 2524), because the frames
it discards are disproportionately ones only one node would have kept — but
whether that trade is right depends on what else consumes the stream.
One measurement that made the trade easier here: on-device edge processing was
**unaffected**. Frames actually processed stayed ~1900 per node per 4 minutes
in both modes, because that pipeline is separately rate-limited. The cost falls
on the uplink, not on local sensing.
### The mesh arm was partly an elapsed arm
The mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`. A
node without valid sync falls back to the elapsed gate rather than gating on a
meaningless epoch -- correct behaviour, but it weakens this comparison.
Three of the nine nodes never synchronised during these runs. For those three,
the `mesh` and `mesh`+`seq` arms silently ran the elapsed gate. So **+0.5 pp is
not a clean fleet-wide measurement of window alignment**; it is "mesh where sync
held, elapsed where it did not", and the true effect of alignment on a fully
synchronised fleet is not measured here.
We are reporting the number as observed rather than reconstructing it, because
the arms were assigned before we understood the fallback, and a post-hoc
per-node reanalysis would not be the experiment we ran.
The cause is worth naming because it is environmental, not a firmware defect.
Time sync is leader-elected and hub-and-spoke: one node broadcasts and the rest
follow. Whether every node hears that leader is a property of the building. A
larger or more divided house fragments into sync domains, and any option that
depends on shared time then degrades quietly for the nodes that fell out --
which is precisely the situation where someone would most want to trust this
measurement.
### A prediction we got wrong
Our mesh had split into two leader domains with three nodes never synchronising
at all. We predicted `seq` would win *most* on node pairs spanning that split.
Per-pair yield says otherwise:
| pair group | pairs | seq / mesh |
|---|---|---|
| same sync domain | 7 | 5.82x |
| spans the two domains | 8 | 6.24x |
| involves a never-synced node | 21 | **5.16x** |
Cross-domain is marginally best, but the never-synced nodes are the *lowest* —
the opposite of the prediction — and within-group spread (3.6x–8.1x) dwarfs
every between-group difference.
So `seq` is **not** a workaround for broken time sync. It wins roughly
uniformly, including where sync is healthy. The useful consequence is negative:
repairing our mesh split would probably not have closed the gap, and we would
have spent that effort for nothing.
## What we suggest
Not "use `seq`". **Make it selectable and measure your own environment.** The
right answer depends on your transmitter population, your node placement and
what consumes your CSI stream — none of which we can see from here.
`run_arms.py` runs the factorial unattended: it applies each arm fleet-wide,
verifies every node adopted it, samples `/api/v1/fusion`, and writes one row
per arm. Run the null pass first; without your own drift floor the contrasts
cannot be judged.
## Limits
- One environment, nine nodes. Treat the numbers as an existence proof, not a
constant.
- Only period 8 was characterised. 4 and 16 are unmeasured, so the
yield/pairing curve has a single point on it.
- `paired_fraction` measures **frame pairing**, which is a precondition for
cross-receiver work. Nothing here measures sensing accuracy, and none of it
should be read as an accuracy claim.
+36
View File
@@ -81,6 +81,42 @@ v1, with its flat loss, was barely better than random on this same test.
params, trained with InfoNCE (temperature 0.1, in-batch + temporal-far negatives), AdamW, 60 epochs.
Temporal positives within 2 s; negatives >30 s apart. Time-disjoint 80/20 split.
**EWC (catastrophic-forgetting protection):** measured forgetting rate on the 4 protected
tasks is **32.97%** — real and non-trivial, disclosed here rather than only in the raw
`training-metrics.json`. Fisher information is computed and applied (protection strength
2000), but this is not "solved forgetting" — a user fine-tuning per-room LoRA adapters
should expect some drift on earlier tasks, not none.
## 🔧 v2.0.1 — fixed the published training-metrics.json itself (2026-09-09)
Separately from the v1→v2 accuracy retraction above, an unrelated bug was found and fixed
in the **export code** (`scripts/train-ruvllm.js`) that produces `config.json` and
`training-metrics.json` for this file set (`model.safetensors`, `model-q2/q4/q8.bin`,
`presence-head.json`, `node-1/2.json`) — separate from the `csi-embed-v2*` files, which
come from a different script.
The exported `lossHistory` and `contrastive.durationMs` were being read from a disconnected
baseline pass (`ContrastiveTrainer.train()`, which computes loss but never updates the
encoder) instead of from the loop that actually performs gradient updates on the encoder
weights. Concretely: `lossHistory` published a flat, non-decreasing curve around `0.1352`
for all 20 epochs, and `durationMs` reported ~12.4s — both measuring the wrong computation.
**This did not affect `finalLoss`/`improvement`/the 82.3% held-out temporal-triplet
accuracy above** — those were already computed from the real training loop and are
unchanged by this fix. It only affected the exported diagnostic curve and duration, which
anyone auditing convergence from the published JSON would have been misled by. Re-run
against the same real capture (`data/recordings/overnight-1775217646.csi.jsonl`) with the
fix applied: `lossHistory` now shows a genuine decreasing curve (0.1037 -> 0.0654 across 20
epochs) and `durationMs` reports the real ~40s gradient-loop time. `config.json`'s
`training.steps` field (`12212300` — epochs × triplets, arithmetically consistent but read
like optimizer-step count) is now reported as separate `epochs`/`tripletUpdatesPerEpoch`
fields instead.
Repro: `node scripts/train-ruvllm.js --data data/recordings/overnight-1775217646.csi.jsonl
--output <dir> --epochs 20` in [github.com/ruvnet/RuView](https://github.com/ruvnet/RuView)
(the `aether-arena/staging/train_csi_embed.py` path cited above is for the separate
`csi-embed-v2*` files and was not touched by this fix).
### v2 files & proof
| File | Size | Use |
|------|------|-----|
+10
View File
@@ -0,0 +1,10 @@
ARM_A1_control,30,1242,2162,0.5745,72.07
ARM_A1_control,30,1199,2114,0.5672,70.47
ARM_A1_control,30,1259,2223,0.5664,74.10
ARM_A1_control,30,1221,2206,0.5535,73.53
ARM_A1_control,30,1246,2182,0.5710,72.73
ARM_A1_control,30,1295,2247,0.5763,74.90
ARM_A1_control,30,1307,2242,0.5830,74.73
ARM_A1_control,30,1232,2191,0.5623,73.03
ARM_A1_control,30,1261,2225,0.5667,74.17
ARM_A1_control,30,1152,2107,0.5467,70.23
1 ARM_A1_control 30 1242 2162 0.5745 72.07
2 ARM_A1_control 30 1199 2114 0.5672 70.47
3 ARM_A1_control 30 1259 2223 0.5664 74.10
4 ARM_A1_control 30 1221 2206 0.5535 73.53
5 ARM_A1_control 30 1246 2182 0.5710 72.73
6 ARM_A1_control 30 1295 2247 0.5763 74.90
7 ARM_A1_control 30 1307 2242 0.5830 74.73
8 ARM_A1_control 30 1232 2191 0.5623 73.03
9 ARM_A1_control 30 1261 2225 0.5667 74.17
10 ARM_A1_control 30 1152 2107 0.5467 70.23
+10
View File
@@ -0,0 +1,10 @@
ARM_A2_control,30,1352,2326,0.5813,77.53
ARM_A2_control,30,1454,2429,0.5986,80.97
ARM_A2_control,30,1474,2479,0.5946,82.63
ARM_A2_control,30,1412,2456,0.5749,81.87
ARM_A2_control,30,1387,2443,0.5677,81.43
ARM_A2_control,30,1389,2384,0.5826,79.47
ARM_A2_control,30,1399,2482,0.5637,82.73
ARM_A2_control,30,1410,2375,0.5937,79.17
ARM_A2_control,30,1509,2537,0.5948,84.57
ARM_A2_control,30,1483,2541,0.5836,84.70
1 ARM_A2_control 30 1352 2326 0.5813 77.53
2 ARM_A2_control 30 1454 2429 0.5986 80.97
3 ARM_A2_control 30 1474 2479 0.5946 82.63
4 ARM_A2_control 30 1412 2456 0.5749 81.87
5 ARM_A2_control 30 1387 2443 0.5677 81.43
6 ARM_A2_control 30 1389 2384 0.5826 79.47
7 ARM_A2_control 30 1399 2482 0.5637 82.73
8 ARM_A2_control 30 1410 2375 0.5937 79.17
9 ARM_A2_control 30 1509 2537 0.5948 84.57
10 ARM_A2_control 30 1483 2541 0.5836 84.70
+10
View File
@@ -0,0 +1,10 @@
ARM_B_filtered,30,734,1062,0.6911,35.40
ARM_B_filtered,30,700,982,0.7128,32.73
ARM_B_filtered,30,734,1002,0.7325,33.40
ARM_B_filtered,30,706,927,0.7616,30.90
ARM_B_filtered,30,730,955,0.7644,31.83
ARM_B_filtered,30,712,915,0.7781,30.50
ARM_B_filtered,30,734,971,0.7559,32.37
ARM_B_filtered,30,742,1103,0.6727,36.77
ARM_B_filtered,30,812,1139,0.7129,37.97
ARM_B_filtered,30,785,1114,0.7047,37.13
1 ARM_B_filtered 30 734 1062 0.6911 35.40
2 ARM_B_filtered 30 700 982 0.7128 32.73
3 ARM_B_filtered 30 734 1002 0.7325 33.40
4 ARM_B_filtered 30 706 927 0.7616 30.90
5 ARM_B_filtered 30 730 955 0.7644 31.83
6 ARM_B_filtered 30 712 915 0.7781 30.50
7 ARM_B_filtered 30 734 971 0.7559 32.37
8 ARM_B_filtered 30 742 1103 0.6727 36.77
9 ARM_B_filtered 30 812 1139 0.7129 37.97
10 ARM_B_filtered 30 785 1114 0.7047 37.13
@@ -0,0 +1,63 @@
# WiFlow Reproduction Protocol
Status: Proposed research protocol
Tracks issue #1786.
## Objective
Determine whether a CSI optical flow style representation improves reusable camera free motion intelligence in RuView rather than only improving a proxy flow metric.
## Required capture metadata
Every run records hardware family, firmware, antenna count and geometry, channel width, center frequency, packet rate, timestamp source, synchronization method, room identity, participant identity class, label source, calibration, packet loss, missing subcarriers, preprocessing version, model commit, accelerator, software versions, power mode, and random seeds.
## Baselines
1. current RuView CSI preprocessing plus strongest task specific sensing head
2. WiFlow style motion field representation
3. simpler temporal derivative or phase and amplitude motion baseline
All conditions use identical train, validation, and held out environment splits.
## Metrics
Primary intermediate metrics:
* endpoint error or equivalent flow error
* directional consistency
* temporal stability
* uncertainty calibration
Required downstream metrics:
* tracking quality
* occupancy or presence quality
* pose or activity metric where available
* spatial state consistency in RuField or WorldGraph
Systems metrics:
* p50 and p95 inference latency
* peak memory
* CPU, GPU, or accelerator utilization
* energy per inference where measurable
* bytes per observation and sustained bandwidth
## Stress tests
Test unseen rooms, unseen participants, changed antenna geometry, a second RF hardware family, packet loss, burst loss, missing subcarriers, lower packet rate, clock jitter, and partial calibration failure.
## Promotion gate
The representation graduates only when the same learned representation improves at least two downstream RuView tasks on held out environments, or reaches equivalent downstream quality with materially lower edge cost.
A better optical flow proxy score alone is insufficient.
## Security and privacy
Visual or LiDAR labels may be used as privileged training evidence but must not become a runtime dependency for camera free deployment claims. Dataset provenance and consent requirements remain explicit.
## Rollback
The experiment is isolated behind a representation interface. Existing RuView sensing heads remain the production fallback.
@@ -0,0 +1,60 @@
# Native macOS USB onboarding firmware witness
## Evidence class
MEASURED on 2026-08-31 with one physical ESP32 S3. This is not an ESP32 C6 or
release distribution qualification.
## Hardware and firmware
The board was identified by the flashing tool as ESP32 S3 QFN56 revision 0.2
with 2 MB embedded PSRAM and 8 MB flash. Firmware 0.8.9 was built for ESP32 S3
with the displayless DevKitC defaults. The private configuration region was
backed up before flashing. The flash operation omitted NVS, preserving the
existing WiFi configuration.
No base MAC, WiFi credential, device digest, or raw CSI is retained in this
witness.
## Results
1. The initial native USB scan failed because the ESP-IDF secondary USB
Serial/JTAG console mirrors output but does not forward input to STDIN.
2. Firmware was corrected to read the bounded onboarding protocol from the
secondary VFS while retaining STDIN for UART console boards.
3. A direct nonce challenge then returned ESP32 S3, firmware 0.8.9, configured
state, the current node identity, and the private server route.
4. The Mac app changed the collided identity from 1 to unused identity 5 while
preserving WiFi. It showed success only after receiving fresh server
evidence at minus 56 dBm.
5. A five minute monitor collected 299 samples with zero node 5 missing events,
zero node 5 stale events, zero server health failures, minus 57 to minus 50
dBm RSSI, and 216 ms maximum packet age.
6. Firmware uptime exceeded 376 seconds. Captured logs advanced from CSI
callback 10,600 through 11,000, emitted a node 5 synchronization packet, and
showed ESP NOW transmit failures equal to zero without reboot, watchdog, or
panic evidence.
7. The final channel isolation hardening image was flashed and challenged
again. A following 60 sample check had zero missing, stale, or health failure
events, a maximum packet age of 100 ms, and minus 57 to minus 50 dBm RSSI.
## Software gates
The nonce and configuration parser host test passed, the complete ESP32 S3
firmware image built under ESP-IDF 5.4, and the application image occupied
941,056 bytes with 55 percent of the smallest application partition free.
## Remaining gates
New WiFi credential entry, signed Mac distribution, and cross platform serial
bridges remain unmeasured.
## ESP32 C6 follow up
On 2026-09-11, an ESP32 C6 on `cu.usbserial-3120` completed the Mac app flash,
hello, configure, and verify sequence with firmware 0.8.12. The app preserved
the existing node identity 3 and verified fresh sensing server evidence at
minus 34 dBm. The board returned `RUVIEW_HELLO_OK_V1` within the bounded serial
exchange after the C6 mmWave defaults moved from the UART0 RX conflict on GPIO
17 to GPIO 4 and GPIO 5. This closes only the physical C6 onboarding gate. It
does not qualify new WiFi credential entry or signed distribution.
+51 -1
View File
@@ -19,6 +19,25 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
## Firmware 0.8.12: guided Mac onboarding
Firmware 0.8.12 adds the protocol used by the RuView macOS Add
Sensor workflow. A directly attached S3 or C6 can identify itself through a
nonce bound USB serial receipt, preserve its current WiFi settings, accept an
unused node ID and private LAN server address, then reboot. The Mac app does
not report success until the sensing server observes that node as fresh.
This is not a firmware flashing API. A board on 0.8.8 or earlier must receive a
matching chip build through the documented backup and flash process once. The
USB protocol rejects public target addresses and invalid identifiers. New WiFi
credentials are bounded, committed only in NVS, and never included in the
receipt or diagnostic output. See
[ADR 363](../../docs/adr/ADR-363-native-macos-usb-node-onboarding.md).
On ESP32 C6, the default optional mmWave UART uses GPIO 4 and GPIO 5. Firmware
refuses any mmWave configuration that overlaps the active console pins. This
prevents a sensor probe from taking over UART0 RX and disabling Mac onboarding.
## Firmware 0.8.8 in plain language
Release 0.8.8 makes the sensing stream more internally consistent and easier
@@ -115,6 +134,30 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
```
#### Seeed XIAO ESP32-C6 external antenna
The XIAO ESP32-C6 has a hardware RF switch between its internal ceramic
antenna and U.FL connector. Generic C6 boards do not share this wiring, so the
firmware leaves GPIO3 and GPIO14 untouched by default. For an XIAO with an
external antenna already attached while powered off, build with one of the
opt-in overlays:
```bash
# Controlled A condition: internal antenna, RF path stated in the boot log.
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-internal" \
set-target esp32c6
idf.py build
# Controlled B condition: external U.FL antenna.
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" \
set-target esp32c6
idf.py build
```
The boot log must report the intended `internal ceramic` or `external U.FL`
path before that measurement is labelled. The standard C6 build keeps the
board default and emits no antenna-path claim.
### 3. Provision WiFi credentials (no reflash needed)
```bash
@@ -257,10 +300,17 @@ Offset Size Field
12 4 Sequence number (LE u32)
16 1 RSSI (i8)
17 1 Noise floor (i8)
18 2 Reserved
18 1 PPDU type (ADR-110; zero when tagging is disabled)
19 1 Flags: bit0=40 MHz, bit2=STBC, bit4=sync valid,
bit5=invalid first CSI word zeroed by firmware
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
```
When ESP-IDF marks `first_word_invalid`, the firmware preserves packet geometry,
zeros the first four CSI bytes, and sets byte 19 bit 5. The same sanitized bytes
feed the edge DSP path. This prevents a documented hardware artifact from being
learned as motion while keeping older readers wire compatible.
### Vitals Packet (32 bytes)
```
+334
View File
@@ -0,0 +1,334 @@
# Firmware runbook — ESP32-C6 CSI node
Operational procedure. `README.md` explains the project; this says exactly what
to run, in what order, and what to check. Follow it top to bottom.
If you are an agent: **read this file before touching firmware.** Everything
below was learned by getting it wrong at least once.
---
## 1. Build
**One command. Copy it.** From the **repository root**, not this directory:
```bash
MSYS_NO_PATHCONV=1 docker run --rm \
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
espressif/idf:v5.4 bash -c \
"cat sdkconfig.defaults sdkconfig.defaults.16mb sdkconfig.defaults.esp32c6 \
> sdkconfig.defaults.build && \
SDKCONFIG_DEFAULTS='sdkconfig.defaults.build' idf.py set-target esp32c6 && \
idf.py build"
```
Takes ~3 minutes cold, well under a minute incremental.
### The three defaults files are NOT all automatic
IDF picks up `sdkconfig.defaults` and `sdkconfig.defaults.<target>` on its own.
It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where
`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives.
Build without it and you get an image that is correct in every visible respect
and **cannot roll back a bad OTA** — while `ota_rollback_boot_check()` sits in
the app expecting the capability. Silent, and only discoverable by pushing a
deliberately bad image.
That is why the command above concatenates all three explicitly rather than
relying on discovery.
### Never `rm -rf sdkconfig` without a backup
The README's headline example opens with `rm -rf build sdkconfig`. `sdkconfig`
is **untracked and gitignored**. `partitions_16mb.csv` was lost to exactly this,
and `CLAUDE.md` warns about it *even when a README documents it*.
```bash
cp sdkconfig "sdkconfig.backup-$(date +%Y%m%d-%H%M%S)" # then regenerate
```
A stale `sdkconfig` is a real hazard, not a hypothetical: one found on
2026-09-03 said `FLASHSIZE=4MB`, `partitions_4mb.csv`,
`DYNAMIC_TX_BUFFER_NUM=64` — against a fleet running 16MB and 128. Anything
built from it would silently not be the configuration under test.
### Verify before you flash anything
```bash
grep -E "^CONFIG_(IDF_TARGET|ESPTOOLPY_FLASHSIZE|PARTITION_TABLE_CUSTOM_FILENAME|\
ESP_WIFI_DYNAMIC_TX_BUFFER_NUM|BOOTLOADER_APP_ROLLBACK_ENABLE)=" sdkconfig
```
All five must read:
| key | expected |
|---|---|
| `CONFIG_IDF_TARGET` | `"esp32c6"` |
| `CONFIG_ESPTOOLPY_FLASHSIZE` | `"16MB"` |
| `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME` | `"partitions_16mb.csv"` |
| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `128` |
| `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` | `y` |
**Do not use the native `C:\Espressif\...esp-idf-v5.5.5`.** Wrong version, its
venv does not match the default python, and a container-built `build/` refuses
a native build outright. Docker only.
---
## 2. What OTA can and cannot reach
This is the distinction that decides whether a change needs a cable.
| region | offset | reachable by OTA? |
|---|---|---|
| bootloader | `0x0` | **NO — USB only** |
| partition table | `0x8000` | **NO — USB only** |
| otadata | `0xF000` | rewritten by OTA |
| ota_0 | `0x20000` | yes (4 MB) |
| ota_1 | `0x420000` | yes (4 MB) |
| coredump | `0x820000` | no |
| storage (fat) | `0x830000` | no |
| nvs | `0x9000` | never written by a flash — provisioning survives |
**OTA replaces the app partition only.** Anything in the bootloader —
`CONFIG_BOOTLOADER_*`, notably `APP_ROLLBACK_ENABLE` — or any partition-table
change **requires USB on every board**.
There is **no bootloader version on the wire**. A node's sync packet reports app
health, not bootloader capability, so you cannot determine remotely which boards
have a rollback-capable bootloader. Track it per board, or do a USB pass across
all of them.
Corollary: "every node reports health" proves the **app** is current and proves
nothing about the bootloader. Do not answer a bootloader question with app
evidence.
---
## 3. Deploying
### Over the air — app-only changes
Works end-to-end and is the default for anything that is not a bootloader or
partition change. Roll to **one node**, soak it, then the rest.
### Over USB — bootloader, partition table, or recovery
```bash
python -m esptool --chip esp32c6 -b 460800 \
--before default_reset --after hard_reset write_flash \
--flash_mode dio --flash_size 16MB --flash_freq 80m \
0x0 build/bootloader/bootloader.bin \
0x8000 build/partition_table/partition-table.bin \
0xf000 build/ota_data_initial.bin \
0x20000 build/esp32-csi-node.bin
```
`0x9000` is not in that list, so SSID, password and `node_id` survive.
### Safe single-board diagnostic flash, with a revert path
Nodes run from whichever slot `otadata` selects. **Parse it before flashing** —
two 32-byte entries at offsets 0 and 0x1000; active slot is
`(max ota_seq - 1) % 2`.
1. Back up `otadata` (8 KB) **and** the live app slot with `esptool read-flash`.
2. Flash the diagnostic build — it lands in `ota_0` and rewrites `otadata`.
3. **Revert = write the 8 KB `otadata` backup back.** Production in `ota_1` was
never touched, so no rebuild is needed.
Validate any backup image before trusting it: byte 0 must be `0xE9`, and the
`esp_app_desc_t` magic at offset `0x20` must be `0xABCD5432`.
**Never flash the whole fleet from a workstation, and never use the server's OTA
endpoint for a diagnostic.** USB, one board, revert path secured first.
---
## 4. Serial
CH343 USB-serial at 115200.
To read a running board **without resetting it**, open with
`DtrEnable=$false; RtsEnable=$false`. esptool asserts RTS and *will* reset the
board, which loses the state you were trying to observe.
---
## 5. Changing config without a cable
Most parameters do not need a flash at all:
```bash
export RUVIEW_OTA_PSK_FILE=<path to the psk file> # never inline the key
python config_push.py --node <node-ip> --get
python config_push.py --node <node-ip> --set led_brightness=10
```
Keys marked `assoc: true` in `config_api.c` (`wifi_ssid`, `wifi_password`,
`channel_hop_count`, `dwell_ms`, `csi_channel`) are applied as a **trial**: the
node banks its current values, reboots, and keeps the change only if it
re-associates. Everything else applies without that risk.
`config_push.py` has **no unset**. To restore a default you must set it
explicitly. Current defaults:
| key | default | source |
|---|---|---|
| `vital_interval_ms` | 1000 | `Kconfig.projbuild` |
| `swarm_heartbeat_sec` | 30 | `nvs_config.c` |
| `swarm_ingest_sec` | 5 | `nvs_config.c` |
| `beacon_period_ms` | 0 = derive from fleet size | `nvs_config.c` |
---
## 6. Reading a node remotely
`GET /api/v1/mesh` returns per-node `health`: `min_heap_kib`, `die_c`,
`thermal_state`, `tx_dbm`, `reset_reason`, and from sync proto v3 the TX-path
counters `send_fail` / `rate_skip` / `early_drop`.
`min_heap_kib` is a **minimum-since-boot watermark**, not an instantaneous
reading. It resets on reboot and only ever falls. Sampling it within a minute of
a restart tells you nothing — that mistake produced a wrong "no effect" verdict
on 2026-09-03; the real answer, once it had settled, was −56 KiB.
`/api/v1/nodes` `rssi_dbm` is **not** the AP link — it is whatever link landed
most recently and swings 20+ dB with no physical change. Filter `/api/v1/links`
by the AP's BSSIDs instead, and read `csi_fps_ema` alongside, because link count
alone hides a starved node.
---
## 7. Tunables, and which ones depend on your house
`idf.py menuconfig` exposes these. The defaults are what a nine-node ESP32-C6
fleet in one house settled on; several are **not** universal, and the point of
listing them here is that you can pick and measure rather than inherit someone
else's building.
| option | default | depends on your house? |
|---|---|---|
| `CSI_GATE_MESH_ALIGNED` | y | **yes** |
| `CSI_SEQ_DIAG` | n | diagnostic only |
| `THERMAL_MONITOR` | y | no |
| `THERMAL_THROTTLE` | n | **yes** |
| `THERMAL_THROTTLE_C` | 72 | **yes** |
| `THERMAL_HYSTERESIS_C` | 8 | no |
| `UPLINK_WATCHDOG` | y | no |
| `UPLINK_WATCHDOG_TIMEOUT_S` | 1200 | **yes** |
### The ones worth measuring yourself
**`CSI_GATE_MESH_ALIGNED`** decides whether nodes keep the *same* frames or
merely the same *number* of frames, which is what makes cross-node pairing
possible at all. Whether alignment helps depends on how much your nodes
actually overhear in common, and that is a property of your walls and your
transmitters. In one measured nine-node fleet, aligning windows bought
+0.5 pp of pairing while selecting frames by `rx_seq` bought +31.7 pp -- so do
not assume the default is doing the heavy lifting in your building.
Turn on `CSI_SEQ_DIAG` to measure it. It logs `(source MAC, rx_seq)` per CSI
callback, which is what lets you compute the fraction of frames two nodes hold
in common instead of guessing.
**`THERMAL_THROTTLE` / `THERMAL_THROTTLE_C`** depend on enclosure and placement,
not on the chip. A bare bench board never reaches 72 C; the same board sealed in
a printed case on a sunny wall can. Read `die_c` from `/api/v1/mesh` for a few
days before deciding, because throttling transmit power costs range.
**`UPLINK_WATCHDOG_TIMEOUT_S`** is a bet about your AP. 1200 s suits a network
where a wedge is rare and a reboot is cheap. If your uplink drops briefly and
often, a short timeout turns a recoverable gap into a reboot loop; if it wedges
hard, a long one leaves a node dark for twenty minutes.
### A worked example: the same two options, in one house
MEASURED on a nine-node ESP32-C6 fleet in a single detached house. **These are
our numbers, not yours.** They are here to show the shape of the decision, and
how much a building can move it.
Four arms as a 2x2 factorial, 20-minute runs, three Latin-square blocks so slow
drift landed on every arm equally. Metric is the fraction of transmissions held
in common by two or more nodes.
| arm | mean |
|---|---|
| `elapsed` (per-node gate) | 0.2434 |
| `mesh` (aligned windows) | 0.2480 |
| `seq` (keep `rx_seq` divisible by 8) | **0.5605** |
| `mesh` + `seq` | 0.5452 |
`seq` roughly doubled pairing, +31.7 pp, about 70x the measured drift floor and
reproduced in all three blocks. Mesh alignment bought **+0.5 pp** -- inside
noise. Stacking them was very slightly *worse* than `seq` alone.
**Why that happened here, and why yours may differ.** These nodes already
overheard a great deal in common -- two boards side by side heard the same
transmissions 72% of the time -- so there was a large pool of shared
transmissions for `seq` to select from, and little left for window alignment to
recover. A house with fewer shared transmitters, thicker interior walls, or
nodes spread so that few pairs hear the same radios has less for `seq` to work
with, and phase alignment may matter more rather than less. We did not measure
such a house and do not claim to know.
**It is a trade, not a free win.** At period 8 the `seq` arms carried ~75-95k
transmissions per 20 minutes against ~120-160k ungated: pairing is bought with
raw frame rate. Absolute paired transmissions per minute still rose (2629 vs
2524), because the discarded frames are disproportionately ones only one node
would have kept. Whether that trade is right depends on what consumes your
stream. On-device edge processing was **unaffected** here -- frames processed
stayed ~1900 per node per 4 minutes in both modes, because that pipeline is
separately rate-limited -- so the cost fell on the uplink, not on local sensing.
If your uplink is the scarce resource, that changes the answer.
**The two options differ in kind, not just in effect.** Mesh alignment is an
agreement between receivers: it needs a shared clock, so it inherits whatever
the sync topology does. Selecting by `rx_seq` needs no agreement at all. The
802.11 sequence control field is assigned by the TRANSMITTER and is identical
at every receiver, so it is a coordination-free name for one transmission --
two nodes that have never heard of each other, sit in different sync domains,
or run with no mesh whatsoever still keep the same subset of frames.
In the code the seq test runs before any sync check and reads only the frame;
only the mesh branch calls `c6_sync_espnow_is_valid()`. That asymmetry is the
reason `seq` had nothing to degrade in our runs, and it is worth weighing
directly: if your fleet's sync is fragile, `seq` is not merely the better
performer, it is the one with no dependency that can quietly fail.
**A limitation of our own measurement, which cuts against the mesh arm.** The
mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`; a node
with no valid sync falls back to the elapsed gate rather than gating on a
meaningless epoch. That is the right behaviour, but it means our `mesh` arms
were partly `elapsed` arms: three of nine nodes never synchronised, so for those
three the setting under test was silently not in effect. Read the +0.5 pp as
"mesh where sync held, elapsed where it did not" rather than as a clean
fleet-wide measurement of alignment.
This is itself a house-dependent property. Time sync here is leader-elected and
hub-and-spoke -- one node broadcasts and the rest follow. Whether every node can
hear that leader is a question about your building, not about the firmware. In a
larger or more divided house the leader may not reach everyone, the fleet
fragments into sync domains, and any option that depends on shared time degrades
quietly for the nodes that fell out. Check `is_leader` and sync validity in
`/api/v1/mesh` before concluding that an alignment setting did or did not help.
**A prediction we got wrong, kept because it is the useful part.** Our mesh had
split into two leader domains with three nodes never synchronising at all. We
expected `seq` to win most on pairs spanning that split. Per-pair yield said
otherwise: same-domain 5.82x, cross-domain 6.24x, involving a never-synced node
**5.16x** -- the lowest, the opposite of the prediction -- and within-group
spread (3.6x-8.1x) dwarfed every between-group difference.
So `seq` is **not** a workaround for broken time sync; it wins roughly
uniformly, including where sync is healthy. The consequence is negative and
worth having: repairing the mesh split would probably not have closed the gap,
and that effort would have been spent for nothing.
### Measuring rather than guessing
A gate comparison needs a fixed environment, repeated blocks, and every arm run
on every node -- otherwise node placement is confounded with the setting. The
harness used for the published comparison is in the repository; use it rather
than eyeballing a frame rate, which moves for reasons unrelated to the setting.
+14 -1
View File
@@ -1,6 +1,7 @@
set(SRCS
"main.c" "csi_collector.c"
"main.c" "csi_collector.c" "node_log.c"
"thermal.c" "stream_sender.c" "nvs_config.c"
"serial_onboarding.c" "serial_onboarding_protocol.c"
"edge_processing.c" "ota_update.c" "power_mgmt.c"
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
"mmwave_sensor.c"
@@ -14,6 +15,7 @@ set(SRCS
"c6_twt.c"
"c6_timesync.c"
"c6_lp_core.c"
"c6_antenna_select.c"
# ADR-110 D1 workaround — ESP-NOW cross-node sync (works on S3+C6)
"c6_sync_espnow.c"
# ADR-110 B1/B2 unblock — soft-AP HE/TWT (C6-only when enabled)
@@ -31,6 +33,9 @@ set(REQUIRES
esp_http_client
esp_app_format
esp_timer
fatfs # node_log.c: the storage FAT volume
wear_levelling # node_log.c: wl_mount behind that volume
espcoredump # coredump-to-flash readback
esp_pm
esp_driver_uart
esp_driver_gpio
@@ -39,6 +44,8 @@ set(REQUIRES
driver
lwip
mbedtls
efuse
led_strip
)
# ADR-110: C6-only components — pulled in when building for esp32c6.
@@ -48,6 +55,12 @@ if(IDF_TARGET STREQUAL "esp32c6")
list(APPEND REQUIRES ieee802154 ulp esp_hw_support)
endif()
# ESP-IDF 6 split the temperature sensor driver out of the legacy `driver`
# component. Keep the 5.4 lane working while declaring the v6 dependency.
if(IDF_VERSION_MAJOR GREATER_EQUAL 6)
list(APPEND REQUIRES esp_driver_tsens)
endif()
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
if(CONFIG_CSI_MOCK_ENABLED)
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
@@ -1,5 +1,27 @@
menu "CSI Node Configuration"
config C6_XIAO_ANTENNA_SELECT
bool "Control the Seeed XIAO ESP32-C6 RF switch"
depends on IDF_TARGET_ESP32C6
default n
help
Enable only for Seeed XIAO ESP32-C6 boards. The XIAO uses GPIO3
to enable RF-switch control and GPIO14 to choose its internal or
external antenna. Leave this disabled for generic ESP32-C6 boards
because GPIO3 and GPIO14 may have unrelated functions there.
choice C6_XIAO_ANTENNA_PATH
prompt "Seeed XIAO ESP32-C6 antenna path"
depends on C6_XIAO_ANTENNA_SELECT
default C6_XIAO_ANTENNA_INTERNAL
config C6_XIAO_ANTENNA_INTERNAL
bool "Internal ceramic antenna"
config C6_XIAO_ANTENNA_EXTERNAL
bool "External U.FL antenna"
endchoice
config CSI_NODE_ID
int "Node ID (0-255)"
default 1
@@ -651,3 +673,20 @@ config UPLINK_WATCHDOG_TIMEOUT_S
packet's health field before assuming the watchdog is responsible.
endmenu
config CSI_GATE_MESH_ALIGNED
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
default y
help
The per-node elapsed gate ("20 ms since MY last accept") gives every node
an independent phase, so two healthy nodes at 50 Hz accept disjoint
frames and nothing can be paired across them. This aligns the 20 ms
window to mesh time so nodes choose the same windows.
config CSI_SEQ_DIAG
bool "Log (source MAC, rx_seq) for every CSI callback (ADR-345 phase 2 probe)"
default n
help
Diagnostic. Emits the transmitter address and 802.11 sequence number for
each CSI callback so the effect of the gate above can be measured rather
than assumed. Verbose; leave off in normal operation.
@@ -0,0 +1,56 @@
#include "c6_antenna_select.h"
#include "sdkconfig.h"
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
#include "driver/gpio.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define XIAO_RF_SWITCH_POWER_GPIO GPIO_NUM_3
#define XIAO_RF_SWITCH_SELECT_GPIO GPIO_NUM_14
static const char *TAG = "c6_antenna";
#endif
esp_err_t c6_xiao_antenna_apply(void)
{
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
gpio_config_t config = {
.pin_bit_mask = (1ULL << XIAO_RF_SWITCH_POWER_GPIO) |
(1ULL << XIAO_RF_SWITCH_SELECT_GPIO),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
esp_err_t err = gpio_config(&config);
if (err != ESP_OK) {
return err;
}
err = gpio_set_level(XIAO_RF_SWITCH_POWER_GPIO, 0);
if (err != ESP_OK) {
return err;
}
vTaskDelay(pdMS_TO_TICKS(100));
#if defined(CONFIG_C6_XIAO_ANTENNA_EXTERNAL)
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_EXTERNAL;
const char *name = "external U.FL";
#else
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_INTERNAL;
const char *name = "internal ceramic";
#endif
err = gpio_set_level(XIAO_RF_SWITCH_SELECT_GPIO,
c6_xiao_antenna_gpio14_level(antenna));
if (err == ESP_OK) {
ESP_LOGI(TAG, "XIAO ESP32-C6 antenna path: %s", name);
}
return err;
#else
return ESP_OK;
#endif
}
@@ -0,0 +1,17 @@
#pragma once
typedef enum {
C6_XIAO_ANTENNA_INTERNAL = 0,
C6_XIAO_ANTENNA_EXTERNAL = 1,
} c6_xiao_antenna_t;
static inline int c6_xiao_antenna_gpio14_level(c6_xiao_antenna_t antenna)
{
return antenna == C6_XIAO_ANTENNA_EXTERNAL ? 1 : 0;
}
#ifdef ESP_PLATFORM
#include "esp_err.h"
esp_err_t c6_xiao_antenna_apply(void);
#endif
+9 -1
View File
@@ -13,7 +13,7 @@
* `ulp_embed_binary(...)` in main/CMakeLists.txt from lp_core/main.c.
*
* 2. DISABLED — falls back to plain deep-sleep + GPIO wake-up
* (`esp_deep_sleep_enable_gpio_wakeup`). No debounce, no
* (ESP-IDF deep-sleep GPIO wake API). No debounce, no
* sub-10 µA floor, but no LP toolchain dependency either.
* This is the path the v0.6.6 firmware shipped with.
*
@@ -26,6 +26,7 @@
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_ULP_COPROC_TYPE_LP_CORE)
#include "c6_lp_core.h"
#include "esp_idf_version.h"
#include "esp_log.h"
#include "esp_sleep.h"
#include "driver/rtc_io.h"
@@ -134,10 +135,17 @@ esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high)
#else
/* --- Fallback path: plain deep-sleep GPIO wakeup (~10 µA floor) --- */
uint64_t mask = 1ULL << wake_gpio;
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
esp_sleep_gpio_wake_up_mode_t mode = active_high
? ESP_GPIO_WAKEUP_GPIO_HIGH
: ESP_GPIO_WAKEUP_GPIO_LOW;
esp_err_t err = esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(mask, mode);
#else
esp_deepsleep_gpio_wake_up_mode_t mode = active_high
? ESP_GPIO_WAKEUP_GPIO_HIGH
: ESP_GPIO_WAKEUP_GPIO_LOW;
esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode);
#endif
if (err != ESP_OK) {
ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err));
return err;
+77 -5
View File
@@ -114,6 +114,9 @@ static int64_t s_last_send_us = 0;
*/
#define CSI_MIN_PROCESS_INTERVAL_US (20 * 1000) /* 50 Hz */
static int64_t s_last_process_us = 0;
/* Mesh-epoch window index of the last accepted frame. UINT64_MAX means "none
* yet", which no real bucket can collide with. */
static uint64_t s_last_gate_bucket = UINT64_MAX;
static uint32_t s_early_drop = 0;
/* ---- ADR-029: Channel-hop state ---- */
@@ -263,8 +266,16 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
buf[19] = 0;
#endif
/* I/Q data */
/* I/Q data. ESP-IDF documents that the first four CSI bytes are invalid
* when first_word_invalid is set. Never let those hardware artifacts feed
* either the host or edge DSP path. Preserve the fixed ADR-018 geometry by
* zeroing rather than removing bytes, and mark the sanitation in bit 5. */
memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len);
if (info->first_word_invalid && iq_len > 0) {
size_t invalid_len = iq_len < 4 ? iq_len : 4;
memset(&buf[CSI_HEADER_SIZE], 0, invalid_len);
buf[19] |= CSI_FLAG_FIRST_WORD_SANITIZED;
}
return frame_size;
}
@@ -277,9 +288,53 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
(void)ctx;
/* Early rate gate: drop excess callbacks to ~50 Hz to prevent
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). */
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq).
*
* HOW the rate is limited decides whether cross-node fusion is possible.
*
* The original gate is "at least 20 ms since MY last accept". Each node
* therefore has an independent phase: node A can accept at t=0,20,40 ms
* while node B accepts at 7,27,47. Both are perfectly healthy and both hit
* 50 Hz, yet they accept disjoint frames and nothing can be paired.
*
* MEASURED 2026-08-30, two boards side by side: they HEARD the same
* transmissions 72% of the time but only 25% of accepted frames were
* common. The frames were there; the gate was throwing away the pairing.
*
* So gate on a bucket of the MESH-ALIGNED epoch instead: every node takes
* the first frame it hears in the same absolute 20 ms window. Two nodes
* that heard the same frame now both accept it. Mesh sync is good to
* roughly 490 us against a 20 ms bucket -- about 2.5% of a window -- so
* boundary disagreements are rare.
*
* Falls back to the original elapsed-time gate whenever mesh sync is not
* valid (no leader heard yet, or a node that just booted), because an
* unsynced epoch would otherwise gate on a meaningless number. A node
* running unsynced simply gets the old behaviour rather than no gate.
*
* The half-interval floor below keeps the crash protection honest: bucket
* gating alone could accept at the end of one window and the start of the
* next, back to back. The floor bounds that to ~100 Hz for a single pair
* while the average stays at 50 Hz. Do not remove it; the gate exists for
* a crash, not for tidiness. */
int64_t now_us = esp_timer_get_time();
if ((now_us - s_last_process_us) < CSI_MIN_PROCESS_INTERVAL_US) {
bool take;
#ifdef CONFIG_CSI_GATE_MESH_ALIGNED
if (c6_sync_espnow_is_valid()) {
uint64_t bucket = c6_sync_espnow_get_epoch_us()
/ (uint64_t)CSI_MIN_PROCESS_INTERVAL_US;
take = (bucket != s_last_gate_bucket)
&& ((now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US / 2);
if (take) {
s_last_gate_bucket = bucket;
}
} else {
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
}
#else
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
#endif
if (!take) {
s_early_drop++;
return;
}
@@ -330,13 +385,16 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
* while the on-device Tier 1/2 pipeline receives a uniform, sustainable
* stream. Enqueuing every burst frame overloaded the unicore C6 DSP and
* turned 30-40 callback pps into an irregular approximately 8 Hz subset. */
if (info->buf && info->len > 0) {
if (frame_len > CSI_HEADER_SIZE) {
if (s_next_edge_enqueue_us == 0) {
s_next_edge_enqueue_us = now_us;
}
if (now_us >= s_next_edge_enqueue_us) {
(void)edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
/* Reuse the sanitized ADR-018 payload. Feeding info->buf here
* would reintroduce first_word_invalid artifacts on device. */
(void)edge_enqueue_csi(&frame_buf[CSI_HEADER_SIZE],
(uint16_t)(frame_len - CSI_HEADER_SIZE),
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
/* Preserve the configured sample clock instead of resetting it to
@@ -692,6 +750,20 @@ uint16_t csi_collector_get_send_fail_count(void)
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
}
/* The gate here is fixed at compile time (mesh-aligned bucketing, falling
* back to elapsed time when unsynced), so there is no mode or period to
* report. Return the explicit sentinel rather than a number that reads as
* a real setting. */
uint8_t csi_collector_get_gate_mode(void)
{
return CSI_GATE_NOT_CONFIGURABLE;
}
uint8_t csi_collector_get_gate_seq_period(void)
{
return CSI_GATE_NOT_CONFIGURABLE;
}
/* ---- ADR-029: Channel hopping ---- */
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
@@ -17,6 +17,9 @@
/** ADR-018 header size in bytes. */
#define CSI_HEADER_SIZE 20
/** ADR-018 byte 19: ESP-IDF reported and firmware sanitized invalid CSI prefix. */
#define CSI_FLAG_FIRST_WORD_SANITIZED (1U << 5)
/** Maximum frame buffer size (header + 4 antennas * 256 subcarriers * 2 bytes). */
#define CSI_MAX_FRAME_SIZE (CSI_HEADER_SIZE + 4 * 256 * 2)
@@ -136,4 +139,22 @@ uint16_t csi_collector_get_pkt_yield_per_sec(void);
*/
uint16_t csi_collector_get_send_fail_count(void);
/** Reported by the gate accessors when this build has no selectable gate. */
#define CSI_GATE_NOT_CONFIGURABLE 0xFF
/**
* Report which frame-selection gate is in force, for the on-node log.
*
* This build has exactly one gate -- mesh-aligned bucketing with an
* elapsed-time fallback -- fixed at compile time, so there is no mode or
* period to report and both accessors return CSI_GATE_NOT_CONFIGURABLE.
*
* They exist so the log record keeps a field of fixed width and meaning
* whether or not a build can select a gate at runtime, and so an explicit
* "not applicable" is stored rather than a plausible number that would
* later be reasoned from.
*/
uint8_t csi_collector_get_gate_mode(void);
uint8_t csi_collector_get_gate_seq_period(void);
#endif /* CSI_COLLECTOR_H */
+57
View File
@@ -21,9 +21,11 @@
#include "led_strip.h"
#include "csi_collector.h"
#include "node_log.h"
#include "thermal.h"
#include "stream_sender.h"
#include "nvs_config.h"
#include "serial_onboarding.h"
#include "edge_processing.h"
#include "ota_update.h"
#include "power_mgmt.h"
@@ -37,6 +39,7 @@
#include "c6_twt.h" /* ADR-110: TWT (no-op stub on S3) */
#include "c6_timesync.h" /* ADR-110: 802.15.4 mesh time-sync (no-op on S3) */
#include "c6_lp_core.h" /* ADR-110: LP-core hibernation (no-op on S3) */
#include "c6_antenna_select.h" /* XIAO C6 RF switch (opt-in; generic C6 no-op) */
#include "c6_sync_espnow.h" /* ADR-110 D1 workaround: ESP-NOW sync */
#include "c6_softap_he.h" /* ADR-110 B1/B2: HE/TWT soft-AP (no-op when disabled) */
#ifdef CONFIG_CSI_MOCK_ENABLED
@@ -115,6 +118,10 @@ static void event_handler(void *arg, esp_event_base_t event_base,
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
/* The 7.5 h outage was root-caused to the MAX_RETRY latch only because
* someone was watching the console at the time. Persist it. */
node_log_note_disconnect((uint8_t)disc->reason, (int8_t)disc->rssi);
node_log_event(NODE_LOG_EV_WIFI_DISCONNECT, disc->reason, disc->rssi);
s_retry_num++;
/* Release the boot wait once, so a node that comes up while the AP is
* down still starts CSI capture and the mesh instead of blocking in
@@ -331,6 +338,19 @@ void app_main(void)
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
target_name, app_desc->version, g_nvs_config.node_id);
/* Apply the opt-in XIAO RF path before WiFi starts. Generic C6 boards keep
* ownership of GPIO3 and GPIO14 because the default implementation is a
* no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */
ESP_ERROR_CHECK(c6_xiao_antenna_apply());
/* The native Mac onboarding bridge must remain available even when WiFi
* credentials or the aggregator address are wrong. Start it before any
* blocking network initialization. The protocol is physical-USB-only,
* nonce-bound, bounded, and never prints credentials. */
esp_err_t onboarding_ret = serial_onboarding_start(&g_nvs_config);
if (onboarding_ret != ESP_OK) {
ESP_LOGW(TAG, "USB onboarding unavailable: %s", esp_err_to_name(onboarding_ret));
}
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the
* common module). On C6, GPIO 38/48 don't exist (only 0-30) — gate by target.
@@ -445,6 +465,24 @@ void app_main(void)
} else {
ESP_LOGI(TAG, "reset reason: %s (%d)", why, (int)rr);
}
/* And persist it. The console line above is exactly what a power cycle
* destroys, which is the whole reason this fleet has no post-mortem for
* a remote fault. node_log_init failing is non-fatal by design -- the
* node comes up either way, it just has no memory. */
if (node_log_init() == ESP_OK) {
uint32_t prev_uptime_s = 0;
nvs_handle_t nh;
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
nvs_get_u32(nh, "last_up_s", &prev_uptime_s);
nvs_set_u32(nh, "last_up_s", 0);
nvs_commit(nh);
nvs_close(nh);
}
node_log_boot((uint32_t)rr, prev_uptime_s);
ESP_LOGI(TAG, "on-node log active: boot_id=%u, %u records retained",
(unsigned)node_log_boot_id(), (unsigned)node_log_count());
}
}
/* Bring thermal monitoring up before the radio is loaded, so the first
@@ -649,5 +687,24 @@ void app_main(void)
#ifdef CONFIG_UPLINK_WATCHDOG
uplink_watchdog_tick();
#endif
/* Offered every 10 s; node_log enforces its own floor, so the cadence
* lives in one place rather than being implied by this loop's delay.
* Also refresh the uptime NVS cell, which is what lets the NEXT boot
* record say how long the previous session actually survived -- the
* difference between "it rebooted" and "it wedged after 225 s". */
node_log_periodic();
{
static uint32_t s_last_persist_s;
uint32_t up_s = (uint32_t)(esp_timer_get_time() / 1000000);
if (up_s - s_last_persist_s >= 60) {
s_last_persist_s = up_s;
nvs_handle_t nh;
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
nvs_set_u32(nh, "last_up_s", up_s);
nvs_commit(nh);
nvs_close(nh);
}
}
}
}
}
@@ -503,9 +503,35 @@ esp_err_t mmwave_sensor_init(int uart_tx_pin, int uart_rx_pin)
return ESP_OK;
#else
/* Default probe pins must never collide with the UART0 console. On the
* ESP32-C6 the console is TX=GPIO16 / RX=GPIO17, so the legacy 17/18 pair
* routed UART1 TX onto the console RX line and silently killed serial
* input (USB onboarding, monitor commands) even when no sensor was found,
* because uart_driver_delete() does not undo the GPIO matrix routing. */
#if defined(CONFIG_IDF_TARGET_ESP32C6)
if (uart_tx_pin < 0) uart_tx_pin = 4;
if (uart_rx_pin < 0) uart_rx_pin = 5;
#else
if (uart_tx_pin < 0) uart_tx_pin = 17;
if (uart_rx_pin < 0) uart_rx_pin = 18;
#endif
#if defined(CONFIG_ESP_CONSOLE_UART_CUSTOM)
const int console_tx = CONFIG_ESP_CONSOLE_UART_TX_GPIO;
const int console_rx = CONFIG_ESP_CONSOLE_UART_RX_GPIO;
#elif defined(CONFIG_IDF_TARGET_ESP32C6)
const int console_tx = 16, console_rx = 17; /* U0TXD / U0RXD IOMUX pins */
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
const int console_tx = 43, console_rx = 44;
#else
const int console_tx = 1, console_rx = 3;
#endif
if (uart_tx_pin == console_tx || uart_tx_pin == console_rx ||
uart_rx_pin == console_tx || uart_rx_pin == console_rx) {
ESP_LOGW(TAG, "mmWave probe pins (TX=%d, RX=%d) overlap the console UART (TX=%d, RX=%d); skipping probe",
uart_tx_pin, uart_rx_pin, console_tx, console_rx);
return ESP_ERR_INVALID_ARG;
}
/* Install UART driver at MR60 baud (will be changed during probe). */
uart_config_t uart_config = {
+424
View File
@@ -0,0 +1,424 @@
#include "node_log.h"
#include <limits.h>
#include <string.h>
#include <unistd.h>
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_vfs_fat.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "c6_sync_espnow.h"
#include "csi_collector.h"
#include "nvs_config.h"
#include "thermal.h"
/* firmware/esp32-csi-node/partitions_4mb.csv has two 1.81 MB app slots and
* only 256 KB free -- no room for the FAT volume this module needs. The
* whole implementation below is compiled out on that profile; every entry
* point still links via the stubs in the #else branch so main.c's call
* sites need no #ifdef of their own. See partitions_display.csv (8MB) and
* partitions_16mb.csv for the profiles where this module is active. */
#ifndef CONFIG_ESPTOOLPY_FLASHSIZE_4MB
static const char *TAG = "node_log";
/* The FAT volume reserved in partitions_16mb.csv (storage, 8000K) and never
* mounted until now. */
#define NODE_LOG_PARTITION "storage"
#define NODE_LOG_MOUNT "/nlog"
#define NODE_LOG_PATH NODE_LOG_MOUNT "/health.bin"
/* Ring capacity. 60000 * 128 B = 7.68 MB, just inside the 8000K volume with
* room for FAT's own metadata. At the 300 s cadence that is ~208 days. */
#define NODE_LOG_MAX_RECORDS 60000
/* Hard floor between periodic samples. The design settled on 300 s; this is
* the enforcement, not the schedule. A caller asking more often is DROPPED
* rather than served, because flash wear is the one way this module can do
* harm and a bug in a caller must not be able to cause it. */
#define NODE_LOG_MIN_PERIOD_S 60
/* Events bypass the periodic floor -- they are rare and they are the whole
* point -- but not without limit, or a disconnect storm becomes a write storm. */
#define NODE_LOG_MIN_EVENT_MS 1000
static bool s_active;
static FILE *s_fp;
static wl_handle_t s_wl = WL_INVALID_HANDLE;
static SemaphoreHandle_t s_lock;
static uint16_t s_boot_id;
static uint32_t s_seq;
static size_t s_count; /* records currently in the ring */
static size_t s_head; /* next slot to write */
static int64_t s_last_periodic_us;
static int64_t s_last_event_us;
/* CRC16/CCITT-FALSE. A record that fails this is skipped by the reader rather
* than trusted, which matters because a power cut mid-write leaves a partial
* record and the ring has no other way to know. */
static uint16_t crc16(const uint8_t *p, size_t n)
{
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < n; i++) {
crc ^= (uint16_t)p[i] << 8;
for (int b = 0; b < 8; b++) {
crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1);
}
}
return crc;
}
/* 16 B header, then a type-specific payload. The header is identical across
* types so a reader can order and validate without dispatching first. */
typedef struct __attribute__((packed)) {
uint8_t type;
uint8_t flags;
uint16_t boot_id;
uint32_t seq;
uint32_t uptime_s;
uint16_t payload_len;
uint16_t crc16; /* over the record with this field zeroed */
} node_log_hdr_t;
_Static_assert(sizeof(node_log_hdr_t) == 16, "header must be 16 B");
/* ~72 B used of the 112 B payload budget, leaving ~40 B of deliberate slack so
* a field can be added later WITHOUT changing the record size and invalidating
* every reader already in the field. */
typedef struct __attribute__((packed)) {
int8_t die_c;
uint8_t thermal_state;
int8_t tx_dbm;
int8_t ap_rssi_dbm; /* the one field we did not have and needed */
uint8_t channel;
uint8_t wifi_state;
uint8_t gate_mode;
uint8_t gate_seq_period;
uint16_t free_heap_kib;
uint16_t min_heap_kib;
uint16_t csi_fps_x100;
uint32_t frames_processed;
uint32_t frames_rejected;
uint32_t seq_drop;
uint32_t tx_early_drop;
uint16_t tx_rate_skip;
uint32_t tx_send_fail;
uint16_t disconnect_count;
uint8_t last_disc_reason;
int8_t last_disc_rssi;
uint8_t mesh_flags; /* bit0 valid, bit1 leader */
uint8_t leader_id;
int32_t mesh_offset_us;
uint16_t mesh_staleness_ms;
uint32_t mesh_seq;
} node_log_periodic_t;
typedef struct __attribute__((packed)) {
uint32_t reset_reason;
uint32_t prev_uptime_s;
node_log_periodic_t health; /* a boot record carries a snapshot too */
} node_log_boot_t;
typedef struct __attribute__((packed)) {
uint8_t subtype;
uint8_t _pad[3];
int32_t a;
int32_t b;
} node_log_event_t;
/* Counters main.c owns; set through the setters below rather than reached for,
* so this module has no include cycle with main.c. */
static volatile uint16_t s_disconnect_count;
static volatile uint8_t s_last_disc_reason;
static volatile int8_t s_last_disc_rssi;
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
{
s_disconnect_count++;
s_last_disc_reason = reason;
s_last_disc_rssi = rssi;
}
static void fill_health(node_log_periodic_t *h)
{
memset(h, 0, sizeof(*h));
/* thermal.c initialises s_last_c to -273.0f and only replaces it on the
* first thermal_tick(), which runs from the adaptive controller -- LATER
* than the boot record and later than the first periodic samples. Casting
* that sentinel to int8 yields -17, a PLAUSIBLE temperature, which is
* exactly the kind of invented-but-believable number that must never reach
* a log someone will later reason from. Store INT8_MIN as an explicit
* "no reading" instead; node_log_read.py renders it as n/a. */
float die = thermal_celsius();
h->die_c = (die < -100.0f || die > 125.0f) ? INT8_MIN : (int8_t)die;
h->thermal_state = (uint8_t)thermal_state();
h->tx_dbm = thermal_tx_dbm();
h->gate_mode = csi_collector_get_gate_mode();
h->gate_seq_period = csi_collector_get_gate_seq_period();
/* The association link, NOT the CSI-derived rssi_median_dbm in
* adaptive_controller.c. Nodes 5 and 6 show every symptom of a bad uplink
* and this is the one call that can confirm or refute it. */
wifi_ap_record_t ap;
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
h->ap_rssi_dbm = (int8_t)ap.rssi;
h->channel = ap.primary;
h->wifi_state = 1;
}
h->free_heap_kib = (uint16_t)(esp_get_free_heap_size() / 1024);
h->min_heap_kib = (uint16_t)(esp_get_minimum_free_heap_size() / 1024);
h->csi_fps_x100 = csi_collector_get_pkt_yield_per_sec() * 100;
h->tx_send_fail = csi_collector_get_send_fail_count();
h->disconnect_count = s_disconnect_count;
h->last_disc_reason = s_last_disc_reason;
h->last_disc_rssi = s_last_disc_rssi;
h->mesh_flags = (uint8_t)((c6_sync_espnow_is_valid() ? 1 : 0)
| (c6_sync_espnow_is_leader() ? 2 : 0));
int64_t off = c6_sync_espnow_get_offset_us();
/* Saturate rather than wrap: a wrapped offset reads as a plausible small
* number and would be believed. */
if (off > INT32_MAX) h->mesh_offset_us = INT32_MAX;
else if (off < INT32_MIN) h->mesh_offset_us = INT32_MIN;
else h->mesh_offset_us = (int32_t)off;
}
/* Append one record. Callers hold no lock; this takes it. */
static void append(uint8_t type, const void *payload, size_t len)
{
if (!s_active || len > (NODE_LOG_RECORD_SIZE - sizeof(node_log_hdr_t))) {
return;
}
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(200)) != pdTRUE) {
return; /* never block a caller on the log */
}
uint8_t rec[NODE_LOG_RECORD_SIZE];
memset(rec, 0, sizeof(rec));
node_log_hdr_t *h = (node_log_hdr_t *)rec;
h->type = type;
h->boot_id = s_boot_id;
h->seq = ++s_seq;
h->uptime_s = (uint32_t)(esp_timer_get_time() / 1000000);
h->payload_len = (uint16_t)len;
h->crc16 = 0;
memcpy(rec + sizeof(*h), payload, len);
h->crc16 = crc16(rec, sizeof(rec));
if (fseek(s_fp, (long)(s_head * NODE_LOG_RECORD_SIZE), SEEK_SET) == 0
&& fwrite(rec, 1, sizeof(rec), s_fp) == sizeof(rec)) {
/* fflush only pushes the C stdio buffer into the VFS; FATFS still holds
* the directory entry and FAT chain in RAM. Without fsync a reboot
* loses the file's size, and on the next open "r+b" sees a zero-length
* file -- which is precisely what the node-3 pilot showed: boot 1's
* records vanished while the NVS-backed prev_uptime survived. A log
* that does not survive a power cycle is the exact thing this module
* exists to prevent, so the cost of an fsync per record is accepted:
* at one record per 60 s it is nothing, and the bounded write rate is
* what keeps it that way. */
fflush(s_fp);
fsync(fileno(s_fp));
s_head = (s_head + 1) % NODE_LOG_MAX_RECORDS;
if (s_count < NODE_LOG_MAX_RECORDS) s_count++;
} else {
ESP_LOGW(TAG, "record write failed; disabling to protect the volume");
s_active = false;
}
xSemaphoreGive(s_lock);
}
esp_err_t node_log_init(void)
{
if (s_active) return ESP_OK;
s_lock = xSemaphoreCreateMutex();
if (!s_lock) return ESP_ERR_NO_MEM;
const esp_vfs_fat_mount_config_t cfg = {
.format_if_mount_failed = true,
.max_files = 2,
.allocation_unit_size = CONFIG_WL_SECTOR_SIZE,
};
esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl(
NODE_LOG_MOUNT, NODE_LOG_PARTITION, &cfg, &s_wl);
if (err != ESP_OK) {
ESP_LOGW(TAG, "FAT mount failed (%s); logging DISABLED, node unaffected",
esp_err_to_name(err));
return err;
}
/* boot_id orders records across reboots. Without an RTC it is the only
* ordering that survives a power cycle. */
nvs_handle_t nh;
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
uint16_t b = 0;
nvs_get_u16(nh, "boot_id", &b);
s_boot_id = (uint16_t)(b + 1);
nvs_set_u16(nh, "boot_id", s_boot_id);
nvs_commit(nh);
nvs_close(nh);
}
s_fp = fopen(NODE_LOG_PATH, "r+b");
if (!s_fp) {
/* First boot only. Create it, then fsync so the directory entry is on
* flash before anything is written into it. */
s_fp = fopen(NODE_LOG_PATH, "w+b");
if (s_fp) {
fflush(s_fp);
fsync(fileno(s_fp));
}
}
if (!s_fp) {
ESP_LOGW(TAG, "cannot open %s; logging DISABLED", NODE_LOG_PATH);
esp_vfs_fat_spiflash_unmount_rw_wl(NODE_LOG_MOUNT, s_wl);
s_wl = WL_INVALID_HANDLE;
return ESP_FAIL;
}
/* Recover head/seq by scanning. The ring has no superblock deliberately:
* a superblock is one more thing a power cut can leave inconsistent, and
* the scan is a one-off cost at boot. */
uint8_t rec[NODE_LOG_RECORD_SIZE];
uint32_t best_seq = 0;
for (size_t i = 0; i < NODE_LOG_MAX_RECORDS; i++) {
if (fseek(s_fp, (long)(i * NODE_LOG_RECORD_SIZE), SEEK_SET) != 0) break;
if (fread(rec, 1, sizeof(rec), s_fp) != sizeof(rec)) break;
node_log_hdr_t *h = (node_log_hdr_t *)rec;
if (h->type == 0) continue;
uint16_t got = h->crc16;
h->crc16 = 0;
if (crc16(rec, sizeof(rec)) != got) continue;
s_count++;
if (h->seq > best_seq) {
best_seq = h->seq;
s_head = (i + 1) % NODE_LOG_MAX_RECORDS;
}
}
s_seq = best_seq;
s_active = true;
ESP_LOGI(TAG, "logging active: boot_id=%u recovered=%u records, head=%u",
(unsigned)s_boot_id, (unsigned)s_count, (unsigned)s_head);
return ESP_OK;
}
bool node_log_is_active(void) { return s_active; }
uint16_t node_log_boot_id(void) { return s_boot_id; }
size_t node_log_count(void) { return s_count; }
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
{
node_log_boot_t b;
memset(&b, 0, sizeof(b));
b.reset_reason = reset_reason;
b.prev_uptime_s = prev_uptime_s;
fill_health(&b.health);
append(NODE_LOG_TYPE_BOOT, &b, sizeof(b));
}
void node_log_periodic(void)
{
int64_t now = esp_timer_get_time();
if (s_last_periodic_us != 0
&& (now - s_last_periodic_us) < (int64_t)NODE_LOG_MIN_PERIOD_S * 1000000) {
return;
}
s_last_periodic_us = now;
node_log_periodic_t h;
fill_health(&h);
append(NODE_LOG_TYPE_PERIODIC, &h, sizeof(h));
}
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
{
int64_t now = esp_timer_get_time();
if (s_last_event_us != 0
&& (now - s_last_event_us) < (int64_t)NODE_LOG_MIN_EVENT_MS * 1000) {
return;
}
s_last_event_us = now;
node_log_event_t e;
memset(&e, 0, sizeof(e));
e.subtype = subtype;
e.a = a;
e.b = b;
append(NODE_LOG_TYPE_EVENT, &e, sizeof(e));
}
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
{
if (!s_active || !out || max_records == 0) return 0;
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(500)) != pdTRUE) return 0;
size_t written = 0;
for (size_t n = 0; n < s_count && written < max_records; n++) {
if (n < skip) continue;
/* newest-first: step back from head */
size_t idx = (s_head + NODE_LOG_MAX_RECORDS - 1 - n) % NODE_LOG_MAX_RECORDS;
if (fseek(s_fp, (long)(idx * NODE_LOG_RECORD_SIZE), SEEK_SET) != 0) break;
if (fread(out + written * NODE_LOG_RECORD_SIZE, 1, NODE_LOG_RECORD_SIZE, s_fp)
!= NODE_LOG_RECORD_SIZE) break;
written++;
}
xSemaphoreGive(s_lock);
return written;
}
esp_err_t node_log_clear(void)
{
if (!s_active) return ESP_ERR_INVALID_STATE;
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) != pdTRUE) return ESP_ERR_TIMEOUT;
esp_err_t err = ESP_OK;
if (s_fp) { fclose(s_fp); s_fp = NULL; }
s_fp = fopen(NODE_LOG_PATH, "w+b");
if (!s_fp) { s_active = false; err = ESP_FAIL; }
s_count = 0;
s_head = 0;
s_seq = 0;
xSemaphoreGive(s_lock);
return err;
}
#else /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
esp_err_t node_log_init(void) { return ESP_ERR_NOT_SUPPORTED; }
bool node_log_is_active(void) { return false; }
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
{
(void)reset_reason;
(void)prev_uptime_s;
}
void node_log_periodic(void) { }
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
{
(void)subtype;
(void)a;
(void)b;
}
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
{
(void)reason;
(void)rssi;
}
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
{
(void)out;
(void)max_records;
(void)skip;
return 0;
}
size_t node_log_count(void) { return 0; }
uint16_t node_log_boot_id(void) { return 0; }
esp_err_t node_log_clear(void) { return ESP_ERR_NOT_SUPPORTED; }
#endif /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
+114
View File
@@ -0,0 +1,114 @@
/* Persistent on-node log -- survives a power cycle, readable without a cable.
*
* The problem this exists for (Joe, 2026-09-07): "a hung node ... doesnt do us
* any good for troubleshooting remotly because the unplug and mount results in
* a clearing of the condtion." Today the post-mortem for any remote fault is
* nothing: main.c decodes the reset reason at boot and logs it to the CONSOLE,
* which is exactly what a power cycle destroys.
*
* The flash was already partitioned for this and the features were off --
* partitions_16mb.csv reserves a 64K coredump partition and an 8000K storage
* FAT volume, neither of which had ever been mounted or written.
*
* # Retention, not wear, is the binding constraint
*
* 128 B records every 300 s fills 8 MB in ~222 days. Endurance at that rate is
* ~61,000 years, so the ring wraps 36 million times before the flash is at
* risk. The write rate is nonetheless bounded explicitly below, because flash
* wear is the one way this module can do harm.
*
* # Why uptime and boot_id rather than a timestamp
*
* These boards have no RTC. Nothing can be timestamped absolutely, so ordering
* comes from boot_id (incremented once per boot, persisted in NVS) plus
* uptime_s within that boot. That ordering survives a power cycle; a wall clock
* would not.
*
* # Counters are stored RAW
*
* Every counter here is cumulative and is written as-is. Differencing happens
* at read time. Storing rates instead would bake in the sampling interval and
* make every record useless the moment the cadence changed.
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/** Every record is exactly this size, so the ring is index-addressable and a
* reader never has to parse forward to find a boundary. */
#define NODE_LOG_RECORD_SIZE 128
/** Record types. A reader dispatches on this byte alone. */
#define NODE_LOG_TYPE_PERIODIC 1
#define NODE_LOG_TYPE_BOOT 2
#define NODE_LOG_TYPE_EVENT 3
/** Event subtypes. Periodic samples give the trend; these give the moment. */
#define NODE_LOG_EV_WIFI_DISCONNECT 1
#define NODE_LOG_EV_WIFI_RECONNECT 2
#define NODE_LOG_EV_THERMAL_STATE 3
#define NODE_LOG_EV_SEND_FAIL_RATE 4
#define NODE_LOG_EV_WATCHDOG 5
/** Mount the FAT volume and open the ring. Call once at boot, after NVS is up
* (the boot counter lives there).
*
* Failure is NOT fatal: on any error this module disables itself and every
* other entry point becomes a no-op. A logging volume that will not mount must
* never take the fleet down with it. */
esp_err_t node_log_init(void);
/** True when the ring mounted and writes are being accepted. */
bool node_log_is_active(void);
/** Append the boot record: reset reason, the previous session's final uptime,
* and a health snapshot.
*
* The highest-value record here. It catches a HANG FOLLOWED BY A WATCHDOG
* RESET, which is the failure that bites this fleet and the one a coredump
* does not capture. */
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s);
/** Append a periodic health sample. Rate-limited internally, so a caller cannot
* wear the flash by calling it in a tight loop. */
void node_log_periodic(void);
/** Append an event record. a/b carry subtype-specific detail -- for a WiFi
* disconnect they are the reason code and the RSSI at that moment, the pair
* main.c currently logs only to the console. */
void node_log_event(uint8_t subtype, int32_t a, int32_t b);
/** Record a WiFi disconnect for the health snapshot's cumulative counters.
* Kept as a setter so this module has no include cycle with main.c, which is
* where the reason and RSSI are actually known. */
void node_log_note_disconnect(uint8_t reason, int8_t rssi);
/** Copy raw records into out, newest-first, for the HTTP reader.
*
* @param out destination, at least max_records * NODE_LOG_RECORD_SIZE
* @param max_records maximum to copy
* @param skip how many of the newest to skip, so a caller can page
* @return number of records written into out */
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip);
/** Total records currently held in the ring. */
size_t node_log_count(void);
/** Boot id of the current session. Records from the same boot share it. */
uint16_t node_log_boot_id(void);
/** Erase every record. Deliberately explicit and never called automatically --
* the whole point of the module is that state survives. */
esp_err_t node_log_clear(void);
#ifdef __cplusplus
}
#endif
+1 -1
View File
@@ -109,7 +109,7 @@ void nvs_config_load(nvs_config_t *cfg)
len = sizeof(buf);
if (nvs_get_str(handle, "ssid", buf, &len) == ESP_OK && len > 1) {
strlcpy(cfg->wifi_ssid, buf, sizeof(cfg->wifi_ssid));
ESP_LOGI(TAG, "NVS override: ssid=%s", cfg->wifi_ssid);
ESP_LOGI(TAG, "NVS override: ssid=<configured>");
}
/* WiFi password */
@@ -0,0 +1,204 @@
#include "serial_onboarding.h"
#include "serial_onboarding_protocol.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "esp_app_desc.h"
#include "esp_efuse.h"
#include "esp_log.h"
#include "esp_mac.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "psa/crypto.h"
static const char *TAG = "serial_onboard";
static nvs_config_t s_current_config;
static char s_issued_nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1];
static int64_t s_nonce_issued_at_us;
#define ONBOARDING_LINE_MAX 384
#define ONBOARDING_NONCE_TTL_US (60LL * 1000LL * 1000LL)
static const char *chip_name(void)
{
#if defined(CONFIG_IDF_TARGET_ESP32C6)
return "esp32c6";
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
return "esp32s3";
#else
return "esp32";
#endif
}
static void device_digest(char output[17])
{
uint8_t base_mac[6] = {0};
uint8_t digest[32] = {0};
static const uint8_t domain[] = "ruview-device-v1";
uint8_t digest_input[sizeof(domain) - 1 + sizeof(base_mac)];
size_t digest_length = 0;
memcpy(digest_input, domain, sizeof(domain) - 1);
const esp_err_t mac_result = esp_efuse_mac_get_default(base_mac);
if (mac_result == ESP_OK) {
memcpy(digest_input + sizeof(domain) - 1, base_mac, sizeof(base_mac));
}
if (mac_result == ESP_OK &&
psa_crypto_init() == PSA_SUCCESS &&
psa_hash_compute(PSA_ALG_SHA_256, digest_input, sizeof(digest_input),
digest, sizeof(digest), &digest_length) == PSA_SUCCESS &&
digest_length == sizeof(digest)) {
for (size_t index = 0; index < 8; index++) {
snprintf(output + index * 2, 3, "%02x", digest[index]);
}
} else {
memcpy(output, "0000000000000000", 17);
}
}
static void emit_hello(const char *nonce)
{
const esp_app_desc_t *description = esp_app_get_description();
char digest[17] = {0};
device_digest(digest);
const bool configured = s_current_config.wifi_ssid[0] != '\0' &&
s_current_config.target_ip[0] != '\0';
printf("RUVIEW_HELLO_OK_V1 nonce=%s chip=%s version=%s node_id=%u "
"target_ip=%s target_port=%u configured=%u device_digest=%s\n",
nonce, chip_name(), description->version,
(unsigned)s_current_config.node_id, s_current_config.target_ip,
(unsigned)s_current_config.target_port, configured ? 1U : 0U, digest);
fflush(stdout);
}
static esp_err_t commit_config(const ruview_onboarding_config_t *config)
{
nvs_handle_t handle;
esp_err_t result = nvs_open("csi_cfg", NVS_READWRITE, &handle);
if (result != ESP_OK) return result;
if (!config->preserve_wifi) {
result = nvs_set_str(handle, "ssid", config->ssid);
if (result == ESP_OK) result = nvs_set_str(handle, "password", config->password);
}
if (result == ESP_OK) result = nvs_set_str(handle, "target_ip", config->target_ip);
if (result == ESP_OK) result = nvs_set_u16(handle, "target_port", config->target_port);
if (result == ESP_OK) result = nvs_set_u8(handle, "node_id", config->node_id);
if (result == ESP_OK) result = nvs_commit(handle);
nvs_close(handle);
return result;
}
static void process_line(char *line)
{
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1] = {0};
if (ruview_onboarding_parse_hello(line, nonce)) {
memcpy(s_issued_nonce, nonce, sizeof(s_issued_nonce));
s_nonce_issued_at_us = esp_timer_get_time();
emit_hello(nonce);
return;
}
if (strncmp(line, "RUVIEW_CONFIG_V1 ", 17) != 0) return;
ruview_onboarding_config_t request;
const ruview_onboarding_result_t parsed = ruview_onboarding_parse_config(line, &request);
if (parsed != RUVIEW_ONBOARDING_OK) {
printf("RUVIEW_CONFIG_ERR_V1 reason=%s\n", ruview_onboarding_result_name(parsed));
fflush(stdout);
return;
}
const int64_t age_us = esp_timer_get_time() - s_nonce_issued_at_us;
if (s_issued_nonce[0] == '\0' || strcmp(request.nonce, s_issued_nonce) != 0 ||
age_us < 0 || age_us > ONBOARDING_NONCE_TTL_US) {
printf("RUVIEW_CONFIG_ERR_V1 nonce=%s reason=claim_expired\n", request.nonce);
fflush(stdout);
return;
}
s_issued_nonce[0] = '\0';
const esp_err_t result = commit_config(&request);
if (result != ESP_OK) {
printf("RUVIEW_CONFIG_ERR_V1 nonce=%s reason=nvs_commit code=%s\n",
request.nonce, esp_err_to_name(result));
fflush(stdout);
return;
}
printf("RUVIEW_CONFIG_OK_V1 nonce=%s node_id=%u rebooting=1\n",
request.nonce, (unsigned)request.node_id);
fflush(stdout);
vTaskDelay(pdMS_TO_TICKS(400));
esp_restart();
}
static void consume_bytes(const uint8_t *input, ssize_t count, char *line,
size_t *length, bool *overflow)
{
for (ssize_t index = 0; index < count; index++) {
const uint8_t byte = input[index];
if (byte == '\r') continue;
if (byte == '\n') {
if (!*overflow && *length > 0) {
line[*length] = '\0';
process_line(line);
}
*length = 0;
*overflow = false;
continue;
}
if (byte < 0x20 || byte == 0x7f) continue;
if (*length + 1 < ONBOARDING_LINE_MAX) line[(*length)++] = (char)byte;
else *overflow = true;
}
}
static void serial_onboarding_task(void *argument)
{
(void)argument;
const int original_flags = fcntl(STDIN_FILENO, F_GETFL, 0);
if (original_flags >= 0) (void)fcntl(STDIN_FILENO, F_SETFL, original_flags | O_NONBLOCK);
/* ESP-IDF secondary consoles mirror output but deliberately do not forward
* input to STDIN. Native S3/C6 USB Serial/JTAG therefore needs a bounded
* direct VFS reader while UART-console boards continue to use STDIN. */
const int usb_fd = open("/dev/secondary", O_RDONLY | O_NONBLOCK);
char primary_line[ONBOARDING_LINE_MAX];
char usb_line[ONBOARDING_LINE_MAX];
size_t primary_length = 0;
size_t usb_length = 0;
bool primary_overflow = false;
bool usb_overflow = false;
for (;;) {
uint8_t input[64];
const ssize_t count = read(STDIN_FILENO, input, sizeof(input));
if (count > 0) {
consume_bytes(input, count, primary_line, &primary_length, &primary_overflow);
}
else if (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
ESP_LOGW(TAG, "serial input unavailable: errno=%d", errno);
vTaskDelay(pdMS_TO_TICKS(500));
}
if (usb_fd >= 0) {
const ssize_t usb_count = read(usb_fd, input, sizeof(input));
if (usb_count > 0) {
consume_bytes(input, usb_count, usb_line, &usb_length, &usb_overflow);
}
}
vTaskDelay(pdMS_TO_TICKS(20));
}
}
esp_err_t serial_onboarding_start(const nvs_config_t *config)
{
if (config == NULL) return ESP_ERR_INVALID_ARG;
memcpy(&s_current_config, config, sizeof(s_current_config));
const BaseType_t created = xTaskCreate(serial_onboarding_task, "serial_onboard",
4096, NULL, 4, NULL);
if (created != pdPASS) return ESP_ERR_NO_MEM;
ESP_LOGI(TAG, "bounded serial onboarding ready");
return ESP_OK;
}
@@ -0,0 +1,15 @@
#ifndef SERIAL_ONBOARDING_H
#define SERIAL_ONBOARDING_H
#include "esp_err.h"
#include "nvs_config.h"
/** Start the bounded USB serial onboarding listener.
*
* The listener exposes device metadata and accepts one nonce-bound
* configuration transaction. It never prints WiFi credentials and reboots
* only after a successful atomic NVS commit.
*/
esp_err_t serial_onboarding_start(const nvs_config_t *config);
#endif
@@ -0,0 +1,214 @@
#ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L /* strtok_r under -std=c11 -pedantic on glibc */
#endif
#include "serial_onboarding_protocol.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define HELLO_COMMAND "RUVIEW_HELLO_V1"
#define CONFIG_COMMAND "RUVIEW_CONFIG_V1"
static bool valid_nonce(const char *value)
{
if (value == NULL || strlen(value) != RUVIEW_ONBOARDING_NONCE_HEX_LEN) return false;
for (size_t index = 0; index < RUVIEW_ONBOARDING_NONCE_HEX_LEN; index++) {
const char c = value[index];
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F'))) return false;
}
return true;
}
static bool parse_u32(const char *value, uint32_t maximum, uint32_t *output)
{
if (value == NULL || value[0] == '\0' || output == NULL) return false;
uint32_t result = 0;
for (const char *cursor = value; *cursor != '\0'; cursor++) {
if (*cursor < '0' || *cursor > '9') return false;
const uint32_t digit = (uint32_t)(*cursor - '0');
if (result > (maximum - digit) / 10U) return false;
result = result * 10U + digit;
}
*output = result;
return true;
}
static bool valid_ipv4(const char *value)
{
if (value == NULL || value[0] == '\0' || strlen(value) > 15) return false;
unsigned octets = 0;
const char *cursor = value;
while (*cursor != '\0') {
if (octets == 4) return false;
if (*cursor == '.') return false;
uint32_t octet = 0;
unsigned digits = 0;
while (*cursor != '\0' && *cursor != '.') {
if (*cursor < '0' || *cursor > '9' || digits == 3) return false;
octet = octet * 10U + (uint32_t)(*cursor - '0');
if (octet > 255U) return false;
digits++;
cursor++;
}
if (digits == 0) return false;
octets++;
if (*cursor == '.') cursor++;
}
return octets == 4;
}
static bool valid_private_ipv4(const char *value)
{
if (!valid_ipv4(value)) return false;
unsigned first = 0;
unsigned second = 0;
if (sscanf(value, "%u.%u", &first, &second) != 2) return false;
return first == 10U || (first == 172U && second >= 16U && second <= 31U) ||
(first == 192U && second == 168U);
}
static int base64_value(char c)
{
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
static bool decode_base64(const char *encoded, uint8_t *output,
size_t output_capacity, size_t *output_length)
{
if (encoded == NULL || output == NULL || output_length == NULL) return false;
const size_t length = strlen(encoded);
if (length == 0 || length % 4 != 0) return false;
size_t written = 0;
for (size_t index = 0; index < length; index += 4) {
int values[4];
unsigned padding = 0;
for (unsigned offset = 0; offset < 4; offset++) {
const char c = encoded[index + offset];
if (c == '=') {
if (offset < 2 || index + 4 != length) return false;
values[offset] = 0;
padding++;
} else {
if (padding != 0) return false;
values[offset] = base64_value(c);
if (values[offset] < 0) return false;
}
}
if (padding > 2) return false;
const uint32_t block = ((uint32_t)values[0] << 18) |
((uint32_t)values[1] << 12) |
((uint32_t)values[2] << 6) |
(uint32_t)values[3];
const unsigned bytes = 3U - padding;
if (written + bytes > output_capacity) return false;
output[written++] = (uint8_t)(block >> 16);
if (bytes > 1) output[written++] = (uint8_t)(block >> 8);
if (bytes > 2) output[written++] = (uint8_t)block;
}
*output_length = written;
return true;
}
static bool printable_secret(const uint8_t *bytes, size_t length)
{
if (bytes == NULL) return false;
for (size_t index = 0; index < length; index++) {
if (bytes[index] < 0x20 || bytes[index] == 0x7f) return false;
}
return true;
}
int ruview_onboarding_parse_hello(
const char *line,
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1])
{
if (line == NULL || nonce == NULL) return 0;
const size_t prefix_length = strlen(HELLO_COMMAND);
if (strncmp(line, HELLO_COMMAND, prefix_length) != 0 || line[prefix_length] != ' ')
return 0;
const char *candidate = line + prefix_length + 1;
if (!valid_nonce(candidate)) return 0;
memcpy(nonce, candidate, RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1);
return 1;
}
ruview_onboarding_result_t ruview_onboarding_parse_config(
const char *line,
ruview_onboarding_config_t *config)
{
if (line == NULL || config == NULL || strlen(line) >= 384) return RUVIEW_ONBOARDING_ERR_FORMAT;
char copy[384];
memcpy(copy, line, strlen(line) + 1);
char *tokens[7] = {0};
size_t token_count = 0;
char *save = NULL;
for (char *token = strtok_r(copy, " ", &save); token != NULL;
token = strtok_r(NULL, " ", &save)) {
if (token_count == 7) return RUVIEW_ONBOARDING_ERR_FORMAT;
tokens[token_count++] = token;
}
if (token_count != 7 || strcmp(tokens[0], CONFIG_COMMAND) != 0)
return RUVIEW_ONBOARDING_ERR_FORMAT;
if (!valid_nonce(tokens[1])) return RUVIEW_ONBOARDING_ERR_NONCE;
uint32_t node_id = 0;
if (!parse_u32(tokens[2], 255, &node_id) || node_id == 0)
return RUVIEW_ONBOARDING_ERR_NODE_ID;
if (!valid_private_ipv4(tokens[3])) return RUVIEW_ONBOARDING_ERR_TARGET_IP;
uint32_t target_port = 0;
if (!parse_u32(tokens[4], 65535, &target_port) || target_port == 0)
return RUVIEW_ONBOARDING_ERR_TARGET_PORT;
const bool preserve_wifi = strcmp(tokens[5], "KEEP") == 0 &&
strcmp(tokens[6], "KEEP") == 0;
if ((strcmp(tokens[5], "KEEP") == 0) != (strcmp(tokens[6], "KEEP") == 0))
return RUVIEW_ONBOARDING_ERR_FORMAT;
uint8_t ssid[RUVIEW_ONBOARDING_SSID_MAX_BYTES];
size_t ssid_length = 0;
if (!preserve_wifi && (!decode_base64(tokens[5], ssid, sizeof(ssid), &ssid_length) ||
ssid_length == 0 || !printable_secret(ssid, ssid_length)))
return RUVIEW_ONBOARDING_ERR_SSID;
uint8_t password[RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES];
size_t password_length = 0;
if (!preserve_wifi && (!decode_base64(tokens[6], password, sizeof(password), &password_length) ||
password_length < 8 || !printable_secret(password, password_length)))
return RUVIEW_ONBOARDING_ERR_PASSWORD;
memset(config, 0, sizeof(*config));
memcpy(config->nonce, tokens[1], RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1);
config->node_id = (uint8_t)node_id;
memcpy(config->target_ip, tokens[3], strlen(tokens[3]) + 1);
config->target_port = (uint16_t)target_port;
config->preserve_wifi = preserve_wifi;
if (!preserve_wifi) {
memcpy(config->ssid, ssid, ssid_length);
config->ssid[ssid_length] = '\0';
memcpy(config->password, password, password_length);
config->password[password_length] = '\0';
}
return RUVIEW_ONBOARDING_OK;
}
const char *ruview_onboarding_result_name(ruview_onboarding_result_t result)
{
switch (result) {
case RUVIEW_ONBOARDING_OK: return "ok";
case RUVIEW_ONBOARDING_ERR_FORMAT: return "format";
case RUVIEW_ONBOARDING_ERR_NONCE: return "nonce";
case RUVIEW_ONBOARDING_ERR_NODE_ID: return "node_id";
case RUVIEW_ONBOARDING_ERR_TARGET_IP: return "target_ip";
case RUVIEW_ONBOARDING_ERR_TARGET_PORT: return "target_port";
case RUVIEW_ONBOARDING_ERR_SSID: return "ssid";
case RUVIEW_ONBOARDING_ERR_PASSWORD: return "password";
default: return "unknown";
}
}
@@ -0,0 +1,42 @@
#ifndef SERIAL_ONBOARDING_PROTOCOL_H
#define SERIAL_ONBOARDING_PROTOCOL_H
#include <stddef.h>
#include <stdbool.h>
#include <stdint.h>
#define RUVIEW_ONBOARDING_NONCE_HEX_LEN 32
#define RUVIEW_ONBOARDING_SSID_MAX_BYTES 32
#define RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES 63
typedef struct {
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1];
uint8_t node_id;
char target_ip[16];
uint16_t target_port;
bool preserve_wifi;
char ssid[RUVIEW_ONBOARDING_SSID_MAX_BYTES + 1];
char password[RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES + 1];
} ruview_onboarding_config_t;
typedef enum {
RUVIEW_ONBOARDING_OK = 0,
RUVIEW_ONBOARDING_ERR_FORMAT,
RUVIEW_ONBOARDING_ERR_NONCE,
RUVIEW_ONBOARDING_ERR_NODE_ID,
RUVIEW_ONBOARDING_ERR_TARGET_IP,
RUVIEW_ONBOARDING_ERR_TARGET_PORT,
RUVIEW_ONBOARDING_ERR_SSID,
RUVIEW_ONBOARDING_ERR_PASSWORD,
} ruview_onboarding_result_t;
int ruview_onboarding_parse_hello(const char *line,
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1]);
ruview_onboarding_result_t ruview_onboarding_parse_config(
const char *line,
ruview_onboarding_config_t *config);
const char *ruview_onboarding_result_name(ruview_onboarding_result_t result);
#endif
@@ -0,0 +1,25 @@
# ESP32-C6 CSI Node - 16MB flash partition table
# For boards with 16MB flash (e.g. ESP32-C6 DevKitC-1 N16).
#
# Two 4MB OTA slots. The app is ~1.04MB, so the slots are far larger than
# needed for the binary alone -- the size is chosen so an OTA never has to
# care how much the image has grown, and it leaves room for a coredump
# partition and an 8MB FAT volume for on-node storage.
#
# The 4MB and display layouts do not fit this: their slots are 1.875MB and
# 2MB, sized for 4MB and 8MB parts respectively.
#
# Usage: set in sdkconfig, or use the matching defaults file:
# CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_16mb.csv"
# idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.16mb" build
#
# Verify before flashing an already-deployed node: a smaller table written
# over a 16MB node relocates NVS and destroys its provisioning.
# Name, Type, SubType, Offset, Size, Flags
nvs,data,nvs,0x9000,24K,
otadata,data,ota,0xf000,8K,
phy_init,data,phy,0x11000,4K,
ota_0,app,ota_0,0x20000,4M,
ota_1,app,ota_1,0x420000,4M,
coredump,data,coredump,0x820000,64K,
storage,data,fat,0x830000,8000K,
Can't render this file because it contains an unexpected character in line 13 and column 44.
@@ -6,3 +6,4 @@ phy_init, data, phy, 0x11000, 0x1000,
ota_0, app, ota_0, 0x20000, 0x200000,
ota_1, app, ota_1, 0x220000, 0x200000,
spiffs, data, spiffs, 0x420000, 0x1E0000,
storage, data, fat, 0x600000, 0x200000,
1 # ESP32-S3 CSI Node — 8MB flash partition table (ADR-045)
6 ota_0, app, ota_0, 0x20000, 0x200000,
7 ota_1, app, ota_1, 0x220000, 0x200000,
8 spiffs, data, spiffs, 0x420000, 0x1E0000,
9 storage, data, fat, 0x600000, 0x200000,
@@ -0,0 +1,4 @@
126a4b9f305e207aa2e0db93caf09e27f77b3bee4b4f618a0cb963f10a4364bc bootloader.bin
183c4557a93f6f0be1680f45988b7c7320130c8e0ed5d9c900add038d5cc84fb esp32-csi-node.bin
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f ota_data_initial.bin
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 partition-table.bin
@@ -0,0 +1 @@
�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������Line truncated
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Run the gate factorial: U / M / S / MS, Latin-square blocked.
Arms (NVS gate_mode, applied live -- no reboot):
0 U elapsed-time gate, as upstream ships it
1 M mesh-aligned bucket gate (ours, 4b54ef8e)
2 S rx_seq selection + elapsed underneath
3 MS both stacked
Why it is built this way, and what each guard is for:
* Every arm is applied to the WHOLE fleet, never to a subset. The stopped
2026-09-04 run compared four gated nodes against four different control
nodes; nodes differ, and that comparison could not be read.
* After every switch the runner reads back `active.gate_mode` from EVERY node
and refuses to sample unless all nine agree. "Config was stored" is not
"gate is running" -- that gap is exactly what the previous run could not
distinguish from a null result.
* Arms are short and blocked in a Latin square rather than run in long
sequential batches, so slow drift lands on all arms equally instead of being
aliased onto whichever arm ran first. Measured drift over 20 min windows is
sd 0.0045; counting noise is +/-0.002.
* Metrics come from differenced cumulative counters, so the server is never
restarted and the fleet stays in production throughout.
Output: arms_<label>.csv, one row per arm, in the fleet-baselines directory
(outside git -- it carries presence information).
"""
import argparse, csv, json, os, sys, time, urllib.request
PORT = 8032
SERVER = "http://127.0.0.1:3000"
OUTDIR = "C:/temp/ruview/fleet-baselines/gate-experiment"
NODES = {0: "192.168.1.171", 1: "192.168.1.219", 2: "192.168.1.72",
3: "192.168.1.112", 4: "192.168.1.144", 5: "192.168.1.198",
6: "192.168.1.199", 7: "192.168.1.25", 8: "192.168.1.122"}
ARM_NAME = {0: "U_elapsed", 1: "M_mesh", 2: "S_seq", 3: "MS_both"}
# Latin square: every arm appears once per block, in a different position.
BLOCKS = [[0, 1, 2, 3], [2, 3, 1, 0], [3, 0, 2, 1]]
def key():
p = os.environ.get("RUVIEW_OTA_PSK_FILE")
if not p or not os.path.isfile(p):
sys.exit("set RUVIEW_OTA_PSK_FILE to the psk file path")
return open(p, encoding="utf-8").read().strip()
def node_get(ip, path, k, timeout=10):
r = urllib.request.Request(f"http://{ip}:{PORT}{path}")
r.add_header("Authorization", "Bearer " + k)
with urllib.request.urlopen(r, timeout=timeout) as resp:
return json.load(resp)
def node_post(ip, path, k, payload, timeout=15):
body = json.dumps(payload).encode()
r = urllib.request.Request(f"http://{ip}:{PORT}{path}", data=body)
r.add_header("Authorization", "Bearer " + k)
r.add_header("Content-Type", "application/json")
with urllib.request.urlopen(r, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
def srv(path):
with urllib.request.urlopen(SERVER + path, timeout=15) as r:
return json.load(r)
def set_arm(mode, period, k):
"""Apply an arm fleet-wide and PROVE every node is running it."""
for nid, ip in sorted(NODES.items()):
try:
node_post(ip, "/config", k, {"gate_mode": mode, "gate_seq_period": period})
except Exception as e:
print(f" n{nid} set FAILED: {e}", file=sys.stderr)
time.sleep(5)
bad = []
for nid, ip in sorted(NODES.items()):
try:
act = node_get(ip, "/config", k).get("active", {})
if act.get("gate_mode") != mode or act.get("gate_seq_period") != period:
bad.append((nid, act))
except Exception as e:
bad.append((nid, str(e)))
return bad
def snapshot():
f = srv("/api/v1/fusion")
m = srv("/api/v1/mesh")["nodes"]
nodes = {}
for k2, v in m.items():
tx = v.get("health", {}).get("tx", {})
nodes[k2] = {"acc": v.get("csi_fps_samples"), "ed": tx.get("early_drop"),
"fps": v.get("csi_fps_ema"), "valid": v.get("is_valid")}
pairs = {f"{p['a']}-{p['b']}": p["common"] for p in f.get("pairs", [])}
return {"t": time.time(), "observations": f["observations"],
"transmissions": f["transmissions"], "paired": f["paired"],
"nodes": nodes, "pairs": pairs}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--label", default="factorial")
ap.add_argument("--arm-minutes", type=float, default=20.0)
ap.add_argument("--settle-minutes", type=float, default=3.0)
ap.add_argument("--period", type=int, default=8)
ap.add_argument("--blocks", type=int, default=3)
a = ap.parse_args()
k = key()
os.makedirs(OUTDIR, exist_ok=True)
out = os.path.join(OUTDIR, f"arms_{a.label}.csv")
new = not os.path.exists(out)
fh = open(out, "a", newline="", encoding="utf-8")
w = csv.writer(fh)
if new:
w.writerow(["block", "arm", "arm_name", "period", "start_iso", "end_iso",
"dur_s", "d_observations", "d_transmissions", "d_paired",
"paired_fraction", "node_json", "pairs_json", "verified"])
fh.flush()
print(f"gate factorial: {a.blocks} blocks x 4 arms x {a.arm_minutes:g} min "
f"(+{a.settle_minutes:g} min settle), seq period {a.period}")
print(f"est. total {a.blocks*4*(a.arm_minutes+a.settle_minutes)/60:.1f} h -> {out}\n")
for bi in range(a.blocks):
order = BLOCKS[bi % len(BLOCKS)]
print(f"--- block {bi}: {' '.join(ARM_NAME[m] for m in order)} ---")
for mode in order:
print(f"[{time.strftime('%H:%M:%S')}] arm {ARM_NAME[mode]} ...", flush=True)
bad = set_arm(mode, a.period, k)
if bad:
print(f" WARNING not all nodes on arm: {bad}", file=sys.stderr)
# Settle: the gate changes instantly, but csi_fps_ema and the
# fusion pairing window carry the previous arm for a while.
time.sleep(a.settle_minutes * 60)
s0 = snapshot()
time.sleep(a.arm_minutes * 60)
s1 = snapshot()
dtr = s1["transmissions"] - s0["transmissions"]
dp = s1["paired"] - s0["paired"]
pf = dp / dtr if dtr > 0 else float("nan")
nodes = {}
for nid in s1["nodes"]:
if nid in s0["nodes"]:
dacc = (s1["nodes"][nid]["acc"] or 0) - (s0["nodes"][nid]["acc"] or 0)
ded = (s1["nodes"][nid]["ed"] or 0) - (s0["nodes"][nid]["ed"] or 0)
nodes[nid] = {"d_acc": dacc, "d_ed": ded,
"R": round((dacc + ded) / dacc, 3) if dacc > 0 else None,
"fps": round(s1["nodes"][nid]["fps"] or 0, 2),
"valid": s1["nodes"][nid]["valid"]}
dpairs = {kk: s1["pairs"].get(kk, 0) - s0["pairs"].get(kk, 0)
for kk in s1["pairs"]}
w.writerow([bi, mode, ARM_NAME[mode], a.period,
time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(s0["t"])),
time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(s1["t"])),
round(s1["t"] - s0["t"]),
s1["observations"] - s0["observations"], dtr, dp,
f"{pf:.6f}",
json.dumps(nodes, separators=(",", ":")),
json.dumps(dpairs, separators=(",", ":")),
"no" if bad else "yes"])
fh.flush()
meanR = [v["R"] for v in nodes.values() if v["R"]]
print(f" paired_fraction={pf:.4f} transmissions={dtr} "
f"meanR={sum(meanR)/len(meanR):.2f}" if meanR else "", flush=True)
fh.close()
print("done")
if __name__ == "__main__":
main()
@@ -0,0 +1,41 @@
# ESP32-C6 CSI Node - 16MB Flash SDK Configuration
# For boards with 16MB flash (e.g. ESP32-C6 DevKitC-1 N16).
#
# Layered on top of sdkconfig.defaults.esp32c6, which keeps a 4MB layout
# because 4MB C6 devkits are the common case and must not be silently
# repartitioned by inheriting this.
#
# Build:
# idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.16mb" build
#
# Confirm before flashing a deployed node: /ota/status must report
# max_size 4194304. Writing a smaller table over a 16MB node relocates NVS
# and destroys its provisioning.
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="16MB"
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_16mb.csv"
# Firmware rollback: an OTA'd image boots PENDING_VERIFY and must confirm
# itself, or the bootloader reverts on the next boot. See ota_rollback_* in
# ota_update.c for when confirmation happens and why.
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
# Coredump to flash. The 64K `coredump` partition has existed in
# partitions_16mb.csv since the 16MB layout was written and has never held
# anything, because the build defaulted to
# CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y. A panic therefore left no stack trace
# on a fleet whose faults are all remote.
#
# Complementary to the on-node log, not a substitute: a coredump captures a
# CRASH, and the log's boot record captures a HANG followed by a watchdog
# reset -- which is the failure this fleet actually sees.
CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y
CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y
CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y
CONFIG_ESP_COREDUMP_LOGS=y
# The `storage` FAT volume (8000K) that node_log.c mounts. Reserved in the
# same partition table and likewise never used until now.
CONFIG_WL_SECTOR_SIZE_4096=y
@@ -0,0 +1,4 @@
# Seeed XIAO ESP32-C6 external U.FL antenna test overlay.
# Use only with the XIAO board; generic C6 boards may use GPIO3/GPIO14.
CONFIG_C6_XIAO_ANTENNA_SELECT=y
CONFIG_C6_XIAO_ANTENNA_EXTERNAL=y
@@ -0,0 +1,4 @@
# Seeed XIAO ESP32-C6 internal ceramic antenna test overlay.
# Keeps RF-switch control enabled so the boot log proves the selected path.
CONFIG_C6_XIAO_ANTENNA_SELECT=y
CONFIG_C6_XIAO_ANTENNA_INTERNAL=y
+46 -5
View File
@@ -39,16 +39,45 @@ CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \
STUBS_SRC = stubs/esp_stubs.c
MAIN_DIR = ../main
# GNU ld and Apple's ld spell dead section elimination differently.
# Keep the host sanitation test portable across the Linux CI runner and macOS.
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Darwin)
DEAD_STRIP_LDFLAGS := -Wl,-dead_strip
else
DEAD_STRIP_LDFLAGS := -Wl,--gc-sections
endif
# Default fuzz duration (seconds) and jobs
FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
test_thermal run_thermal
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
# --- XIAO ESP32-C6 RF switch selection ---
# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven
# low by the device implementation before this level is applied.
test_c6_antenna: test_c6_antenna_select.c $(MAIN_DIR)/c6_antenna_select.h
cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $<
run_c6_antenna: test_c6_antenna
./test_c6_antenna
# --- ESP-IDF invalid first-word sanitation ---
# Links the production serializer. Function sections and dead stripping keep
# unrelated device-only callback code out of this host executable.
test_csi_sanitize: test_csi_sanitize.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC)
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -ffunction-sections \
$^ $(DEAD_STRIP_LDFLAGS) -o $@ -lm
run_csi_sanitize: test_csi_sanitize
./test_csi_sanitize
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
@@ -93,8 +122,19 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
run_thermal: test_thermal
./test_thermal
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135 + thermal)"
# --- Native Mac USB onboarding protocol ---
# Tests the production parser and secret validation without ESP-IDF. This pins
# nonce shape, IPv4 and node bounds, base64 handling, and control-byte denial.
test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.h
cc -std=c11 -Wall -Wextra -Werror -pedantic -I$(MAIN_DIR) -o $@ \
test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c
run_serial_onboarding: test_serial_onboarding
./test_serial_onboarding
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
run_c6_antenna run_serial_onboarding
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -130,5 +170,6 @@ run_nvs: fuzz_nvs
run_all: run_serialize run_edge run_nvs
clean:
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
@@ -90,6 +90,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
info.rx_ctrl.sig_mode = legacy_inputs & 0x03;
info.rx_ctrl.cwb = (legacy_inputs >> 2) & 0x01;
info.rx_ctrl.stbc = (legacy_inputs >> 3) & 0x01;
info.first_word_invalid = (test_case & 0x80) != 0;
/* Use remaining fuzz data as I/Q buffer content. */
uint16_t iq_len;
@@ -136,6 +137,17 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
if (result > out_len) {
__builtin_trap(); /* Buffer overflow detected. */
}
if (result >= CSI_HEADER_SIZE + iq_len && info.first_word_invalid && iq_len > 0) {
size_t invalid_len = iq_len < 4 ? iq_len : 4;
for (size_t i = 0; i < invalid_len; i++) {
if (out_buf[CSI_HEADER_SIZE + i] != 0) {
__builtin_trap(); /* Invalid hardware prefix escaped sanitation. */
}
}
if ((out_buf[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0) {
__builtin_trap(); /* Sanitized payload must carry provenance. */
}
}
/* --- Test case 1: NULL info pointer --- */
if (test_case & 0x01) {
@@ -90,6 +90,7 @@ typedef struct {
typedef struct {
wifi_pkt_rx_ctrl_t rx_ctrl;
uint8_t mac[6];
bool first_word_invalid;
int16_t len; /**< Length of the I/Q buffer in bytes. */
int8_t *buf; /**< Pointer to I/Q data. */
} wifi_csi_info_t;
@@ -0,0 +1,13 @@
#include <assert.h>
#include <stdio.h>
#include "c6_antenna_select.h"
int main(void)
{
assert(c6_xiao_antenna_gpio14_level(C6_XIAO_ANTENNA_INTERNAL) == 0);
assert(c6_xiao_antenna_gpio14_level(C6_XIAO_ANTENNA_EXTERNAL) == 1);
puts("PASS: XIAO ESP32-C6 antenna selection truth table");
return 0;
}
@@ -0,0 +1,56 @@
/** Deterministic host test for ESP-IDF first_word_invalid sanitation. */
#include "esp_stubs.h"
#include "nvs_config.h"
#include "csi_collector.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
nvs_config_t g_nvs_config;
static void make_info(wifi_csi_info_t *info, int8_t *iq, uint16_t len, bool invalid)
{
memset(info, 0, sizeof(*info));
info->rx_ctrl.channel = 6;
info->rx_ctrl.rssi = -42;
info->rx_ctrl.noise_floor = -91;
info->len = (int16_t)len;
info->buf = iq;
info->first_word_invalid = invalid;
}
int main(void)
{
int8_t iq[] = {1, 2, 3, 4, 5, 6, 7, 8};
uint8_t frame[CSI_MAX_FRAME_SIZE];
wifi_csi_info_t info;
make_info(&info, iq, sizeof(iq), true);
size_t len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(iq));
assert(frame[CSI_HEADER_SIZE] == 0);
assert(frame[CSI_HEADER_SIZE + 1] == 0);
assert(frame[CSI_HEADER_SIZE + 2] == 0);
assert(frame[CSI_HEADER_SIZE + 3] == 0);
assert(frame[CSI_HEADER_SIZE + 4] == 5);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0);
make_info(&info, iq, sizeof(iq), false);
len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(iq));
assert(memcmp(&frame[CSI_HEADER_SIZE], iq, sizeof(iq)) == 0);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0);
int8_t short_iq[] = {9, 10};
make_info(&info, short_iq, sizeof(short_iq), true);
len = csi_serialize_frame(&info, frame, sizeof(frame));
assert(len == CSI_HEADER_SIZE + sizeof(short_iq));
assert(frame[CSI_HEADER_SIZE] == 0);
assert(frame[CSI_HEADER_SIZE + 1] == 0);
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0);
puts("CSI invalid-prefix sanitation: PASS");
return 0;
}
@@ -0,0 +1,68 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "../main/serial_onboarding_protocol.h"
static void test_hello(void)
{
char nonce[33] = {0};
assert(ruview_onboarding_parse_hello(
"RUVIEW_HELLO_V1 00112233445566778899aabbccddeeff", nonce));
assert(strcmp(nonce, "00112233445566778899aabbccddeeff") == 0);
assert(!ruview_onboarding_parse_hello("RUVIEW_HELLO_V1 bad", nonce));
assert(!ruview_onboarding_parse_hello(
"RUVIEW_HELLO_V1 00112233445566778899aabbccddeeff extra", nonce));
}
static void test_config(void)
{
ruview_onboarding_config_t config;
assert(ruview_onboarding_parse_config(
"RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff 5 192.168.1.166 5005 "
"UnVWaWV3IExhYg== U3Ryb25nUGFzc3dvcmQ=", &config) == RUVIEW_ONBOARDING_OK);
assert(config.node_id == 5);
assert(config.target_port == 5005);
assert(strcmp(config.target_ip, "192.168.1.166") == 0);
assert(strcmp(config.ssid, "RuView Lab") == 0);
assert(strcmp(config.password, "StrongPassword") == 0);
assert(!config.preserve_wifi);
assert(ruview_onboarding_parse_config(
"RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff 6 192.168.1.166 5005 KEEP KEEP",
&config) == RUVIEW_ONBOARDING_OK);
assert(config.preserve_wifi);
}
static void test_rejections(void)
{
ruview_onboarding_config_t config;
const char *prefix = "RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff";
char line[512];
snprintf(line, sizeof(line), "%s 0 192.168.1.2 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_NODE_ID);
snprintf(line, sizeof(line), "%s 5 999.1.1.1 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP);
snprintf(line, sizeof(line), "%s 5 8.8.8.8 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP);
snprintf(line, sizeof(line), "%s 5 172.32.0.1 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP);
snprintf(line, sizeof(line), "%s 5 192.168.1.2 0 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_PORT);
snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 !!!! U3Ryb25nUGFzc3dvcmQ=", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_SSID);
snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 UnVWaWV3 c2hvcnQ=", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_PASSWORD);
snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 UnVWaWV3 UGFzc3dvcmQxCg==", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_PASSWORD);
snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 KEEP U3Ryb25nUGFzc3dvcmQ=", prefix);
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_FORMAT);
}
int main(void)
{
test_hello();
test_config();
test_rejections();
puts("serial onboarding protocol tests passed");
return 0;
}
+1 -1
View File
@@ -1 +1 @@
0.8.8
0.8.12
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
RTL8721Dx (Realtek AmebaDplus) live CSI throughput/latency benchmark.
Listens on the wifi-densepose-sensing-server's CSI UDP port for real RAC1
frames (ADR-323, docs/adr/ADR-323-rtl8721dx-ameba-csi-wire-protocol.md) sent
by the ruview_csi_node firmware (branch feat/rtl8721dx-csi-bringup) running
on real RTL8721Dx hardware, and reports MEASURED — not simulated — throughput
and frame-rate statistics.
This does not itself trigger a board reset; run it while the firmware is
already connected and streaming (see CLAUDE.local.md for the reset+capture
pattern used to bring a fresh boot up during firmware iteration).
Usage:
python scripts/benchmark-rtl8721dx-csi.py --duration 40
python scripts/benchmark-rtl8721dx-csi.py --duration 40 --port 5005 --output results.json
"""
from __future__ import annotations
import argparse
import json
import socket
import struct
import sys
import time
from dataclasses import dataclass, asdict, field
from pathlib import Path
RAC1_MAGIC = bytes.fromhex("52414331") # "RAC1" little-endian, see ADR-323
RHB1_MAGIC = bytes.fromhex("52484231") # heartbeat marker (not a CSI frame)
RAC1_HEADER_LEN = 49
@dataclass
class Rac1Frame:
recv_time: float
length: int
sequence: int
timestamp_us: int
channel: int
bandwidth: int
num_sub_carrier: int
rssi_dbm: int
csi_valid: int
def parse_rac1_header(data: bytes, recv_time: float) -> Rac1Frame | None:
if len(data) < RAC1_HEADER_LEN or data[:4] != RAC1_MAGIC:
return None
sequence = struct.unpack_from("<I", data, 13)[0]
timestamp_us = struct.unpack_from("<I", data, 17)[0]
channel = data[33]
bandwidth = data[34]
num_sub_carrier = struct.unpack_from("<H", data, 37)[0]
rssi_dbm = struct.unpack_from("<b", data, 41)[0]
csi_valid = data[43]
return Rac1Frame(
recv_time=recv_time,
length=len(data),
sequence=sequence,
timestamp_us=timestamp_us,
channel=channel,
bandwidth=bandwidth,
num_sub_carrier=num_sub_carrier,
rssi_dbm=rssi_dbm,
csi_valid=csi_valid,
)
@dataclass
class BenchmarkResult:
duration_s: float
total_datagrams: int
rac1_frames: int
heartbeat_packets: int
other_packets: int
rac1_bytes: int
rac1_fps: float
rac1_bytes_per_sec: float
sequence_gaps: int
first_sequence: int | None
last_sequence: int | None
rssi_min_dbm: int | None
rssi_max_dbm: int | None
rssi_mean_dbm: float | None
inter_frame_interval_ms_mean: float | None
inter_frame_interval_ms_p95: float | None
channels_seen: list[int] = field(default_factory=list)
evidence: str = "MEASURED"
def run_benchmark(port: int, duration: float, bind_ip: str = "0.0.0.0") -> BenchmarkResult:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((bind_ip, port))
sock.settimeout(0.5)
frames: list[Rac1Frame] = []
total = 0
heartbeats = 0
other = 0
deadline = time.time() + duration
print(f"Listening on {bind_ip}:{port} for {duration}s ...", file=sys.stderr)
while time.time() < deadline:
try:
data, _addr = sock.recvfrom(4096)
except socket.timeout:
continue
recv_time = time.time()
total += 1
if data[:4] == RHB1_MAGIC:
heartbeats += 1
continue
frame = parse_rac1_header(data, recv_time)
if frame is not None:
frames.append(frame)
else:
other += 1
sock.close()
rac1_bytes = sum(f.length for f in frames)
seqs = [f.sequence for f in frames]
rssis = [f.rssi_dbm for f in frames]
channels = sorted({f.channel for f in frames})
gaps = 0
for a, b in zip(seqs, seqs[1:]):
if b != a + 1:
gaps += 1
intervals_ms = [
(b.recv_time - a.recv_time) * 1000.0 for a, b in zip(frames, frames[1:])
]
intervals_ms.sort()
def percentile(values: list[float], p: float) -> float | None:
if not values:
return None
idx = min(len(values) - 1, int(len(values) * p))
return values[idx]
return BenchmarkResult(
duration_s=duration,
total_datagrams=total,
rac1_frames=len(frames),
heartbeat_packets=heartbeats,
other_packets=other,
rac1_bytes=rac1_bytes,
rac1_fps=len(frames) / duration if duration > 0 else 0.0,
rac1_bytes_per_sec=rac1_bytes / duration if duration > 0 else 0.0,
sequence_gaps=gaps,
first_sequence=seqs[0] if seqs else None,
last_sequence=seqs[-1] if seqs else None,
rssi_min_dbm=min(rssis) if rssis else None,
rssi_max_dbm=max(rssis) if rssis else None,
rssi_mean_dbm=(sum(rssis) / len(rssis)) if rssis else None,
inter_frame_interval_ms_mean=(
sum(intervals_ms) / len(intervals_ms) if intervals_ms else None
),
inter_frame_interval_ms_p95=percentile(intervals_ms, 0.95),
channels_seen=channels,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", type=int, default=5005, help="UDP port to listen on (default: 5005, matches wifi-densepose-sensing-server)")
parser.add_argument("--bind-ip", type=str, default="0.0.0.0")
parser.add_argument("--duration", type=float, default=40.0, help="capture window in seconds")
parser.add_argument("--output", type=Path, default=None, help="write results as JSON to this path")
args = parser.parse_args()
result = run_benchmark(args.port, args.duration, args.bind_ip)
print(f"\n=== RTL8721Dx RAC1 CSI benchmark ({result.evidence}) ===")
print(f"duration: {result.duration_s:.1f}s")
print(f"total UDP datagrams: {result.total_datagrams}")
print(f"RAC1 CSI frames: {result.rac1_frames}")
print(f"heartbeat packets: {result.heartbeat_packets}")
print(f"other/unrecognized: {result.other_packets}")
print(f"RAC1 throughput: {result.rac1_fps:.2f} fps, {result.rac1_bytes_per_sec:.1f} bytes/s")
print(f"sequence gaps: {result.sequence_gaps} (dropped/reordered frames)")
if result.first_sequence is not None:
print(f"sequence range: {result.first_sequence} .. {result.last_sequence}")
if result.rssi_mean_dbm is not None:
print(f"RSSI: min={result.rssi_min_dbm} max={result.rssi_max_dbm} mean={result.rssi_mean_dbm:.1f} dBm")
if result.inter_frame_interval_ms_mean is not None:
print(f"inter-frame interval: mean={result.inter_frame_interval_ms_mean:.1f}ms p95={result.inter_frame_interval_ms_p95:.1f}ms")
print(f"channels seen: {result.channels_seen}")
if args.output:
args.output.write_text(json.dumps(asdict(result), indent=2))
print(f"\nwrote {args.output}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+27 -4
View File
@@ -1166,11 +1166,21 @@ async function main() {
// We iterate over triplets, compute gradients via computeGradient(), and apply
// them to update the encoder's w2 layer (the embedding projection layer).
console.log(' Applying gradient updates to encoder weights...');
// Same bug class as lossHistory: contrastiveResult.durationMs times the
// no-op contrastiveTrainer.train() baseline pass above, not this loop --
// the actual gradient-update work. Time it here instead.
const contrastiveLoopStart = Date.now();
const contrastiveLr = CONFIG.learningRate;
const contrastiveEpochs = CONFIG.epochs;
let initialContrastiveLoss = 0;
let finalContrastiveLoss = 0;
// Real per-epoch loss from the loop that actually updates encoder.w2 below —
// NOT contrastiveResult.history, which comes from contrastiveTrainer.train()
// above and never touches the encoder (see its own comment). Exporting that
// instead of this produced a flat, meaningless lossHistory in the published
// model's training-metrics.json.
const realContrastiveLossHistory = [];
// Compute initial loss
for (const triplet of triplets) {
@@ -1230,7 +1240,9 @@ async function main() {
if (CONFIG.verbose) console.log(` Epoch ${epoch + 1}/${contrastiveEpochs}: loss=${epochLoss.toFixed(6)}`);
}
finalContrastiveLoss = epochLoss;
realContrastiveLossHistory.push({ epoch: epoch + 1, loss: epochLoss });
}
const realContrastiveDurationMs = Date.now() - contrastiveLoopStart;
// Re-encode all features with updated encoder
console.log(' Re-encoding features with updated encoder...');
@@ -1494,10 +1506,15 @@ async function main() {
const exportModel = {
metadata: {
name: 'wifi-densepose-csi-embedding',
version: '1.0.0',
version: '2.0.1',
architecture: 'csi-encoder-8-64-128',
training: {
steps: contrastiveResult.history.length * contrastiveTrainer.getTripletCount(),
// Real epoch count and real per-epoch triplet-update count, reported
// separately rather than pre-multiplied into one "steps" figure --
// that reads as optimizer steps, but every triplet with loss>0 gets
// an immediate direct weight update here, not a batched gradient step.
epochs: realContrastiveLossHistory.length,
tripletUpdatesPerEpoch: triplets.length,
loss: contrastiveResult.finalLoss,
learningRate: CONFIG.learningRate,
},
@@ -1609,8 +1626,14 @@ async function main() {
initialLoss: contrastiveResult.initialLoss,
finalLoss: contrastiveResult.finalLoss,
improvement: contrastiveResult.improvement,
durationMs: contrastiveResult.durationMs,
lossHistory: contrastiveResult.history,
// Real wall-clock of the actual gradient loop -- contrastiveResult.durationMs
// timed the no-op baseline pass instead, same bug as lossHistory above.
durationMs: realContrastiveDurationMs,
// The real per-epoch loss from the loop that actually updates the
// encoder (see realContrastiveLossHistory above) -- contrastiveResult.history
// comes from a separate, non-weight-updating baseline pass and was flat/
// meaningless here.
lossHistory: realContrastiveLossHistory,
},
taskHeads: taskTrainingData.length > 0 ? {
samples: labeledCount,
Generated
+4 -4
View File
@@ -9109,9 +9109,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
@@ -9173,9 +9173,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
+82 -1
View File
@@ -234,9 +234,30 @@ impl StreamingEngine {
/// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed
/// trust cycles even after widening the guard elsewhere.
///
/// Rebuilds the fuser, so call before any frames are processed.
/// Rebuilds the fuser, so call before any frames are processed. Preserves
/// any node positions configured via [`Self::set_node_positions`] across
/// the rebuild (issue #1870) — a caller that configures positions once at
/// startup and calls this later (e.g. after deriving a TDM schedule) must
/// not have that configuration silently discarded.
pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) {
let positions = self.fuser.node_positions_by_id().clone();
self.fuser = MultistaticFuser::with_config(cfg);
self.fuser.set_node_positions_by_id(positions);
}
/// Configure node positions for the governed multistatic fuser, keyed by
/// node id (issue #1870). Without this, `StreamingEngine`'s fuser — the
/// one behind `fuse_scored_calibrated`, which feeds provenance and the
/// witness — always fuses with every node at the origin, regardless of
/// deployment configuration, because nothing ever called this on it. This
/// is a separate `MultistaticFuser` instance from the one
/// `wifi-densepose-sensing-server`'s `AppState` owns (fixed for that
/// instance in #1860); each fuser instance needs positions set on it
/// independently. Safe to call before or after [`Self::set_multistatic_config`] —
/// order does not matter, since that method now preserves whatever
/// positions are already set across its rebuild.
pub fn set_node_positions(&mut self, positions: std::collections::HashMap<u8, [f32; 3]>) {
self.fuser.set_node_positions_by_id(positions);
}
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
@@ -1359,4 +1380,64 @@ mod tests {
"an extra evidence ref must not collide with a model_version that absorbs it"
);
}
// ---- Issue #1870: StreamingEngine's own fuser never received positions ----
/// The governed path (`fuse_scored_calibrated`, feeding provenance and the
/// witness) must carry each node's configured position -- not the origin
/// for every node, which is what happened before `set_node_positions`
/// existed on `StreamingEngine` and nothing ever configured this fuser
/// instance (distinct from the one `wifi-densepose-sensing-server`'s
/// `AppState` owns, fixed separately in #1860).
#[test]
fn governed_fuse_carries_each_nodes_configured_position() {
let (mut e, _room) = engine();
let mut positions = std::collections::HashMap::new();
positions.insert(0u8, [1.0, 2.0, 3.0]);
positions.insert(1u8, [4.0, 5.0, 6.0]);
e.set_node_positions(positions.clone());
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
let (fused, _score) = e
.fuser
.fuse_scored_calibrated(&frames, &[None, None], StreamingEngine::DEFAULT_COHERENCE_ACCEPT)
.expect("cohort fuses");
assert_eq!(fused.node_positions.len(), frames.len());
for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) {
let expected = positions
.get(&frame.node_id)
.copied()
.expect("every configured node has a position in this test");
assert_eq!(
*position, expected,
"node {} received the wrong position on the governed path",
frame.node_id
);
}
}
/// `set_multistatic_config` used to rebuild the fuser from scratch,
/// silently discarding any positions configured before it. A caller that
/// sets positions once at startup and derives/applies a TDM-schedule
/// config later (a real, documented ordering per #1049/#1057) must not
/// lose them.
#[test]
fn positions_survive_a_later_set_multistatic_config() {
let (mut e, _room) = engine();
let mut positions = std::collections::HashMap::new();
positions.insert(0u8, [7.0, 8.0, 9.0]);
e.set_node_positions(positions.clone());
e.set_multistatic_config(MultistaticConfig {
guard_interval_us: 30_000,
..MultistaticConfig::default()
});
assert_eq!(
e.fuser.node_position(0),
Some([7.0, 8.0, 9.0]),
"set_multistatic_config must not discard previously-configured positions"
);
}
}
@@ -0,0 +1,69 @@
//! Benchmarks for ADR-323 Realtek RTL8721Dx (AmebaDplus) CSI encode/decode
//! throughput, at the real hardware's own reported dimensions (52
//! subcarriers, 16-bit tone precision — see `example/wifi/wifi_csi/README.md`
//! in the vendor SDK).
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use wifi_densepose_hardware::realtek_csi::simulator::{RealtekCsiSimulator, SimulatorConfig};
use wifi_densepose_hardware::realtek_csi::CsiFrame;
fn bench_to_bytes_8bit(c: &mut Criterion) {
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let frame = sim.next_frame();
c.bench_function("realtek_csi_to_bytes_52sc_8bit", |b| {
b.iter(|| black_box(frame.to_bytes().unwrap()));
});
}
fn bench_from_bytes_8bit(c: &mut Criterion) {
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let wire = sim.next_frame().to_bytes().unwrap();
c.bench_function("realtek_csi_from_bytes_52sc_8bit", |b| {
b.iter(|| black_box(CsiFrame::from_bytes(black_box(&wire)).unwrap()));
});
}
fn bench_to_bytes_16bit(c: &mut Criterion) {
let cfg = SimulatorConfig {
num_bit_per_tone: 32,
..Default::default()
};
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
let frame = sim.next_frame();
c.bench_function("realtek_csi_to_bytes_52sc_16bit", |b| {
b.iter(|| black_box(frame.to_bytes().unwrap()));
});
}
fn bench_from_bytes_16bit(c: &mut Criterion) {
let cfg = SimulatorConfig {
num_bit_per_tone: 32,
..Default::default()
};
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
let wire = sim.next_frame().to_bytes().unwrap();
c.bench_function("realtek_csi_from_bytes_52sc_16bit", |b| {
b.iter(|| black_box(CsiFrame::from_bytes(black_box(&wire)).unwrap()));
});
}
fn bench_round_trip_full_datagram(c: &mut Criterion) {
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
c.bench_function("realtek_csi_round_trip_52sc_8bit", |b| {
b.iter(|| {
let frame = sim.next_frame();
let wire = black_box(frame.to_bytes().unwrap());
black_box(CsiFrame::from_bytes(&wire).unwrap());
});
});
}
criterion_group!(
benches,
bench_to_bytes_8bit,
bench_from_bytes_8bit,
bench_to_bytes_16bit,
bench_from_bytes_16bit,
bench_round_trip_full_datagram,
);
criterion_main!(benches);
@@ -0,0 +1,107 @@
//! Deterministic Realtek RTL8721Dx (AmebaDplus) 1x1 CSI simulator (ADR-323).
use clap::Parser;
use std::{
fs::File,
io::{self, Write},
net::{SocketAddr, UdpSocket},
path::PathBuf,
thread,
time::Duration,
};
use wifi_densepose_hardware::realtek_csi::{
simulator::{RealtekCsiSimulator, SimulatorConfig},
CsiFrame, CsiMode,
};
#[derive(Debug, Parser)]
#[command(
name = "realtek-csi-sim",
about = "Emit synthetic ADR-323 Realtek RTL8721Dx CSI frames"
)]
struct Args {
#[arg(long, default_value_t = 100)]
frames: u32,
#[arg(long, default_value = "0x5241433152544c31", value_parser=parse_u64)]
seed: u64,
#[arg(long, default_value_t = 6)]
channel: u8,
#[arg(long)]
bandwidth_40mhz: bool,
#[arg(long, default_value_t = 52)]
subcarriers: u16,
#[arg(long, default_value_t = 16)]
tone_bits: u8,
#[arg(long, default_value_t = 1)]
decimation: u8,
#[arg(long, default_value_t = 64)]
interval_ms: u64,
#[arg(long)]
udp: Option<SocketAddr>,
/// Replay: little-endian u32 length followed by one ADR-323 envelope.
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
realtime: bool,
}
fn parse_u64(v: &str) -> Result<u64, String> {
if let Some(h) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
u64::from_str_radix(h, 16).map_err(|e| e.to_string())
} else {
v.parse()
.map_err(|e: std::num::ParseIntError| e.to_string())
}
}
fn emit(
frame: CsiFrame,
socket: Option<&UdpSocket>,
destination: Option<SocketAddr>,
output: &mut Option<File>,
) -> Result<usize, Box<dyn std::error::Error>> {
let wire = frame.to_bytes()?;
if let (Some(s), Some(d)) = (socket, destination) {
if s.send_to(&wire, d)? != wire.len() {
return Err(io::Error::new(io::ErrorKind::WriteZero, "partial UDP datagram").into());
}
}
if let Some(f) = output {
f.write_all(&(wire.len() as u32).to_le_bytes())?;
f.write_all(&wire)?;
}
Ok(wire.len())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let a = Args::parse();
if a.udp.is_none() && a.output.is_none() {
return Err("select at least one sink with --udp or --output".into());
}
let cfg = SimulatorConfig {
seed: a.seed,
csi_mode: CsiMode::Passive,
channel: a.channel,
bandwidth: if a.bandwidth_40mhz { 1 } else { 0 },
num_sub_carrier: a.subcarriers,
num_bit_per_tone: a.tone_bits,
decimation: a.decimation,
frame_period_us: (a.interval_ms * 1000) as u32,
..Default::default()
};
let mut sim = RealtekCsiSimulator::new(cfg)?;
let socket = a.udp.map(|_| UdpSocket::bind("0.0.0.0:0")).transpose()?;
let mut output = a.output.as_ref().map(File::create).transpose()?;
let mut bytes = 0usize;
for _ in 0..a.frames {
bytes += emit(sim.next_frame(), socket.as_ref(), a.udp, &mut output)?;
if a.realtime {
thread::sleep(Duration::from_millis(a.interval_ms));
}
}
eprintln!(
"emitted {} synthetic Realtek RTL8721Dx CSI frames ({} bytes, channel={}, subcarriers={}, tone_bits={}, seed={:#x})",
a.frames, bytes, a.channel, a.subcarriers, a.tone_bits, a.seed
);
Ok(())
}
@@ -145,13 +145,16 @@ impl PpduType {
/// bit 2 : STBC
/// bit 3 : LDPC (reserved — not yet populated by firmware)
/// bit 4 : 802.15.4 time-sync valid (C6 only)
/// bit 5-7 : reserved
/// bit 5 : ESP-IDF invalid first CSI word was zeroed by firmware
/// bit 6-7 : reserved
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adr018Flags {
pub bw40: bool,
pub stbc: bool,
pub ldpc: bool,
pub ieee802154_sync_valid: bool,
#[serde(default)]
pub first_word_sanitized: bool,
}
impl Adr018Flags {
@@ -161,6 +164,7 @@ impl Adr018Flags {
stbc: (b & 0x04) != 0,
ldpc: (b & 0x08) != 0,
ieee802154_sync_valid: (b & 0x10) != 0,
first_word_sanitized: (b & 0x20) != 0,
}
}
pub fn to_byte(self) -> u8 {
@@ -169,6 +173,7 @@ impl Adr018Flags {
if self.stbc { b |= 0x04; }
if self.ldpc { b |= 0x08; }
if self.ieee802154_sync_valid { b |= 0x10; }
if self.first_word_sanitized { b |= 0x20; }
b
}
}
@@ -413,15 +413,16 @@ mod tests {
#[test]
fn adr110_flags_round_trip_all_bits() {
// All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D);
// All known flag bits set: bw40 + STBC + LDPC + sync + sanitized prefix = 0x3D.
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x3D);
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
assert!(frame.metadata.adr018_flags.bw40);
assert!(frame.metadata.adr018_flags.stbc);
assert!(frame.metadata.adr018_flags.ldpc);
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
assert!(frame.metadata.adr018_flags.first_word_sanitized);
// Round-trip the encoder
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D);
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x3D);
}
#[test]
@@ -62,6 +62,9 @@ pub mod qualcomm_csi;
/// ADR-264 host-side framing for Realtek RTL8720F CFR and FMCW radar reports.
/// This module has no dependency on the vendor SDK.
pub mod rtl8720f;
/// ADR-323 vendor-neutral Realtek RTL8721Dx (AmebaDplus) 1x1 CSI framing and
/// simulator. This module has no dependency on the vendor SDK.
pub mod realtek_csi;
pub use bridge::CsiData;
pub use csi_frame::{
@@ -0,0 +1,563 @@
//! Vendor-neutral Realtek RTL8721Dx (AmebaDplus) 1x1 CSI transport and
//! deterministic simulator. This is not the vendor SDK's raw
//! `rtw_event_csi_report_info` ABI (no CRC, C flexible-array payload); see
//! ADR-323 for the field-by-field mapping and rationale.
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const RAC1_MAGIC: u32 = 0x3143_4152; // "RAC1" little endian
pub const RAC1_VERSION: u8 = 1;
pub const RAC1_HEADER_LEN: usize = 49;
pub const RAC1_CRC_LEN: usize = 4;
pub const RAC1_MAX_FRAME_LEN: usize = 65_507;
/// Upper bound on subcarriers a 1x1 20/40 MHz OFDM/HT/VHT/HE capture can
/// plausibly report; guards `to_bytes`/`from_bytes` against runaway sizes.
pub const RAC1_MAX_SUBCARRIERS: usize = 4_096;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum CsiMode {
Passive = 0,
Active = 1,
BoardToBoard = 2,
}
impl TryFrom<u8> for CsiMode {
type Error = CsiParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Passive),
1 => Ok(Self::Active),
2 => Ok(Self::BoardToBoard),
_ => Err(CsiParseError::UnknownCsiMode(value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ProtocolMode {
Ofdm = 0,
Ht = 1,
Vht = 2,
He = 3,
}
impl TryFrom<u8> for ProtocolMode {
type Error = CsiParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Ofdm),
1 => Ok(Self::Ht),
2 => Ok(Self::Vht),
3 => Ok(Self::He),
_ => Err(CsiParseError::UnknownProtocolMode(value)),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CsiFlags(pub u8);
impl CsiFlags {
pub const SYNTHETIC: u8 = 1 << 0;
pub fn contains(self, flag: u8) -> bool {
self.0 & flag != 0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CsiFrame {
/// Disambiguates multi-board illuminator/receiver deployments; 0 = unset.
pub node_id: u8,
pub csi_mode: CsiMode,
pub sequence: u32,
/// Hardware-clock microseconds; wraps roughly every 71 minutes. Consumers
/// must reorder by `sequence`, not raw timestamp arithmetic, across a wrap.
pub timestamp_us: u32,
pub peer_mac: [u8; 6],
pub trig_mac: [u8; 6],
pub channel: u8,
/// 0 = 20 MHz, 1 = 40 MHz.
pub bandwidth: u8,
pub rx_rate: u8,
pub protocol_mode: ProtocolMode,
pub num_sub_carrier: u16,
/// 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q).
pub num_bit_per_tone: u8,
/// Tone group: 1/2/4/8 (every Nth tone reported).
pub decimation: u8,
/// Single RF chain (1x1 hardware).
pub rssi_dbm: i8,
pub rxsc: u8,
pub csi_valid: bool,
pub flags: CsiFlags,
/// One (I, Q) pair per reported subcarrier, widened to i16 regardless of
/// wire precision (an 8-bit wire value fits losslessly).
pub payload: Vec<(i16, i16)>,
}
impl CsiFrame {
fn component_bytes(&self) -> Result<usize, CsiParseError> {
match self.num_bit_per_tone {
16 => Ok(1),
32 => Ok(2),
other => Err(CsiParseError::InvalidToneBits(other)),
}
}
pub fn to_bytes(&self) -> Result<Vec<u8>, CsiParseError> {
self.validate()?;
let comp_bytes = self.component_bytes()?;
let payload_len = self
.payload
.len()
.checked_mul(comp_bytes * 2)
.ok_or(CsiParseError::LengthOverflow)?;
let frame_len = RAC1_HEADER_LEN
.checked_add(payload_len)
.and_then(|n| n.checked_add(RAC1_CRC_LEN))
.ok_or(CsiParseError::LengthOverflow)?;
if frame_len > RAC1_MAX_FRAME_LEN {
return Err(CsiParseError::FrameTooLarge(frame_len));
}
let mut out = Vec::with_capacity(frame_len);
out.extend_from_slice(&RAC1_MAGIC.to_le_bytes());
out.push(RAC1_VERSION);
out.extend_from_slice(&(RAC1_HEADER_LEN as u16).to_le_bytes());
out.extend_from_slice(&(frame_len as u32).to_le_bytes());
out.push(self.node_id);
out.push(self.csi_mode as u8);
out.extend_from_slice(&self.sequence.to_le_bytes());
out.extend_from_slice(&self.timestamp_us.to_le_bytes());
out.extend_from_slice(&self.peer_mac);
out.extend_from_slice(&self.trig_mac);
out.push(self.channel);
out.push(self.bandwidth);
out.push(self.rx_rate);
out.push(self.protocol_mode as u8);
out.extend_from_slice(&self.num_sub_carrier.to_le_bytes());
out.push(self.num_bit_per_tone);
out.push(self.decimation);
out.push(self.rssi_dbm as u8);
out.push(self.rxsc);
out.push(self.csi_valid as u8);
out.push(self.flags.0);
out.extend_from_slice(&(payload_len as u32).to_le_bytes());
debug_assert_eq!(out.len(), RAC1_HEADER_LEN);
for (i, q) in &self.payload {
if comp_bytes == 1 {
out.push(*i as i8 as u8);
out.push(*q as i8 as u8);
} else {
out.extend_from_slice(&i.to_le_bytes());
out.extend_from_slice(&q.to_le_bytes());
}
}
out.extend_from_slice(&crc32_ieee(&out).to_le_bytes());
Ok(out)
}
pub fn from_bytes(input: &[u8]) -> Result<(Self, usize), CsiParseError> {
if input.len() < RAC1_HEADER_LEN {
return Err(CsiParseError::InsufficientData {
needed: RAC1_HEADER_LEN,
got: input.len(),
});
}
let magic = u32_at(input, 0);
if magic != RAC1_MAGIC {
return Err(CsiParseError::InvalidMagic(magic));
}
if input[4] != RAC1_VERSION {
return Err(CsiParseError::UnsupportedVersion(input[4]));
}
let header_len = u16_at(input, 5) as usize;
if header_len != RAC1_HEADER_LEN {
return Err(CsiParseError::InvalidHeaderLength(header_len));
}
let frame_len = u32_at(input, 7) as usize;
if frame_len > RAC1_MAX_FRAME_LEN {
return Err(CsiParseError::FrameTooLarge(frame_len));
}
if frame_len < header_len + RAC1_CRC_LEN {
return Err(CsiParseError::InvalidFrameLength(frame_len));
}
if input.len() < frame_len {
return Err(CsiParseError::InsufficientData {
needed: frame_len,
got: input.len(),
});
}
let expected_crc = u32_at(input, frame_len - 4);
let actual_crc = crc32_ieee(&input[..frame_len - 4]);
if expected_crc != actual_crc {
return Err(CsiParseError::CrcMismatch {
expected: expected_crc,
actual: actual_crc,
});
}
let node_id = input[11];
let csi_mode = CsiMode::try_from(input[12])?;
let sequence = u32_at(input, 13);
let timestamp_us = u32_at(input, 17);
let mut peer_mac = [0u8; 6];
peer_mac.copy_from_slice(&input[21..27]);
let mut trig_mac = [0u8; 6];
trig_mac.copy_from_slice(&input[27..33]);
let channel = input[33];
let bandwidth = input[34];
let rx_rate = input[35];
let protocol_mode = ProtocolMode::try_from(input[36])?;
let num_sub_carrier = u16_at(input, 37);
let num_bit_per_tone = input[39];
let decimation = input[40];
let rssi_dbm = input[41] as i8;
let rxsc = input[42];
let csi_valid = input[43] != 0;
let flags = CsiFlags(input[44]);
let payload_len = u32_at(input, 45) as usize;
if header_len + payload_len + RAC1_CRC_LEN != frame_len {
return Err(CsiParseError::PayloadLengthMismatch);
}
let comp_bytes = match num_bit_per_tone {
16 => 1usize,
32 => 2usize,
other => return Err(CsiParseError::InvalidToneBits(other)),
};
let expected_payload_len = (num_sub_carrier as usize)
.checked_mul(comp_bytes * 2)
.ok_or(CsiParseError::LengthOverflow)?;
if payload_len != expected_payload_len {
return Err(CsiParseError::PayloadLengthMismatch);
}
let payload_bytes = &input[header_len..header_len + payload_len];
let payload = if comp_bytes == 1 {
payload_bytes
.chunks_exact(2)
.map(|b| (b[0] as i8 as i16, b[1] as i8 as i16))
.collect()
} else {
payload_bytes
.chunks_exact(4)
.map(|b| {
(
i16::from_le_bytes([b[0], b[1]]),
i16::from_le_bytes([b[2], b[3]]),
)
})
.collect()
};
let frame = Self {
node_id,
csi_mode,
sequence,
timestamp_us,
peer_mac,
trig_mac,
channel,
bandwidth,
rx_rate,
protocol_mode,
num_sub_carrier,
num_bit_per_tone,
decimation,
rssi_dbm,
rxsc,
csi_valid,
flags,
payload,
};
frame.validate()?;
Ok((frame, frame_len))
}
fn validate(&self) -> Result<(), CsiParseError> {
if !matches!(self.bandwidth, 0 | 1) {
return Err(CsiParseError::InvalidBandwidth(self.bandwidth));
}
let comp_bytes = self.component_bytes()?;
if self.num_sub_carrier == 0 || self.num_sub_carrier as usize > RAC1_MAX_SUBCARRIERS {
return Err(CsiParseError::InvalidSubcarrierCount(self.num_sub_carrier));
}
if self.payload.len() != self.num_sub_carrier as usize {
return Err(CsiParseError::PayloadLengthMismatch);
}
if comp_bytes == 1 {
let in_range = |v: i16| (i8::MIN as i16..=i8::MAX as i16).contains(&v);
if self.payload.iter().any(|(i, q)| !in_range(*i) || !in_range(*q)) {
return Err(CsiParseError::ToneValueOutOfRange);
}
}
Ok(())
}
}
#[derive(Debug, Error, PartialEq)]
pub enum CsiParseError {
#[error("insufficient data: needed {needed}, got {got}")]
InsufficientData { needed: usize, got: usize },
#[error("invalid magic {0:#010x}")]
InvalidMagic(u32),
#[error("unsupported version {0}")]
UnsupportedVersion(u8),
#[error("unknown CSI mode {0}")]
UnknownCsiMode(u8),
#[error("unknown protocol mode {0}")]
UnknownProtocolMode(u8),
#[error("invalid header length {0}")]
InvalidHeaderLength(usize),
#[error("invalid frame length {0}")]
InvalidFrameLength(usize),
#[error("frame too large: {0}")]
FrameTooLarge(usize),
#[error("length arithmetic overflow")]
LengthOverflow,
#[error("payload length mismatch")]
PayloadLengthMismatch,
#[error("invalid num_bit_per_tone {0} (must be 16 or 32)")]
InvalidToneBits(u8),
#[error("invalid subcarrier count {0}")]
InvalidSubcarrierCount(u16),
#[error("invalid bandwidth code {0} (must be 0 or 1)")]
InvalidBandwidth(u8),
#[error("tone I/Q value out of range for 8-bit precision")]
ToneValueOutOfRange,
#[error("CRC mismatch: expected {expected:#010x}, actual {actual:#010x}")]
CrcMismatch { expected: u32, actual: u32 },
}
pub mod simulator {
use super::*;
#[derive(Debug, Clone)]
pub struct SimulatorConfig {
pub seed: u64,
pub node_id: u8,
pub csi_mode: CsiMode,
pub peer_mac: [u8; 6],
pub trig_mac: [u8; 6],
pub channel: u8,
pub bandwidth: u8,
pub rx_rate: u8,
pub protocol_mode: ProtocolMode,
pub num_sub_carrier: u16,
pub num_bit_per_tone: u8,
pub decimation: u8,
pub rssi_dbm: i8,
pub frame_period_us: u32,
}
impl Default for SimulatorConfig {
fn default() -> Self {
Self {
seed: 0x5241_4331_5254_4c31, // "RAC1RTL1"-ish
node_id: 1,
csi_mode: CsiMode::Passive,
peer_mac: [0xa4, 0x39, 0xb3, 0xa4, 0xbe, 0x2d],
trig_mac: [0x00, 0xe0, 0x4c, 0x00, 0x0a, 0xa3],
channel: 6,
bandwidth: 0,
rx_rate: 12, // RTW_RATE_6M
protocol_mode: ProtocolMode::Ofdm,
num_sub_carrier: 52,
num_bit_per_tone: 16,
decimation: 1,
rssi_dbm: -45,
frame_period_us: 64_000,
}
}
}
pub struct RealtekCsiSimulator {
config: SimulatorConfig,
rng: u64,
sequence: u32,
timestamp_us: u32,
motion_phase: f32,
}
impl RealtekCsiSimulator {
pub fn new(config: SimulatorConfig) -> Result<Self, CsiParseError> {
let s = Self {
rng: config.seed,
config,
sequence: 0,
timestamp_us: 0,
motion_phase: 0.0,
};
s.csi_frame().validate()?;
Ok(s)
}
pub fn next_frame(&mut self) -> CsiFrame {
let frame = self.csi_frame();
self.sequence = self.sequence.wrapping_add(1);
self.timestamp_us = self.timestamp_us.wrapping_add(self.config.frame_period_us);
self.motion_phase += 0.041;
frame
}
fn csi_frame(&self) -> CsiFrame {
let mut rng = self.rng ^ self.sequence as u64;
let comp_max = if self.config.num_bit_per_tone == 16 {
110.0
} else {
1800.0
};
let payload = (0..self.config.num_sub_carrier as usize)
.map(|tone| {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
let noise = ((rng >> 48) as i16 % 8) as f32;
let phase = tone as f32 * 0.11 + self.motion_phase;
(
((phase.cos() * comp_max) + noise) as i16,
((phase.sin() * comp_max) - noise) as i16,
)
})
.collect();
CsiFrame {
node_id: self.config.node_id,
csi_mode: self.config.csi_mode,
sequence: self.sequence,
timestamp_us: self.timestamp_us,
peer_mac: self.config.peer_mac,
trig_mac: self.config.trig_mac,
channel: self.config.channel,
bandwidth: self.config.bandwidth,
rx_rate: self.config.rx_rate,
protocol_mode: self.config.protocol_mode,
num_sub_carrier: self.config.num_sub_carrier,
num_bit_per_tone: self.config.num_bit_per_tone,
decimation: self.config.decimation,
rssi_dbm: self.config.rssi_dbm,
rxsc: 0,
csi_valid: true,
flags: CsiFlags(CsiFlags::SYNTHETIC),
payload,
}
}
}
}
fn u16_at(b: &[u8], o: usize) -> u16 {
u16::from_le_bytes([b[o], b[o + 1]])
}
fn u32_at(b: &[u8], o: usize) -> u32 {
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
}
fn crc32_ieee(data: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
crc = (crc >> 1) ^ ((0u32.wrapping_sub(crc & 1)) & 0xedb8_8320);
}
}
!crc
}
#[cfg(test)]
mod tests {
use super::*;
use simulator::*;
#[test]
fn simulator_round_trip_is_deterministic() {
let cfg = SimulatorConfig::default();
let mut a = RealtekCsiSimulator::new(cfg.clone()).unwrap();
let mut b = RealtekCsiSimulator::new(cfg).unwrap();
let wa = a.next_frame().to_bytes().unwrap();
assert_eq!(wa, b.next_frame().to_bytes().unwrap());
let (decoded, n) = CsiFrame::from_bytes(&wa).unwrap();
assert_eq!(n, wa.len());
assert!(decoded.flags.contains(CsiFlags::SYNTHETIC));
assert_eq!(decoded.payload.len(), 52);
}
#[test]
fn sixteen_bit_precision_round_trip() {
let cfg = SimulatorConfig {
num_bit_per_tone: 32,
..Default::default()
};
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
let frame = sim.next_frame();
let wire = frame.to_bytes().unwrap();
let (decoded, n) = CsiFrame::from_bytes(&wire).unwrap();
assert_eq!(n, wire.len());
assert_eq!(decoded, frame);
}
#[test]
fn crc_corruption_is_rejected() {
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let mut w = s.next_frame().to_bytes().unwrap();
let last = w.len() - 1;
w[last] ^= 1;
assert!(matches!(
CsiFrame::from_bytes(&w),
Err(CsiParseError::CrcMismatch { .. })
));
}
#[test]
fn truncation_is_rejected() {
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let w = s.next_frame().to_bytes().unwrap();
assert!(matches!(
CsiFrame::from_bytes(&w[..w.len() - 1]),
Err(CsiParseError::InsufficientData { .. })
));
}
#[test]
fn zero_subcarriers_is_rejected() {
let cfg = SimulatorConfig {
num_sub_carrier: 0,
..Default::default()
};
assert!(matches!(
RealtekCsiSimulator::new(cfg),
Err(CsiParseError::InvalidSubcarrierCount(0))
));
}
#[test]
fn invalid_tone_bits_is_rejected() {
let cfg = SimulatorConfig {
num_bit_per_tone: 24,
..Default::default()
};
assert!(matches!(
RealtekCsiSimulator::new(cfg),
Err(CsiParseError::InvalidToneBits(24))
));
}
#[test]
fn parser_never_panics_on_prefixes() {
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let w = s.next_frame().to_bytes().unwrap();
for end in 0..w.len() {
let _ = CsiFrame::from_bytes(&w[..end]);
}
}
#[test]
fn magic_mismatch_is_rejected() {
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let mut w = s.next_frame().to_bytes().unwrap();
w[0] ^= 0xff;
assert!(matches!(
CsiFrame::from_bytes(&w),
Err(CsiParseError::InvalidMagic(_))
));
}
}
@@ -30,7 +30,7 @@ mdns-sd = "0.11"
# Serialization
serde = { workspace = true }
serde_json.workspace = true
serde_json = { workspace = true, features = ["raw_value"] }
# Logging
tracing.workspace = true
@@ -43,6 +43,8 @@ per ADR-022 Phase 3.
- **WebSocket broadcast** -- Real-time sensing updates pushed to all connected clients at
`ws://localhost:8765/ws/sensing`.
- **Static file serving** -- Hosts the sensing UI on port 8080 with CORS headers.
- **Private startup baseline** -- Restores a recent, installation-bound empty-room field model
without storing raw CSI or authorizing numeric vital signs (ADR-355).
## Modules
@@ -74,6 +76,23 @@ cargo run -p wifi-densepose-sensing-server -- \
--static-dir ./ui
```
### Empty-room startup baseline
Provide a stable installation ID and an application-owned private state directory:
```bash
cargo run -p wifi-densepose-sensing-server -- \
--installation-id installation-01 \
--data-dir /path/to/private/application-state
```
Complete the normal empty-room calibration, then call
`POST /api/v1/calibration/bootstrap/promote`. The server measures a separate 12-sample holdout
before storing an aggregate field-model snapshot. On a later start with the same installation ID,
the snapshot is restored with `bootstrap_only` authority. It can reduce startup background false
positives, but cannot authorize calibrated evidence or numeric heart and breathing rates. Use
`POST /api/v1/calibration/reset` with administrator scope to remove it.
### Using as a library
```rust
@@ -139,6 +139,39 @@ pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_
]
}
/// Preserve the fifth feature's historical meaning for models trained before
/// the server began measuring temporal frequency from a frame window.
///
/// Version 1 models learned a proxy equal to the strongest subcarrier index
/// multiplied by 0.05. Version 2 and later models consume the measured
/// temporal frequency in hertz. Keeping this translation at the model boundary
/// prevents a server upgrade from silently changing an existing model's input
/// distribution.
pub fn compatible_dominant_frequency(
model_version: u32,
temporal_frequency_hz: f64,
amplitudes: &[f64],
) -> f64 {
if model_version >= 2 {
return if temporal_frequency_hz.is_finite() {
temporal_frequency_hz.max(0.0)
} else {
0.0
};
}
amplitudes
.iter()
.enumerate()
.filter(|(_, amplitude)| amplitude.is_finite())
.max_by(|(_, left), (_, right)| {
left.partial_cmp(right)
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(index, _)| index as f64 * 0.05)
.unwrap_or(0.0)
}
/// Compute statistical features from raw subcarrier amplitudes.
fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
if amps.is_empty() {
@@ -642,3 +675,21 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
pub fn model_path() -> PathBuf {
PathBuf::from("data/adaptive_model.json")
}
#[cfg(test)]
mod compatibility_tests {
use super::compatible_dominant_frequency;
#[test]
fn version_one_keeps_legacy_subcarrier_proxy() {
let amplitudes = [1.0, 2.0, 8.0, 3.0];
assert_eq!(compatible_dominant_frequency(1, 1.25, &amplitudes), 0.10);
}
#[test]
fn version_two_uses_measured_temporal_frequency() {
let amplitudes = [1.0, 20.0, 2.0];
assert_eq!(compatible_dominant_frequency(2, 1.25, &amplitudes), 1.25);
assert_eq!(compatible_dominant_frequency(2, f64::NAN, &amplitudes), 0.0);
}
}
@@ -491,6 +491,10 @@ const READ_SAFE_MUTATIONS: &[&str] = &[
// Capture and calibration: create data, never destroy it.
"/api/v1/calibration/start",
"/api/v1/calibration/stop",
// Promotion consumes only server measured held out evidence and stores a
// bounded privacy reduced prior. Cancel discards only an unfinished run.
"/api/v1/calibration/bootstrap/promote",
"/api/v1/calibration/cancel",
"/api/v1/pose/calibrate",
"/api/v1/recording/start",
"/api/v1/recording/stop",
@@ -1970,6 +1974,7 @@ mod scope_gate_polarity_tests {
(Method::DELETE, "/api/v1/models/m1"),
(Method::DELETE, "/api/v1/recording/r1"),
(Method::POST, "/api/v1/config/ground-truth"),
(Method::POST, "/api/v1/calibration/reset"),
] {
assert_eq!(
required_scope_for(&m, p),
@@ -0,0 +1,532 @@
//! Local, privacy reduced empty room bootstrap model persistence.
//!
//! A bootstrap image is deliberately lower authority than a completed room
//! calibration. It can suppress background perturbations while the server
//! starts, but restoring it never authorizes calibrated evidence or vitals.
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use wifi_densepose_signal::ruvsense::field_model::{
FieldModel, FieldModelError, FieldModelSnapshotV1,
};
pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.v2";
pub const BOOTSTRAP_BASELINE_AUTHORITY: &str = "bootstrap_only";
pub const BOOTSTRAP_VALIDATION_SAMPLES: usize = 12;
pub const BOOTSTRAP_VALIDATION_MIN_EMPTY: usize = 10;
pub const BOOTSTRAP_VALIDATION_MIN_SPACING_MS: u64 = 1_000;
pub const BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS: u64 = 4_000;
const BOOTSTRAP_MAX_FILE_BYTES: u64 = 1_048_576;
/// Exact CSI layout used to train the persisted empty room model.
///
/// Restoring code must require incoming frames to match this binding before
/// the bootstrap image can influence inference.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BootstrapCsiGrid {
pub n_subcarriers: u16,
pub ppdu_type: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct BootstrapPayloadV2 {
schema: String,
authority: String,
installation_binding_sha256: String,
source_node_ids: Vec<u8>,
source_grid: BootstrapCsiGrid,
source_model_id: String,
created_at_unix_ms: u64,
expires_at_unix_ms: u64,
field_model: FieldModelSnapshotV1,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct BootstrapImageV2 {
payload: BootstrapPayloadV2,
content_sha256: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct BootstrapImageRawV2 {
payload: Box<RawValue>,
content_sha256: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BootstrapBaselineMetadata {
pub authority: &'static str,
pub source_node_ids: Vec<u8>,
pub source_grid: BootstrapCsiGrid,
pub source_model_id: String,
pub created_at_unix_ms: u64,
pub expires_at_unix_ms: u64,
pub content_sha256: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BootstrapValidationSample {
pub fresh_tick: bool,
pub calibrated_empty: bool,
pub vital_signs_absent: bool,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
pub struct BootstrapValidationResult {
pub sample_count: usize,
pub empty_sample_count: usize,
pub stale_sample_count: usize,
pub vital_sign_sample_count: usize,
pub passed: bool,
}
#[derive(Debug, Error)]
pub enum BootstrapBaselineError {
#[error("a stable installation_id is required for a local bootstrap baseline")]
MissingInstallationId,
#[error("bootstrap baseline path is invalid")]
InvalidPath,
#[error("bootstrap baseline file is too large")]
FileTooLarge,
#[error("bootstrap baseline image is malformed: {0}")]
Malformed(String),
#[error("bootstrap baseline belongs to another installation")]
InstallationMismatch,
#[error("bootstrap baseline has expired")]
Expired,
#[error("bootstrap baseline digest does not verify")]
DigestMismatch,
#[error("field model snapshot is invalid: {0}")]
FieldModel(#[from] FieldModelError),
#[error("bootstrap baseline storage failed: {0}")]
Io(#[from] std::io::Error),
#[error("bootstrap baseline serialization failed: {0}")]
Json(#[from] serde_json::Error),
}
pub fn path_in(data_dir: &Path) -> PathBuf {
data_dir.join("calibration").join("bootstrap-empty-v1.json")
}
pub fn installation_binding(installation_id: &str) -> Result<String, BootstrapBaselineError> {
let installation_id = installation_id.trim();
if installation_id.is_empty() || installation_id.len() > 128 {
return Err(BootstrapBaselineError::MissingInstallationId);
}
Ok(hex_sha256(installation_id.as_bytes()))
}
pub fn evaluate_validation(samples: &[BootstrapValidationSample]) -> BootstrapValidationResult {
let empty_sample_count = samples
.iter()
.filter(|sample| sample.fresh_tick && sample.calibrated_empty)
.count();
let stale_sample_count = samples.iter().filter(|sample| !sample.fresh_tick).count();
let vital_sign_sample_count = samples
.iter()
.filter(|sample| !sample.vital_signs_absent)
.count();
BootstrapValidationResult {
sample_count: samples.len(),
empty_sample_count,
stale_sample_count,
vital_sign_sample_count,
passed: samples.len() == BOOTSTRAP_VALIDATION_SAMPLES
&& empty_sample_count >= BOOTSTRAP_VALIDATION_MIN_EMPTY
&& stale_sample_count == 0
&& vital_sign_sample_count == 0,
}
}
pub fn store(
path: &Path,
installation_id: &str,
source_node_ids: &[u8],
source_grid: BootstrapCsiGrid,
source_model_id: &str,
created_at_unix_ms: u64,
field_model: &FieldModel,
) -> Result<BootstrapBaselineMetadata, BootstrapBaselineError> {
let snapshot = field_model.export_snapshot()?;
let calibrated_at_ms = snapshot.modes.calibrated_at_us / 1_000;
let expiry_ms = (snapshot.config.baseline_expiry_s * 1_000.0) as u64;
let payload = BootstrapPayloadV2 {
schema: BOOTSTRAP_BASELINE_SCHEMA.to_string(),
authority: BOOTSTRAP_BASELINE_AUTHORITY.to_string(),
installation_binding_sha256: installation_binding(installation_id)?,
source_node_ids: source_node_ids.to_vec(),
source_grid,
source_model_id: source_model_id.to_string(),
created_at_unix_ms,
expires_at_unix_ms: calibrated_at_ms.saturating_add(expiry_ms),
field_model: snapshot,
};
validate_payload(&payload, installation_id, created_at_unix_ms)?;
let content_sha256 = payload_digest(&payload)?;
let image = BootstrapImageV2 {
payload,
content_sha256,
};
let bytes = serde_json::to_vec(&image)?;
if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
atomic_write_private(path, &bytes)?;
Ok(metadata(&image))
}
pub fn load(
path: &Path,
installation_id: &str,
current_unix_ms: u64,
) -> Result<(FieldModel, BootstrapBaselineMetadata), BootstrapBaselineError> {
let file_metadata = fs::symlink_metadata(path)?;
if file_metadata.file_type().is_symlink() || !file_metadata.is_file() {
return Err(BootstrapBaselineError::InvalidPath);
}
if file_metadata.len() > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
let mut bytes = Vec::with_capacity(file_metadata.len() as usize);
OpenOptions::new()
.read(true)
.open(path)?
.take(BOOTSTRAP_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > BOOTSTRAP_MAX_FILE_BYTES {
return Err(BootstrapBaselineError::FileTooLarge);
}
// Verify the exact payload bytes written by storage before decoding the
// floating point model. Equivalent JSON number spellings may serialize
// differently, so decode and reserialize is not an integrity contract.
let raw_image: BootstrapImageRawV2 = serde_json::from_slice(&bytes)?;
if hex_sha256(raw_image.payload.get().as_bytes()) != raw_image.content_sha256 {
return Err(BootstrapBaselineError::DigestMismatch);
}
let payload: BootstrapPayloadV2 = serde_json::from_str(raw_image.payload.get())?;
validate_payload(&payload, installation_id, current_unix_ms)?;
let image = BootstrapImageV2 {
payload,
content_sha256: raw_image.content_sha256,
};
let model = FieldModel::from_snapshot(
image.payload.field_model.clone(),
current_unix_ms.saturating_mul(1_000),
)?;
Ok((model, metadata(&image)))
}
pub fn remove(path: &Path) -> Result<bool, BootstrapBaselineError> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(BootstrapBaselineError::InvalidPath);
}
fs::remove_file(path)?;
Ok(true)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn validate_payload(
payload: &BootstrapPayloadV2,
installation_id: &str,
current_unix_ms: u64,
) -> Result<(), BootstrapBaselineError> {
if payload.schema != BOOTSTRAP_BASELINE_SCHEMA
|| payload.authority != BOOTSTRAP_BASELINE_AUTHORITY
|| payload.source_model_id.is_empty()
|| payload.source_model_id.len() > 128
|| payload.source_node_ids.len() != 1
|| !(1..=2_048).contains(&payload.source_grid.n_subcarriers)
|| payload.source_grid.ppdu_type > 3
|| payload.created_at_unix_ms == 0
|| payload.expires_at_unix_ms <= payload.created_at_unix_ms
{
return Err(BootstrapBaselineError::Malformed(
"invalid schema, authority, identity, node set, source grid, or lifetime".into(),
));
}
if payload.installation_binding_sha256 != installation_binding(installation_id)? {
return Err(BootstrapBaselineError::InstallationMismatch);
}
if current_unix_ms > payload.expires_at_unix_ms {
return Err(BootstrapBaselineError::Expired);
}
Ok(())
}
fn payload_digest(payload: &BootstrapPayloadV2) -> Result<String, serde_json::Error> {
Ok(hex_sha256(&serde_json::to_vec(payload)?))
}
fn metadata(image: &BootstrapImageV2) -> BootstrapBaselineMetadata {
BootstrapBaselineMetadata {
authority: BOOTSTRAP_BASELINE_AUTHORITY,
source_node_ids: image.payload.source_node_ids.clone(),
source_grid: image.payload.source_grid,
source_model_id: image.payload.source_model_id.clone(),
created_at_unix_ms: image.payload.created_at_unix_ms,
expires_at_unix_ms: image.payload.expires_at_unix_ms,
content_sha256: image.content_sha256.clone(),
}
}
fn hex_sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn atomic_write_private(path: &Path, bytes: &[u8]) -> Result<(), BootstrapBaselineError> {
let parent = path.parent().ok_or(BootstrapBaselineError::InvalidPath)?;
fs::create_dir_all(parent)?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or(BootstrapBaselineError::InvalidPath)?;
let mut nonce = [0_u8; 8];
OsRng.fill_bytes(&mut nonce);
let temporary = parent.join(format!(
".{file_name}.{:016x}.tmp",
u64::from_le_bytes(nonce)
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let write_result = (|| -> Result<(), std::io::Error> {
let mut file = options.open(&temporary)?;
file.write_all(bytes)?;
file.sync_all()?;
fs::rename(&temporary, path)?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary);
}
write_result.map_err(Into::into)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig;
const TEST_GRID: BootstrapCsiGrid = BootstrapCsiGrid {
n_subcarriers: 192,
ppdu_type: 2,
};
fn completed_model(now_us: u64) -> FieldModel {
let mut model = FieldModel::new(FieldModelConfig {
n_links: 1,
n_subcarriers: 4,
n_modes: 2,
min_calibration_frames: 3,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 3_600.0,
})
.unwrap();
for index in 0..3 {
model
.feed_calibration(&[vec![1.0 + index as f64, 2.0, 3.0, 4.0]])
.unwrap();
}
model.finalize_calibration(now_us, 7).unwrap();
model
}
#[test]
fn round_trip_is_installation_bound_and_contains_no_raw_frames() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
let stored = store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let text = String::from_utf8(fs::read(&path).unwrap()).unwrap();
assert!(!text.contains("home-a"));
assert!(!text.contains("raw_csi"));
assert_eq!(stored.source_grid, TEST_GRID);
let (restored, loaded) = load(&path, "home-a", now_ms + 1).unwrap();
assert_eq!(stored, loaded);
assert_eq!(
restored.export_snapshot().unwrap(),
model.export_snapshot().unwrap()
);
assert!(matches!(
load(&path, "home-b", now_ms + 1),
Err(BootstrapBaselineError::InstallationMismatch)
));
}
#[test]
fn tamper_and_expiry_fail_closed() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let mut image: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
image["payload"]["source_grid"]["n_subcarriers"] = serde_json::json!(64);
fs::write(&path, serde_json::to_vec(&image).unwrap()).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 1),
Err(BootstrapBaselineError::DigestMismatch)
));
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 3_600_001),
Err(BootstrapBaselineError::Expired)
| Err(BootstrapBaselineError::FieldModel(
FieldModelError::BaselineExpired { .. }
))
));
}
#[test]
fn store_rejects_invalid_source_grid() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
for source_grid in [
BootstrapCsiGrid {
n_subcarriers: 0,
ppdu_type: 0,
},
BootstrapCsiGrid {
n_subcarriers: 2_049,
ppdu_type: 0,
},
BootstrapCsiGrid {
n_subcarriers: 192,
ppdu_type: 4,
},
] {
assert!(matches!(
store(&path, "home-a", &[5], source_grid, "model", now_ms, &model,),
Err(BootstrapBaselineError::Malformed(_))
));
}
}
#[test]
fn store_requires_exactly_one_source_node() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
for source_node_ids in [&[][..], &[5, 7][..]] {
assert!(matches!(
store(
&path,
"home-a",
source_node_ids,
TEST_GRID,
"model",
now_ms,
&model,
),
Err(BootstrapBaselineError::Malformed(_))
));
}
}
#[test]
fn source_grid_is_required_when_loading_a_v2_image() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let mut image: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
image["payload"]
.as_object_mut()
.unwrap()
.remove("source_grid");
let payload = serde_json::to_vec(&image["payload"]).unwrap();
let rewritten = format!(
"{{\"payload\":{},\"content_sha256\":\"{}\"}}",
String::from_utf8(payload.clone()).unwrap(),
hex_sha256(&payload)
);
fs::write(&path, rewritten).unwrap();
assert!(matches!(
load(&path, "home-a", now_ms + 1),
Err(BootstrapBaselineError::Json(_))
));
}
#[test]
fn promotion_gate_requires_twelve_fresh_samples_and_zero_vitals() {
assert_eq!(BOOTSTRAP_VALIDATION_MIN_SPACING_MS, 1_000);
assert_eq!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS, 4_000);
assert!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS);
let passing = vec![
BootstrapValidationSample {
fresh_tick: true,
calibrated_empty: true,
vital_signs_absent: true,
};
BOOTSTRAP_VALIDATION_SAMPLES
];
assert!(evaluate_validation(&passing).passed);
let mut stale = passing.clone();
stale[0].fresh_tick = false;
assert!(!evaluate_validation(&stale).passed);
let mut vital = passing;
vital[0].vital_signs_absent = false;
assert!(!evaluate_validation(&vital).passed);
}
#[test]
fn restart_verifies_the_exact_stored_payload_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = path_in(dir.path());
let now_ms = 1_000_000;
let model = completed_model(now_ms * 1_000);
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
let original = fs::read_to_string(&path).unwrap();
let image: BootstrapImageRawV2 = serde_json::from_str(&original).unwrap();
let rewritten_payload = image.payload.get().replacen(
"\"min_calibration_duration_s\":0.0",
"\"min_calibration_duration_s\":0.00",
1,
);
assert_ne!(rewritten_payload, image.payload.get());
let digest = hex_sha256(rewritten_payload.as_bytes());
let rewritten =
format!("{{\"payload\":{rewritten_payload},\"content_sha256\":\"{digest}\"}}");
fs::write(&path, rewritten).unwrap();
let (_, loaded) = load(&path, "home-a", now_ms + 1).unwrap();
assert_eq!(loaded.content_sha256, digest);
}
}
@@ -178,7 +178,12 @@ pub struct Args {
#[arg(long, value_name = "TYPE")]
pub build_index: Option<String>,
/// Node positions for multistatic fusion (format: "x,y,z;x,y,z;...")
/// Node positions for multistatic fusion.
///
/// Two forms, mixable: "x,y,z;..." positional, where each entry's identity
/// is its index, and "node_id:x,y,z;..." explicit. Prefer the explicit form
/// unless the fleet is numbered 0,1,2,... — otherwise "the third entry" and
/// "node 3" are different nodes and nothing in the string says which is meant.
#[arg(long, env = "SENSING_NODE_POSITIONS")]
pub node_positions: Option<String>,
@@ -11,6 +11,141 @@ use crate::vital_signs::VitalSigns;
const EDGE_MAX_PERSONS: u8 = 4;
/// Estimate a real temporal motion frequency from recent CSI amplitude
/// history. Each frame is reduced to its mean amplitude, detrended, Hann
/// windowed, and evaluated on exact DFT bins.
pub fn estimate_temporal_dominant_frequency_hz(
frame_history: &VecDeque<Vec<f64>>,
sample_rate_hz: f64,
) -> f64 {
if !sample_rate_hz.is_finite() || sample_rate_hz <= 0.0 {
return 0.0;
}
let recent_frames: Vec<&Vec<f64>> = frame_history
.iter()
.rev()
.take(128)
.collect();
if recent_frames
.iter()
.any(|frame| frame.is_empty() || frame.iter().any(|value| !value.is_finite()))
{
return 0.0;
}
let samples: Vec<f64> = recent_frames
.into_iter()
.rev()
.map(|frame| frame.iter().sum::<f64>() / frame.len() as f64)
.collect();
let n = samples.len();
if n < 16 {
return 0.0;
}
let mean = samples.iter().sum::<f64>() / n as f64;
let upper_hz = 4.0_f64.min(sample_rate_hz * 0.45);
if upper_hz < 0.1 {
return 0.0;
}
let mut powers = Vec::new();
for bin in 1..=(n / 2) {
let frequency_hz = bin as f64 * sample_rate_hz / n as f64;
if !(0.1..=upper_hz).contains(&frequency_hz) {
continue;
}
let mut real = 0.0;
let mut imag = 0.0;
let angle_step = 2.0 * std::f64::consts::PI * bin as f64 / n as f64;
let (step_sin, step_cos) = angle_step.sin_cos();
let mut angle_cos = 1.0;
let mut angle_sin = 0.0;
for (index, sample) in samples.iter().enumerate() {
let window =
0.5 * (1.0 - (2.0 * std::f64::consts::PI * index as f64 / (n - 1) as f64).cos());
let value = (sample - mean) * window;
real += value * angle_cos;
imag -= value * angle_sin;
let next_cos = angle_cos * step_cos - angle_sin * step_sin;
angle_sin = angle_sin * step_cos + angle_cos * step_sin;
angle_cos = next_cos;
}
powers.push((frequency_hz, real * real + imag * imag));
}
if powers.is_empty() {
return 0.0;
}
let mean_power = powers.iter().map(|(_, power)| power).sum::<f64>() / powers.len() as f64;
let (peak_hz, peak_power) = powers
.into_iter()
.max_by(|left, right| {
left.1
.partial_cmp(&right.1)
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or((0.0, 0.0));
if mean_power <= f64::EPSILON || peak_power / mean_power < 2.0 {
0.0
} else {
peak_hz
}
}
#[cfg(test)]
mod temporal_frequency_tests {
use super::*;
#[test]
fn recovers_temporal_frequency_in_hertz() {
let sample_rate_hz = 20.0;
let target_hz = 1.2;
let mut history = VecDeque::new();
for sample in 0..100 {
let t = sample as f64 / sample_rate_hz;
let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * target_hz * t).sin();
history.push_back(vec![value; 56]);
}
let measured = estimate_temporal_dominant_frequency_hz(&history, sample_rate_hz);
assert!((measured - target_hz).abs() <= 0.21, "measured {measured} Hz");
}
#[test]
fn abstains_on_short_or_flat_history() {
let short = VecDeque::from(vec![vec![20.0; 56]; 15]);
assert_eq!(estimate_temporal_dominant_frequency_hz(&short, 20.0), 0.0);
let flat = VecDeque::from(vec![vec![20.0; 56]; 100]);
assert_eq!(estimate_temporal_dominant_frequency_hz(&flat, 20.0), 0.0);
let mut corrupt = flat;
corrupt[50][4] = f64::NAN;
assert_eq!(estimate_temporal_dominant_frequency_hz(&corrupt, 20.0), 0.0);
}
#[test]
fn temporal_frequency_p95_stays_below_server_budget() {
let sample_rate_hz = 50.0;
let mut history = VecDeque::new();
for sample in 0..128 {
let t = sample as f64 / sample_rate_hz;
let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * 1.2 * t).sin();
history.push_back(vec![value; 256]);
}
let mut timings = Vec::with_capacity(1_000);
for _ in 0..1_000 {
let start = std::time::Instant::now();
std::hint::black_box(estimate_temporal_dominant_frequency_hz(
std::hint::black_box(&history),
sample_rate_hz,
));
timings.push(start.elapsed());
}
timings.sort_unstable();
let p95 = timings[949];
eprintln!("temporal dominant frequency p95: {p95:?}");
assert!(p95 < std::time::Duration::from_millis(10), "p95 {p95:?}");
}
}
/// Person count is supporting evidence, never an independent occupancy claim.
/// Return zero and mark invalid for contradictory or out-of-range firmware.
fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) {
@@ -469,14 +604,7 @@ pub fn extract_features_from_frame(
0.0
};
let peak_idx = frame
.amplitudes
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
let dominant_freq_hz = peak_idx as f64 * 0.05;
let dominant_freq_hz = estimate_temporal_dominant_frequency_hz(frame_history, sample_rate_hz);
let threshold = mean_amp * 1.2;
let change_points = frame
@@ -485,7 +613,9 @@ pub fn extract_features_from_frame(
.filter(|w| (w[0] < threshold) != (w[1] < threshold))
.count();
let temporal_motion_score = if let Some(prev_frame) = frame_history.back() {
// The caller appends the current frame before extraction, so the true
// predecessor is the second item from the back.
let temporal_motion_score = if let Some(prev_frame) = frame_history.iter().rev().nth(1) {
let n_cmp = n_sub.min(prev_frame.len());
if n_cmp > 0 {
let diff_energy: f64 = (0..n_cmp)
@@ -636,13 +766,18 @@ pub fn adaptive_override(
.back()
.map(|v| v.as_slice())
.unwrap_or(&[]);
let dominant_frequency = adaptive_classifier::compatible_dominant_frequency(
model.version,
features.dominant_freq_hz,
amps,
);
let feat_arr = adaptive_classifier::features_from_runtime(
&serde_json::json!({
"variance": features.variance,
"motion_band_power": features.motion_band_power,
"breathing_band_power": features.breathing_band_power,
"spectral_power": features.spectral_power,
"dominant_freq_hz": features.dominant_freq_hz,
"dominant_freq_hz": dominant_frequency,
"change_points": features.change_points,
"mean_rssi": features.mean_rssi,
}),
@@ -15,6 +15,15 @@ use wifi_densepose_signal::ruvsense::field_model::{
use super::score_to_person_count;
/// Versioned schema for the immutable receipt emitted after field-model calibration.
pub const CALIBRATION_MODEL_RECEIPT_SCHEMA: &str =
"ruview.calibration.field-model-receipt.v1";
/// Versioned schema for per-frame calibrated occupancy evidence bound to a
/// model receipt, published on the sensing stream.
pub const CALIBRATED_PRESENCE_EVIDENCE_SCHEMA: &str =
"ruview.calibration.calibrated-presence-evidence.v2";
/// Length-only canonicalizer for calibration frames (issue #1170 pattern,
/// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the
/// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is
@@ -40,6 +49,106 @@ const ENERGY_THRESH_3: f64 = 25.0;
/// reliable — genuine higher counts come from the multistatic fusion path.
const MAX_SINGLE_LINK_OCCUPANCY: usize = 3;
/// Low-authority comparison of the current source window with a restored
/// empty-room bootstrap image. This is background conformance only and cannot
/// authorize presence, person count, pose, identity, or vital signs.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BootstrapBackgroundMatch {
pub matches_empty: bool,
pub score: Option<f64>,
pub normalized_residual_z: Option<f64>,
pub maturity: f64,
pub reliable: bool,
pub residual_energy: f64,
pub residual_energy_threshold: f64,
pub window_size: usize,
pub reference_window_count: usize,
}
fn mean_frame(frames: &[Vec<f64>]) -> Option<Vec<f64>> {
let width = frames.first()?.len();
if width == 0 {
return None;
}
let mut mean = vec![0.0_f64; width];
let mut count = 0_usize;
for frame in frames {
if frame.len() != width {
continue;
}
for (sum, value) in mean.iter_mut().zip(frame.iter()) {
*sum += value;
}
count += 1;
}
if count == 0 {
return None;
}
for value in &mut mean {
*value /= count as f64;
}
Some(mean)
}
fn perturbation_occupancy(field: &FieldModel, frames: &[Vec<f64>]) -> Option<usize> {
let adaptive_threshold = field.empty_room_residual_energy_threshold();
let frame = match adaptive_threshold {
Some(_) => mean_frame(frames)?,
None => frames.first()?.clone(),
};
let perturbation = field.extract_perturbation(&[frame]).ok()?;
let count = match adaptive_threshold {
Some(threshold) => {
let empty_threshold = threshold.max(1.0);
if perturbation.total_energy > empty_threshold * 25.0 {
3
} else if perturbation.total_energy > empty_threshold * 12.0 {
2
} else if perturbation.total_energy > empty_threshold {
1
} else {
0
}
}
None => {
if perturbation.total_energy > ENERGY_THRESH_3 {
3
} else if perturbation.total_energy > ENERGY_THRESH_2 {
2
} else if perturbation.total_energy > 1.0 {
1
} else {
0
}
}
};
Some(count)
}
/// Provenance for a count produced by the calibrated field model. Callers
/// that attach calibration evidence must never substitute the score heuristic
/// when the calibrated path cannot evaluate the observation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalibratedOccupancyMethod {
Eigenvalue,
PerturbationEnergy,
}
impl CalibratedOccupancyMethod {
pub const fn wire_name(self) -> &'static str {
match self {
Self::Eigenvalue => "field_model_eigenvalue_v1",
Self::PerturbationEnergy => "field_model_perturbation_energy_v1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CalibratedOccupancy {
pub person_count: usize,
pub method: CalibratedOccupancyMethod,
}
/// Create a FieldModelConfig for single-link mode (one ESP32 node = one link).
/// This avoids the DimensionMismatch error when feeding single-frame observations.
pub fn single_link_config() -> FieldModelConfig {
@@ -49,6 +158,18 @@ pub fn single_link_config() -> FieldModelConfig {
}
}
/// Resolve the model status at the observation wall clock. `FieldModel::status`
/// records the state at collection or restore time and does not advance as a
/// long-running server crosses the stale and expiry boundaries.
pub fn calibration_status_at(field: &FieldModel, observed_at_us: u64) -> CalibrationStatus {
match field.status() {
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting => field.status(),
CalibrationStatus::Fresh | CalibrationStatus::Stale | CalibrationStatus::Expired => {
field.check_freshness(observed_at_us)
}
}
}
/// Estimate occupancy using the FieldModel when calibrated, falling back
/// to the score-based heuristic otherwise.
///
@@ -58,16 +179,17 @@ pub fn single_link_config() -> FieldModelConfig {
pub fn occupancy_or_fallback(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
smoothed_score: f64,
prev_count: usize,
) -> usize {
match field.status() {
match calibration_status_at(field, observed_at_us) {
CalibrationStatus::Fresh | CalibrationStatus::Stale => {
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.cloned()
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
if frames.is_empty() {
@@ -87,82 +209,197 @@ pub fn occupancy_or_fallback(
// Fallback: perturbation energy thresholds.
// FieldModel expects [n_links][n_subcarriers] — we use n_links=1.
let observation = vec![frames[0].clone()];
match field.extract_perturbation(&observation) {
Ok(perturbation) => {
if perturbation.total_energy > ENERGY_THRESH_3 {
3
} else if perturbation.total_energy > ENERGY_THRESH_2 {
2
} else if perturbation.total_energy > 1.0 {
1
} else {
0
}
}
Err(_) => score_to_person_count(smoothed_score, prev_count),
}
perturbation_occupancy(field, &frames)
.unwrap_or_else(|| score_to_person_count(smoothed_score, prev_count))
}
_ => score_to_person_count(smoothed_score, prev_count),
}
}
/// Feed the latest frame to the FieldModel during calibration collection.
/// Return true only when a restored low-authority bootstrap model has a full
/// runtime window and that model resolves the window as empty. This helper is
/// deliberately one-way: a bootstrap prior may suppress an obvious background
/// false positive, but cannot authorize presence or vital signs.
pub fn bootstrap_empty_prior_applies(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> bool {
bootstrap_background_match(field, frame_history, observed_at_us)
.is_some_and(|result| result.matches_empty)
}
/// Score the current canonical runtime window against the learned empty-room
/// residual references. Legacy models without scalar references preserve the
/// binary match but intentionally return no score.
pub fn bootstrap_background_match(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> Option<BootstrapBackgroundMatch> {
if frame_history.len() < OCCUPANCY_WINDOW
|| field.check_freshness(observed_at_us) != CalibrationStatus::Fresh
{
return None;
}
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
let result = field.empty_room_match(&frames)?;
Some(BootstrapBackgroundMatch {
matches_empty: result.matches_empty,
score: result.score,
normalized_residual_z: result.normalized_residual_z,
maturity: result.maturity,
reliable: result.reliable,
residual_energy: result.residual_energy,
residual_energy_threshold: result.residual_energy_threshold,
window_size: result.window_size,
reference_window_count: result.reference_window_count,
})
}
/// Produce a fail-closed occupancy result from a fresh calibrated model.
pub fn calibrated_occupancy(
field: &FieldModel,
frame_history: &VecDeque<Vec<f64>>,
observed_at_us: u64,
) -> Option<CalibratedOccupancy> {
if field.check_freshness(observed_at_us) != CalibrationStatus::Fresh {
return None;
}
let frames: Vec<Vec<f64>> = frame_history
.iter()
.rev()
.take(OCCUPANCY_WINDOW)
.map(|frame| CALIB_NORMALIZER.resample_to_canonical(frame))
.collect();
if frames.is_empty() {
return None;
}
if let Ok(person_count) = field.estimate_occupancy(&frames) {
return Some(CalibratedOccupancy {
person_count: person_count.min(MAX_SINGLE_LINK_OCCUPANCY),
method: CalibratedOccupancyMethod::Eigenvalue,
});
}
Some(CalibratedOccupancy {
person_count: perturbation_occupancy(field, &frames)?,
method: CalibratedOccupancyMethod::PerturbationEnergy,
})
}
/// Feed the current CSI frame to the FieldModel during calibration.
///
/// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame
/// flips a freshly-started (`Uncalibrated`) model to `Collecting` inside
/// `feed_calibration`; without accepting the `Uncalibrated` state here the two
/// gates deadlock and the frame count never leaves 0 (calibration/start yields
/// an `Uncalibrated` model that nothing would ever advance). Wraps the latest
/// frame as a single-link observation (n_links=1) and feeds it.
pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<Vec<f64>>) {
/// an `Uncalibrated` model that nothing would ever advance). Edge-vitals
/// packets have no CSI observation and must never call this helper. The caller
/// owns the stateful sequence admission gate. Wraps the current frame as a
/// single-link observation (n_links=1) and feeds it.
pub fn maybe_feed_calibration(field: &mut FieldModel, amplitudes: &[f64]) -> bool {
if !matches!(
field.status(),
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting
) {
return;
) || amplitudes.is_empty()
{
return false;
}
if let Some(latest) = frame_history.back() {
// Resample the raw amplitude vector onto the FieldModel's canonical
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
// feeding those raw made every `feed_calibration` fail DimensionMismatch
// (swallowed at debug level), pinning frame_count at 0 even after the
// status-gate deadlock was fixed. Single-link observation: [1][56].
let canonical = CALIB_NORMALIZER.resample_to_canonical(latest);
let observations = vec![canonical];
if let Err(e) = field.feed_calibration(&observations) {
// Resample the raw amplitude vector onto the FieldModel's canonical
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
// feeding those raw made every `feed_calibration` fail DimensionMismatch
// (swallowed at debug level), pinning frame_count at 0 even after the
// status-gate deadlock was fixed. Single-link observation: [1][56].
let canonical = CALIB_NORMALIZER.resample_to_canonical(amplitudes);
let observations = vec![canonical];
match field.feed_calibration(&observations) {
Ok(()) => true,
Err(e) => {
tracing::debug!("FieldModel calibration feed: {e}");
false
}
}
}
/// Parse node positions from a semicolon-delimited string.
/// One parsed `--node-positions` entry: a position, and the node id if the
/// operator named one explicitly.
///
/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`.
/// Malformed entries are skipped with a warning log.
pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
/// `node_id: None` means the entry was given positionally, so its identity is
/// its index in the list — which is only the same thing as a node id on a fleet
/// numbered `0, 1, 2, ...`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NodePositionEntry {
pub node_id: Option<u8>,
pub position: [f32; 3],
}
/// Parse node positions, keeping any explicit node ids.
///
/// Two accepted forms, per entry:
///
/// * `x,y,z` — positional; identity is the entry's index.
/// * `node_id:x,y,z` — explicit; identity is the stated node id.
///
/// The explicit form exists because the positional one is only unambiguous on a
/// fleet whose ids happen to run `0, 1, 2, ...`. Anywhere else, "the third
/// entry" and "node 3" are different nodes, and nothing in the string says
/// which was meant. Naming the id removes the guess rather than relocating it.
///
/// Both forms may be mixed; a positional entry keeps its index as its identity.
/// Malformed entries are skipped with a warning rather than failing the boot —
/// a typo in one triplet should not take the server down.
pub fn parse_node_position_entries(input: &str) -> Vec<NodePositionEntry> {
if input.is_empty() {
return Vec::new();
}
input
.split(';')
.enumerate()
.filter_map(|(idx, triplet)| {
.filter_map(|(idx, raw)| {
let entry = raw.trim();
if entry.is_empty() {
return None;
}
// An explicit id is everything before the first ':'. Coordinates
// never contain one, so this cannot be ambiguous.
let (node_id, triplet) = match entry.split_once(':') {
Some((id_str, rest)) => match id_str.trim().parse::<u8>() {
Ok(id) => (Some(id), rest),
Err(_) => {
tracing::warn!(
"Skipping node position entry {idx}: '{entry}' has \
an unparseable node id before ':' (expected \
node_id:x,y,z with node_id in 0..=255)"
);
return None;
}
},
None => (None, entry),
};
let parts: Vec<&str> = triplet.split(',').collect();
if parts.len() != 3 {
tracing::warn!(
"Skipping malformed node position entry {idx}: '{triplet}' (expected x,y,z)"
"Skipping malformed node position entry {idx}: '{entry}' (expected x,y,z)"
);
return None;
}
match (
parts[0].parse::<f32>(),
parts[1].parse::<f32>(),
parts[2].parse::<f32>(),
parts[0].trim().parse::<f32>(),
parts[1].trim().parse::<f32>(),
parts[2].trim().parse::<f32>(),
) {
(Ok(x), Ok(y), Ok(z)) => Some([x, y, z]),
(Ok(x), Ok(y), Ok(z)) => Some(NodePositionEntry {
node_id,
position: [x, y, z],
}),
_ => {
tracing::warn!("Skipping unparseable node position entry {idx}: '{triplet}'");
tracing::warn!("Skipping unparseable node position entry {idx}: '{entry}'");
None
}
}
@@ -170,10 +407,81 @@ pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
.collect()
}
/// Build the `node_id -> position` map the live `NodeInfo` output reads.
///
/// Identity is the explicit `node_id:` prefix when the operator gave one, and
/// the entry's list index otherwise.
///
/// This exists as a function rather than a loop inside `main()` because the bug
/// it fixes was invisible without a test: positions were inserted keyed by list
/// index and read back by `node_id`, so on any fleet not numbered `0, 1, 2, ...`
/// every lookup missed and silently fell through to the hardcoded
/// `[2.0, 0.0, 1.5]` that `--node-positions` exists to replace. The config
/// looked applied and did nothing.
pub fn node_positions_by_id(
entries: &[NodePositionEntry],
) -> std::collections::HashMap<u8, [f32; 3]> {
let mut map = std::collections::HashMap::new();
for (idx, e) in entries.iter().enumerate() {
let key = e.node_id.unwrap_or(idx as u8);
if let Some(prev) = map.insert(key, e.position) {
tracing::warn!(
"node position for node {key} given twice ({prev:?} then {:?}); the later entry wins",
e.position
);
}
}
map
}
/// Parse node positions from a semicolon-delimited string.
///
/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`.
/// Malformed entries are skipped with a warning log.
///
/// Positional view, for consumers that index the list rather than key it by
/// node id. Since issue #1866 the fusion path is no longer one of them: it
/// takes the keyed map from [`node_positions_by_id`] via
/// `MultistaticFuser::set_node_positions_by_id()`. Use
/// [`parse_node_position_entries`] where identity is needed.
pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
parse_node_position_entries(input)
.into_iter()
.map(|e| e.position)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_test_model() -> FieldModel {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 10,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 2.0,
};
let mut field = FieldModel::new(config).expect("field model");
for index in 0..20 {
let phase = index as f64 * 0.1;
let observation = vec![(0..56)
.map(|subcarrier| {
1.0 + subcarrier as f64 * 0.01 + (phase + subcarrier as f64 * 0.03).sin() * 0.01
})
.collect()];
field
.feed_calibration(&observation)
.expect("calibration frame");
}
field
.finalize_calibration(1_000_000, 7)
.expect("finalize calibration");
field
}
#[test]
fn test_parse_node_positions() {
let positions = parse_node_positions("0,0,1.5;3,0,1.5;1.5,3,1.5");
@@ -203,6 +511,158 @@ mod tests {
assert_eq!(positions[0], [3.0, 4.0, 5.0]);
}
// ---- Explicit node ids (the indexing-mismatch regression) ----
/// Positional entries carry no id, so identity falls back to the index.
/// This is the ONLY case the old behaviour handled correctly.
#[test]
fn positional_entries_report_no_explicit_id() {
let e = parse_node_position_entries("0,0,1.5;3,0,1.5");
assert_eq!(e.len(), 2);
assert!(e.iter().all(|x| x.node_id.is_none()));
assert_eq!(e[1].position, [3.0, 0.0, 1.5]);
}
/// THE REGRESSION. Positions were stored keyed by list index and read back
/// by node_id, so on any fleet not numbered 0,1,2,... every lookup missed
/// and fell through to the hardcoded default the option exists to replace.
/// Non-sequential ids are what catch that.
#[test]
fn non_sequential_node_ids_are_preserved() {
let e = parse_node_position_entries("11:1,2,3;12:4,5,6;13:7,8,9");
assert_eq!(e.len(), 3);
assert_eq!(e[0].node_id, Some(11));
assert_eq!(e[1].node_id, Some(12));
assert_eq!(e[2].node_id, Some(13));
assert_eq!(e[2].position, [7.0, 8.0, 9.0]);
// The index would have been 0,1,2 -- none of which is a real node here.
for (idx, entry) in e.iter().enumerate() {
assert_ne!(
entry.node_id,
Some(idx as u8),
"index and node id must not be conflated"
);
}
}
/// Two-digit ids, and a fleet re-homed to even numbers -- the shape of the
/// planned hardware check (nine boards moved from 0..8 to 0,2,4,...,16).
#[test]
fn two_digit_and_even_numbered_fleet_ids_parse() {
let spec = "0:0,0,1;2:1,0,1;4:2,0,1;6:3,0,1;8:4,0,1;10:5,0,1;12:6,0,1;14:7,0,1;16:8,0,1";
let e = parse_node_position_entries(spec);
assert_eq!(e.len(), 9);
let ids: Vec<u8> = e.iter().map(|x| x.node_id.unwrap()).collect();
assert_eq!(ids, vec![0, 2, 4, 6, 8, 10, 12, 14, 16]);
assert_eq!(e[8].position, [8.0, 0.0, 1.0]);
}
/// A non-zero starting index must work too -- nothing requires node 0.
#[test]
fn a_fleet_that_does_not_start_at_zero_parses() {
let e = parse_node_position_entries("18:1.5,2.5,3.5");
assert_eq!(e[0].node_id, Some(18));
assert_eq!(e[0].position, [1.5, 2.5, 3.5]);
}
/// Mixing is allowed: an unkeyed entry keeps its index.
#[test]
fn mixed_forms_keep_their_own_identity() {
let e = parse_node_position_entries("0,0,1;7:9,9,9");
assert_eq!(e[0].node_id, None);
assert_eq!(e[1].node_id, Some(7));
}
/// An unparseable id is skipped rather than silently treated as positional
/// -- guessing there would reintroduce the exact ambiguity being removed.
#[test]
fn an_unparseable_node_id_is_skipped_not_guessed() {
let e = parse_node_position_entries("abc:1,2,3;5:4,5,6");
assert_eq!(e.len(), 1, "only the well-formed entry survives");
assert_eq!(e[0].node_id, Some(5));
}
/// 256 is out of range for a u8 node id and must not wrap to 0.
#[test]
fn an_out_of_range_node_id_is_rejected() {
let e = parse_node_position_entries("256:1,2,3");
assert!(e.is_empty(), "256 must not wrap to node 0");
}
/// The positional view stays byte-compatible for the fuser, which indexes
/// the list rather than keying it.
#[test]
fn the_positional_view_is_unchanged_by_explicit_ids() {
let p = parse_node_positions("11:1,2,3;12:4,5,6");
assert_eq!(p, vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
}
/// The regression itself, at the map that `NodeInfo` actually reads.
///
/// The old code inserted keyed by list index and looked up by node_id. On a
/// fleet numbered 11,12,13 that map holds keys 0,1,2, so every lookup misses
/// and the node reports the hardcoded default. This asserts both halves:
/// the real ids resolve, and the indices are absent.
#[test]
fn the_map_is_keyed_by_node_id_not_list_index() {
let entries = parse_node_position_entries("11:1,2,3;12:4,5,6;13:7,8,9");
let map = node_positions_by_id(&entries);
assert_eq!(map.get(&11), Some(&[1.0, 2.0, 3.0]));
assert_eq!(map.get(&12), Some(&[4.0, 5.0, 6.0]));
assert_eq!(map.get(&13), Some(&[7.0, 8.0, 9.0]));
for idx in 0u8..3 {
assert!(
map.get(&idx).is_none(),
"list index {idx} must not be a key -- that is the bug: a lookup by node_id would miss and fall back to the hardcoded position"
);
}
}
/// Positional input keeps the old behaviour exactly: index becomes the key.
/// This is what made the bug invisible on a 0..8 fleet.
#[test]
fn positional_input_still_keys_by_index() {
let entries = parse_node_position_entries("0,0,1;3,0,1;6,0,1");
let map = node_positions_by_id(&entries);
assert_eq!(map.get(&0), Some(&[0.0, 0.0, 1.0]));
assert_eq!(map.get(&1), Some(&[3.0, 0.0, 1.0]));
assert_eq!(map.get(&2), Some(&[6.0, 0.0, 1.0]));
}
/// A re-homed fleet at 0,2,4,...,16 resolves on the real ids, and the odd
/// indices in between are absent.
#[test]
fn an_even_numbered_fleet_resolves_by_id() {
let spec = "0:0,0,1;2:1,0,1;4:2,0,1;6:3,0,1;8:4,0,1;10:5,0,1;12:6,0,1;14:7,0,1;16:8,0,1";
let map = node_positions_by_id(&parse_node_position_entries(spec));
assert_eq!(map.len(), 9);
assert_eq!(map.get(&16), Some(&[8.0, 0.0, 1.0]));
assert_eq!(map.get(&10), Some(&[5.0, 0.0, 1.0]));
// Index-keyed would have produced 0..8; 1,3,5,7 are not real nodes here.
for odd in [1u8, 3, 5, 7] {
assert!(map.get(&odd).is_none(), "node {odd} does not exist in this fleet");
}
}
/// A duplicate id keeps the later entry rather than silently holding two.
#[test]
fn a_duplicate_node_id_keeps_the_later_entry() {
let map = node_positions_by_id(&parse_node_position_entries("4:1,1,1;4:2,2,2"));
assert_eq!(map.len(), 1);
assert_eq!(map.get(&4), Some(&[2.0, 2.0, 2.0]));
}
/// Whitespace around entries and coordinates is tolerated.
#[test]
fn whitespace_is_tolerated() {
let e = parse_node_position_entries(" 3 : 1.0 , 2.0 , 3.0 ");
assert_eq!(e.len(), 1);
assert_eq!(e[0].node_id, Some(3));
assert_eq!(e[0].position, [1.0, 2.0, 3.0]);
}
/// Regression: a freshly-started (`Uncalibrated`) field model must begin
/// collecting once frames arrive. Before the fix, `maybe_feed_calibration`
/// only fed while already `Collecting`, but only `feed_calibration` sets
@@ -215,10 +675,7 @@ mod tests {
// n_subcarriers defaults to 56; one single-link frame of that width.
let frame = vec![0.5_f64; 56];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(frame);
maybe_feed_calibration(&mut field, &history);
assert!(maybe_feed_calibration(&mut field, &frame));
assert_eq!(
field.status(),
@@ -231,8 +688,8 @@ mod tests {
"frame count must advance past 0"
);
// Subsequent frames keep accumulating while Collecting.
maybe_feed_calibration(&mut field, &history);
// A subsequent unique frame keeps accumulating while Collecting.
assert!(maybe_feed_calibration(&mut field, &frame));
assert_eq!(field.calibration_frame_count(), 2);
}
@@ -247,10 +704,7 @@ mod tests {
// 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw.
let wide = vec![0.5_f64; 128];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(wide);
maybe_feed_calibration(&mut field, &history);
assert!(maybe_feed_calibration(&mut field, &wide));
assert_eq!(
field.status(),
@@ -263,4 +717,129 @@ mod tests {
"wide frame must accumulate, not be silently dropped"
);
}
#[test]
fn maybe_feed_calibration_rejects_an_empty_observation() {
let mut field = FieldModel::new(single_link_config()).expect("field model");
assert!(!maybe_feed_calibration(&mut field, &[]));
assert_eq!(field.calibration_frame_count(), 0);
}
#[test]
fn calibrated_occupancy_is_fresh_model_only_and_normalizes_runtime_frames() {
let field = fresh_test_model();
let mut history = VecDeque::new();
history.push_back(vec![1.02; 128]);
let result =
calibrated_occupancy(&field, &history, 1_500_000).expect("fresh calibrated inference");
assert!(result.person_count <= MAX_SINGLE_LINK_OCCUPANCY);
assert!(matches!(
result.method,
CalibratedOccupancyMethod::Eigenvalue | CalibratedOccupancyMethod::PerturbationEnergy
));
assert_eq!(calibrated_occupancy(&field, &history, 4_000_000), None);
assert_eq!(
calibrated_occupancy(&field, &VecDeque::new(), 1_500_000),
None
);
}
#[test]
fn occupancy_falls_back_after_expiry_without_a_process_restart() {
let field = fresh_test_model();
let baseline = field.modes().unwrap().baseline[0].clone();
let history: VecDeque<Vec<f64>> =
(0..OCCUPANCY_WINDOW).map(|_| baseline.clone()).collect();
assert_eq!(field.status(), CalibrationStatus::Fresh);
assert_eq!(
calibration_status_at(&field, 1_500_000),
CalibrationStatus::Fresh
);
assert_eq!(
occupancy_or_fallback(&field, &history, 1_500_000, 0.0, 0),
0,
"a fresh field model may resolve its learned baseline as empty"
);
assert_eq!(field.status(), CalibrationStatus::Fresh);
assert_eq!(
calibration_status_at(&field, 4_000_000),
CalibrationStatus::Expired
);
assert_eq!(
occupancy_or_fallback(&field, &history, 4_000_000, 0.0, 0),
score_to_person_count(0.0, 0),
"an expired in-memory model must fall back even though its cached status is fresh"
);
}
#[test]
fn adaptive_runtime_reference_scores_empty_and_rejects_shift() {
let config = FieldModelConfig {
n_links: 1,
n_subcarriers: 56,
n_modes: 3,
min_calibration_frames: 500,
min_calibration_duration_s: 0.0,
baseline_expiry_s: 60.0,
};
let mut field = FieldModel::new(config).expect("field model");
let mut calibration = Vec::new();
for frame_index in 0..600 {
let time = frame_index as f64 * 0.071;
let frame: Vec<f64> = (0..56)
.map(|subcarrier| {
let carrier = subcarrier as f64;
18.0 + carrier * 0.08
+ (time + carrier * 0.13).sin() * 0.8
+ (time * 0.37 + carrier * 0.031).cos() * 0.35
})
.collect();
field.feed_calibration(&[frame.clone()]).unwrap();
calibration.push(frame);
}
field.finalize_calibration(1_000_000, 7).unwrap();
let empty_history: VecDeque<Vec<f64>> = calibration[550..600].iter().cloned().collect();
let empty = calibrated_occupancy(&field, &empty_history, 1_500_000)
.expect("adaptive perturbation result");
assert_eq!(empty.person_count, 0);
let background = bootstrap_background_match(&field, &empty_history, 1_500_000)
.expect("background score");
assert!(background.matches_empty);
assert_eq!(background.reference_window_count, 12);
assert!(background.reliable);
assert_eq!(background.maturity, 0.6);
assert!(background.normalized_residual_z.is_some());
assert!(background
.score
.is_some_and(|score| (0.5..=1.0).contains(&score)));
let short_history: VecDeque<Vec<f64>> = empty_history.iter().take(49).cloned().collect();
assert!(!bootstrap_empty_prior_applies(
&field,
&short_history,
1_500_000
));
let shifted_history: VecDeque<Vec<f64>> = calibration[550..600]
.iter()
.map(|frame| {
frame
.iter()
.enumerate()
.map(|(index, value)| value + if index % 3 == 0 { 8.0 } else { -5.0 })
.collect()
})
.collect();
let shifted = calibrated_occupancy(&field, &shifted_history, 1_500_000)
.expect("shifted perturbation result");
assert!(shifted.person_count >= 1);
let shifted_background = bootstrap_background_match(&field, &shifted_history, 1_500_000)
.expect("shifted background result");
assert!(!shifted_background.matches_empty);
assert_eq!(shifted_background.score, Some(0.0));
assert!(shifted_background.reliable);
}
}
@@ -9,6 +9,7 @@
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
pub mod bearer_auth;
pub mod bootstrap_baseline;
pub mod browser_session;
pub mod ws_ticket;
pub mod cli;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,124 @@
//! Bounded summaries for ADR-323 Realtek RTL8721Dx (AmebaDplus) 1x1 CSI frames.
use serde::Serialize;
use wifi_densepose_hardware::realtek_csi::{CsiFlags, CsiFrame};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct RealtekCsiSnapshot {
pub event_type: &'static str,
pub source: &'static str,
pub csi_mode: &'static str,
pub node_id: u8,
pub sequence: u32,
pub timestamp_us: u32,
pub peer_mac: String,
pub trig_mac: String,
pub channel: u8,
pub bandwidth_mhz: u16,
pub rx_rate: u8,
pub protocol_mode: String,
pub num_sub_carrier: u16,
pub num_bit_per_tone: u8,
pub decimation: u8,
pub rssi_dbm: i8,
pub rxsc: u8,
pub csi_valid: bool,
pub synthetic: bool,
pub mean_amplitude: Option<f32>,
pub peak_amplitude: Option<f32>,
}
impl RealtekCsiSnapshot {
pub(crate) fn from_frame(frame: &CsiFrame) -> Self {
let synthetic = frame.flags.contains(CsiFlags::SYNTHETIC);
let (mean_amplitude, peak_amplitude) = amplitude_summary(frame);
Self {
event_type: "realtek_csi",
source: if synthetic {
"realtek_csi:simulated"
} else {
"realtek_csi"
},
csi_mode: match frame.csi_mode {
wifi_densepose_hardware::realtek_csi::CsiMode::Passive => "passive",
wifi_densepose_hardware::realtek_csi::CsiMode::Active => "active",
wifi_densepose_hardware::realtek_csi::CsiMode::BoardToBoard => "board_to_board",
},
node_id: frame.node_id,
sequence: frame.sequence,
timestamp_us: frame.timestamp_us,
peer_mac: mac_string(&frame.peer_mac),
trig_mac: mac_string(&frame.trig_mac),
channel: frame.channel,
bandwidth_mhz: if frame.bandwidth == 1 { 40 } else { 20 },
rx_rate: frame.rx_rate,
protocol_mode: format!("{:?}", frame.protocol_mode).to_ascii_lowercase(),
num_sub_carrier: frame.num_sub_carrier,
num_bit_per_tone: frame.num_bit_per_tone,
decimation: frame.decimation,
rssi_dbm: frame.rssi_dbm,
rxsc: frame.rxsc,
csi_valid: frame.csi_valid,
synthetic,
mean_amplitude,
peak_amplitude,
}
}
}
fn mac_string(mac: &[u8; 6]) -> String {
mac.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":")
}
fn amplitude_summary(frame: &CsiFrame) -> (Option<f32>, Option<f32>) {
if frame.payload.is_empty() {
return (None, None);
}
let amplitudes: Vec<f32> = frame
.payload
.iter()
.map(|(i, q)| (*i as f32).hypot(*q as f32))
.collect();
let mean = amplitudes.iter().sum::<f32>() / amplitudes.len() as f32;
let peak = amplitudes.into_iter().max_by(f32::total_cmp);
(Some(mean), peak)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_hardware::realtek_csi::simulator::{RealtekCsiSimulator, SimulatorConfig};
#[test]
fn simulator_summary_preserves_dimensions_and_provenance() {
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
assert_eq!(snapshot.source, "realtek_csi:simulated");
assert_eq!(snapshot.num_sub_carrier, 52);
assert_eq!(snapshot.csi_mode, "passive");
assert!(snapshot.mean_amplitude.unwrap() > 0.0);
assert!(snapshot.peak_amplitude.unwrap() >= snapshot.mean_amplitude.unwrap());
}
#[test]
fn source_string_is_distinct_from_realtek_radar() {
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
// Must never collapse to the bare "realtek"/"realtek:simulated" source
// strings already owned by the RTL8720F radar path (realtek_radar.rs) —
// effective_source()'s prefix matching in main.rs depends on this.
assert_ne!(snapshot.source, "realtek");
assert_ne!(snapshot.source, "realtek:simulated");
assert!(snapshot.source.starts_with("realtek_csi"));
}
#[test]
fn mac_addresses_are_formatted_colon_separated() {
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
assert_eq!(snapshot.peer_mac, "a4:39:b3:a4:be:2d");
}
}
@@ -544,6 +544,10 @@ impl AppStateInner {
pub fn person_count(&self) -> usize {
use crate::csi::score_to_person_count;
use crate::field_bridge;
let observed_at_us = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_micros() as u64)
.unwrap_or(0);
match self.field_model.as_ref() {
Some(fm) => {
let history = if !self.frame_history.is_empty() {
@@ -559,6 +563,7 @@ impl AppStateInner {
field_bridge::occupancy_or_fallback(
fm,
history,
observed_at_us,
self.smoothed_person_score,
self.prev_person_count,
)
@@ -346,6 +346,31 @@ impl VitalSignDetector {
self.frame_count = 0;
}
/// Retune temporal windows to an observed CSI clock. Changes smaller than
/// twenty percent are ignored to avoid resetting on normal arrival jitter.
/// A real clock change clears history so samples from different clocks are
/// never interpreted in one spectrum.
pub fn reconfigure_sample_rate(&mut self, sample_rate: f64) -> bool {
if !sample_rate.is_finite() || sample_rate < 1.0 {
return false;
}
let relative_change = (sample_rate - self.sample_rate).abs() / self.sample_rate.max(1.0);
if relative_change < 0.20 {
return false;
}
self.sample_rate = sample_rate;
self.breathing_capacity =
((sample_rate * self.breathing_window_secs) as usize).max(1);
self.heartbeat_capacity =
((sample_rate * self.heartbeat_window_secs) as usize).max(1);
self.reset();
true
}
pub fn sample_rate_hz(&self) -> f64 {
self.sample_rate
}
/// Current buffer fill levels for diagnostics.
/// Returns (breathing_len, breathing_capacity, heartbeat_len, heartbeat_capacity).
pub fn buffer_status(&self) -> (usize, usize, usize, usize) {
@@ -842,6 +867,22 @@ mod tests {
assert_eq!(hb_len, 0);
}
#[test]
fn measured_clock_reconfigures_once_and_clears_mixed_history() {
let mut detector = VitalSignDetector::new(20.0);
let amp = vec![10.0; 56];
let phase = vec![0.0; 56];
for _ in 0..32 {
detector.process_frame(&amp, &phase);
}
assert!(!detector.reconfigure_sample_rate(18.0));
assert_eq!(detector.sample_rate_hz(), 20.0);
assert!(detector.reconfigure_sample_rate(12.0));
assert_eq!(detector.sample_rate_hz(), 12.0);
assert_eq!(detector.buffer_status(), (0, 360, 0, 180));
assert!(!detector.reconfigure_sample_rate(f64::NAN));
}
#[test]
fn test_vital_signs_default() {
let vs = VitalSigns::default();
File diff suppressed because it is too large. Load diff
@@ -26,6 +26,7 @@
//! - `ruvector-attn-mincut` for cross-node spectrogram attention gating
//! - `ruvector-mincut` for person separation (DynamicMinCut)
use std::collections::HashMap;
use std::sync::Arc;
use super::cir::{CirConfig, CirEstimator};
@@ -72,6 +73,12 @@ pub struct FusedSensingFrame {
/// Per-node multi-band frames (preserved for geometry computations).
pub node_frames: Vec<MultiBandCsiFrame>,
/// Node positions (x, y, z) in meters from deployment configuration.
///
/// Parallel to [`Self::node_frames`]: `node_positions[i]` is the
/// configured position of `node_frames[i].node_id`, resolved by identity
/// rather than by rank (issue #1866). Nodes with no configured position
/// appear at the origin. Index `i` is this cycle's cohort rank, which is
/// *not* a node id -- read the id from the paired frame.
pub node_positions: Vec<[f32; 3]>,
/// Number of active nodes contributing to this frame.
pub active_nodes: usize,
@@ -147,8 +154,31 @@ impl MultistaticConfig {
/// maximum legitimate spread between two paired node frames in one cycle is
/// the full cycle length `tdm_total_slots × slot_duration_us` (last slot vs
/// first slot). The hard guard is set to that cycle length plus 20% jitter
/// headroom; the soft guard to ~⅓ of the cycle (a normal adjacent-slot pair
/// fuses cleanly, a near-full-cycle spread is flagged as loose alignment).
/// headroom; the soft guard to the schedule's own **designed stagger**,
/// `(slots - 1) * slot_duration_us` -- the offset between the first and
/// last slot, which is the spread a complete, healthy cycle produces.
///
/// # Why not ~⅓ of the cycle (issue #1866-adjacent, corrected)
///
/// The soft guard was previously `cycle_us / 3`, reasoned about as "a
/// normal *adjacent-slot pair* fuses cleanly". But [`MultistaticFuser::fuse_scored`]
/// does not compare pairs -- it takes `max(timestamp) - min(timestamp)`
/// across the **whole cohort**, so the spread it measures is always the
/// first-to-last offset, `(N - 1)` slots. Against that:
///
/// ```text
/// soft < stagger <=> N*slot/3 < (N-1)*slot <=> N/3 < N-1 <=> N > 1.5
/// ```
///
/// which holds for **every** `N >= 2`. The derived soft guard therefore
/// never covered the schedule it was derived from, at any fleet size, and
/// a complete healthy cycle was flagged as a `TimestampMismatch`
/// contradiction -- which demotes the BFLD privacy class one step
/// (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs.
///
/// The doc example below is the smallest demonstration: the 2-slot mesh
/// whose **measured** 18,194 µs spread motivated this constructor derived
/// a 12,000 µs soft guard, below its own motivating measurement.
///
/// `tdm_total_slots` is clamped to ≥ 1. All other fields take their
/// [`Default`] values.
@@ -160,6 +190,9 @@ impl MultistaticConfig {
/// // reported 18,194 µs 2-slot spread.
/// let cfg = MultistaticConfig::for_tdm_schedule(2, 18_000);
/// assert!(cfg.guard_interval_us >= 18_194);
/// // ...and the soft guard now covers it too, so a healthy 2-slot cycle
/// // is not recorded as a contradiction.
/// assert!(cfg.soft_guard_us >= 18_194);
/// ```
#[must_use]
pub fn for_tdm_schedule(tdm_total_slots: usize, slot_duration_us: u64) -> Self {
@@ -167,8 +200,19 @@ impl MultistaticConfig {
let cycle_us = slots.saturating_mul(slot_duration_us);
// +20% jitter headroom on the full cycle.
let guard_interval_us = cycle_us.saturating_add(cycle_us / 5).max(1);
// Soft band at ~⅓ cycle, kept strictly below the hard guard.
let soft_guard_us = (cycle_us / 3).clamp(1, guard_interval_us.saturating_sub(1).max(1));
// Soft band at the schedule's designed stagger -- the first-to-last
// slot offset, which is what `fuse_scored` measures. Kept strictly
// below the hard guard, so a spread approaching a full cycle is still
// flagged as loose alignment.
// +20% jitter headroom, mirroring the hard guard: the *nominal* stagger
// is not the observed one. The 2-slot mesh below has an 18,000 us
// nominal stagger but a MEASURED 18,194 us spread, so a bare nominal
// bound would flag the very cycle it was derived from. Always strictly
// below the hard guard, since stagger < cycle.
let stagger_us = slots.saturating_sub(1).saturating_mul(slot_duration_us);
let soft_guard_us = stagger_us
.saturating_add(stagger_us / 5)
.clamp(1, guard_interval_us.saturating_sub(1).max(1));
Self {
guard_interval_us,
soft_guard_us,
@@ -191,8 +235,16 @@ impl MultistaticConfig {
/// disable the CIR path and keep the legacy frequency-domain coherence only.
pub struct MultistaticFuser {
config: MultistaticConfig,
/// Node positions in 3D space (meters).
node_positions: Vec<[f32; 3]>,
/// Node positions in 3D space (meters), keyed by node id (issue #1866).
///
/// Keyed rather than indexed because [`Self::fuse`] receives a *compacted*
/// cohort: `node_frames_from_states_with_guard` drops nodes that are
/// stale, outside the timestamp guard, or have an empty history. Under
/// index addressing, one node missing a single beacon re-ranks every node
/// above it and silently hands them their neighbour's coordinates. Each
/// `MultiBandCsiFrame` carries its own `node_id`, so the position is
/// looked up by identity and a silent node moves nobody.
node_positions: HashMap<u8, [f32; 3]>,
/// Optional shared CIR estimator (ADR-134). `None` = legacy path only.
cir_estimator: Option<Arc<CirEstimator>>,
}
@@ -212,7 +264,7 @@ impl MultistaticFuser {
pub fn new() -> Self {
Self {
config: MultistaticConfig::default(),
node_positions: Vec::new(),
node_positions: HashMap::new(),
cir_estimator: None,
}
}
@@ -221,7 +273,7 @@ impl MultistaticFuser {
pub fn with_config(config: MultistaticConfig) -> Self {
Self {
config,
node_positions: Vec::new(),
node_positions: HashMap::new(),
cir_estimator: None,
}
}
@@ -267,11 +319,39 @@ impl MultistaticFuser {
fuser
}
/// Set node positions for geometric diversity computations.
/// Set node positions from a positional list (**legacy compatibility**).
///
/// The list index is the node id: `positions[i]` belongs to node `i`. That
/// is the meaning the `--node-positions` flag has always carried when its
/// entries are given without an explicit `node_id:` prefix, so this
/// mapping is preserved exactly rather than reinterpreted.
///
/// This is only correct for fleets whose ids are dense and zero-based.
/// Prefer [`Self::set_node_positions_by_id`], which states the identity
/// instead of inferring it from ordering.
pub fn set_node_positions(&mut self, positions: Vec<[f32; 3]>) {
self.node_positions = positions
.into_iter()
.enumerate()
.map(|(idx, position)| (idx as u8, position))
.collect();
}
/// Set node positions keyed by node id (issue #1866, preferred).
///
/// The position travels with the board it was measured for. Nodes absent
/// from the map fuse at the origin, exactly as before; nodes present but
/// not currently reporting cost nothing and shift nobody.
pub fn set_node_positions_by_id(&mut self, positions: HashMap<u8, [f32; 3]>) {
self.node_positions = positions;
}
/// Return the configured position for one node id, if any.
#[must_use]
pub fn node_position(&self, node_id: u8) -> Option<[f32; 3]> {
self.node_positions.get(&node_id).copied()
}
/// Return the configured hard timestamp guard in microseconds.
///
/// Callers that assemble frames before invoking [`Self::fuse`] use this
@@ -283,11 +363,33 @@ impl MultistaticFuser {
self.config.guard_interval_us
}
/// Return the current node positions.
pub fn node_positions(&self) -> &[[f32; 3]] {
/// Return the configured positions keyed by node id.
#[must_use]
pub fn node_positions_by_id(&self) -> &HashMap<u8, [f32; 3]> {
&self.node_positions
}
/// Return the configured positions as a dense id-indexed list
/// (**legacy compatibility view**).
///
/// Entry `i` is node `i`'s position, with unconfigured ids filled at the
/// origin, so this round-trips [`Self::set_node_positions`]. It is a view
/// of the configuration, *not* of what any particular fusion cycle used —
/// a cycle's cohort is compacted, so read
/// [`FusedSensingFrame::node_positions`] alongside its `node_frames` for
/// that.
#[must_use]
pub fn node_positions(&self) -> Vec<[f32; 3]> {
let Some(&max_id) = self.node_positions.keys().max() else {
return Vec::new();
};
let mut dense = vec![[0.0_f32, 0.0, 0.0]; max_id as usize + 1];
for (&id, &position) in &self.node_positions {
dense[id as usize] = position;
}
dense
}
/// Fuse multiple node frames into a single `FusedSensingFrame`.
///
/// When only one node is provided, falls back to single-node mode
@@ -360,11 +462,19 @@ impl MultistaticFuser {
timestamps.sort_unstable();
let timestamp_us = timestamps[timestamps.len() / 2];
// Build node positions list, filling with origin for unknown nodes
let positions: Vec<[f32; 3]> = (0..n_nodes)
.map(|i| {
// Build the node positions list parallel to `node_frames`, resolving
// each entry by that frame's own `node_id` (issue #1866). The cohort
// handed to `fuse` is compacted -- stale, out-of-guard and
// empty-history nodes are already dropped -- so a frame's index here
// is its rank in this cycle, not its identity. Addressing by index
// gave every node above a gap its neighbour's coordinates whenever a
// single board missed a beacon. Unknown nodes still fill with the
// origin, unchanged.
let positions: Vec<[f32; 3]> = node_frames
.iter()
.map(|frame| {
self.node_positions
.get(i)
.get(&frame.node_id)
.copied()
.unwrap_or([0.0, 0.0, 0.0])
})
@@ -964,6 +1074,88 @@ mod tests {
);
}
/// A derived schedule must not flag its own healthy cycle as a
/// contradiction.
///
/// Every prior assertion on `soft_guard_us` was *relational* (`soft <
/// hard`, `soft >= 1`), so nothing compared it to a spread the schedule
/// actually produces -- which is how `cycle/3` survived. `fuse_scored`
/// measures `max - min` across the whole cohort, i.e. `(N-1)` slots, so
/// that is what the soft guard must cover.
#[test]
fn derived_soft_guard_covers_the_schedules_own_stagger() {
for (slots, slot_us) in [
(2_usize, 18_000_u64), // the measured 2-slot mesh in the doc comment
(3, 27_000),
(4, 12_500),
(6, 33_300),
(9, 35_600), // a nine-node fleet: 320 ms cycle
(13, 36_900),
] {
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
// Nominal stagger; the derived guard must clear it WITH headroom,
// because observed spread runs slightly over nominal (18,194 us
// measured against an 18,000 us nominal 2-slot stagger).
let stagger = (slots as u64 - 1) * slot_us;
assert!(
cfg.soft_guard_us > stagger,
"{slots} slots x {slot_us} us: soft guard {} us is below the \
schedule's own first-to-last stagger of {stagger} us, so a \
complete healthy cycle is recorded as a TimestampMismatch \
contradiction and demotes the privacy class",
cfg.soft_guard_us
);
assert!(cfg.soft_guard_us < cfg.guard_interval_us);
}
}
/// End to end: a full, evenly-staggered cycle fuses with **no**
/// contradiction under its own derived config.
#[test]
fn a_healthy_full_cycle_raises_no_contradiction() {
let (slots, slot_us) = (9_usize, 35_600_u64);
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
let fuser = MultistaticFuser::with_config(cfg.clone());
// Node k transmits in slot k -- the schedule working exactly as designed.
let frames: Vec<MultiBandCsiFrame> = (0..slots)
.map(|k| make_node_frame(k as u8, k as u64 * slot_us, 56, 1.0))
.collect();
let (_fused, score) = fuser
.fuse_scored(&frames, 0.0)
.expect("a healthy cycle must fuse");
assert!(
!score.forces_privacy_demotion(),
"a complete {slots}-slot cycle is the schedule operating normally \
and must not demote the privacy class; flags: {:?}",
score.contradiction_flags
);
}
/// The soft band still does its job: a spread beyond the designed stagger
/// is flagged.
#[test]
fn a_spread_beyond_the_stagger_is_still_flagged() {
let (slots, slot_us) = (9_usize, 35_600_u64);
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
let hard = cfg.guard_interval_us;
let fuser = MultistaticFuser::with_config(cfg);
// Two nodes, inside the hard guard but wider than a full cycle's stagger.
let late = hard - 1;
let frames = vec![
make_node_frame(0, 0, 56, 1.0),
make_node_frame(1, late, 56, 1.0),
];
let (_f, score) = fuser.fuse_scored(&frames, 0.0).expect("within hard guard");
assert!(
score.forces_privacy_demotion(),
"a spread of {late} us exceeds the designed stagger and must still \
be recorded as loose alignment"
);
}
/// The derived soft guard stays strictly below the hard guard, and a
/// degenerate (0-slot) schedule clamps to a usable config.
#[test]
@@ -1064,10 +1256,16 @@ mod tests {
#[test]
fn node_positions_set_and_retrieved() {
// The legacy positional setter round-trips through the dense view:
// index i in, index i out. Storage is keyed by id underneath, and
// `positions[i]` is documented to mean node i.
let mut fuser = MultistaticFuser::new();
let positions = vec![[0.0, 0.0, 1.0], [3.0, 0.0, 1.0]];
fuser.set_node_positions(positions.clone());
assert_eq!(fuser.node_positions(), &positions[..]);
assert_eq!(fuser.node_positions(), positions);
assert_eq!(fuser.node_position(0), Some([0.0, 0.0, 1.0]));
assert_eq!(fuser.node_position(1), Some([3.0, 0.0, 1.0]));
assert_eq!(fuser.node_position(2), None);
}
#[test]
@@ -1083,6 +1281,255 @@ mod tests {
assert_eq!(fused.node_positions[1], [0.0, 0.0, 0.0]); // default
}
/// Issue #1866 regressions: a fused contribution must carry the position
/// belonging to its own `node_id`, under every cohort shape the live
/// collector can produce.
///
/// `node_frames_from_states_with_guard` hands `fuse` a *compacted* cohort:
/// stale, out-of-guard and empty-history nodes are already gone. So the
/// index of a frame is its rank in this cycle, never its identity.
mod positions_keyed_by_node_id {
use super::*;
fn positions(entries: &[(u8, [f32; 3])]) -> HashMap<u8, [f32; 3]> {
entries.iter().copied().collect()
}
/// Every frame gets its own node's coordinates -- the core assertion.
fn assert_each_frame_owns_its_position(
configured: &HashMap<u8, [f32; 3]>,
frames: &[MultiBandCsiFrame],
) {
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured.clone());
let fused = fuser.fuse(frames).expect("cohort fuses");
assert_eq!(fused.node_positions.len(), frames.len());
for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) {
let expected = configured
.get(&frame.node_id)
.copied()
.unwrap_or([0.0, 0.0, 0.0]);
assert_eq!(
*position, expected,
"node {} received the wrong position",
frame.node_id
);
}
}
#[test]
fn sparse_node_ids_each_get_their_own_position() {
// Real fleets use logical ids like 11/12/13. Under rank addressing
// every one of these resolved to the origin, because indices
// 11..13 do not exist in a 3-entry list.
let configured = positions(&[
(11, [1.0, 0.0, 0.0]),
(12, [2.0, 0.0, 0.0]),
(13, [3.0, 0.0, 0.0]),
]);
let frames = vec![
make_node_frame(11, 100, 56, 1.0),
make_node_frame(12, 101, 56, 1.0),
make_node_frame(13, 102, 56, 1.0),
];
assert_each_frame_owns_its_position(&configured, &frames);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
let fused = fuser.fuse(&frames).expect("cohort fuses");
assert_eq!(fused.node_positions[0], [1.0, 0.0, 0.0]);
assert_eq!(fused.node_positions[2], [3.0, 0.0, 0.0]);
}
#[test]
fn configuration_order_does_not_change_any_position() {
// Insertion order must be irrelevant: identity is stated, not
// inferred from where an entry sat in the config.
let ascending = positions(&[
(3, [3.0, 0.0, 0.0]),
(5, [5.0, 0.0, 0.0]),
(9, [9.0, 0.0, 0.0]),
]);
let reversed = positions(&[
(9, [9.0, 0.0, 0.0]),
(5, [5.0, 0.0, 0.0]),
(3, [3.0, 0.0, 0.0]),
]);
let frames = vec![
make_node_frame(3, 100, 56, 1.0),
make_node_frame(5, 101, 56, 1.0),
make_node_frame(9, 102, 56, 1.0),
];
let mut a = MultistaticFuser::new();
a.set_node_positions_by_id(ascending);
let mut b = MultistaticFuser::new();
b.set_node_positions_by_id(reversed);
assert_eq!(
a.fuse(&frames).expect("fuses").node_positions,
b.fuse(&frames).expect("fuses").node_positions
);
}
#[test]
fn a_silent_node_does_not_move_the_others() {
// THE REASON THIS IS KEYED BY ID. Node 2 misses a beacon and drops
// out of the cohort. Under rank addressing nodes 3 and 4 shift
// down one slot and silently inherit their neighbours' coordinates
// -- a fleet-wide position error caused by one dropped frame.
let configured = positions(&[
(1, [1.0, 0.0, 0.0]),
(2, [2.0, 0.0, 0.0]),
(3, [3.0, 0.0, 0.0]),
(4, [4.0, 0.0, 0.0]),
]);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
let all_up = vec![
make_node_frame(1, 100, 56, 1.0),
make_node_frame(2, 101, 56, 1.0),
make_node_frame(3, 102, 56, 1.0),
make_node_frame(4, 103, 56, 1.0),
];
let two_silent = vec![
make_node_frame(1, 100, 56, 1.0),
make_node_frame(3, 102, 56, 1.0),
make_node_frame(4, 103, 56, 1.0),
];
let full = fuser.fuse(&all_up).expect("fuses");
let gapped = fuser.fuse(&two_silent).expect("fuses");
assert_eq!(full.node_positions[2], [3.0, 0.0, 0.0]);
assert_eq!(full.node_positions[3], [4.0, 0.0, 0.0]);
// Node 3 is now at rank 1 and node 4 at rank 2, but both keep
// their own coordinates.
assert_eq!(gapped.node_positions[0], [1.0, 0.0, 0.0]);
assert_eq!(gapped.node_positions[1], [3.0, 0.0, 0.0]);
assert_eq!(gapped.node_positions[2], [4.0, 0.0, 0.0]);
}
#[test]
fn join_and_leave_churn_never_reassigns_a_position() {
// Walk a node through leave -> rejoin. Every cohort shape must
// still pair each frame with its own configured coordinates.
let configured = positions(&[
(0, [0.0, 0.0, 0.0]),
(1, [1.0, 1.0, 0.0]),
(2, [2.0, 2.0, 0.0]),
(3, [3.0, 3.0, 0.0]),
]);
let cohorts: Vec<Vec<u8>> = vec![
vec![0, 1, 2, 3],
vec![0, 2, 3],
vec![2, 3],
vec![3],
vec![0, 3],
vec![0, 1, 2, 3],
];
for ids in cohorts {
let frames: Vec<MultiBandCsiFrame> = ids
.iter()
.enumerate()
.map(|(i, &id)| make_node_frame(id, 100 + i as u64, 56, 1.0))
.collect();
assert_each_frame_owns_its_position(&configured, &frames);
}
}
#[test]
fn a_configured_but_absent_node_costs_nothing() {
// Positions may be configured for boards that are not reporting.
// They must not consume a slot or displace anyone.
let configured = positions(&[
(1, [1.0, 0.0, 0.0]),
(7, [7.0, 0.0, 0.0]),
(42, [42.0, 0.0, 0.0]),
]);
let frames = vec![
make_node_frame(1, 100, 56, 1.0),
make_node_frame(7, 101, 56, 1.0),
];
assert_each_frame_owns_its_position(&configured, &frames);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
let fused = fuser.fuse(&frames).expect("fuses");
assert_eq!(fused.node_positions.len(), 2);
assert_eq!(fused.node_positions[1], [7.0, 0.0, 0.0]);
}
#[test]
fn an_unconfigured_node_still_fuses_at_the_origin() {
// Unchanged fallback behaviour: unknown node -> origin, and it
// must not disturb its neighbours.
let configured = positions(&[(1, [1.0, 0.0, 0.0]), (3, [3.0, 0.0, 0.0])]);
let frames = vec![
make_node_frame(1, 100, 56, 1.0),
make_node_frame(2, 101, 56, 1.0),
make_node_frame(3, 102, 56, 1.0),
];
assert_each_frame_owns_its_position(&configured, &frames);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
let fused = fuser.fuse(&frames).expect("fuses");
assert_eq!(fused.node_positions[1], [0.0, 0.0, 0.0]);
}
#[test]
fn legacy_positional_setter_still_means_index_equals_node_id() {
// Documented compatibility mapping: a bare `--node-positions`
// list has always meant "the i-th entry is node i". Preserved
// exactly, so existing deployments are unaffected.
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions(vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
]);
let frames = vec![
make_node_frame(0, 100, 56, 1.0),
make_node_frame(1, 101, 56, 1.0),
make_node_frame(2, 102, 56, 1.0),
];
let fused = fuser.fuse(&frames).expect("fuses");
assert_eq!(fused.node_positions[0], [0.0, 0.0, 0.0]);
assert_eq!(fused.node_positions[1], [1.0, 0.0, 0.0]);
assert_eq!(fused.node_positions[2], [2.0, 0.0, 0.0]);
}
/// Negative control: the pre-fix behaviour must actually be gone.
///
/// Rank addressing over a dense zero-based config only diverges from
/// id addressing once the cohort is compacted, so this pins the exact
/// case the old code got wrong rather than restating the happy path.
#[test]
fn rank_addressing_would_have_failed_this_cohort() {
let configured = positions(&[
(0, [0.0, 0.0, 0.0]),
(1, [1.0, 0.0, 0.0]),
(2, [2.0, 0.0, 0.0]),
]);
let mut fuser = MultistaticFuser::new();
fuser.set_node_positions_by_id(configured);
// Node 1 is silent; the cohort is [0, 2].
let frames = vec![
make_node_frame(0, 100, 56, 1.0),
make_node_frame(2, 101, 56, 1.0),
];
let fused = fuser.fuse(&frames).expect("fuses");
// Rank addressing would hand rank 1 the position of node 1.
assert_ne!(fused.node_positions[1], [1.0, 0.0, 0.0]);
assert_eq!(fused.node_positions[1], [2.0, 0.0, 0.0]);
}
}
#[test]
fn geometric_diversity_single_node() {
assert_eq!(geometric_diversity(&[[0.0, 0.0, 0.0]]), 0.0);
@@ -24,8 +24,8 @@
//!
//! macOS only. Gated behind `#[cfg(target_os = "macos")]` at the module level.
use std::process::Command;
use std::time::Instant;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
use crate::error::WifiScanError;
@@ -64,11 +64,14 @@ impl MacosCoreWlanScanner {
/// Run the Swift helper and parse the output synchronously.
///
/// Returns one [`BssidObservation`] per BSSID seen in the scan.
/// Returns one [`BssidObservation`] for the connected link.
/// Helpers that fail to exit within five seconds are killed and reaped.
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
let output = Command::new(&self.helper_path)
let mut child = Command::new(&self.helper_path)
.arg("--scan-once")
.output()
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
WifiScanError::ProcessError(format!(
"failed to run mac_wifi helper ({}): {e}",
@@ -76,6 +79,29 @@ impl MacosCoreWlanScanner {
))
})?;
// Older helpers ignore --scan-once and stream forever. Bound the
// wait so an outdated installation cannot hang capture or auto-detect.
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(10));
}
status => {
let _ = child.kill();
let _ = child.wait();
return Err(WifiScanError::ProcessError(match status {
Err(e) => format!("failed to wait for mac_wifi: {e}"),
_ => "mac_wifi --scan-once timed out; rebuild the Swift helper".into(),
}));
}
}
}
let output = child.wait_with_output().map_err(|e| {
WifiScanError::ProcessError(format!("failed to read mac_wifi output: {e}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(WifiScanError::ScanFailed {
@@ -160,7 +186,8 @@ fn parse_json_line(line: &str, timestamp: Instant) -> Option<BssidObservation> {
/// Resolve a BSSID string to a [`BssidId`].
///
/// If the MAC is all-zeros (macOS redaction), generate a synthetic
/// locally-administered MAC from `SHA-256(ssid:channel)`.
/// locally-administered MAC from the SSID and channel. Abstain when the
/// real BSSID is unavailable and the SSID is blank.
fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
// Try parsing the real BSSID first.
if let Ok(id) = BssidId::parse(bssid_str) {
@@ -170,7 +197,13 @@ fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
}
}
// Generate synthetic BSSID: SHA-256(ssid:channel), take first 6 bytes,
// Without either identity, unrelated networks on the same channel would
// collapse to one synthetic BSSID. Do not emit an observation in that case.
if ssid.trim().is_empty() {
return None;
}
// Generate synthetic BSSID from SSID and channel, take first 6 bytes,
// set locally-administered + unicast bits (byte 0: bit 1 set, bit 0 clear).
Some(synthetic_bssid(ssid, channel))
}
@@ -276,6 +309,32 @@ mod tests {
{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"}
"#;
#[test]
fn helper_process_errors_are_reported() {
assert!(matches!(
MacosCoreWlanScanner::with_path("/usr/bin/false").scan_sync(),
Err(WifiScanError::ScanFailed { .. })
));
assert!(matches!(
MacosCoreWlanScanner::with_path("/dev/null/mac_wifi").scan_sync(),
Err(WifiScanError::ProcessError(_))
));
}
#[test]
fn legacy_helper_is_timed_out() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!("mac-wifi-timeout-{}", std::process::id()));
// exec preserves the helper PID, so killing it cannot orphan sleep.
std::fs::write(&path, "#!/bin/sh\nexec /bin/sleep 30\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
let start = Instant::now();
let result = MacosCoreWlanScanner::with_path(path.to_string_lossy()).scan_sync();
std::fs::remove_file(path).unwrap();
assert!(matches!(result, Err(WifiScanError::ProcessError(ref e)) if e.contains("timed out")));
assert!(start.elapsed() < Duration::from_secs(15));
}
#[test]
fn parse_valid_output() {
let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap();
@@ -304,6 +363,29 @@ mod tests {
assert_eq!(obs[2].bssid.0[0] & 0x01, 0x00);
}
#[test]
fn unavailable_bssid_without_ssid_abstains() {
for bssid in ["00:00:00:00:00:00", "", "invalid"] {
for ssid in ["", " "] {
let output = format!(
r#"{{"ssid":"{ssid}","bssid":"{bssid}","rssi":-65,"noise":-88,"channel":36}}"#
);
assert!(parse_macos_scan_output(&output).unwrap().is_empty());
}
}
}
#[test]
fn real_bssid_without_ssid_is_preserved() {
let output = r#"{"ssid":"","bssid":"aa:bb:cc:dd:ee:ff","rssi":-65,"noise":-88,"channel":36}"#;
let obs = parse_macos_scan_output(output).unwrap();
assert_eq!(obs.len(), 1);
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
assert!(obs[0].ssid.is_empty());
assert_eq!(obs[0].rssi_dbm, -65.0);
assert_eq!(obs[0].channel, 36);
}
#[test]
fn synthetic_bssid_is_deterministic() {
let a = synthetic_bssid("TestNet", 36);