Compare commits

...
21 Commits
Author SHA1 Message Date
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
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
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
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
46 changed files with 3371 additions and 49 deletions

No files matched your search

+5 -6
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,14 +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 test_c6_antenna
./test_c6_antenna
make host_tests
- name: Verify binary size budget
working-directory: firmware/esp32-csi-node
+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,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.
+1
View File
@@ -115,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 |
+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,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.
+19
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
+7 -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"
@@ -32,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
@@ -40,6 +44,8 @@ set(REQUIRES
driver
lwip
mbedtls
efuse
led_strip
)
# ADR-110: C6-only components — pulled in when building for esp32c6.
@@ -750,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)
@@ -139,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 */
+51
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"
@@ -116,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
@@ -336,6 +342,14 @@ void app_main(void)
* 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
@@ -451,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
@@ -655,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 = {
+382
View File
@@ -0,0 +1,382 @@
#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"
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;
}
+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.
@@ -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
@@ -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
+15 -5
View File
@@ -55,10 +55,10 @@ 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_csi_sanitize run_csi_sanitize \
test_c6_antenna run_c6_antenna
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_csi_sanitize test_c6_antenna
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
@@ -122,9 +122,19 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
run_thermal: test_thermal
./test_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
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna)"
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.
@@ -161,5 +171,5 @@ run_all: run_serialize run_edge run_nvs
clean:
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal test_csi_sanitize test_c6_antenna
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
@@ -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.10
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(())
}
@@ -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(_))
));
}
}
@@ -19,6 +19,7 @@ mod model_format;
mod multistatic_bridge;
mod mediatek_csi;
mod qualcomm_csi;
mod realtek_csi;
mod realtek_radar;
mod path_safety;
pub mod pose;
@@ -82,6 +83,7 @@ use rvf_pipeline::ProgressiveLoader;
use vital_signs::{VitalSignDetector, VitalSigns};
// ADR-022 Phase 3: Multi-BSSID pipeline integration
#[cfg(not(target_os = "macos"))]
use wifi_densepose_wifiscan::parse_netsh_output as parse_netsh_bssid_output;
use wifi_densepose_wifiscan::{BssidRegistry, WindowsWifiPipeline};
@@ -819,6 +821,51 @@ mod debounce_room_classification_tests {
}
}
/// Upper plausibility ceiling (dBm) for a real 2.4/5 GHz WiFi RSSI reading.
///
/// A received WiFi signal is always attenuated by free-space path loss and
/// receiver noise floor; no deployed node has ever measured better than
/// roughly -20 dBm. The ESP32 edge-vitals packet (magic 0xC511_0002,
/// ADR-039) carries a raw `i8` RSSI byte with no "valid" flag, and has been
/// observed sending near-zero sentinel values (e.g. -1, -2 dBm) when the
/// edge pipeline hasn't sampled a real reading yet. Left unguarded, that
/// sentinel overwrote the node's `rssi_history` and the room's fused
/// `mean_rssi` for the tick it arrived on (found 2026-09-09: node 3
/// reporting -2.0 dBm while simultaneously showing a real ~-50 dBm CSI
/// reading on the same node).
pub(crate) const MAX_PLAUSIBLE_RSSI_DBM: i8 = -10;
/// Returns `true` if `rssi_dbm` is a physically plausible WiFi RSSI
/// reading (see [`MAX_PLAUSIBLE_RSSI_DBM`]).
pub(crate) fn is_plausible_rssi(rssi_dbm: i8) -> bool {
rssi_dbm <= MAX_PLAUSIBLE_RSSI_DBM
}
#[cfg(test)]
mod rssi_plausibility_tests {
use super::{is_plausible_rssi, MAX_PLAUSIBLE_RSSI_DBM};
#[test]
fn realistic_readings_are_plausible() {
assert!(is_plausible_rssi(-42));
assert!(is_plausible_rssi(-53));
assert!(is_plausible_rssi(-90));
}
#[test]
fn near_zero_sentinel_values_are_rejected() {
assert!(!is_plausible_rssi(-1));
assert!(!is_plausible_rssi(-2));
assert!(!is_plausible_rssi(0));
}
#[test]
fn boundary_is_inclusive() {
assert!(is_plausible_rssi(MAX_PLAUSIBLE_RSSI_DBM));
assert!(!is_plausible_rssi(MAX_PLAUSIBLE_RSSI_DBM + 1));
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SignalField {
grid_size: [usize; 3],
@@ -1722,6 +1769,10 @@ struct AppStateInner {
latest_qualcomm_csi: Option<qualcomm_csi::QualcommCsiSnapshot>,
/// Instant of the last validated Qualcomm CSI UDP frame.
last_qualcomm_frame: Option<std::time::Instant>,
/// Latest validated RTL8721Dx CSI summary; distinct from RTL8720F radar.
latest_realtek_csi: Option<realtek_csi::RealtekCsiSnapshot>,
/// Instant of the last validated RTL8721Dx CSI UDP frame.
last_realtek_csi_frame: Option<std::time::Instant>,
/// Latest bounded ADR-270 event per vendor. Complex CSI uses dedicated transports.
latest_vendor_rf: BTreeMap<String, wifi_densepose_sensing_server::vendor_rf::VendorEventSnapshot>,
tx: broadcast::Sender<String>,
@@ -2266,7 +2317,13 @@ impl AppStateInner {
}
}
}
if self.source.starts_with("realtek") {
if self.source.starts_with("realtek_csi") {
if let Some(last) = self.last_realtek_csi_frame {
if last.elapsed() > ESP32_OFFLINE_TIMEOUT {
return format!("{}:offline", self.source);
}
}
} else if self.source.starts_with("realtek") {
if let Some(last) = self.last_realtek_frame {
if last.elapsed() > ESP32_OFFLINE_TIMEOUT {
return format!("{}:offline", self.source);
@@ -2336,6 +2393,8 @@ impl AppStateInner {
last_mediatek_frame: None,
latest_qualcomm_csi: None,
last_qualcomm_frame: None,
latest_realtek_csi: None,
last_realtek_csi_frame: None,
latest_vendor_rf: BTreeMap::new(),
tx: broadcast::channel::<String>(16).0,
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
@@ -3797,6 +3856,7 @@ fn trimmed_mean(buf: &VecDeque<f64>) -> f64 {
// ── Windows WiFi RSSI collector ──────────────────────────────────────────────
/// Parse `netsh wlan show interfaces` output for RSSI and signal quality
#[cfg(not(target_os = "macos"))]
fn parse_netsh_interfaces_output(output: &str) -> Option<(f64, f64, String)> {
let mut rssi = None;
let mut signal = None;
@@ -3829,7 +3889,7 @@ fn parse_netsh_interfaces_output(output: &str) -> Option<(f64, f64, String)> {
}
}
async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
async fn wifi_task(state: SharedState, tick_ms: u64) {
let mut interval = tokio::time::interval(Duration::from_millis(tick_ms));
let mut seq: u32 = 0;
@@ -3838,7 +3898,8 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
let mut pipeline = WindowsWifiPipeline::new();
info!(
"Windows WiFi multi-BSSID pipeline active (tick={}ms, max_bssids=32)",
"WiFi RSSI pipeline active (platform={}, tick={}ms, max_bssids=32)",
std::env::consts::OS,
tick_ms
);
@@ -3847,8 +3908,16 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
seq += 1;
// ── Step 1: Run multi-BSSID scan via spawn_blocking ──────────
// NetshBssidScanner is not Send, so we run `netsh` and parse
// the output inside a blocking closure.
// Keep platform subprocess calls off the async runtime workers.
#[cfg(target_os = "macos")]
let bssid_scan_result = tokio::task::spawn_blocking(|| {
wifi_densepose_wifiscan::adapter::MacosCoreWlanScanner::new()
.scan_sync()
.map_err(|e| e.to_string())
})
.await;
#[cfg(not(target_os = "macos"))]
let bssid_scan_result = tokio::task::spawn_blocking(|| {
let output = std::process::Command::new("netsh")
.args(["wlan", "show", "networks", "mode=bssid"])
@@ -3873,12 +3942,14 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
let observations = match bssid_scan_result {
Ok(Ok(obs)) if !obs.is_empty() => obs,
Ok(Ok(_empty)) => {
debug!("Multi-BSSID scan returned 0 observations, falling back");
debug!("WiFi scan returned 0 observations");
#[cfg(not(target_os = "macos"))]
windows_wifi_fallback_tick(&state, seq).await;
continue;
}
Ok(Err(e)) => {
warn!("Multi-BSSID scan error: {e}, falling back");
warn!("WiFi scan error: {e}");
#[cfg(not(target_os = "macos"))]
windows_wifi_fallback_tick(&state, seq).await;
continue;
}
@@ -4075,6 +4146,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
/// Fallback: single-RSSI collection via `netsh wlan show interfaces`.
///
/// Used when the multi-BSSID scan fails or returns 0 observations.
#[cfg(not(target_os = "macos"))]
async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
let output = match tokio::process::Command::new("netsh")
.args(["wlan", "show", "interfaces"])
@@ -4226,8 +4298,21 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
s.latest_update = Some(update);
}
/// Probe if Windows WiFi is connected
async fn probe_windows_wifi() -> bool {
/// Probe the platform WiFi source using the same helper as capture.
#[cfg(target_os = "macos")]
async fn probe_wifi() -> bool {
matches!(
tokio::task::spawn_blocking(|| {
wifi_densepose_wifiscan::adapter::MacosCoreWlanScanner::new().scan_sync()
})
.await,
Ok(Ok(observations)) if !observations.is_empty()
)
}
/// Probe if Windows WiFi is connected.
#[cfg(not(target_os = "macos"))]
async fn probe_wifi() -> bool {
match tokio::process::Command::new("netsh")
.args(["wlan", "show", "interfaces"])
.output()
@@ -4289,7 +4374,7 @@ struct SourcePlan {
bind_udp: bool,
/// Run the simulated-data generator (serves poses until a real frame arrives).
run_simulator: bool,
/// Run the Windows WiFi capture task.
/// Run the platform WiFi capture task.
run_wifi: bool,
}
@@ -4797,6 +4882,53 @@ async fn latest_qualcomm_csi(State(state): State<SharedState>) -> Json<serde_jso
}
}
async fn latest_realtek_csi(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.latest_realtek_csi {
Some(snapshot) => Json(serde_json::to_value(snapshot).unwrap_or_default()),
None => Json(serde_json::json!({"status": "no Realtek RTL8721Dx CSI data yet"})),
}
}
fn primary_source_with_realtek(
last_esp32_frame: Option<std::time::Instant>,
realtek_source: &str,
) -> String {
if last_esp32_frame.is_some_and(|seen| seen.elapsed() < ESP32_OFFLINE_TIMEOUT) {
"esp32".to_string()
} else {
realtek_source.to_string()
}
}
#[cfg(test)]
mod realtek_ingest_tests {
use super::*;
use wifi_densepose_hardware::realtek_csi::simulator::{RealtekCsiSimulator, SimulatorConfig};
#[test]
fn secondary_realtek_does_not_displace_fresh_esp32_room_source() {
assert_eq!(
primary_source_with_realtek(Some(std::time::Instant::now()), "realtek_csi"),
"esp32"
);
assert_eq!(primary_source_with_realtek(None, "realtek_csi"), "realtek_csi");
}
#[tokio::test]
async fn latest_route_keeps_synthetic_provenance_and_node_identity() {
let state: SharedState = Arc::new(RwLock::new(AppStateInner::minimal()));
let mut simulator = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = realtek_csi::RealtekCsiSnapshot::from_frame(&simulator.next_frame());
let expected_node_id = snapshot.node_id;
state.write().await.latest_realtek_csi = Some(snapshot);
let Json(value) = latest_realtek_csi(State(state)).await;
assert_eq!(value["node_id"], expected_node_id);
assert_eq!(value["source"], "realtek_csi:simulated");
assert_eq!(value["synthetic"], true);
}
}
async fn vendor_descriptors() -> Json<serde_json::Value> {
Json(
serde_json::to_value(wifi_densepose_sensing_server::vendor_rf::descriptors())
@@ -8754,7 +8886,7 @@ async fn udp_receiver_task(
let addr = format!("{bind_ip}:{udp_port}");
let socket = match UdpSocket::bind(&addr).await {
Ok(s) => {
info!("UDP listening on {addr} for ESP32, MediaTek, Qualcomm CSI, and RTL8720F radar frames");
info!("UDP listening on {addr} for ESP32, MediaTek, Qualcomm, RTL8721Dx CSI, and RTL8720F radar frames");
s
}
Err(e) => {
@@ -8856,6 +8988,33 @@ async fn udp_receiver_task(
}
continue;
}
if len >= 4
&& u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice"))
== wifi_densepose_hardware::realtek_csi::RAC1_MAGIC
{
match wifi_densepose_hardware::realtek_csi::CsiFrame::from_bytes(&buf[..len]) {
Ok((frame, consumed)) if consumed == len => {
let snapshot = realtek_csi::RealtekCsiSnapshot::from_frame(&frame);
debug!("RTL8721Dx CSI from {src}: node={} seq={} subcarriers={}", snapshot.node_id, snapshot.sequence, snapshot.num_sub_carrier);
let json = serde_json::to_string(&snapshot).ok();
let mut s = state.write().await;
// A secondary Realtek link must not replace a fresh ESP32
// room source and make its vitals disappear from the UI.
s.source = primary_source_with_realtek(
s.last_esp32_frame,
snapshot.source,
);
s.last_realtek_csi_frame = Some(std::time::Instant::now());
s.latest_realtek_csi = Some(snapshot);
if let Some(json) = json {
let _ = s.tx.send(json);
}
}
Ok((_, consumed)) => warn!("RTL8721Dx CSI datagram from {src} has trailing bytes: consumed={consumed} received={len}"),
Err(error) => warn!("Rejected RTL8721Dx CSI datagram from {src}: {error}"),
}
continue;
}
// ADR-039: Try edge vitals packet first (magic 0xC511_0002).
if let Some(vitals) = parse_esp32_vitals(&buf[..len]) {
debug!(
@@ -8889,10 +9048,18 @@ async fn udp_receiver_task(
warn!(name: semconv::EVENT_RUVIEW_FALL_DETECTED, { "ruview.node.id" = node_id }, "fall detected by node {node_id}");
}
ns.edge_vitals = Some(vitals.clone());
ns.rssi_history.push_back(vitals.rssi as f64);
if ns.rssi_history.len() > 60 {
ns.rssi_history.pop_front();
}
// An implausible RSSI (e.g. -1/-2 dBm, see
// `is_plausible_rssi`) is a firmware sentinel, not a
// measurement — don't let it clobber the node's history.
let mean_rssi_dbm = if is_plausible_rssi(vitals.rssi) {
ns.rssi_history.push_back(vitals.rssi as f64);
if ns.rssi_history.len() > 60 {
ns.rssi_history.pop_front();
}
vitals.rssi as f64
} else {
ns.rssi_history.back().copied().unwrap_or(0.0)
};
// Store per-node person count from edge vitals.
let node_est = if vitals.presence {
@@ -9010,7 +9177,7 @@ async fn udp_receiver_task(
);
let features = FeatureInfo {
mean_rssi: vitals.rssi as f64,
mean_rssi: mean_rssi_dbm,
variance: vitals.motion_energy as f64,
motion_band_power: vitals.motion_energy as f64,
breathing_band_power: if vitals.presence { 0.5 } else { 0.0 },
@@ -10914,11 +11081,11 @@ async fn main() {
let plan = if normalized == "auto" {
info!("Auto-detecting data source (UDP :{} bound either way)...", args.udp_port);
let esp32 = probe_esp32(args.udp_port).await;
let wifi = if esp32 { false } else { probe_windows_wifi().await };
let wifi = if esp32 { false } else { probe_wifi().await };
if esp32 {
info!(" ESP32 CSI detected on UDP :{}", args.udp_port);
} else if wifi {
info!(" Windows WiFi detected");
info!(" WiFi detected ({})", std::env::consts::OS);
} else {
warn!(
"No real CSI source at boot — serving SIMULATED data (tagged as \
@@ -11191,6 +11358,8 @@ async fn main() {
last_mediatek_frame: None,
latest_qualcomm_csi: None,
last_qualcomm_frame: None,
latest_realtek_csi: None,
last_realtek_csi_frame: None,
latest_vendor_rf: BTreeMap::new(),
tx,
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
@@ -11395,7 +11564,7 @@ async fn main() {
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
}
if plan.run_wifi {
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
tokio::spawn(wifi_task(state.clone(), args.tick_ms));
}
if plan.run_simulator {
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
@@ -11536,6 +11705,7 @@ async fn main() {
.route("/api/v1/radar/latest", get(latest_realtek_radar))
.route("/api/v1/csi/mediatek/latest", get(latest_mediatek_csi))
.route("/api/v1/csi/qualcomm/latest", get(latest_qualcomm_csi))
.route("/api/v1/csi/realtek/latest", get(latest_realtek_csi))
.route("/api/v1/rf/vendors", get(vendor_descriptors))
.route("/api/v1/rf/vendors/latest", get(latest_vendor_events))
.route("/api/v1/rf/vendors/:vendor/latest", get(latest_vendor_event))
@@ -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");
}
}
@@ -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);