mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-19 21:30:06 -05:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42a22235c0 | ||
|
|
6524712900 | ||
|
|
dd02efe2fe | ||
|
|
7d15e9fb4a | ||
|
|
69106970dd | ||
|
|
686e4bad00 | ||
|
|
b713a23cac | ||
|
|
6eb79cad26 | ||
|
|
45fc41ac4c | ||
|
|
901cf16eb3 | ||
|
|
af2d6981a3 | ||
|
|
a04fbc4694 | ||
|
|
dbaa3c7f52 | ||
|
|
6cf0356554 | ||
|
|
b2ad3a65dc | ||
|
|
815384dcc4 | ||
|
|
8cd8742d2b | ||
|
|
dbf4788628 | ||
|
|
cdad032296 | ||
|
|
0decc5c3fa | ||
|
|
d4a3c7f17f | ||
|
|
2c0abcc802 | ||
|
|
f1dbaac452 | ||
|
|
33a9e90896 | ||
|
|
bbbb14169d | ||
|
|
4fbe58f736 | ||
|
|
a64e9e98fb | ||
|
|
130afabae8 | ||
|
|
36e4df95b7 | ||
|
|
b370d4fcd4 | ||
|
|
500cfe4787 | ||
|
|
f7c2c6587e | ||
|
|
30391bfbd2 | ||
|
|
00a42d7cda | ||
|
|
bd6f38e2df | ||
|
|
203bd66daa | ||
|
|
2c3a924231 | ||
|
|
e5422d52e4 | ||
|
|
26e8ca3f39 | ||
|
|
c407e738a3 | ||
|
|
755e9bca05 | ||
|
|
73e15a7d29 | ||
|
|
f38beff441 | ||
|
|
830abd7e18 | ||
|
|
b296ea26a4 | ||
|
|
0b29902d8b | ||
|
|
b18b34e9f5 | ||
|
|
169597a7d2 | ||
|
|
0d1a247bfe | ||
|
|
a4d49f76b9 | ||
|
|
51102b3baf | ||
|
|
977fa26731 | ||
|
|
2841b22cc9 | ||
|
|
3fd3851633 | ||
|
|
d6b940184e | ||
|
|
60adac05b7 | ||
|
|
ec9da648b8 | ||
|
|
0fd1c63563 | ||
|
|
521b29ea21 | ||
|
|
e8e4e5cf67 | ||
|
|
9af1cc6644 | ||
|
|
56fcc49104 | ||
|
|
11c014485e | ||
|
|
86e45980fe | ||
|
|
d613a576ea | ||
|
|
97578ee401 | ||
|
|
e54b4171ca | ||
|
|
338c523217 | ||
|
|
224c9eb671 | ||
|
|
2deb6d2d64 | ||
|
|
82e05d0664 | ||
|
|
a2c662f9f7 | ||
|
|
48c6244d37 | ||
|
|
30404dd9cf | ||
|
|
767c36ad12 | ||
|
|
d6347fe12e | ||
|
|
7cdf5714e8 |
No files matched your search
@@ -293,7 +293,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Update deployment status
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
|
||||
with:
|
||||
script: |
|
||||
const deployEnv = '${{ needs.pre-deployment.outputs.deploy_env }}';
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
|
||||
- name: Create deployment issue on failure
|
||||
if: needs.deploy-production.result == 'failure'
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
|
||||
@@ -53,7 +53,9 @@ jobs:
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_display.csv
|
||||
size_warn_kb: 1100
|
||||
size_limit_kb: 1152
|
||||
# Current 8 MB image is 1163 KiB after USB onboarding. The OTA app
|
||||
# partition is 2048 KiB; retain a 1200 KiB hard gate and warning.
|
||||
size_limit_kb: 1200
|
||||
artifact_app: esp32-csi-node.bin
|
||||
artifact_pt: partition-table.bin
|
||||
- variant: 4mb
|
||||
@@ -92,12 +94,11 @@ jobs:
|
||||
idf.py set-target ${{ matrix.target }}
|
||||
idf.py build
|
||||
|
||||
- name: Build and run host-side ADR-110 unit tests
|
||||
- name: Build and run host-side firmware unit tests
|
||||
if: matrix.variant == 'c6-4mb'
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
make test_adr110
|
||||
./test_adr110
|
||||
make host_tests
|
||||
|
||||
- name: Verify binary size budget
|
||||
working-directory: firmware/esp32-csi-node
|
||||
@@ -184,3 +185,22 @@ jobs:
|
||||
name: esp32-csi-node-firmware-${{ matrix.variant }}
|
||||
path: firmware/esp32-csi-node/release-staging/
|
||||
retention-days: 90
|
||||
|
||||
build-c6-idf6:
|
||||
name: Build XIAO C6 external antenna (ESP-IDF 6.0.2)
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v6.0.2
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Build the IDF 6 canary image
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" set-target esp32c6
|
||||
idf.py build
|
||||
test "$(stat -c%s build/esp32-csi-node.bin)" -le $((1152 * 1024))
|
||||
strings build/esp32-csi-node.bin | grep -F "external U.FL"
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
- name: Create security issue on critical findings
|
||||
continue-on-error: true
|
||||
if: needs.sast.result == 'failure' || needs.dependency-scan.result == 'failure'
|
||||
uses: actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325 # v6
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
|
||||
+10
-5
@@ -17,11 +17,16 @@ firmware/esp32-csi-node/sdkconfig.defaults.bak
|
||||
firmware/esp32-hello-world/sdkconfig.old
|
||||
|
||||
# Host-built firmware test binaries (compiled from test/*.c, not source)
|
||||
firmware/esp32-csi-node/test/test_adr110
|
||||
firmware/esp32-csi-node/test/test_vitals
|
||||
firmware/esp32-csi-node/test/fuzz_serialize
|
||||
firmware/esp32-csi-node/test/fuzz_edge
|
||||
firmware/esp32-csi-node/test/fuzz_nvs
|
||||
# Compiled host-test and fuzz binaries. The Makefile writes them beside their
|
||||
# sources, so they land in a tracked directory. Ignore them by SHAPE rather
|
||||
# than by name: every source here carries an extension and no binary does.
|
||||
#
|
||||
# This replaced a hand-maintained list of five targets, which had gone stale --
|
||||
# test_csi_sanitize, test_mmwave_detect and test_thermal were all built by
|
||||
# `make host_tests`, ignored by nothing, and got committed by accident.
|
||||
firmware/esp32-csi-node/test/test_*
|
||||
firmware/esp32-csi-node/test/fuzz_*
|
||||
!firmware/esp32-csi-node/test/*.*
|
||||
firmware/esp32-csi-node/test/*.exe
|
||||
firmware/esp32-csi-node/test/*.obj
|
||||
|
||||
|
||||
@@ -719,6 +719,8 @@ Start with the user, build, and calibration guides; expand for the full referenc
|
||||
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
|
||||
| [CSI Frame Selection — Measured](docs/csi-frame-selection-measured.md) | Which rate gate actually buys cross-node pairing, measured on a nine-node ESP32-C6 fleet as a 2x2 factorial in three Latin-square blocks. Selecting frames by 802.11 `rx_seq` roughly doubled pairing (+31.7 pp) while mesh-time alignment bought +0.5 pp, inside noise. Includes the trade it makes, a prediction that turned out wrong, the limits of the measurement, and `run_arms.py` so the comparison can be re-run rather than taken on trust. |
|
||||
| [ESP32-C6 Firmware Runbook](firmware/esp32-csi-node/RUNBOOK.md) | What to run, in what order, and what to check: the three-file build that keeps OTA rollback compiled in, a five-key pre-flash gate, which flash regions OTA can and cannot reach, a single-board diagnostic flash with a revert path, and the tunables whose right value depends on your house rather than on the chip. |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 205 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
|
||||
|
||||
@@ -10,6 +10,35 @@ func main() {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// One connected-link RSSI sample for the Rust adapter. Keep the legacy
|
||||
// no-argument 10 Hz stream below unchanged for Python callers.
|
||||
if CommandLine.arguments.contains("--scan-once") {
|
||||
guard interface.powerOn(),
|
||||
let channel = interface.wlanChannel(),
|
||||
interface.rssiValue() < 0 else {
|
||||
fputs("WiFi is not connected\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let sample: [String: Any] = [
|
||||
"ssid": interface.ssid() ?? "",
|
||||
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
|
||||
"channel": channel.channelNumber,
|
||||
"rssi": interface.rssiValue(),
|
||||
"noise": interface.noiseMeasurement(),
|
||||
"timestamp": Date().timeIntervalSince1970,
|
||||
"tx_rate": interface.transmitRate()
|
||||
]
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: sample, options: [.sortedKeys])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write(Data([0x0a]))
|
||||
} catch {
|
||||
fputs("Could not encode WiFi sample: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Flush stdout automatically to prevent buffering issues with Python subprocess
|
||||
setbuf(stdout, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# ADR-323: RTL8721Dx (Realtek AmebaDplus) CSI Wire Protocol
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-09-15
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: realtek, ameba, amebadplus, rtl8721dx, csi, protocol, rust, udp
|
||||
|
||||
## Context
|
||||
|
||||
Unlike the MediaTek/Qualcomm CSI paths (ADR-266..269), which are simulator-first
|
||||
because no supported public vendor interface exports complex CSI, Realtek's
|
||||
official Ameba RTOS SDK (`Ameba-AIoT/ameba-rtos`, Apache-2.0) documents and
|
||||
exposes a real, callback-driven CSI API for the RTL8721Dx ("AmebaDplus")
|
||||
family: `wifi_csi_config()` plus the `RTW_EVENT_CSI_DONE` event delivering a
|
||||
`struct rtw_event_csi_report_info` (per-tone signed I/Q, 8-bit or 16-bit
|
||||
precision, selectable tone decimation, passive or active/triggered capture).
|
||||
This has been confirmed directly against SDK source
|
||||
(`component/wifi/api/wifi_api_event.h`) and against the official
|
||||
`example/wifi/wifi_csi` example, and validated on real PKM8721DAF-C13-F10
|
||||
hardware (build+flash+boot confirmed on branch `feat/rtl8721dx-csi-bringup`).
|
||||
|
||||
The RTL8721Dx is a 1x1 WiFi 4 part — single TX/RX chain, not MIMO — so the
|
||||
MediaTek/Qualcomm wire format (Tx/Rx dimensioned matrices, chipset chain
|
||||
counts) does not fit. The vendor SDK's raw struct is also not a safe host
|
||||
wire format as-is: it has no CRC, a C flexible-array-member payload, and
|
||||
fields sized for firmware convenience (e.g. a wrapping `u32` microsecond
|
||||
timestamp) rather than network robustness.
|
||||
|
||||
Per the existing repo convention (ESP32/ADR-018, MediaTek/ADR-266-267,
|
||||
Qualcomm/ADR-268-269, RTL8720F radar/ADR-263-264), each vendor CSI/radar
|
||||
source gets its own self-delimiting envelope and its own Rust module pair
|
||||
(`wifi-densepose-hardware` for framing+codec+simulator,
|
||||
`wifi-densepose-sensing-server` for the bounded JSON-safe snapshot), never a
|
||||
shared "generic vendor CSI" struct that would either lose vendor-specific
|
||||
fields or invite magic-space collisions. `SourceKind::Realtek` (value 2) is
|
||||
already assigned to the RTL8720F FMCW radar path (ADR-263/264) and must not
|
||||
be reused for CSI.
|
||||
|
||||
## Decision
|
||||
|
||||
Define `RAC1` ("Realtek Ameba CSI", version 1) as a little-endian,
|
||||
self-delimiting envelope, magic `0x3143_4152` (distinct from `MTC1`
|
||||
`0x3143544d`, `QCS1` 0x31535143, `RTR1` 0x31525452, and all ADR-018
|
||||
`0xC511000x` siblings):
|
||||
|
||||
Fixed 49-byte header (explicit field-by-field serialization, no `#[repr(C)]`
|
||||
padding, matching the MediaTek/Qualcomm modules' style):
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| magic | u32 LE | `0x3143_4152` |
|
||||
| version | u8 | 1 |
|
||||
| header_len | u16 LE | 49 |
|
||||
| frame_len | u32 LE | header + payload + 4-byte CRC |
|
||||
| node_id | u8 | disambiguates multi-board illuminator/receiver deployments; 0 = unset |
|
||||
| csi_mode | u8 | 0=passive, 1=active, 2=board-to-board (`CsiMode`) |
|
||||
| sequence | u32 LE | from firmware `csi_sequence` |
|
||||
| timestamp_us | u32 LE | from firmware `hw_assigned_timestamp` (hardware-clock microseconds, wraps ~71 min — consumers must not assume monotonic across a capture longer than that) |
|
||||
| peer_mac | [u8; 6] | firmware `mac_addr` |
|
||||
| trig_mac | [u8; 6] | firmware `trig_addr` |
|
||||
| channel | u8 | operating channel |
|
||||
| bandwidth | u8 | 0=20MHz, 1=40MHz |
|
||||
| rx_rate | u8 | firmware `rx_rate` |
|
||||
| protocol_mode | u8 | 0=OFDM,1=HT,2=VHT,3=HE (`ProtocolMode`) |
|
||||
| num_sub_carrier | u16 LE | tone count in payload |
|
||||
| num_bit_per_tone | u8 | 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q) |
|
||||
| decimation | u8 | tone group (1/2/4/8 — every Nth tone) |
|
||||
| rssi_dbm | i8 | single RF chain (1x1 hardware) |
|
||||
| rxsc | u8 | sub-20 MHz channel used |
|
||||
| csi_valid | u8 | 0/1 from firmware |
|
||||
| flags | u8 | bit 0 = SYNTHETIC (simulator provenance, never clearable downstream) |
|
||||
| payload_len | u32 LE | bytes, == `num_sub_carrier * (num_bit_per_tone / 8)` |
|
||||
|
||||
Payload: `num_sub_carrier` consecutive (I, Q) tone pairs, each
|
||||
`num_bit_per_tone / 16` bytes per component (1 byte/component when
|
||||
`num_bit_per_tone == 16`, 2 bytes/component little-endian signed when `== 32`)
|
||||
— i.e. exactly the byte layout the firmware already produces in
|
||||
`csi_data[]`, just framed. CRC-32/IEEE over header+payload, final 4
|
||||
little-endian bytes. One envelope per UDP datagram.
|
||||
|
||||
Parsers reject: unknown version, wrong `header_len`, `frame_len` exceeding
|
||||
the IPv4 UDP payload cap (65,507 bytes), `num_bit_per_tone` outside
|
||||
`{16, 32}`, `payload_len` inconsistent with `num_sub_carrier *
|
||||
(num_bit_per_tone/8)`, trailing bytes, and bad CRC — mirroring the
|
||||
MediaTek/Qualcomm parser contract exactly.
|
||||
|
||||
Host side: add `realtek_csi.rs` to both
|
||||
`wifi-densepose-hardware` (frame + codec + simulator, mirroring
|
||||
`mediatek_csi.rs`/`qualcomm_csi.rs`) and `wifi-densepose-sensing-server`
|
||||
(bounded `RealtekCsiSnapshot`, mirroring `QualcommCsiSnapshot`), wired into
|
||||
the same UDP listener loop and `/api/v1/csi/realtek/latest` route as the
|
||||
existing vendor sources. The initial Mac compatibility port keeps Realtek CSI
|
||||
as a separate bounded diagnostic stream. Fresh ESP32 frames remain the primary
|
||||
sensing source. This change does not add `SourceKind::RealtekCsi` to a signed
|
||||
ingest contract; that requires a separate reviewed identity-bound hardware
|
||||
proof. RAC1 CRC checks corruption, not origin.
|
||||
|
||||
Firmware side (`example/wifi/wifi_csi` derivative, built against the
|
||||
official Ameba RTOS SDK, tracked outside this repository per the existing
|
||||
`CLAUDE.local.md` convention — the vendor SDK itself is never vendored into
|
||||
this repo): join the board to the deployment's WiFi AP in STA mode
|
||||
(passive CSI against the real AP as peer, since only one physical board is
|
||||
available in this bring-up), read the AP's BSSID at runtime after
|
||||
association (never hardcode a MAC into firmware source), pack each
|
||||
`RTW_EVENT_CSI_DONE` report into one `RAC1` datagram, and `sendto()` it over
|
||||
UDP to the host sensing-server's existing CSI port.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- CSI is real, MEASURED hardware output from day one — no simulator-first
|
||||
bridge phase is needed the way MediaTek/Qualcomm required, because the
|
||||
vendor SDK genuinely exposes the capability and we have the board in hand.
|
||||
- One board is sufficient for a first useful signal (passive CSI against the
|
||||
home AP); active and board-to-board modes are additive later without a
|
||||
wire-format break (`csi_mode` already reserves the values).
|
||||
- Reuses the bounded UDP parser and status-route pattern without changing
|
||||
existing ESP32 sensing. Unsigned RAC1 diagnostics are not authorized as
|
||||
calibrated presence, person count, pose, or vital evidence.
|
||||
|
||||
### Negative
|
||||
|
||||
- 1x1 hardware means no spatial (multi-antenna) diversity from a single
|
||||
board; multi-board deployment is required for the differential/localization
|
||||
use cases described in the original RTL8721Dx capability analysis.
|
||||
- The 71-minute microsecond timestamp wraparound (inherited from the
|
||||
firmware's `u32` field) means long unattended captures need host-side
|
||||
sequence-based reordering, not raw timestamp arithmetic, across a wrap.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The RAC1 envelope intentionally does not carry the SDK's raw
|
||||
`csi_signature` (0xABCD) marker — our own `magic`/CRC supersede it as the
|
||||
framing integrity check.
|
||||
|
||||
## Hardware gates
|
||||
|
||||
- [x] Confirm SDK source exposes `RTW_EVENT_CSI_DONE` / `rtw_event_csi_report_info` (done — `component/wifi/api/wifi_api_event.h`).
|
||||
- [x] Build and flash a baseline image on real PKM8721DAF-C13-F10 hardware, confirm boot over serial (done — `feat/rtl8721dx-csi-bringup`).
|
||||
- [x] Flash the CSI-streaming firmware and confirm `RAC1` datagrams parse on the host from the real board (done, 2026-09-15 — see below).
|
||||
- [x] Record MEASURED throughput/latency/jitter from the real running firmware (done, 2026-09-15 — see below).
|
||||
- [ ] A human walk-test across the link (amplitude perturbation vs. static baseline) is deferred — requires the operator physically present; not claimed until performed.
|
||||
|
||||
### MEASURED: real RAC1 capture and sustained throughput (2026-09-15)
|
||||
|
||||
Passive CSI (`RTW_CSI_MODE_NORMAL`, `RTW_CSI_TRIG_BEACON | RTW_CSI_TRIG_DATA | RTW_CSI_TRIG_QOS_DATA`)
|
||||
against the deployment AP. A single `RAC1` datagram was first
|
||||
cross-checked field-by-field against the firmware's own serial log of the
|
||||
same report — every byte matched: magic `0x31434152`, RSSI byte exact match,
|
||||
channel/bandwidth/protocol/subcarrier-count exact match, 157-byte total frame
|
||||
(49-byte header + 104-byte payload + 4-byte CRC, exactly as specified above).
|
||||
|
||||
`RTW_CSI_MODE_RX_RESP` (estimate CSI from ACKs to the STA's own transmissions)
|
||||
never fired `RTW_EVENT_CSI_DONE` across two full test runs with confirmed
|
||||
real traffic (not a hang) — this mode does not work for this hardware/AP
|
||||
combination and `RTW_CSI_MODE_NORMAL` with beacon+data triggers is the
|
||||
working configuration.
|
||||
|
||||
**An earlier note here claimed `NORMAL` mode fires only once per CSI enable
|
||||
— that was wrong**, caused by a debug log line gated to print only every
|
||||
50th frame (`frame_count % 50 == 1`), which was misread as "only one report
|
||||
total." A follow-up capture with a UDP listener running concurrently with a
|
||||
board reset measured **277 real RAC1 datagrams in 40 seconds (43,489 bytes)**,
|
||||
with `csi_sequence` climbing continuously and monotonically in the firmware's
|
||||
own serial log across six separate 50-frame log checkpoints in one 45s
|
||||
session — CSI reporting is continuous, roughly 7-12 fps, not sparse.
|
||||
|
||||
### MEASURED: throughput optimization pass (2026-09-15)
|
||||
|
||||
Formalized as a repeatable tool: `scripts/benchmark-rtl8721dx-csi.py` (listens
|
||||
on the sensing-server's CSI UDP port, reports fps/throughput/RSSI/inter-frame
|
||||
interval/sequence-gap stats from real received `RAC1` datagrams).
|
||||
|
||||
**Baseline** (`trig_period=200` [64ms], `trig_frame_ctrl=0`,
|
||||
`CSI_REPORT_BUF_NUM=1`), 30s capture:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| RAC1 fps | 14.67 |
|
||||
| Throughput | 2,302.7 bytes/s |
|
||||
| Mean inter-frame interval | 68.3 ms |
|
||||
| RSSI | mean -34.4 dBm |
|
||||
|
||||
`trig_period` (units of 320µs) was hypothesized as the rate-limiting
|
||||
throttle — the observed 68.3ms mean interval tracks the 64ms configured
|
||||
period almost exactly. Confirmed by tuning:
|
||||
|
||||
1. `trig_period`: 200 → 15 (the SDK doc's stated minimum recommended value,
|
||||
~4.8ms)
|
||||
2. `trig_frame_ctrl`: 0 → `RTW_CSI_TRIG_ACK` (second trigger source: every
|
||||
link-layer ACK the AP sends for the heartbeat's own outgoing traffic, on
|
||||
top of the existing beacon trigger)
|
||||
3. `CSI_REPORT_BUF_NUM`: 1 → 4 (a "lack of csi buf!" drop was observed even
|
||||
at the slower baseline rate; more headroom needed at a higher rate)
|
||||
|
||||
**Tuned**, 30s capture:
|
||||
|
||||
| Metric | Value | vs. baseline |
|
||||
|---|---|---|
|
||||
| RAC1 fps | 66.40 | **4.5x** |
|
||||
| Throughput | 10,424.8 bytes/s | **4.5x** |
|
||||
| Mean inter-frame interval | 14.9 ms | 4.6x lower |
|
||||
| Sequence gaps | 78 / ~2036 (~3.8%) | new (0 observed at the slower baseline rate) |
|
||||
| RSSI | mean -31.1 dBm | (different session/position, not a controlled variable here) |
|
||||
|
||||
The `trig_period` hypothesis is confirmed as the primary lever. The ~3.8%
|
||||
frame-loss rate at 66 fps is a reasonable place to stop for now — it is
|
||||
UDP-over-WiFi loss, not a firmware defect, and is small enough not to block
|
||||
downstream sensing use; revisit only if a use case needs stronger delivery
|
||||
guarantees (sequence-gap-triggered re-request, TCP fallback, etc.).
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-266](ADR-266-mediatek-filogic-csi-platform.md) / [ADR-267](ADR-267-mediatek-mimo-csi-wire-protocol.md) — sibling vendor CSI ADR pair (simulator-first precedent)
|
||||
- [ADR-268](ADR-268-qualcomm-atheros-csi-platform.md) / [ADR-269](ADR-269-qualcomm-csi-wire-protocol.md) — sibling vendor CSI ADR pair
|
||||
- [ADR-263](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md) / [ADR-264](ADR-264-rtl8720f-radar-wire-protocol.md) — existing Realtek (RTL8720F radar) path; `SourceKind::Realtek` owner
|
||||
- `CLAUDE.local.md` — RTL8721Dx toolchain setup, board/COM port, first bring-up log
|
||||
@@ -0,0 +1,193 @@
|
||||
# ADR-345: Per-link CSI attribution and node-to-node ranging
|
||||
|
||||
- **Status**: Proposed — measurement layer implemented and validated on hardware; localization not built
|
||||
- **Date**: 2026-08-28
|
||||
- **Deciders**: Joe
|
||||
- **Tags**: csi, wire-format, esp32-c6, localization, adr-018, adr-060, adr-110
|
||||
|
||||
## Context
|
||||
|
||||
Every attempt at device-free position estimation in this repository has failed
|
||||
against the same wall, and the wall has now been measured twice from different
|
||||
directions.
|
||||
|
||||
**Phase is unusable.** The bistatic tier needed a signed per-link radial
|
||||
velocity, which requires the common-mode component of CSI phase. Measured
|
||||
2026-08-28 across all three ESP32-C6 nodes over ~40,000 frames, the
|
||||
frame-to-frame change in common-mode phase is statistically indistinguishable
|
||||
from uniform random — resultant length `R` = 0.011–0.032 against a 0.000
|
||||
reference, standard deviation 1.78–1.81 against `pi/sqrt(3)` = 1.814, and no
|
||||
lag-1 autocorrelation. A second, unwrap-free estimator agreed. The mechanism is
|
||||
packet-detection timing quantization: common-mode phase is `-2*pi*f_c*tau`, and
|
||||
one ADC sample of jitter at 20 MHz is `2*pi * 2.412e9 * 50e-9` ~= 758 rad, about
|
||||
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
|
||||
is default-off as a result (`bistatic_tier_enabled`).
|
||||
|
||||
**Amplitude works but had no parallax.** The three AP -> node links leave a
|
||||
single distant transmitter within a 36.5 degree fan and converge on the node
|
||||
cluster. Bearings computed from `room_config.json`: 198.5, 221.2, 235.0 degrees.
|
||||
Scalar per-node energy weighting was closed with a clean negative result on
|
||||
2026-08-27 — magnitude responds to proximity, direction does not.
|
||||
|
||||
**The receivers were hearing far more than we recorded.** A node in promiscuous
|
||||
MGMT+DATA mode with no `filter_mac` produces CSI for every transmitter on the
|
||||
channel. Measured on node 2: the AP accounted for only **34%** of frames; the
|
||||
two peer nodes supplied 39% and 27%. Those peer frames are valid measurements
|
||||
of *node-to-node* channels — but ADR-018's wire format carries no transmitter
|
||||
address, so the sink could not attribute them. They interleaved into one
|
||||
`frame_history` with mixed geometry, and the only available remedy
|
||||
(`filter_mac`, ADR-060) fixes the mixing by discarding ~75% of the data.
|
||||
|
||||
The node-to-node links between the same three boards cross the room from three
|
||||
sides at **108.7 degrees** of angular spread (bearings 0.0, 73.2, 108.7) versus
|
||||
36.5 for the AP links — roughly 3x the parallax, with both endpoints known,
|
||||
fixed, and under our control rather than wherever the router happens to live.
|
||||
Link count also grows as `N*(N-1)/2`: 3 nodes give 3 links, 6 give 15, 13 give
|
||||
78.
|
||||
|
||||
## Decision
|
||||
|
||||
Carry the transmitter MAC on the CSI wire and model CSI per **link**
|
||||
(receiver, transmitter) rather than per **node**, as a layer alongside the
|
||||
existing per-node model.
|
||||
|
||||
### 1. Wire format v2
|
||||
|
||||
`CSI_MAGIC_V2 = 0xC5110008`, header 26 bytes: the v1 header unchanged through
|
||||
byte 19, then the transmitter address (`info->mac`, 802.11 addr2) at bytes
|
||||
20..25, with I/Q from byte 26.
|
||||
|
||||
`0xC5110002`..`0xC5110007` were already claimed by the vitals, feature and other
|
||||
edge packets, so v2 takes the next free value rather than an adjacent one — see
|
||||
`issue_928_magic_collision_tests` for why that matters. The parser accepts both
|
||||
magics, so a mixed fleet keeps working and v1 nodes simply report no
|
||||
transmitter.
|
||||
|
||||
### 2. Per-link state (`links.rs`)
|
||||
|
||||
`LinkTable` keyed by `LinkId { rx_node, tx_mac }`, each link holding its own
|
||||
amplitude history, RSSI EMA, learned resting baseline and motion metric.
|
||||
|
||||
The metric is the mean over subcarriers of the temporal standard deviation of
|
||||
**AGC-normalized** amplitude. Removing each frame's own mean across subcarriers
|
||||
is essential rather than cosmetic: the ESP32 applies automatic gain control per
|
||||
packet, so absolute amplitude jumps between packets for reasons unrelated to the
|
||||
channel, and any statistic built on the raw level measures the radio's gain
|
||||
control instead of the room.
|
||||
|
||||
Amplitude only, deliberately — nothing here can depend on phase coherence.
|
||||
|
||||
### 3. Beacon rate
|
||||
|
||||
`BEACON_PERIOD_MS` 100 -> 20. At 10 Hz a peer samples a node-to-node link with a
|
||||
5 Hz Nyquist while motion Doppler lives at 1–20 Hz; it aliases before it starts.
|
||||
20 ms matches both the receiver's own `CSI_MIN_PROCESS_INTERVAL_US` ceiling and
|
||||
the gateway self-ping already used for the AP link.
|
||||
|
||||
### 4. `GET /api/v1/links` and a live UI panel
|
||||
|
||||
The link table was initially reachable only through a 30-second log line, which
|
||||
is the wrong medium for a quantity whose value is watching it respond while you
|
||||
walk across the room. `GET /api/v1/links` returns one row per link — `motion`,
|
||||
`raw_motion`, `rssi_dbm`, `frames`, `kind` — and `LinkMeshPanel` renders them in
|
||||
the Sensing tab at 1 Hz. `raw_motion` is shown beside `motion` deliberately: a
|
||||
link whose baseline has not settled reports a healthy raw value and a near-zero
|
||||
motion, and with only the one number that is indistinguishable from a dead link.
|
||||
|
||||
Node identity is **inferred, not measured**. Nothing in the system records a
|
||||
node's own MAC — a node reports the addresses it hears, never its own. But a
|
||||
radio does not receive its own transmissions, so given the set of receivers
|
||||
currently reporting, a transmitter heard by all of them except exactly one is
|
||||
that one receiver's own board; a transmitter heard by every receiver is external
|
||||
infrastructure. `links::infer_transmitting_node` declines (returns `None`) with
|
||||
fewer than three receivers, where "heard by all but one" and "heard by exactly
|
||||
one" are the same observation, and whenever more than one receiver is missing,
|
||||
which a peer out of range produces indistinguishably. The field is named
|
||||
`tx_node_inferred` and the UI draws the peer id with a trailing `?`.
|
||||
|
||||
Checked against the known MAC map on 2026-08-28 the inference labelled all four
|
||||
transmitters correctly — nodes 0, 1 and 2 by address, and the AP as external —
|
||||
with no access to that map. A wrong answer here mislabels a display row; it
|
||||
never reaches a metric.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Validated on hardware, 2026-08-28
|
||||
|
||||
All three nodes on wire v2 at 20 ms produced the full mesh: **9 links** (3 AP +
|
||||
6 directional node-to-node).
|
||||
|
||||
Reciprocity — each physical channel measured independently from both ends:
|
||||
|
||||
| channel | direction A | direction B | ratio |
|
||||
|---|---|---|---|
|
||||
| 0 <-> 1 | 2.229 | 1.930 | 1.15 |
|
||||
| 0 <-> 2 | 1.353 | 1.000 | 1.35 |
|
||||
| 1 <-> 2 | 1.398 | 1.486 | 1.06 |
|
||||
|
||||
Nothing in the implementation enforces this. Three independent radio pairs
|
||||
agreeing within 6–35% is the strongest available evidence that the per-link
|
||||
separation is real rather than an artifact of frame bucketing.
|
||||
|
||||
Link quality is also far more uniform than the AP links: node-to-node raw motion
|
||||
spans 1.0–2.2 across all six, versus 3.8–17.8 for the three AP links (which
|
||||
track RSSI: -67, -72, -80). Uniform link quality is desirable for tomographic
|
||||
localization — no single link dominates the solution.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **Peer links are ~10x weaker.** A 16-byte ESP-NOW beacon offers far less
|
||||
energy to estimate a channel response from than a full data frame. Whether
|
||||
1.0–2.2 perturbation is sufficient to localize with is **unknown and untested**.
|
||||
- **Three links is below threshold.** Tomographic localization needs many
|
||||
crossing links; 3 will not localize regardless of quality. This ADR delivers
|
||||
the measurement layer, not localization.
|
||||
- **Localization is not implemented.** No position tier consumes `LinkTable`.
|
||||
The live position estimate remains `doppler_centroid`.
|
||||
- **Per-node path regression.** Removing `filter_mac` to admit peer frames
|
||||
returned the *per-node* `frame_history` to a mixed-transmitter stream, since
|
||||
it still consumes every frame regardless of source. Node 2's classification
|
||||
signal is noisier as a result. The fix is to select a link to feed the
|
||||
per-node path — software filtering that keeps the other links — and it is not
|
||||
yet done.
|
||||
|
||||
### Scaling limits to settle before more nodes
|
||||
|
||||
- **Airtime.** Every node broadcasts, so N nodes put N/period frames per second
|
||||
on the channel being sensed. 3 nodes at 20 ms is 150 pps; 13 would be 650 pps.
|
||||
`BEACON_PERIOD_MS` should become an NVS setting scaled to fleet size.
|
||||
- **`MAX_LINKS` is 64.** 13 nodes give 78 receiver-transmitter pairs plus the AP
|
||||
plus neighbours, which overflows. Raise deliberately, with the memory implied.
|
||||
- **Placement.** Links must *cross* the target area. Nodes spread through a
|
||||
house give roughly one link per room, each attenuated by walls and perturbed
|
||||
by anyone anywhere along it — whole-house coarse presence, not position.
|
||||
Perimeter placement around a single room is the geometry that works.
|
||||
|
||||
## Related bugs found and fixed while implementing
|
||||
|
||||
- **ADR-060 MAC filtering cost ~75% of capture rate.** The 50 Hz rate gate ran
|
||||
*before* the MAC filter and stamped `s_last_process_us` before the check, so a
|
||||
frame from any other transmitter consumed the slot and was then discarded.
|
||||
Enabling `filter_mac` dropped yield from ~42 pps to 6–13. Filtering first
|
||||
restores it (measured 31–37 pps). A 6-byte `memcmp` is negligible ISR work next
|
||||
to the CSI processing the gate guards.
|
||||
- **The entire edge pipeline was dead on ESP32-C6.** `EDGE_MAX_SUBCARRIERS` was
|
||||
128, an S3-era assumption; a C6 on an HE-capable AP delivers HE20 frames with
|
||||
256 bins, and `process_frame` rejected every one. ADR-110 onboarded the C6 to
|
||||
the capture path but not this one. Vitals, presence, fall detection and
|
||||
per-slot counting had never run on these boards. Now target-conditional on
|
||||
`CONFIG_SOC_WIFI_HE_SUPPORT`, with the four subcarrier-sized arrays hoisted to
|
||||
static so peak stack is *lower* than it was at 128.
|
||||
- **A target-conditional constant that failed silently.** `edge_processing.h`
|
||||
had no `#include` at all, so `CONFIG_SOC_WIFI_HE_SUPPORT` was undefined and
|
||||
`#if` evaluated it as 0 — selecting the wrong size with no warning. An
|
||||
undefined identifier in `#if` is 0 in C. This is the same failure mode as the
|
||||
bug it was fixing, one layer up.
|
||||
|
||||
## References
|
||||
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/links.rs` — per-link state, 8 tests
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/phase_diag.rs` — the phase measurement
|
||||
- `firmware/esp32-csi-node/main/csi_collector.c` — wire v2 serialization, filter ordering
|
||||
- `firmware/esp32-csi-node/main/edge_processing.h` — `EDGE_MAX_SUBCARRIERS`
|
||||
- ADR-018 (wire format), ADR-060 (MAC filtering), ADR-110 (C6 onboarding)
|
||||
@@ -25,8 +25,10 @@ prior only after two server controlled stages:
|
||||
|
||||
1. Normal calibration reaches both 1,000 accepted frames and 600 seconds.
|
||||
The frame target represents twenty complete fifty frame runtime windows.
|
||||
Collection binds to the first accepted ESP32 source and rejects frames from
|
||||
other radios for that single link model.
|
||||
A caller may select one currently live ESP32 source. The server checks its
|
||||
freshness before replacing an active bootstrap prior, binds collection to
|
||||
that source, and rejects frames from other radios. When no source is
|
||||
selected, the first accepted ESP32 source preserves legacy behavior.
|
||||
2. Promotion observes 12 new samples spaced by at least one second. Each
|
||||
sample has a four second bounded acquisition timeout. At least 10 must
|
||||
resolve empty, all ticks must be fresh, and none may publish numeric vital
|
||||
@@ -52,6 +54,14 @@ evidence or numeric heart and breathing rates. Explicit calibration replaces
|
||||
it with fresh statistics. Cancelling an unfinished capture restores a valid
|
||||
prior. Administrator scoped reset removes it.
|
||||
|
||||
A restored image may receive one operator confirmed held out empty refinement.
|
||||
The server measures 12 fresh, source bound runtime residuals at one second
|
||||
spacing and requires zero stale samples and zero numeric vital samples. It
|
||||
persists only the scalar residuals. The new boundary is the larger of the
|
||||
existing threshold and 110 percent of the held out 95th percentile, capped at
|
||||
125 percent of the prior threshold. The one use marker persists in the model,
|
||||
so repeated requests cannot ratchet the threshold upward.
|
||||
|
||||
The status endpoint reports the learned runtime window, residual threshold,
|
||||
reference count, current background conformance, and the negative only
|
||||
authority boundary. A restored model never resumes collection and never
|
||||
@@ -89,6 +99,12 @@ occupied sequences. Empty data is never relabeled as human training.
|
||||
gate as `sensing_update`. When the bootstrap background matches, it emits
|
||||
absent state, zero people, null numeric rates, and an explicit abstention
|
||||
reason instead of forwarding raw edge candidates.
|
||||
7. `POST /api/v1/calibration/start?source_node_id=N` selects a live source for
|
||||
deterministic single link capture. A missing or stale selection fails
|
||||
without disabling the active bootstrap prior.
|
||||
8. `POST /api/v1/calibration/bootstrap/refine?confirmed_empty=true&source_node_id=N`
|
||||
performs the bounded one time held out refinement. It requires administrator
|
||||
authority when transport authentication is enabled.
|
||||
|
||||
## Measured installation evidence
|
||||
|
||||
@@ -111,3 +127,8 @@ and data directory. Status must report `binding_mode=bootstrap_only`, zero
|
||||
collection frames, and false authority for calibrated evidence and numeric
|
||||
vitals. Twelve fresh empty samples must contain no numeric vital signs.
|
||||
Starting a new room calibration must replace the prior and begin at frame zero.
|
||||
When several radios are live, selecting Node 5 must leave Nodes 3 and 7 out of
|
||||
the source set for the entire capture.
|
||||
After restart, a confirmed empty holdout may refine the restored boundary once.
|
||||
The stored snapshot must reject a second refinement and must continue to deny
|
||||
calibrated evidence and numeric vital authority.
|
||||
@@ -0,0 +1,151 @@
|
||||
# ADR 356: Rate truthful CSI and reliability gated room adaptation
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-09-05
|
||||
|
||||
## Context
|
||||
|
||||
RuView mixed three different notions of time. ESP32 firmware requested a packet
|
||||
rate, the server observed a delivered rate, and several server paths still used
|
||||
fixed 2 Hz, 10 Hz, or 20 Hz assumptions. One feature named
|
||||
`dominant_freq_hz` was not a temporal frequency at all. It was the index of the
|
||||
strongest subcarrier multiplied by 0.05. A second defect compared a newly
|
||||
inserted frame with itself, forcing that motion delta to zero.
|
||||
|
||||
These errors can move a spectral peak into the breathing or heartbeat band and
|
||||
produce plausible but false values. They also make models sensitive to WiFi
|
||||
traffic and processor scheduling rather than physical motion.
|
||||
|
||||
ESP-IDF documents that `wifi_csi_info_t.first_word_invalid` marks the first four
|
||||
CSI bytes invalid because of a hardware limitation. It also documents that the
|
||||
CSI receive callback runs in the WiFi task and should hand work to a lower
|
||||
priority task rather than perform lengthy processing. RuView serialized the
|
||||
invalid bytes unchanged and fed the original buffer to edge processing.
|
||||
|
||||
Recent work suggests four useful directions with different maturity:
|
||||
|
||||
1. OpenCSI represents each link as a dimensionless residual against its own
|
||||
quiet period temporal standard deviation and exposes baseline maturity. Its
|
||||
reported transfer results are limited to binary presence.
|
||||
2. MORIC derives motion centered delay and Doppler views to reduce dependence
|
||||
on static environment specific CSI structure.
|
||||
3. Parameter efficient CSI crowd counting combines self supervised pretraining,
|
||||
small room adapters, and a stateful counting machine.
|
||||
4. WiFi JEPA masks complete radio links and predicts their latent embeddings,
|
||||
using real and ray traced CSI for pose representation learning.
|
||||
|
||||
The papers report promising external results. None is measured RuView evidence,
|
||||
and none authorizes deployment without leakage free occupied and empty held out
|
||||
evaluation.
|
||||
|
||||
## Decision
|
||||
|
||||
### Firmware ingestion
|
||||
|
||||
1. When ESP-IDF marks the first CSI word invalid, zero the first four serialized
|
||||
CSI bytes while retaining the fixed packet shape.
|
||||
2. Set ADR-018 byte 19 bit 5 to record that sanitation occurred.
|
||||
3. Feed edge DSP from the sanitized serialized payload, never the original
|
||||
invalid driver buffer.
|
||||
4. Keep callback work bounded to sanitation, serialization, and the existing
|
||||
queue handoff. Do not add transforms to the WiFi task.
|
||||
|
||||
### Server time model
|
||||
|
||||
1. Estimate delivered CSI rate from accepted monotonic frame timestamps after
|
||||
five samples. Use 20 Hz only during startup.
|
||||
2. Cap the physical input rate at the firmware contract of 50 Hz so a burst or
|
||||
timestamp artifact cannot retune downstream filters above their supported
|
||||
range.
|
||||
3. Reconfigure the vital detector only after a 20 percent rate change. Clear
|
||||
temporal and smoothing state whenever its clock changes. Samples calculated
|
||||
with different clocks must never be blended.
|
||||
4. Compute temporal dominant frequency from the recent mean amplitude series
|
||||
with demeaning, a Hann window, and bounded direct Fourier bins from 0.1 Hz to
|
||||
the smaller of 4 Hz or 45 percent of sample rate.
|
||||
5. Return zero for short, flat, nonfinite, or spectrally ambiguous windows.
|
||||
6. Compare the current frame with the prior frame, not itself, for motion delta.
|
||||
7. Export zero sample rate until the estimate is mature. Never present a startup
|
||||
fallback as a measured node property.
|
||||
|
||||
### Model compatibility and room reliability
|
||||
|
||||
1. Preserve the historical fifth input feature for version 1 adaptive models at
|
||||
the model boundary. Version 2 and later models consume the true temporal
|
||||
frequency. This prevents an existing model from silently receiving a
|
||||
different input distribution after a server update.
|
||||
2. A background model is unreliable until it contains at least ten finite,
|
||||
nonnegative residual references. An unreliable model cannot suppress a
|
||||
runtime change and exposes no conformance score.
|
||||
3. Expose background maturity, reliability, and a robust residual Z score based
|
||||
on the median and median absolute deviation.
|
||||
4. Keep the persisted empty home prior negative only. It cannot authorize
|
||||
calibrated presence, person count, pose, or numeric vitals.
|
||||
|
||||
### Model roadmap
|
||||
|
||||
The following work remains proposal only:
|
||||
|
||||
1. Evaluate an OpenCSI style per link quiet residual normalization for binary
|
||||
presence without replacing absolute magnitude features used for motion.
|
||||
2. Evaluate delay and Doppler views after phase sanitation for activity.
|
||||
3. Pretrain a frozen multilink encoder and adapt small room specific modules for
|
||||
counting.
|
||||
4. Evaluate link masked self supervision for pose only when at least three
|
||||
spatially distinct links and synchronized positive pose labels exist.
|
||||
|
||||
Every candidate uses immutable manifests and splits by room, subject,
|
||||
recording, and time. It is compared with the frozen baseline under identical
|
||||
seeds. Counting includes confusion matrices and mean absolute error. Pose
|
||||
includes PCK at 20 centimetres, MPJPE, and the mean pose baseline. A shuffled
|
||||
label control must fail.
|
||||
|
||||
Promotion requires at least 10 percent held out lift, less than 10 milliseconds
|
||||
added p95 inference latency, no more than 20 percent peak memory overhead, zero
|
||||
leakage or secret findings, frozen anchor retention, and no regression in empty
|
||||
rejection or occupied recall. Promotion remains an explicit maintainer action.
|
||||
|
||||
## Consequences
|
||||
|
||||
The firmware no longer preserves a known invalid prefix as sensor evidence. The
|
||||
server uses an observed physical clock for spectral features and vital filters.
|
||||
Existing version 1 adaptive models remain compatible. Sparse background samples
|
||||
fail open to observation and fail closed for suppression.
|
||||
|
||||
The direct Fourier estimator is intentionally small and bounded to 128 temporal
|
||||
samples. It is portable and deterministic, but it is not a replacement for a
|
||||
phase sanitized delay and Doppler pipeline.
|
||||
|
||||
The largest remaining uncertainty is human recall. Empty home evidence can
|
||||
measure false presence but cannot show whether weak occupied signals are being
|
||||
suppressed. The fix path is a consented, leakage separated occupied holdout with
|
||||
multiple spatial links and synchronized reference labels.
|
||||
|
||||
## Research basis
|
||||
|
||||
1. Espressif ESP-IDF WiFi CSI programming guide:
|
||||
https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32/api-guides/wifi.html
|
||||
2. Espressif ESP CSI reference implementation:
|
||||
https://github.com/espressif/esp-csi
|
||||
3. OpenCSI:
|
||||
https://arxiv.org/abs/2607.26665
|
||||
4. MORIC:
|
||||
https://arxiv.org/abs/2506.12997
|
||||
5. Parameter efficient CSI crowd counting:
|
||||
https://arxiv.org/abs/2601.02203
|
||||
6. WiFi JEPA:
|
||||
https://arxiv.org/abs/2607.11064
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Run the firmware serializer host tests and the focused hardware, signal, and
|
||||
sensing server Rust suites. The deterministic serializer test must prove the
|
||||
first four bytes are zero only when ESP-IDF marks them invalid and bit 5 must
|
||||
round trip through the Rust parser. The temporal estimator must recover a
|
||||
1.2 Hz synthetic signal within 0.21 Hz, abstain on short and flat inputs, and
|
||||
remain below 10 milliseconds p95 on the target Mac. Existing version 1 model
|
||||
input must retain the legacy proxy. A background model with nine references
|
||||
must never report a match or suppress a runtime change. Physical firmware is
|
||||
not qualified until an S3 and C6 boot and stream the sanitized flag without
|
||||
watchdog, memory, or transport regressions during a five minute burn in.
|
||||
@@ -0,0 +1,143 @@
|
||||
# ADR 357: Raw CSI calibration sample integrity
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-09-06
|
||||
|
||||
## Context
|
||||
|
||||
The empty room field model requires 1,000 accepted observations over at least
|
||||
600 seconds. The sensing server previously invoked calibration from both the
|
||||
raw CSI path and the one second edge vital path. Edge vital packets did not add
|
||||
a CSI observation, so that path repeatedly fed the existing history tail. A
|
||||
counter could therefore satisfy the frame gate without representing 1,000
|
||||
independent CSI packets.
|
||||
|
||||
Node liveness also combined edge vital and CSI traffic. A source emitting only
|
||||
edge vital summaries could appear live enough to start calibration even though
|
||||
it had no fresh raw CSI. Finally, cloning the complete per node history before
|
||||
each feed copied as much as 204,800 bytes for a 100 frame, 256 subcarrier
|
||||
window even though calibration consumes only the current observation.
|
||||
|
||||
The earlier single installation result recorded in ADR 355 predates this
|
||||
admission boundary. Its frame count is not accepted as evidence of independent
|
||||
CSI coverage. It remains useful only as diagnosis of the failure mode and must
|
||||
not be promoted as a production bootstrap prior.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Only a packet on the frozen, model specific CSI grid may advance field
|
||||
calibration. Edge vital packets update their own health and presentation
|
||||
state but never feed the field model. The independent live inference grid
|
||||
remains unchanged.
|
||||
2. Each calibration session stores the last admitted wrapping sequence for
|
||||
every contributing node. Duplicate sequences are ignored. Normal forward
|
||||
progress and the `u32::MAX` to zero wrap are accepted.
|
||||
3. A packet one through sixteen sequence values behind the current high water mark
|
||||
is bounded UDP reordering. It is recorded and dropped without feeding the
|
||||
model or moving the high water mark. A backward depth above sixteen, or an
|
||||
ambiguous half range sequence, latches that node as faulted for the remainder
|
||||
of the session. Status exposes the policy, reorder window, per node reorder
|
||||
count, maximum observed depth, and `sequence_fault_node_ids`. Finalization
|
||||
returns `calibration_sequence_discontinuity` after a fault. The operator must
|
||||
cancel and restart the empty room capture.
|
||||
4. A requested source may start calibration only after a header only, bounded
|
||||
observation window identifies one stable grid. The selected grid needs at
|
||||
least ten seconds of evidence, a measured rate of at least 2 Hz, a latest
|
||||
sample newer than five seconds, and no observed gap of five seconds or more.
|
||||
Calibration status reports the immutable grid, selection evidence, current
|
||||
grid age, and `last_sequence_by_node` without exposing CSI values.
|
||||
5. Start, cancel, reset, and process initialization clear sequence admission
|
||||
state. A failed feed does not advance the sequence cursor, so the same
|
||||
packet may be retried after a transient model input failure.
|
||||
6. The field bridge receives the current amplitude slice rather than a cloned
|
||||
history. A separate bounded runtime history retains only that same frozen
|
||||
grid, so the model is never trained on one symbol layout and evaluated on a
|
||||
different layout. Canonical grid normalization remains unchanged.
|
||||
7. The exact source grid is stored inside the digest protected bootstrap v2
|
||||
payload. A restored model rebuilds only its matching runtime history. An
|
||||
unbound legacy image cannot become an active startup prior.
|
||||
8. Successful finalization clears the selected grid runtime history. Bootstrap
|
||||
promotion waits up to 35 seconds for a complete fresh runtime window before
|
||||
scoring twelve additional samples. This covers a 50 frame window at the 2 Hz
|
||||
admission floor plus ten seconds of bounded jitter. Calibration observations
|
||||
therefore cannot enter the held out decision window.
|
||||
9. Stop and promotion require the frozen grid to remain fresh and reject any
|
||||
latched sequence fault immediately before persistence. Bootstrap v2 accepts
|
||||
exactly one source node because the current runtime supports one grid bound
|
||||
field model.
|
||||
10. The legacy `--calibrate` startup switch fails closed before opening ports.
|
||||
Grid evidence does not exist at process start, so operators start the server,
|
||||
wait ten seconds, then call the explicit single source calibration API.
|
||||
|
||||
## Consequences
|
||||
|
||||
Calibration frame count now means unique, forward raw CSI observations within
|
||||
one uninterrupted sequence epoch. A vitals only node cannot produce a
|
||||
bootstrap model. Late packets never become calibration evidence.
|
||||
|
||||
One physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563,
|
||||
4,119,564, and 4,119,565 in one receive batch. A second independent 90 second
|
||||
capture observed bounded late packets at depths 9, 10, and 11. The window of
|
||||
sixteen covers the measured maximum with five positions of margin. At the
|
||||
firmware acceptance ceiling of 50 Hz, the window represents no more than 320
|
||||
milliseconds. Late packets are always dropped. Larger regressions remain
|
||||
failed closed.
|
||||
|
||||
The same 90 second capture measured three interleaved HT grids on Node 5. The
|
||||
64 subcarrier stream delivered 436 frames at 4.84 Hz with a 1.56 second maximum
|
||||
gap. The 128 stream delivered 100 frames at 1.11 Hz with a 5.05 second maximum
|
||||
gap. The 192 stream delivered 49 frames at 0.54 Hz with an 11.53 second maximum
|
||||
gap. Only the 64 grid exceeded the 2 Hz admission floor and stayed inside the
|
||||
five second freshness boundary, so the final field model binds to that grid.
|
||||
|
||||
The current wire format still has no authenticated boot epoch or stable device
|
||||
identity. A restart very early in a session or a same endpoint adversary cannot
|
||||
be distinguished cryptographically. A future ADR 018 revision must carry an
|
||||
authenticated device identifier and a random per boot identifier before the
|
||||
server claims exact restart or identity collision detection.
|
||||
|
||||
No new raw CSI is persisted. Sequence cursors and fault markers remain bounded
|
||||
in memory to active calibration sources. The bootstrap schema advances to v2
|
||||
to protect the grid identity while its negative only authority remains
|
||||
unchanged.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. `NodeState` records header only recent grid evidence and a separate bounded
|
||||
field model history.
|
||||
2. `AppStateInner::maybe_feed_calibration_frame` owns source binding, sequence
|
||||
ordering, fault latching, model feed, and cursor commit.
|
||||
3. `field_bridge::maybe_feed_calibration` accepts exactly one current amplitude
|
||||
slice and rejects an empty observation.
|
||||
4. Calibration start, status, stop, cancel, reset, promotion, refinement, and
|
||||
restart enforce the frozen source and grid contract.
|
||||
5. `bootstrap_baseline` persists the source grid inside the verified v2 image.
|
||||
6. Finalization clears training history, and promotion blocks until the model
|
||||
has a complete fresh scoring window plus twelve separately spaced samples.
|
||||
7. Startup calibration without measured grid evidence exits with a typed
|
||||
operator instruction instead of creating a model that can never receive a
|
||||
frame.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Unit tests must prove stable grid selection, rate and gap rejection, first
|
||||
packet acceptance, forward progress, duplicate rejection, source and grid
|
||||
isolation, maximum counter wrap, empty input retry, measured depth eleven
|
||||
reordering without a model feed, depth sixteen acceptance, depth seventeen
|
||||
latching, half range rejection, session clear, receipt visibility, protected
|
||||
grid persistence, and typed stop refusal.
|
||||
Tests must also prove startup flag refusal, missing and stale grid refusal,
|
||||
complete holdout isolation, and sequence fault rejection during holdout.
|
||||
|
||||
On physical hardware, begin a Node 5 empty room capture and observe a fresh
|
||||
sixty second interval. Calibration frame growth must match unique 64 subcarrier
|
||||
Node 5 sequences while the independent global inference grid remains unchanged.
|
||||
Edge vital packets and other grids must produce zero growth. The bound grid
|
||||
must remain active, sequence faults must remain empty, and numeric vital output
|
||||
must remain absent. Any bounded late packet must appear in the reorder receipts
|
||||
while leaving the model count unchanged. Complete both the 1,000 frame and 600
|
||||
second gates, then run the twelve sample server controlled bootstrap holdout.
|
||||
Restart and prove that only the persisted grid rebuilds the runtime history.
|
||||
Do not claim human detection improvement until a separate consented occupied
|
||||
holdout demonstrates retained recall.
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-361: Point the whole fleet at one transmitter, to remove selection ambiguity
|
||||
|
||||
**Status:** Proposed — built, deployed fleet-wide, A-B-A measured. **Hypothesis CONFIRMED, adoption NOT justified.** The pairing *fraction* rises sharply; paired observations per second fall.
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** ADR-358 (falsified), ADR-359 (mechanism confirmed, not adopted), ADR-360 (falsified).
|
||||
|
||||
## The hypothesis, recorded long before it could be tested
|
||||
|
||||
From `docs/BACKLOG.md`, "Next: the actual pairing constraint":
|
||||
|
||||
> Two nodes can therefore fire in the same aligned window and still pick frames
|
||||
> from *different* transmitters, which can never pair under the
|
||||
> `(tx_mac, rx_seq)` key. Cheapest test and likely fix: **point the whole fleet
|
||||
> at one strong transmitter with the existing ADR-060 MAC filter.**
|
||||
|
||||
It had never been run, and the reason was mechanical rather than scientific:
|
||||
**`filter_mac` was writable only by `provision.py` over USB.** It is a 6-byte
|
||||
blob, absent from the `cfg_key_t` scalar table the config API validates
|
||||
against, so the test would have required nine physical visits. Exposing it over
|
||||
the network (see the firmware change accompanying this ADR) reduced the whole
|
||||
experiment to two config pushes.
|
||||
|
||||
## What was measured
|
||||
|
||||
The associated AP was chosen on measurement, not assumption. Five transmitters
|
||||
reach all nine receivers; only one carries a usable rate:
|
||||
|
||||
```
|
||||
transmitter n_rx total_fps mean_rssi
|
||||
8c:30:66:86:a4:21 9 109.81 -66.6 <- the AP, 26x the next
|
||||
96:30:66:86:a4:21 9 4.24 -65.9
|
||||
08:36:c9:71:ff:52 9 0.18 -75.9
|
||||
64:16:66:cb:17:07 9 0.08 -81.7
|
||||
10:a5:62:24:bb:30 9 0.06 -78.2
|
||||
```
|
||||
|
||||
A-B-A, ten 30 s windows per arm. `/api/v1/fusion` counters are cumulative since
|
||||
sink start, so they are **differenced over each window** and never divided as
|
||||
lifetime totals — the trap already recorded for `early_drop`/`csi_fps_samples`.
|
||||
The filter applied live on all nine nodes with **no restart**, so no reboot
|
||||
confounds any arm.
|
||||
|
||||
```
|
||||
arm n paired_fraction tx/s paired/s
|
||||
A1 control 10 0.5668 +/- 0.0107 73.0 41.4
|
||||
B filtered 10 0.7287 +/- 0.0352 33.9 24.7
|
||||
A2 control 10 0.5836 +/- 0.0120 81.5 47.6
|
||||
```
|
||||
|
||||
**Pairing fraction +0.1535 against the pooled control (+26.7% relative),
|
||||
Welch t = 13.26, df = 10.5, p < 0.0001.** Reversible: A2 returned to control,
|
||||
+0.0168 against A1.
|
||||
|
||||
Per-node confirmation that the filter did what it claims, taken on node 3 while
|
||||
eight nodes were still unfiltered:
|
||||
|
||||
```
|
||||
node 3 (filtered) AP fps 13.56 non-AP fps 0.28 non-AP share 2.0%
|
||||
node 8 (control) AP fps 12.18 non-AP fps 5.66 non-AP share 31.7%
|
||||
```
|
||||
|
||||
The AP rate is untouched; peer traffic falls to residue.
|
||||
|
||||
## The hypothesis is confirmed. The fix is not.
|
||||
|
||||
Transmitter ambiguity **was** suppressing pairing, and removing it lifts the
|
||||
fraction by a quarter. That settles a question that had been open since
|
||||
2026-09-07 and had resisted three other experiments.
|
||||
|
||||
But the backlog called this the "likely fix", and that half does not survive.
|
||||
It predicted yield would fall to ~13 fps and that "the frames that remain are
|
||||
pairable". Both are true, and neither is sufficient:
|
||||
|
||||
- **tx/s fell 56%** (77.3 -> 33.9)
|
||||
- **paired observations per second fell 44%** (44.4 -> 24.7)
|
||||
|
||||
A higher fraction of a much smaller pie. The quantity downstream sensing
|
||||
actually consumes is paired observations per second, and that went **down**.
|
||||
Fraction was the wrong figure of merit, and choosing it is what made this look
|
||||
like a fix rather than a diagnosis.
|
||||
|
||||
## Decision
|
||||
|
||||
**Do not adopt. Keep the branch unmerged.** Production stays unfiltered,
|
||||
`gate_mode=2`, `gate_seq_period=8`.
|
||||
|
||||
`filter_mac` **is** kept in the config API. It costs nothing when unset, it is
|
||||
now the cheapest available probe for "is this node's pairing transmitter-
|
||||
limited?", and the ADR-060 filter had been effectively unreachable for the
|
||||
fleet's whole life.
|
||||
|
||||
## What this opens
|
||||
|
||||
A **partial** filter is the obvious next question and is NOT what was tested
|
||||
here. Restricting to a handful of widely-heard transmitters rather than exactly
|
||||
one would trade less yield for some of the gain, and there is no reason to think
|
||||
the optimum is at either extreme. The five 9-receiver transmitters above are the
|
||||
natural candidate set, though four of them carry almost no rate.
|
||||
|
||||
That is a new experiment with a different independent variable, not a variant
|
||||
of this one. It should be sized on **paired observations per second**, which is
|
||||
the lesson this ADR paid for.
|
||||
|
||||
## Honest scope
|
||||
|
||||
Software-only. One house, one AP, nine nodes, ~15 minutes per arm, at 05:00
|
||||
local with the house quiet. The effect is far larger than the within-arm
|
||||
scatter and it reversed cleanly, but it has not been repeated across times of
|
||||
day, and pairing is known to move with the transmitter population (see
|
||||
"Transmitter population instability", 2026-09-02).
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR-362: Reading the on-node log is on-demand, never streamed
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** the on-node log engine (persistent 128 B-record ring + coredump-to-flash)
|
||||
|
||||
## Context
|
||||
|
||||
The firmware now keeps a persistent log on the node's `storage` volume and can
|
||||
write a coredump to flash. Nothing reads either one. The log is a new concept
|
||||
in this tree, so there is no existing consumer to extend and the access
|
||||
pattern is genuinely open.
|
||||
|
||||
Two shapes were considered:
|
||||
|
||||
1. **Ingest** — the server polls every node on a schedule, drains new records,
|
||||
and keeps its own copy that the UI reads.
|
||||
2. **On-demand** — the server fetches a node's log only when a human asks for
|
||||
it, and keeps nothing.
|
||||
|
||||
## Decision
|
||||
|
||||
**On-demand only. No streaming, no periodic polling, no server-side copy, no
|
||||
alerting.**
|
||||
|
||||
The log is pulled after a board hangs or crashes, or while troubleshooting a
|
||||
specific node. That is the whole access pattern.
|
||||
|
||||
## Why
|
||||
|
||||
- **The persistence already lives on the node.** The ring holds ~208 days at
|
||||
128 B per 300 s, and surviving a power cycle is the entire reason the module
|
||||
exists — a power cycle is how a wedged node gets recovered. A second copy on
|
||||
the server duplicates a durability property that is already satisfied.
|
||||
- **Airtime is this fleet's binding constraint, and it is the thing being
|
||||
diagnosed.** Two nodes uplinking at 1 Mbps were measured consuming 91% of
|
||||
fleet airtime. A periodic log drain across nine nodes would spend exactly the
|
||||
resource whose exhaustion is the most common thing you would open the log to
|
||||
investigate. An instrument must not perturb what it measures.
|
||||
- **Coredumps are 64 KB.** They are only ever worth moving when something has
|
||||
actually crashed.
|
||||
- **The use is diagnostic, not observational.** Nobody watches nine logs. You
|
||||
open one node's history when that node misbehaves.
|
||||
|
||||
## What this implies gets built
|
||||
|
||||
- A server route that proxies to the node on request — `GET
|
||||
/api/v1/nodes/{id}/log`, and the same for `/coredump` — authenticating with
|
||||
the OTA PSK the firmware endpoints already sit behind.
|
||||
- A page under the existing Tools menu to view one node's records.
|
||||
- **No** scheduler, **no** new storage, **no** retention policy on the server.
|
||||
|
||||
## Open question, deliberately not settled here
|
||||
|
||||
Whether the server decodes the 128 B records into JSON, or proxies raw bytes
|
||||
and lets the decoder at the edge (browser, or `node_log_read.py`) interpret
|
||||
them.
|
||||
|
||||
Decoding server-side means the record layout is defined in two places — C and
|
||||
Rust — and can drift silently. The pilot already paid for a version of this:
|
||||
a field whose sentinel the host had to learn to render as `n/a`, where a
|
||||
plausible wrong value would have been worse than an obvious gap. That argues
|
||||
for the raw proxy and a single definition of the format.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Real-time monitoring, log streaming, fleet-wide log aggregation, and alerting
|
||||
on log contents are all explicitly out of scope. If a future need genuinely
|
||||
requires one of them, it should revisit this ADR rather than grow into it.
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR 363: Native macOS USB node onboarding
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and physically qualified with one attached ESP32 S3 and one attached
|
||||
ESP32 C6 through an unsigned Mac Catalyst development build. Signed
|
||||
distribution remains a release gate.
|
||||
|
||||
## Context
|
||||
|
||||
RuView operators currently provision ESP32 nodes with command line tooling.
|
||||
The macOS app can list server accepted node identities, but it cannot establish
|
||||
that an attached serial device is RuView firmware, assign a collision free node
|
||||
identity, preserve its WiFi configuration, or prove that it joined the sensing
|
||||
server. A UI that merely stores a friendly name would create false success.
|
||||
|
||||
Generic shell access from the app would also expand authority beyond the task.
|
||||
Serial logs can contain operational data, and WiFi credentials must not enter
|
||||
application diagnostics or provisioning receipts.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Firmware 0.8.12 provides a bounded line protocol over the physical USB serial
|
||||
channel. A discovery request carries a random 128 bit nonce. The device
|
||||
response binds that nonce to chip family, firmware version, current node
|
||||
identity, private server target, configuration state, and a pseudonymous
|
||||
64 bit device digest derived from the base MAC under a RuView domain.
|
||||
|
||||
2. A configuration request is accepted only for the most recently issued
|
||||
nonce within 60 seconds. The nonce is single use. Node identity is limited
|
||||
to 1 through 255, the target is limited to RFC 1918 IPv4, and the target port
|
||||
is limited to 1 through 65535.
|
||||
|
||||
3. Existing WiFi configuration can be preserved. New credentials use strict
|
||||
base64 framing, bounded lengths, and control character rejection. Firmware
|
||||
commits NVS before acknowledging and rebooting. Credentials are never
|
||||
returned, logged, or included in the receipt.
|
||||
|
||||
4. The macOS client uses a native serial bridge. It allowlists exact character
|
||||
device prefixes, configures the port directly, and does not invoke a shell,
|
||||
Python, or an external process. It rechecks the device digest immediately
|
||||
before configuration to reduce port substitution risk.
|
||||
|
||||
5. The app suggests the lowest unused server node identity and the Mac private
|
||||
LAN address. Success is shown only after the configured node appears in the
|
||||
server inventory as active with evidence no older than three seconds.
|
||||
|
||||
6. Old firmware is reported as requiring an update. The app must not claim it
|
||||
provisioned a device based only on generic RuView serial logs.
|
||||
|
||||
7. Automatic firmware flashing is outside this decision. Flashing requires an
|
||||
exact chip and port check, a private backup, a matching image, and captured
|
||||
boot evidence. Release publication remains a separate maintainer action.
|
||||
|
||||
8. The device digest uses the PSA Crypto SHA 256 interface on ESP IDF 5.4 and
|
||||
6.0. This keeps the stable domain separated identity while avoiding the
|
||||
legacy Mbed TLS hash API removed by ESP IDF 6.
|
||||
|
||||
9. ESP32 C6 mmWave UART defaults use GPIO 4 and GPIO 5. Startup rejects any
|
||||
configured mmWave pin pair that overlaps the active console RX or TX pins.
|
||||
This preserves the onboarding input path after a failed mmWave probe.
|
||||
|
||||
## Security and privacy consequences
|
||||
|
||||
The app gains Mac App Sandbox USB and serial device access, but not general file
|
||||
or shell authority. The protocol does not authenticate a device with a hardware
|
||||
root of trust; the digest is stable identification, not a cryptographic device
|
||||
certificate. Physical USB possession is therefore the trust boundary for this
|
||||
version. An authenticated fleet identity remains ADR 305 scope.
|
||||
|
||||
The largest failure mode is a successful NVS write followed by failure to join
|
||||
the LAN. The user receives an explicit bounded verification failure instead of
|
||||
a success screen, while the board retains its committed settings and can be
|
||||
rescanned over USB.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Connect an ESP32 with firmware 0.8.12 to a Mac that is already receiving RuView
|
||||
nodes. Use only the macOS Add Sensor workflow to preserve WiFi, assign the
|
||||
lowest unused identity, and set the Mac private address. Pass when the UI
|
||||
reports the expected chip and firmware, the reassigned node remains fresh for
|
||||
five minutes, the stale collided identity expires, app restart preserves the
|
||||
profile, no server health, send, watchdog, panic, or reboot evidence appears,
|
||||
and no credential appears in app logs or the provisioning receipt.
|
||||
|
||||
The 2026-08-31 physical run passed on an ESP32 S3. The measured witness is in
|
||||
`docs/validation/2026-08-31-macos-usb-node-onboarding.md`.
|
||||
|
||||
The 2026-09-11 physical run passed on an ESP32 C6 over
|
||||
`cu.usbserial-3120`. The Mac app flashed the image, received the nonce bound
|
||||
hello receipt, preserved node identity 3, committed the server route, and
|
||||
verified fresh server evidence at minus 34 dBm. The run also exposed and fixed
|
||||
the GPIO 17 console RX conflict described above.
|
||||
@@ -92,11 +92,13 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-150](ADR-150-rf-foundation-encoder.md) | RF Foundation Encoder: pose-preserving, subject/room/device-invariant CSI embedding | Proposed |
|
||||
| [ADR-151](ADR-151-room-calibration-specialist-training.md) | Per-Room Calibration & Specialized Model Training (room-first → bank of small ruVector specialists) | Proposed |
|
||||
| [ADR-152](ADR-152-wifi-pose-sota-2026-intake.md) | WiFi-Pose SOTA 2026 Intake: geometry-conditioned calibration, external benchmarks, foundation-encoder recipe | Proposed |
|
||||
| [ADR-345](ADR-345-per-link-csi-and-node-to-node-ranging.md) | Per-link CSI attribution and node-to-node ranging — why phase-based position fails on this hardware (measured), and the coordination-free `(tx_mac, rx_seq)` join key that does not need it | Proposed — measurement layer implemented and validated on hardware; localization not built |
|
||||
| [ADR-348](ADR-348-independent-rust-multivariate-forecasting.md) | Independent Rust multivariate forecasting for RuView | Proposed |
|
||||
| [ADR-349](ADR-349-governed-local-and-fal-forecast-training.md) | Governed local and fal.ai forecast training | Proposed |
|
||||
| [ADR-350](ADR-350-ruvector-predictive-memory-and-ruvllm-boundary.md) | RuVector predictive memory and RuVLLM authority boundary | Proposed |
|
||||
| [ADR-354](ADR-354-forecast-governance-enforcement-in-ruforecast-submodule.md) | fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule | Accepted |
|
||||
| [ADR-355](ADR-355-local-empty-room-bootstrap-prior.md) | Local empty room bootstrap prior | Accepted |
|
||||
| [ADR-356](ADR-356-rate-truthful-csi-and-reliability-gated-adaptation.md) | Rate truthful CSI and reliability gated room adaptation | Accepted |
|
||||
|
||||
### Platform and UI
|
||||
|
||||
@@ -113,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 |
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Which CSI frames a node keeps decides whether cross-node fusion works
|
||||
|
||||
A measured comparison of three frame-selection gates on a nine-node ESP32-C6
|
||||
fleet, and a method for repeating it in your own environment.
|
||||
|
||||
**This is one house.** Nine boards, one floor plan, one RF environment, one
|
||||
transmitter population. The numbers below are what we measured here; the part
|
||||
worth reusing is the method, not our figures. `run_arms.py` is included so you
|
||||
can measure yours.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
Cross-node fusion pairs frames by transmission identity: two nodes are fusing
|
||||
usefully only when they hold measurements of *the same packet in the air*. The
|
||||
metric is `paired_fraction` — of the transmissions the fleet reported, what
|
||||
share were captured by two or more nodes.
|
||||
|
||||
Every node also rate-limits which CSI callbacks it keeps, because processing
|
||||
every callback at 100–500 Hz destabilises the WiFi ISR. **How that limiting is
|
||||
done turns out to matter far more than we expected**, because it decides
|
||||
whether two nodes keep the *same* frames or merely the *same number* of frames.
|
||||
|
||||
Three mechanisms, all valid rate limits:
|
||||
|
||||
| gate | rule |
|
||||
|---|---|
|
||||
| `elapsed` | at least 20 ms since **this node's** last accepted frame |
|
||||
| `mesh` | first frame heard in each shared 20 ms window of a mesh-aligned clock |
|
||||
| `seq` | keep frames where the 802.11 `rx_seq` is divisible by N |
|
||||
|
||||
`elapsed` gives every node an independent phase. `mesh` makes nodes agree on
|
||||
the *window*. `seq` makes them agree on the *frame*, using a number the
|
||||
transmitter assigns — so it needs no clock, no leader and no synchronisation.
|
||||
|
||||
## Method
|
||||
|
||||
Four arms — the three gates plus `mesh`+`seq` stacked — run as a 2x2 factorial.
|
||||
Three deliberate choices, each of which we got wrong first:
|
||||
|
||||
**Whole fleet per arm, never a subset.** An earlier attempt compared four gated
|
||||
nodes against four ungated ones. Nodes differ enormously in how much traffic
|
||||
they hear, so that comparison could not be read at all.
|
||||
|
||||
**Verify the gate is actually running.** Each switch is read back from every
|
||||
node before sampling. "Config was stored" is not "gate is live", and an earlier
|
||||
run could not distinguish a null result from a change that never applied.
|
||||
|
||||
**Frame rate is NOT a valid check that a gate is working.** A bucket gate needs
|
||||
only one survivor per window, so at ~14 candidate frames per bucket even a
|
||||
perfect 1-in-4 gate moves frame rate about 2%. Use instead
|
||||
|
||||
R_window = (delta early_drop + delta accepted) / delta accepted
|
||||
|
||||
computed from **differenced** counters. Never divide lifetime totals: they
|
||||
carry a large post-boot burst. One node read R = 1126 moments after a reboot,
|
||||
26.5 an hour later, and 19.2 windowed.
|
||||
|
||||
**Establish the noise floor first.** A null run — three hours, no changes —
|
||||
gave drift of sd **0.45 pp** across 20-minute windows, about 2x the ±0.2 pp
|
||||
binomial counting error. Without that number none of the results below can be
|
||||
called significant.
|
||||
|
||||
Arms are 20 minutes in Latin-square blocks (`U M S MS / S MS M U / MS U S M`),
|
||||
so slow drift lands on every arm equally rather than on whichever ran first.
|
||||
|
||||
## Results
|
||||
|
||||
Three blocks, twelve arms, every arm verified live on all nine nodes.
|
||||
|
||||
| arm | block 0 | block 1 | block 2 | mean |
|
||||
|---|---|---|---|---|
|
||||
| `elapsed` | 0.2881 | 0.2409 | 0.2012 | **0.2434** |
|
||||
| `mesh` | 0.2692 | 0.2580 | 0.2167 | **0.2480** |
|
||||
| `seq` (period 8) | 0.5819 | 0.5573 | 0.5422 | **0.5605** |
|
||||
| `mesh`+`seq` | 0.5639 | 0.5623 | 0.5093 | 0.5452 |
|
||||
|
||||
**`seq` roughly doubles pairing: +31.7 pp, 2.30x.** That gap is ~70x the
|
||||
measured drift floor and reproduces in all three blocks; within-block contrasts
|
||||
sit at +0.153 to +0.175 every time.
|
||||
|
||||
**Mesh-time alignment bought essentially nothing here: +0.5 pp**, about one
|
||||
standard deviation of the noise floor. We built that gate and expected it to be
|
||||
the answer; it is indistinguishable from doing nothing. Stacking it on top of
|
||||
`seq` is marginally *worse* than `seq` alone.
|
||||
|
||||
### It is a trade, not a free win
|
||||
|
||||
At period 8 the seq arms carried ~75–95k transmissions per 20 minutes against
|
||||
~120–160k ungated. You are buying pairing with raw frame rate. Absolute paired
|
||||
transmissions per minute still went **up** (2629 vs 2524), because the frames
|
||||
it discards are disproportionately ones only one node would have kept — but
|
||||
whether that trade is right depends on what else consumes the stream.
|
||||
|
||||
One measurement that made the trade easier here: on-device edge processing was
|
||||
**unaffected**. Frames actually processed stayed ~1900 per node per 4 minutes
|
||||
in both modes, because that pipeline is separately rate-limited. The cost falls
|
||||
on the uplink, not on local sensing.
|
||||
|
||||
### The mesh arm was partly an elapsed arm
|
||||
|
||||
The mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`. A
|
||||
node without valid sync falls back to the elapsed gate rather than gating on a
|
||||
meaningless epoch -- correct behaviour, but it weakens this comparison.
|
||||
|
||||
Three of the nine nodes never synchronised during these runs. For those three,
|
||||
the `mesh` and `mesh`+`seq` arms silently ran the elapsed gate. So **+0.5 pp is
|
||||
not a clean fleet-wide measurement of window alignment**; it is "mesh where sync
|
||||
held, elapsed where it did not", and the true effect of alignment on a fully
|
||||
synchronised fleet is not measured here.
|
||||
|
||||
We are reporting the number as observed rather than reconstructing it, because
|
||||
the arms were assigned before we understood the fallback, and a post-hoc
|
||||
per-node reanalysis would not be the experiment we ran.
|
||||
|
||||
The cause is worth naming because it is environmental, not a firmware defect.
|
||||
Time sync is leader-elected and hub-and-spoke: one node broadcasts and the rest
|
||||
follow. Whether every node hears that leader is a property of the building. A
|
||||
larger or more divided house fragments into sync domains, and any option that
|
||||
depends on shared time then degrades quietly for the nodes that fell out --
|
||||
which is precisely the situation where someone would most want to trust this
|
||||
measurement.
|
||||
|
||||
### A prediction we got wrong
|
||||
|
||||
Our mesh had split into two leader domains with three nodes never synchronising
|
||||
at all. We predicted `seq` would win *most* on node pairs spanning that split.
|
||||
Per-pair yield says otherwise:
|
||||
|
||||
| pair group | pairs | seq / mesh |
|
||||
|---|---|---|
|
||||
| same sync domain | 7 | 5.82x |
|
||||
| spans the two domains | 8 | 6.24x |
|
||||
| involves a never-synced node | 21 | **5.16x** |
|
||||
|
||||
Cross-domain is marginally best, but the never-synced nodes are the *lowest* —
|
||||
the opposite of the prediction — and within-group spread (3.6x–8.1x) dwarfs
|
||||
every between-group difference.
|
||||
|
||||
So `seq` is **not** a workaround for broken time sync. It wins roughly
|
||||
uniformly, including where sync is healthy. The useful consequence is negative:
|
||||
repairing our mesh split would probably not have closed the gap, and we would
|
||||
have spent that effort for nothing.
|
||||
|
||||
## What we suggest
|
||||
|
||||
Not "use `seq`". **Make it selectable and measure your own environment.** The
|
||||
right answer depends on your transmitter population, your node placement and
|
||||
what consumes your CSI stream — none of which we can see from here.
|
||||
|
||||
`run_arms.py` runs the factorial unattended: it applies each arm fleet-wide,
|
||||
verifies every node adopted it, samples `/api/v1/fusion`, and writes one row
|
||||
per arm. Run the null pass first; without your own drift floor the contrasts
|
||||
cannot be judged.
|
||||
|
||||
## Limits
|
||||
|
||||
- One environment, nine nodes. Treat the numbers as an existence proof, not a
|
||||
constant.
|
||||
- Only period 8 was characterised. 4 and 16 are unmeasured, so the
|
||||
yield/pairing curve has a single point on it.
|
||||
- `paired_fraction` measures **frame pairing**, which is a precondition for
|
||||
cross-receiver work. Nothing here measures sensing accuracy, and none of it
|
||||
should be read as an accuracy claim.
|
||||
@@ -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 |
|
||||
|------|------|-----|
|
||||
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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,6 +19,25 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr
|
||||
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
|
||||
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
|
||||
|
||||
## Firmware 0.8.12: guided Mac onboarding
|
||||
|
||||
Firmware 0.8.12 adds the protocol used by the RuView macOS Add
|
||||
Sensor workflow. A directly attached S3 or C6 can identify itself through a
|
||||
nonce bound USB serial receipt, preserve its current WiFi settings, accept an
|
||||
unused node ID and private LAN server address, then reboot. The Mac app does
|
||||
not report success until the sensing server observes that node as fresh.
|
||||
|
||||
This is not a firmware flashing API. A board on 0.8.8 or earlier must receive a
|
||||
matching chip build through the documented backup and flash process once. The
|
||||
USB protocol rejects public target addresses and invalid identifiers. New WiFi
|
||||
credentials are bounded, committed only in NVS, and never included in the
|
||||
receipt or diagnostic output. See
|
||||
[ADR 363](../../docs/adr/ADR-363-native-macos-usb-node-onboarding.md).
|
||||
|
||||
On ESP32 C6, the default optional mmWave UART uses GPIO 4 and GPIO 5. Firmware
|
||||
refuses any mmWave configuration that overlaps the active console pins. This
|
||||
prevents a sensor probe from taking over UART0 RX and disabling Mac onboarding.
|
||||
|
||||
## Firmware 0.8.8 in plain language
|
||||
|
||||
Release 0.8.8 makes the sensing stream more internally consistent and easier
|
||||
@@ -115,6 +134,30 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
0x20000 firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
#### Seeed XIAO ESP32-C6 external antenna
|
||||
|
||||
The XIAO ESP32-C6 has a hardware RF switch between its internal ceramic
|
||||
antenna and U.FL connector. Generic C6 boards do not share this wiring, so the
|
||||
firmware leaves GPIO3 and GPIO14 untouched by default. For an XIAO with an
|
||||
external antenna already attached while powered off, build with one of the
|
||||
opt-in overlays:
|
||||
|
||||
```bash
|
||||
# Controlled A condition: internal antenna, RF path stated in the boot log.
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-internal" \
|
||||
set-target esp32c6
|
||||
idf.py build
|
||||
|
||||
# Controlled B condition: external U.FL antenna.
|
||||
idf.py -D SDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.xiao-external" \
|
||||
set-target esp32c6
|
||||
idf.py build
|
||||
```
|
||||
|
||||
The boot log must report the intended `internal ceramic` or `external U.FL`
|
||||
path before that measurement is labelled. The standard C6 build keeps the
|
||||
board default and emits no antenna-path claim.
|
||||
|
||||
### 3. Provision WiFi credentials (no reflash needed)
|
||||
|
||||
```bash
|
||||
@@ -257,10 +300,17 @@ Offset Size Field
|
||||
12 4 Sequence number (LE u32)
|
||||
16 1 RSSI (i8)
|
||||
17 1 Noise floor (i8)
|
||||
18 2 Reserved
|
||||
18 1 PPDU type (ADR-110; zero when tagging is disabled)
|
||||
19 1 Flags: bit0=40 MHz, bit2=STBC, bit4=sync valid,
|
||||
bit5=invalid first CSI word zeroed by firmware
|
||||
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
```
|
||||
|
||||
When ESP-IDF marks `first_word_invalid`, the firmware preserves packet geometry,
|
||||
zeros the first four CSI bytes, and sets byte 19 bit 5. The same sanitized bytes
|
||||
feed the edge DSP path. This prevents a documented hardware artifact from being
|
||||
learned as motion while keeping older readers wire compatible.
|
||||
|
||||
### Vitals Packet (32 bytes)
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
# Firmware runbook — ESP32-C6 CSI node
|
||||
|
||||
Operational procedure. `README.md` explains the project; this says exactly what
|
||||
to run, in what order, and what to check. Follow it top to bottom.
|
||||
|
||||
If you are an agent: **read this file before touching firmware.** Everything
|
||||
below was learned by getting it wrong at least once.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build
|
||||
|
||||
**One command. Copy it.** From the **repository root**, not this directory:
|
||||
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$(pwd)/firmware/esp32-csi-node:/project" -w /project \
|
||||
espressif/idf:v5.4 bash -c \
|
||||
"cat sdkconfig.defaults sdkconfig.defaults.16mb sdkconfig.defaults.esp32c6 \
|
||||
> sdkconfig.defaults.build && \
|
||||
SDKCONFIG_DEFAULTS='sdkconfig.defaults.build' idf.py set-target esp32c6 && \
|
||||
idf.py build"
|
||||
```
|
||||
|
||||
Takes ~3 minutes cold, well under a minute incremental.
|
||||
|
||||
### The three defaults files are NOT all automatic
|
||||
|
||||
IDF picks up `sdkconfig.defaults` and `sdkconfig.defaults.<target>` on its own.
|
||||
It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where
|
||||
`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives.
|
||||
|
||||
Build without it and you get an image that is correct in every visible respect
|
||||
and **cannot roll back a bad OTA** — while `ota_rollback_boot_check()` sits in
|
||||
the app expecting the capability. Silent, and only discoverable by pushing a
|
||||
deliberately bad image.
|
||||
|
||||
That is why the command above concatenates all three explicitly rather than
|
||||
relying on discovery.
|
||||
|
||||
### Never `rm -rf sdkconfig` without a backup
|
||||
|
||||
The README's headline example opens with `rm -rf build sdkconfig`. `sdkconfig`
|
||||
is **untracked and gitignored**. `partitions_16mb.csv` was lost to exactly this,
|
||||
and `CLAUDE.md` warns about it *even when a README documents it*.
|
||||
|
||||
```bash
|
||||
cp sdkconfig "sdkconfig.backup-$(date +%Y%m%d-%H%M%S)" # then regenerate
|
||||
```
|
||||
|
||||
A stale `sdkconfig` is a real hazard, not a hypothetical: one found on
|
||||
2026-09-03 said `FLASHSIZE=4MB`, `partitions_4mb.csv`,
|
||||
`DYNAMIC_TX_BUFFER_NUM=64` — against a fleet running 16MB and 128. Anything
|
||||
built from it would silently not be the configuration under test.
|
||||
|
||||
### Verify before you flash anything
|
||||
|
||||
```bash
|
||||
grep -E "^CONFIG_(IDF_TARGET|ESPTOOLPY_FLASHSIZE|PARTITION_TABLE_CUSTOM_FILENAME|\
|
||||
ESP_WIFI_DYNAMIC_TX_BUFFER_NUM|BOOTLOADER_APP_ROLLBACK_ENABLE)=" sdkconfig
|
||||
```
|
||||
|
||||
All five must read:
|
||||
|
||||
| key | expected |
|
||||
|---|---|
|
||||
| `CONFIG_IDF_TARGET` | `"esp32c6"` |
|
||||
| `CONFIG_ESPTOOLPY_FLASHSIZE` | `"16MB"` |
|
||||
| `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME` | `"partitions_16mb.csv"` |
|
||||
| `CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM` | `128` |
|
||||
| `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` | `y` |
|
||||
|
||||
**Do not use the native `C:\Espressif\...esp-idf-v5.5.5`.** Wrong version, its
|
||||
venv does not match the default python, and a container-built `build/` refuses
|
||||
a native build outright. Docker only.
|
||||
|
||||
---
|
||||
|
||||
## 2. What OTA can and cannot reach
|
||||
|
||||
This is the distinction that decides whether a change needs a cable.
|
||||
|
||||
| region | offset | reachable by OTA? |
|
||||
|---|---|---|
|
||||
| bootloader | `0x0` | **NO — USB only** |
|
||||
| partition table | `0x8000` | **NO — USB only** |
|
||||
| otadata | `0xF000` | rewritten by OTA |
|
||||
| ota_0 | `0x20000` | yes (4 MB) |
|
||||
| ota_1 | `0x420000` | yes (4 MB) |
|
||||
| coredump | `0x820000` | no |
|
||||
| storage (fat) | `0x830000` | no |
|
||||
| nvs | `0x9000` | never written by a flash — provisioning survives |
|
||||
|
||||
**OTA replaces the app partition only.** Anything in the bootloader —
|
||||
`CONFIG_BOOTLOADER_*`, notably `APP_ROLLBACK_ENABLE` — or any partition-table
|
||||
change **requires USB on every board**.
|
||||
|
||||
There is **no bootloader version on the wire**. A node's sync packet reports app
|
||||
health, not bootloader capability, so you cannot determine remotely which boards
|
||||
have a rollback-capable bootloader. Track it per board, or do a USB pass across
|
||||
all of them.
|
||||
|
||||
Corollary: "every node reports health" proves the **app** is current and proves
|
||||
nothing about the bootloader. Do not answer a bootloader question with app
|
||||
evidence.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploying
|
||||
|
||||
### Over the air — app-only changes
|
||||
|
||||
Works end-to-end and is the default for anything that is not a bootloader or
|
||||
partition change. Roll to **one node**, soak it, then the rest.
|
||||
|
||||
### Over USB — bootloader, partition table, or recovery
|
||||
|
||||
```bash
|
||||
python -m esptool --chip esp32c6 -b 460800 \
|
||||
--before default_reset --after hard_reset write_flash \
|
||||
--flash_mode dio --flash_size 16MB --flash_freq 80m \
|
||||
0x0 build/bootloader/bootloader.bin \
|
||||
0x8000 build/partition_table/partition-table.bin \
|
||||
0xf000 build/ota_data_initial.bin \
|
||||
0x20000 build/esp32-csi-node.bin
|
||||
```
|
||||
|
||||
`0x9000` is not in that list, so SSID, password and `node_id` survive.
|
||||
|
||||
### Safe single-board diagnostic flash, with a revert path
|
||||
|
||||
Nodes run from whichever slot `otadata` selects. **Parse it before flashing** —
|
||||
two 32-byte entries at offsets 0 and 0x1000; active slot is
|
||||
`(max ota_seq - 1) % 2`.
|
||||
|
||||
1. Back up `otadata` (8 KB) **and** the live app slot with `esptool read-flash`.
|
||||
2. Flash the diagnostic build — it lands in `ota_0` and rewrites `otadata`.
|
||||
3. **Revert = write the 8 KB `otadata` backup back.** Production in `ota_1` was
|
||||
never touched, so no rebuild is needed.
|
||||
|
||||
Validate any backup image before trusting it: byte 0 must be `0xE9`, and the
|
||||
`esp_app_desc_t` magic at offset `0x20` must be `0xABCD5432`.
|
||||
|
||||
**Never flash the whole fleet from a workstation, and never use the server's OTA
|
||||
endpoint for a diagnostic.** USB, one board, revert path secured first.
|
||||
|
||||
---
|
||||
|
||||
## 4. Serial
|
||||
|
||||
CH343 USB-serial at 115200.
|
||||
|
||||
To read a running board **without resetting it**, open with
|
||||
`DtrEnable=$false; RtsEnable=$false`. esptool asserts RTS and *will* reset the
|
||||
board, which loses the state you were trying to observe.
|
||||
|
||||
---
|
||||
|
||||
## 5. Changing config without a cable
|
||||
|
||||
Most parameters do not need a flash at all:
|
||||
|
||||
```bash
|
||||
export RUVIEW_OTA_PSK_FILE=<path to the psk file> # never inline the key
|
||||
python config_push.py --node <node-ip> --get
|
||||
python config_push.py --node <node-ip> --set led_brightness=10
|
||||
```
|
||||
|
||||
Keys marked `assoc: true` in `config_api.c` (`wifi_ssid`, `wifi_password`,
|
||||
`channel_hop_count`, `dwell_ms`, `csi_channel`) are applied as a **trial**: the
|
||||
node banks its current values, reboots, and keeps the change only if it
|
||||
re-associates. Everything else applies without that risk.
|
||||
|
||||
`config_push.py` has **no unset**. To restore a default you must set it
|
||||
explicitly. Current defaults:
|
||||
|
||||
| key | default | source |
|
||||
|---|---|---|
|
||||
| `vital_interval_ms` | 1000 | `Kconfig.projbuild` |
|
||||
| `swarm_heartbeat_sec` | 30 | `nvs_config.c` |
|
||||
| `swarm_ingest_sec` | 5 | `nvs_config.c` |
|
||||
| `beacon_period_ms` | 0 = derive from fleet size | `nvs_config.c` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Reading a node remotely
|
||||
|
||||
`GET /api/v1/mesh` returns per-node `health`: `min_heap_kib`, `die_c`,
|
||||
`thermal_state`, `tx_dbm`, `reset_reason`, and from sync proto v3 the TX-path
|
||||
counters `send_fail` / `rate_skip` / `early_drop`.
|
||||
|
||||
`min_heap_kib` is a **minimum-since-boot watermark**, not an instantaneous
|
||||
reading. It resets on reboot and only ever falls. Sampling it within a minute of
|
||||
a restart tells you nothing — that mistake produced a wrong "no effect" verdict
|
||||
on 2026-09-03; the real answer, once it had settled, was −56 KiB.
|
||||
|
||||
`/api/v1/nodes` `rssi_dbm` is **not** the AP link — it is whatever link landed
|
||||
most recently and swings 20+ dB with no physical change. Filter `/api/v1/links`
|
||||
by the AP's BSSIDs instead, and read `csi_fps_ema` alongside, because link count
|
||||
alone hides a starved node.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tunables, and which ones depend on your house
|
||||
|
||||
`idf.py menuconfig` exposes these. The defaults are what a nine-node ESP32-C6
|
||||
fleet in one house settled on; several are **not** universal, and the point of
|
||||
listing them here is that you can pick and measure rather than inherit someone
|
||||
else's building.
|
||||
|
||||
| option | default | depends on your house? |
|
||||
|---|---|---|
|
||||
| `CSI_GATE_MESH_ALIGNED` | y | **yes** |
|
||||
| `CSI_SEQ_DIAG` | n | diagnostic only |
|
||||
| `THERMAL_MONITOR` | y | no |
|
||||
| `THERMAL_THROTTLE` | n | **yes** |
|
||||
| `THERMAL_THROTTLE_C` | 72 | **yes** |
|
||||
| `THERMAL_HYSTERESIS_C` | 8 | no |
|
||||
| `UPLINK_WATCHDOG` | y | no |
|
||||
| `UPLINK_WATCHDOG_TIMEOUT_S` | 1200 | **yes** |
|
||||
|
||||
### The ones worth measuring yourself
|
||||
|
||||
**`CSI_GATE_MESH_ALIGNED`** decides whether nodes keep the *same* frames or
|
||||
merely the same *number* of frames, which is what makes cross-node pairing
|
||||
possible at all. Whether alignment helps depends on how much your nodes
|
||||
actually overhear in common, and that is a property of your walls and your
|
||||
transmitters. In one measured nine-node fleet, aligning windows bought
|
||||
+0.5 pp of pairing while selecting frames by `rx_seq` bought +31.7 pp -- so do
|
||||
not assume the default is doing the heavy lifting in your building.
|
||||
|
||||
Turn on `CSI_SEQ_DIAG` to measure it. It logs `(source MAC, rx_seq)` per CSI
|
||||
callback, which is what lets you compute the fraction of frames two nodes hold
|
||||
in common instead of guessing.
|
||||
|
||||
**`THERMAL_THROTTLE` / `THERMAL_THROTTLE_C`** depend on enclosure and placement,
|
||||
not on the chip. A bare bench board never reaches 72 C; the same board sealed in
|
||||
a printed case on a sunny wall can. Read `die_c` from `/api/v1/mesh` for a few
|
||||
days before deciding, because throttling transmit power costs range.
|
||||
|
||||
**`UPLINK_WATCHDOG_TIMEOUT_S`** is a bet about your AP. 1200 s suits a network
|
||||
where a wedge is rare and a reboot is cheap. If your uplink drops briefly and
|
||||
often, a short timeout turns a recoverable gap into a reboot loop; if it wedges
|
||||
hard, a long one leaves a node dark for twenty minutes.
|
||||
|
||||
### A worked example: the same two options, in one house
|
||||
|
||||
MEASURED on a nine-node ESP32-C6 fleet in a single detached house. **These are
|
||||
our numbers, not yours.** They are here to show the shape of the decision, and
|
||||
how much a building can move it.
|
||||
|
||||
Four arms as a 2x2 factorial, 20-minute runs, three Latin-square blocks so slow
|
||||
drift landed on every arm equally. Metric is the fraction of transmissions held
|
||||
in common by two or more nodes.
|
||||
|
||||
| arm | mean |
|
||||
|---|---|
|
||||
| `elapsed` (per-node gate) | 0.2434 |
|
||||
| `mesh` (aligned windows) | 0.2480 |
|
||||
| `seq` (keep `rx_seq` divisible by 8) | **0.5605** |
|
||||
| `mesh` + `seq` | 0.5452 |
|
||||
|
||||
`seq` roughly doubled pairing, +31.7 pp, about 70x the measured drift floor and
|
||||
reproduced in all three blocks. Mesh alignment bought **+0.5 pp** -- inside
|
||||
noise. Stacking them was very slightly *worse* than `seq` alone.
|
||||
|
||||
**Why that happened here, and why yours may differ.** These nodes already
|
||||
overheard a great deal in common -- two boards side by side heard the same
|
||||
transmissions 72% of the time -- so there was a large pool of shared
|
||||
transmissions for `seq` to select from, and little left for window alignment to
|
||||
recover. A house with fewer shared transmitters, thicker interior walls, or
|
||||
nodes spread so that few pairs hear the same radios has less for `seq` to work
|
||||
with, and phase alignment may matter more rather than less. We did not measure
|
||||
such a house and do not claim to know.
|
||||
|
||||
**It is a trade, not a free win.** At period 8 the `seq` arms carried ~75-95k
|
||||
transmissions per 20 minutes against ~120-160k ungated: pairing is bought with
|
||||
raw frame rate. Absolute paired transmissions per minute still rose (2629 vs
|
||||
2524), because the discarded frames are disproportionately ones only one node
|
||||
would have kept. Whether that trade is right depends on what consumes your
|
||||
stream. On-device edge processing was **unaffected** here -- frames processed
|
||||
stayed ~1900 per node per 4 minutes in both modes, because that pipeline is
|
||||
separately rate-limited -- so the cost fell on the uplink, not on local sensing.
|
||||
If your uplink is the scarce resource, that changes the answer.
|
||||
|
||||
**The two options differ in kind, not just in effect.** Mesh alignment is an
|
||||
agreement between receivers: it needs a shared clock, so it inherits whatever
|
||||
the sync topology does. Selecting by `rx_seq` needs no agreement at all. The
|
||||
802.11 sequence control field is assigned by the TRANSMITTER and is identical
|
||||
at every receiver, so it is a coordination-free name for one transmission --
|
||||
two nodes that have never heard of each other, sit in different sync domains,
|
||||
or run with no mesh whatsoever still keep the same subset of frames.
|
||||
|
||||
In the code the seq test runs before any sync check and reads only the frame;
|
||||
only the mesh branch calls `c6_sync_espnow_is_valid()`. That asymmetry is the
|
||||
reason `seq` had nothing to degrade in our runs, and it is worth weighing
|
||||
directly: if your fleet's sync is fragile, `seq` is not merely the better
|
||||
performer, it is the one with no dependency that can quietly fail.
|
||||
|
||||
**A limitation of our own measurement, which cuts against the mesh arm.** The
|
||||
mesh gate uses mesh-time buckets only while `c6_sync_espnow_is_valid()`; a node
|
||||
with no valid sync falls back to the elapsed gate rather than gating on a
|
||||
meaningless epoch. That is the right behaviour, but it means our `mesh` arms
|
||||
were partly `elapsed` arms: three of nine nodes never synchronised, so for those
|
||||
three the setting under test was silently not in effect. Read the +0.5 pp as
|
||||
"mesh where sync held, elapsed where it did not" rather than as a clean
|
||||
fleet-wide measurement of alignment.
|
||||
|
||||
This is itself a house-dependent property. Time sync here is leader-elected and
|
||||
hub-and-spoke -- one node broadcasts and the rest follow. Whether every node can
|
||||
hear that leader is a question about your building, not about the firmware. In a
|
||||
larger or more divided house the leader may not reach everyone, the fleet
|
||||
fragments into sync domains, and any option that depends on shared time degrades
|
||||
quietly for the nodes that fell out. Check `is_leader` and sync validity in
|
||||
`/api/v1/mesh` before concluding that an alignment setting did or did not help.
|
||||
|
||||
**A prediction we got wrong, kept because it is the useful part.** Our mesh had
|
||||
split into two leader domains with three nodes never synchronising at all. We
|
||||
expected `seq` to win most on pairs spanning that split. Per-pair yield said
|
||||
otherwise: same-domain 5.82x, cross-domain 6.24x, involving a never-synced node
|
||||
**5.16x** -- the lowest, the opposite of the prediction -- and within-group
|
||||
spread (3.6x-8.1x) dwarfed every between-group difference.
|
||||
|
||||
So `seq` is **not** a workaround for broken time sync; it wins roughly
|
||||
uniformly, including where sync is healthy. The consequence is negative and
|
||||
worth having: repairing the mesh split would probably not have closed the gap,
|
||||
and that effort would have been spent for nothing.
|
||||
|
||||
### Measuring rather than guessing
|
||||
|
||||
A gate comparison needs a fixed environment, repeated blocks, and every arm run
|
||||
on every node -- otherwise node placement is confounded with the setting. The
|
||||
harness used for the published comparison is in the repository; use it rather
|
||||
than eyeballing a frame rate, which moves for reasons unrelated to the setting.
|
||||
@@ -1,6 +1,7 @@
|
||||
set(SRCS
|
||||
"main.c" "csi_collector.c"
|
||||
"main.c" "csi_collector.c" "node_log.c"
|
||||
"thermal.c" "stream_sender.c" "nvs_config.c"
|
||||
"serial_onboarding.c" "serial_onboarding_protocol.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
@@ -14,6 +15,7 @@ set(SRCS
|
||||
"c6_twt.c"
|
||||
"c6_timesync.c"
|
||||
"c6_lp_core.c"
|
||||
"c6_antenna_select.c"
|
||||
# ADR-110 D1 workaround — ESP-NOW cross-node sync (works on S3+C6)
|
||||
"c6_sync_espnow.c"
|
||||
# ADR-110 B1/B2 unblock — soft-AP HE/TWT (C6-only when enabled)
|
||||
@@ -31,6 +33,9 @@ set(REQUIRES
|
||||
esp_http_client
|
||||
esp_app_format
|
||||
esp_timer
|
||||
fatfs # node_log.c: the storage FAT volume
|
||||
wear_levelling # node_log.c: wl_mount behind that volume
|
||||
espcoredump # coredump-to-flash readback
|
||||
esp_pm
|
||||
esp_driver_uart
|
||||
esp_driver_gpio
|
||||
@@ -39,6 +44,8 @@ set(REQUIRES
|
||||
driver
|
||||
lwip
|
||||
mbedtls
|
||||
efuse
|
||||
led_strip
|
||||
)
|
||||
|
||||
# ADR-110: C6-only components — pulled in when building for esp32c6.
|
||||
@@ -48,6 +55,12 @@ if(IDF_TARGET STREQUAL "esp32c6")
|
||||
list(APPEND REQUIRES ieee802154 ulp esp_hw_support)
|
||||
endif()
|
||||
|
||||
# ESP-IDF 6 split the temperature sensor driver out of the legacy `driver`
|
||||
# component. Keep the 5.4 lane working while declaring the v6 dependency.
|
||||
if(IDF_VERSION_MAJOR GREATER_EQUAL 6)
|
||||
list(APPEND REQUIRES esp_driver_tsens)
|
||||
endif()
|
||||
|
||||
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
|
||||
if(CONFIG_CSI_MOCK_ENABLED)
|
||||
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
menu "CSI Node Configuration"
|
||||
|
||||
config C6_XIAO_ANTENNA_SELECT
|
||||
bool "Control the Seeed XIAO ESP32-C6 RF switch"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
default n
|
||||
help
|
||||
Enable only for Seeed XIAO ESP32-C6 boards. The XIAO uses GPIO3
|
||||
to enable RF-switch control and GPIO14 to choose its internal or
|
||||
external antenna. Leave this disabled for generic ESP32-C6 boards
|
||||
because GPIO3 and GPIO14 may have unrelated functions there.
|
||||
|
||||
choice C6_XIAO_ANTENNA_PATH
|
||||
prompt "Seeed XIAO ESP32-C6 antenna path"
|
||||
depends on C6_XIAO_ANTENNA_SELECT
|
||||
default C6_XIAO_ANTENNA_INTERNAL
|
||||
|
||||
config C6_XIAO_ANTENNA_INTERNAL
|
||||
bool "Internal ceramic antenna"
|
||||
|
||||
config C6_XIAO_ANTENNA_EXTERNAL
|
||||
bool "External U.FL antenna"
|
||||
endchoice
|
||||
|
||||
config CSI_NODE_ID
|
||||
int "Node ID (0-255)"
|
||||
default 1
|
||||
@@ -651,3 +673,20 @@ config UPLINK_WATCHDOG_TIMEOUT_S
|
||||
packet's health field before assuming the watchdog is responsible.
|
||||
|
||||
endmenu
|
||||
|
||||
config CSI_GATE_MESH_ALIGNED
|
||||
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
|
||||
default y
|
||||
help
|
||||
The per-node elapsed gate ("20 ms since MY last accept") gives every node
|
||||
an independent phase, so two healthy nodes at 50 Hz accept disjoint
|
||||
frames and nothing can be paired across them. This aligns the 20 ms
|
||||
window to mesh time so nodes choose the same windows.
|
||||
|
||||
config CSI_SEQ_DIAG
|
||||
bool "Log (source MAC, rx_seq) for every CSI callback (ADR-345 phase 2 probe)"
|
||||
default n
|
||||
help
|
||||
Diagnostic. Emits the transmitter address and 802.11 sequence number for
|
||||
each CSI callback so the effect of the gate above can be measured rather
|
||||
than assumed. Verbose; leave off in normal operation.
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "c6_antenna_select.h"
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define XIAO_RF_SWITCH_POWER_GPIO GPIO_NUM_3
|
||||
#define XIAO_RF_SWITCH_SELECT_GPIO GPIO_NUM_14
|
||||
|
||||
static const char *TAG = "c6_antenna";
|
||||
#endif
|
||||
|
||||
esp_err_t c6_xiao_antenna_apply(void)
|
||||
{
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_XIAO_ANTENNA_SELECT)
|
||||
gpio_config_t config = {
|
||||
.pin_bit_mask = (1ULL << XIAO_RF_SWITCH_POWER_GPIO) |
|
||||
(1ULL << XIAO_RF_SWITCH_SELECT_GPIO),
|
||||
.mode = GPIO_MODE_OUTPUT,
|
||||
.pull_up_en = GPIO_PULLUP_DISABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
};
|
||||
esp_err_t err = gpio_config(&config);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
err = gpio_set_level(XIAO_RF_SWITCH_POWER_GPIO, 0);
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
|
||||
#if defined(CONFIG_C6_XIAO_ANTENNA_EXTERNAL)
|
||||
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_EXTERNAL;
|
||||
const char *name = "external U.FL";
|
||||
#else
|
||||
const c6_xiao_antenna_t antenna = C6_XIAO_ANTENNA_INTERNAL;
|
||||
const char *name = "internal ceramic";
|
||||
#endif
|
||||
|
||||
err = gpio_set_level(XIAO_RF_SWITCH_SELECT_GPIO,
|
||||
c6_xiao_antenna_gpio14_level(antenna));
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "XIAO ESP32-C6 antenna path: %s", name);
|
||||
}
|
||||
return err;
|
||||
#else
|
||||
return ESP_OK;
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
typedef enum {
|
||||
C6_XIAO_ANTENNA_INTERNAL = 0,
|
||||
C6_XIAO_ANTENNA_EXTERNAL = 1,
|
||||
} c6_xiao_antenna_t;
|
||||
|
||||
static inline int c6_xiao_antenna_gpio14_level(c6_xiao_antenna_t antenna)
|
||||
{
|
||||
return antenna == C6_XIAO_ANTENNA_EXTERNAL ? 1 : 0;
|
||||
}
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t c6_xiao_antenna_apply(void);
|
||||
#endif
|
||||
@@ -13,7 +13,7 @@
|
||||
* `ulp_embed_binary(...)` in main/CMakeLists.txt from lp_core/main.c.
|
||||
*
|
||||
* 2. DISABLED — falls back to plain deep-sleep + GPIO wake-up
|
||||
* (`esp_deep_sleep_enable_gpio_wakeup`). No debounce, no
|
||||
* (ESP-IDF deep-sleep GPIO wake API). No debounce, no
|
||||
* sub-10 µA floor, but no LP toolchain dependency either.
|
||||
* This is the path the v0.6.6 firmware shipped with.
|
||||
*
|
||||
@@ -26,6 +26,7 @@
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_ULP_COPROC_TYPE_LP_CORE)
|
||||
|
||||
#include "c6_lp_core.h"
|
||||
#include "esp_idf_version.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "driver/rtc_io.h"
|
||||
@@ -134,10 +135,17 @@ esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high)
|
||||
#else
|
||||
/* --- Fallback path: plain deep-sleep GPIO wakeup (~10 µA floor) --- */
|
||||
uint64_t mask = 1ULL << wake_gpio;
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
esp_sleep_gpio_wake_up_mode_t mode = active_high
|
||||
? ESP_GPIO_WAKEUP_GPIO_HIGH
|
||||
: ESP_GPIO_WAKEUP_GPIO_LOW;
|
||||
esp_err_t err = esp_sleep_enable_gpio_wakeup_on_hp_periph_powerdown(mask, mode);
|
||||
#else
|
||||
esp_deepsleep_gpio_wake_up_mode_t mode = active_high
|
||||
? ESP_GPIO_WAKEUP_GPIO_HIGH
|
||||
: ESP_GPIO_WAKEUP_GPIO_LOW;
|
||||
esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode);
|
||||
#endif
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
|
||||
@@ -114,6 +114,9 @@ static int64_t s_last_send_us = 0;
|
||||
*/
|
||||
#define CSI_MIN_PROCESS_INTERVAL_US (20 * 1000) /* 50 Hz */
|
||||
static int64_t s_last_process_us = 0;
|
||||
/* Mesh-epoch window index of the last accepted frame. UINT64_MAX means "none
|
||||
* yet", which no real bucket can collide with. */
|
||||
static uint64_t s_last_gate_bucket = UINT64_MAX;
|
||||
static uint32_t s_early_drop = 0;
|
||||
|
||||
/* ---- ADR-029: Channel-hop state ---- */
|
||||
@@ -263,8 +266,16 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
|
||||
buf[19] = 0;
|
||||
#endif
|
||||
|
||||
/* I/Q data */
|
||||
/* I/Q data. ESP-IDF documents that the first four CSI bytes are invalid
|
||||
* when first_word_invalid is set. Never let those hardware artifacts feed
|
||||
* either the host or edge DSP path. Preserve the fixed ADR-018 geometry by
|
||||
* zeroing rather than removing bytes, and mark the sanitation in bit 5. */
|
||||
memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len);
|
||||
if (info->first_word_invalid && iq_len > 0) {
|
||||
size_t invalid_len = iq_len < 4 ? iq_len : 4;
|
||||
memset(&buf[CSI_HEADER_SIZE], 0, invalid_len);
|
||||
buf[19] |= CSI_FLAG_FIRST_WORD_SANITIZED;
|
||||
}
|
||||
|
||||
return frame_size;
|
||||
}
|
||||
@@ -277,9 +288,53 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
(void)ctx;
|
||||
|
||||
/* Early rate gate: drop excess callbacks to ~50 Hz to prevent
|
||||
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). */
|
||||
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq).
|
||||
*
|
||||
* HOW the rate is limited decides whether cross-node fusion is possible.
|
||||
*
|
||||
* The original gate is "at least 20 ms since MY last accept". Each node
|
||||
* therefore has an independent phase: node A can accept at t=0,20,40 ms
|
||||
* while node B accepts at 7,27,47. Both are perfectly healthy and both hit
|
||||
* 50 Hz, yet they accept disjoint frames and nothing can be paired.
|
||||
*
|
||||
* MEASURED 2026-08-30, two boards side by side: they HEARD the same
|
||||
* transmissions 72% of the time but only 25% of accepted frames were
|
||||
* common. The frames were there; the gate was throwing away the pairing.
|
||||
*
|
||||
* So gate on a bucket of the MESH-ALIGNED epoch instead: every node takes
|
||||
* the first frame it hears in the same absolute 20 ms window. Two nodes
|
||||
* that heard the same frame now both accept it. Mesh sync is good to
|
||||
* roughly 490 us against a 20 ms bucket -- about 2.5% of a window -- so
|
||||
* boundary disagreements are rare.
|
||||
*
|
||||
* Falls back to the original elapsed-time gate whenever mesh sync is not
|
||||
* valid (no leader heard yet, or a node that just booted), because an
|
||||
* unsynced epoch would otherwise gate on a meaningless number. A node
|
||||
* running unsynced simply gets the old behaviour rather than no gate.
|
||||
*
|
||||
* The half-interval floor below keeps the crash protection honest: bucket
|
||||
* gating alone could accept at the end of one window and the start of the
|
||||
* next, back to back. The floor bounds that to ~100 Hz for a single pair
|
||||
* while the average stays at 50 Hz. Do not remove it; the gate exists for
|
||||
* a crash, not for tidiness. */
|
||||
int64_t now_us = esp_timer_get_time();
|
||||
if ((now_us - s_last_process_us) < CSI_MIN_PROCESS_INTERVAL_US) {
|
||||
bool take;
|
||||
#ifdef CONFIG_CSI_GATE_MESH_ALIGNED
|
||||
if (c6_sync_espnow_is_valid()) {
|
||||
uint64_t bucket = c6_sync_espnow_get_epoch_us()
|
||||
/ (uint64_t)CSI_MIN_PROCESS_INTERVAL_US;
|
||||
take = (bucket != s_last_gate_bucket)
|
||||
&& ((now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US / 2);
|
||||
if (take) {
|
||||
s_last_gate_bucket = bucket;
|
||||
}
|
||||
} else {
|
||||
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
|
||||
}
|
||||
#else
|
||||
take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US;
|
||||
#endif
|
||||
if (!take) {
|
||||
s_early_drop++;
|
||||
return;
|
||||
}
|
||||
@@ -330,13 +385,16 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
* while the on-device Tier 1/2 pipeline receives a uniform, sustainable
|
||||
* stream. Enqueuing every burst frame overloaded the unicore C6 DSP and
|
||||
* turned 30-40 callback pps into an irregular approximately 8 Hz subset. */
|
||||
if (info->buf && info->len > 0) {
|
||||
if (frame_len > CSI_HEADER_SIZE) {
|
||||
if (s_next_edge_enqueue_us == 0) {
|
||||
s_next_edge_enqueue_us = now_us;
|
||||
}
|
||||
|
||||
if (now_us >= s_next_edge_enqueue_us) {
|
||||
(void)edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
|
||||
/* Reuse the sanitized ADR-018 payload. Feeding info->buf here
|
||||
* would reintroduce first_word_invalid artifacts on device. */
|
||||
(void)edge_enqueue_csi(&frame_buf[CSI_HEADER_SIZE],
|
||||
(uint16_t)(frame_len - CSI_HEADER_SIZE),
|
||||
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
|
||||
|
||||
/* Preserve the configured sample clock instead of resetting it to
|
||||
@@ -692,6 +750,20 @@ uint16_t csi_collector_get_send_fail_count(void)
|
||||
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
|
||||
}
|
||||
|
||||
/* The gate here is fixed at compile time (mesh-aligned bucketing, falling
|
||||
* back to elapsed time when unsynced), so there is no mode or period to
|
||||
* report. Return the explicit sentinel rather than a number that reads as
|
||||
* a real setting. */
|
||||
uint8_t csi_collector_get_gate_mode(void)
|
||||
{
|
||||
return CSI_GATE_NOT_CONFIGURABLE;
|
||||
}
|
||||
|
||||
uint8_t csi_collector_get_gate_seq_period(void)
|
||||
{
|
||||
return CSI_GATE_NOT_CONFIGURABLE;
|
||||
}
|
||||
|
||||
/* ---- ADR-029: Channel hopping ---- */
|
||||
|
||||
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
/** ADR-018 header size in bytes. */
|
||||
#define CSI_HEADER_SIZE 20
|
||||
|
||||
/** ADR-018 byte 19: ESP-IDF reported and firmware sanitized invalid CSI prefix. */
|
||||
#define CSI_FLAG_FIRST_WORD_SANITIZED (1U << 5)
|
||||
|
||||
/** Maximum frame buffer size (header + 4 antennas * 256 subcarriers * 2 bytes). */
|
||||
#define CSI_MAX_FRAME_SIZE (CSI_HEADER_SIZE + 4 * 256 * 2)
|
||||
|
||||
@@ -136,4 +139,22 @@ uint16_t csi_collector_get_pkt_yield_per_sec(void);
|
||||
*/
|
||||
uint16_t csi_collector_get_send_fail_count(void);
|
||||
|
||||
/** Reported by the gate accessors when this build has no selectable gate. */
|
||||
#define CSI_GATE_NOT_CONFIGURABLE 0xFF
|
||||
|
||||
/**
|
||||
* Report which frame-selection gate is in force, for the on-node log.
|
||||
*
|
||||
* This build has exactly one gate -- mesh-aligned bucketing with an
|
||||
* elapsed-time fallback -- fixed at compile time, so there is no mode or
|
||||
* period to report and both accessors return CSI_GATE_NOT_CONFIGURABLE.
|
||||
*
|
||||
* They exist so the log record keeps a field of fixed width and meaning
|
||||
* whether or not a build can select a gate at runtime, and so an explicit
|
||||
* "not applicable" is stored rather than a plausible number that would
|
||||
* later be reasoned from.
|
||||
*/
|
||||
uint8_t csi_collector_get_gate_mode(void);
|
||||
uint8_t csi_collector_get_gate_seq_period(void);
|
||||
|
||||
#endif /* CSI_COLLECTOR_H */
|
||||
@@ -21,9 +21,11 @@
|
||||
#include "led_strip.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "node_log.h"
|
||||
#include "thermal.h"
|
||||
#include "stream_sender.h"
|
||||
#include "nvs_config.h"
|
||||
#include "serial_onboarding.h"
|
||||
#include "edge_processing.h"
|
||||
#include "ota_update.h"
|
||||
#include "power_mgmt.h"
|
||||
@@ -37,6 +39,7 @@
|
||||
#include "c6_twt.h" /* ADR-110: TWT (no-op stub on S3) */
|
||||
#include "c6_timesync.h" /* ADR-110: 802.15.4 mesh time-sync (no-op on S3) */
|
||||
#include "c6_lp_core.h" /* ADR-110: LP-core hibernation (no-op on S3) */
|
||||
#include "c6_antenna_select.h" /* XIAO C6 RF switch (opt-in; generic C6 no-op) */
|
||||
#include "c6_sync_espnow.h" /* ADR-110 D1 workaround: ESP-NOW sync */
|
||||
#include "c6_softap_he.h" /* ADR-110 B1/B2: HE/TWT soft-AP (no-op when disabled) */
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
@@ -115,6 +118,10 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
|
||||
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
|
||||
/* The 7.5 h outage was root-caused to the MAX_RETRY latch only because
|
||||
* someone was watching the console at the time. Persist it. */
|
||||
node_log_note_disconnect((uint8_t)disc->reason, (int8_t)disc->rssi);
|
||||
node_log_event(NODE_LOG_EV_WIFI_DISCONNECT, disc->reason, disc->rssi);
|
||||
s_retry_num++;
|
||||
/* Release the boot wait once, so a node that comes up while the AP is
|
||||
* down still starts CSI capture and the mesh instead of blocking in
|
||||
@@ -331,6 +338,19 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
|
||||
target_name, app_desc->version, g_nvs_config.node_id);
|
||||
|
||||
/* Apply the opt-in XIAO RF path before WiFi starts. Generic C6 boards keep
|
||||
* ownership of GPIO3 and GPIO14 because the default implementation is a
|
||||
* no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */
|
||||
ESP_ERROR_CHECK(c6_xiao_antenna_apply());
|
||||
/* The native Mac onboarding bridge must remain available even when WiFi
|
||||
* credentials or the aggregator address are wrong. Start it before any
|
||||
* blocking network initialization. The protocol is physical-USB-only,
|
||||
* nonce-bound, bounded, and never prints credentials. */
|
||||
esp_err_t onboarding_ret = serial_onboarding_start(&g_nvs_config);
|
||||
if (onboarding_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "USB onboarding unavailable: %s", esp_err_to_name(onboarding_ret));
|
||||
}
|
||||
|
||||
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
|
||||
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the
|
||||
* common module). On C6, GPIO 38/48 don't exist (only 0-30) — gate by target.
|
||||
@@ -445,6 +465,24 @@ void app_main(void)
|
||||
} else {
|
||||
ESP_LOGI(TAG, "reset reason: %s (%d)", why, (int)rr);
|
||||
}
|
||||
|
||||
/* And persist it. The console line above is exactly what a power cycle
|
||||
* destroys, which is the whole reason this fleet has no post-mortem for
|
||||
* a remote fault. node_log_init failing is non-fatal by design -- the
|
||||
* node comes up either way, it just has no memory. */
|
||||
if (node_log_init() == ESP_OK) {
|
||||
uint32_t prev_uptime_s = 0;
|
||||
nvs_handle_t nh;
|
||||
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
|
||||
nvs_get_u32(nh, "last_up_s", &prev_uptime_s);
|
||||
nvs_set_u32(nh, "last_up_s", 0);
|
||||
nvs_commit(nh);
|
||||
nvs_close(nh);
|
||||
}
|
||||
node_log_boot((uint32_t)rr, prev_uptime_s);
|
||||
ESP_LOGI(TAG, "on-node log active: boot_id=%u, %u records retained",
|
||||
(unsigned)node_log_boot_id(), (unsigned)node_log_count());
|
||||
}
|
||||
}
|
||||
|
||||
/* Bring thermal monitoring up before the radio is loaded, so the first
|
||||
@@ -649,5 +687,24 @@ void app_main(void)
|
||||
#ifdef CONFIG_UPLINK_WATCHDOG
|
||||
uplink_watchdog_tick();
|
||||
#endif
|
||||
/* Offered every 10 s; node_log enforces its own floor, so the cadence
|
||||
* lives in one place rather than being implied by this loop's delay.
|
||||
* Also refresh the uptime NVS cell, which is what lets the NEXT boot
|
||||
* record say how long the previous session actually survived -- the
|
||||
* difference between "it rebooted" and "it wedged after 225 s". */
|
||||
node_log_periodic();
|
||||
{
|
||||
static uint32_t s_last_persist_s;
|
||||
uint32_t up_s = (uint32_t)(esp_timer_get_time() / 1000000);
|
||||
if (up_s - s_last_persist_s >= 60) {
|
||||
s_last_persist_s = up_s;
|
||||
nvs_handle_t nh;
|
||||
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
|
||||
nvs_set_u32(nh, "last_up_s", up_s);
|
||||
nvs_commit(nh);
|
||||
nvs_close(nh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,9 +503,35 @@ esp_err_t mmwave_sensor_init(int uart_tx_pin, int uart_rx_pin)
|
||||
|
||||
return ESP_OK;
|
||||
|
||||
#else
|
||||
/* Default probe pins must never collide with the UART0 console. On the
|
||||
* ESP32-C6 the console is TX=GPIO16 / RX=GPIO17, so the legacy 17/18 pair
|
||||
* routed UART1 TX onto the console RX line and silently killed serial
|
||||
* input (USB onboarding, monitor commands) even when no sensor was found,
|
||||
* because uart_driver_delete() does not undo the GPIO matrix routing. */
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
if (uart_tx_pin < 0) uart_tx_pin = 4;
|
||||
if (uart_rx_pin < 0) uart_rx_pin = 5;
|
||||
#else
|
||||
if (uart_tx_pin < 0) uart_tx_pin = 17;
|
||||
if (uart_rx_pin < 0) uart_rx_pin = 18;
|
||||
#endif
|
||||
#if defined(CONFIG_ESP_CONSOLE_UART_CUSTOM)
|
||||
const int console_tx = CONFIG_ESP_CONSOLE_UART_TX_GPIO;
|
||||
const int console_rx = CONFIG_ESP_CONSOLE_UART_RX_GPIO;
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
const int console_tx = 16, console_rx = 17; /* U0TXD / U0RXD IOMUX pins */
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
|
||||
const int console_tx = 43, console_rx = 44;
|
||||
#else
|
||||
const int console_tx = 1, console_rx = 3;
|
||||
#endif
|
||||
if (uart_tx_pin == console_tx || uart_tx_pin == console_rx ||
|
||||
uart_rx_pin == console_tx || uart_rx_pin == console_rx) {
|
||||
ESP_LOGW(TAG, "mmWave probe pins (TX=%d, RX=%d) overlap the console UART (TX=%d, RX=%d); skipping probe",
|
||||
uart_tx_pin, uart_rx_pin, console_tx, console_rx);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
/* Install UART driver at MR60 baud (will be changed during probe). */
|
||||
uart_config_t uart_config = {
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
#include "node_log.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_vfs_fat.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include "c6_sync_espnow.h"
|
||||
#include "csi_collector.h"
|
||||
#include "nvs_config.h"
|
||||
#include "thermal.h"
|
||||
|
||||
/* firmware/esp32-csi-node/partitions_4mb.csv has two 1.81 MB app slots and
|
||||
* only 256 KB free -- no room for the FAT volume this module needs. The
|
||||
* whole implementation below is compiled out on that profile; every entry
|
||||
* point still links via the stubs in the #else branch so main.c's call
|
||||
* sites need no #ifdef of their own. See partitions_display.csv (8MB) and
|
||||
* partitions_16mb.csv for the profiles where this module is active. */
|
||||
#ifndef CONFIG_ESPTOOLPY_FLASHSIZE_4MB
|
||||
|
||||
static const char *TAG = "node_log";
|
||||
|
||||
/* The FAT volume reserved in partitions_16mb.csv (storage, 8000K) and never
|
||||
* mounted until now. */
|
||||
#define NODE_LOG_PARTITION "storage"
|
||||
#define NODE_LOG_MOUNT "/nlog"
|
||||
#define NODE_LOG_PATH NODE_LOG_MOUNT "/health.bin"
|
||||
|
||||
/* Ring capacity. 60000 * 128 B = 7.68 MB, just inside the 8000K volume with
|
||||
* room for FAT's own metadata. At the 300 s cadence that is ~208 days. */
|
||||
#define NODE_LOG_MAX_RECORDS 60000
|
||||
|
||||
/* Hard floor between periodic samples. The design settled on 300 s; this is
|
||||
* the enforcement, not the schedule. A caller asking more often is DROPPED
|
||||
* rather than served, because flash wear is the one way this module can do
|
||||
* harm and a bug in a caller must not be able to cause it. */
|
||||
#define NODE_LOG_MIN_PERIOD_S 60
|
||||
|
||||
/* Events bypass the periodic floor -- they are rare and they are the whole
|
||||
* point -- but not without limit, or a disconnect storm becomes a write storm. */
|
||||
#define NODE_LOG_MIN_EVENT_MS 1000
|
||||
|
||||
static bool s_active;
|
||||
static FILE *s_fp;
|
||||
static wl_handle_t s_wl = WL_INVALID_HANDLE;
|
||||
static SemaphoreHandle_t s_lock;
|
||||
static uint16_t s_boot_id;
|
||||
static uint32_t s_seq;
|
||||
static size_t s_count; /* records currently in the ring */
|
||||
static size_t s_head; /* next slot to write */
|
||||
static int64_t s_last_periodic_us;
|
||||
static int64_t s_last_event_us;
|
||||
|
||||
/* CRC16/CCITT-FALSE. A record that fails this is skipped by the reader rather
|
||||
* than trusted, which matters because a power cut mid-write leaves a partial
|
||||
* record and the ring has no other way to know. */
|
||||
static uint16_t crc16(const uint8_t *p, size_t n)
|
||||
{
|
||||
uint16_t crc = 0xFFFF;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
crc ^= (uint16_t)p[i] << 8;
|
||||
for (int b = 0; b < 8; b++) {
|
||||
crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1);
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
/* 16 B header, then a type-specific payload. The header is identical across
|
||||
* types so a reader can order and validate without dispatching first. */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t type;
|
||||
uint8_t flags;
|
||||
uint16_t boot_id;
|
||||
uint32_t seq;
|
||||
uint32_t uptime_s;
|
||||
uint16_t payload_len;
|
||||
uint16_t crc16; /* over the record with this field zeroed */
|
||||
} node_log_hdr_t;
|
||||
|
||||
_Static_assert(sizeof(node_log_hdr_t) == 16, "header must be 16 B");
|
||||
|
||||
/* ~72 B used of the 112 B payload budget, leaving ~40 B of deliberate slack so
|
||||
* a field can be added later WITHOUT changing the record size and invalidating
|
||||
* every reader already in the field. */
|
||||
typedef struct __attribute__((packed)) {
|
||||
int8_t die_c;
|
||||
uint8_t thermal_state;
|
||||
int8_t tx_dbm;
|
||||
int8_t ap_rssi_dbm; /* the one field we did not have and needed */
|
||||
uint8_t channel;
|
||||
uint8_t wifi_state;
|
||||
uint8_t gate_mode;
|
||||
uint8_t gate_seq_period;
|
||||
uint16_t free_heap_kib;
|
||||
uint16_t min_heap_kib;
|
||||
uint16_t csi_fps_x100;
|
||||
uint32_t frames_processed;
|
||||
uint32_t frames_rejected;
|
||||
uint32_t seq_drop;
|
||||
uint32_t tx_early_drop;
|
||||
uint16_t tx_rate_skip;
|
||||
uint32_t tx_send_fail;
|
||||
uint16_t disconnect_count;
|
||||
uint8_t last_disc_reason;
|
||||
int8_t last_disc_rssi;
|
||||
uint8_t mesh_flags; /* bit0 valid, bit1 leader */
|
||||
uint8_t leader_id;
|
||||
int32_t mesh_offset_us;
|
||||
uint16_t mesh_staleness_ms;
|
||||
uint32_t mesh_seq;
|
||||
} node_log_periodic_t;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t reset_reason;
|
||||
uint32_t prev_uptime_s;
|
||||
node_log_periodic_t health; /* a boot record carries a snapshot too */
|
||||
} node_log_boot_t;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t subtype;
|
||||
uint8_t _pad[3];
|
||||
int32_t a;
|
||||
int32_t b;
|
||||
} node_log_event_t;
|
||||
|
||||
/* Counters main.c owns; set through the setters below rather than reached for,
|
||||
* so this module has no include cycle with main.c. */
|
||||
static volatile uint16_t s_disconnect_count;
|
||||
static volatile uint8_t s_last_disc_reason;
|
||||
static volatile int8_t s_last_disc_rssi;
|
||||
|
||||
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
|
||||
{
|
||||
s_disconnect_count++;
|
||||
s_last_disc_reason = reason;
|
||||
s_last_disc_rssi = rssi;
|
||||
}
|
||||
|
||||
static void fill_health(node_log_periodic_t *h)
|
||||
{
|
||||
memset(h, 0, sizeof(*h));
|
||||
|
||||
/* thermal.c initialises s_last_c to -273.0f and only replaces it on the
|
||||
* first thermal_tick(), which runs from the adaptive controller -- LATER
|
||||
* than the boot record and later than the first periodic samples. Casting
|
||||
* that sentinel to int8 yields -17, a PLAUSIBLE temperature, which is
|
||||
* exactly the kind of invented-but-believable number that must never reach
|
||||
* a log someone will later reason from. Store INT8_MIN as an explicit
|
||||
* "no reading" instead; node_log_read.py renders it as n/a. */
|
||||
float die = thermal_celsius();
|
||||
h->die_c = (die < -100.0f || die > 125.0f) ? INT8_MIN : (int8_t)die;
|
||||
h->thermal_state = (uint8_t)thermal_state();
|
||||
h->tx_dbm = thermal_tx_dbm();
|
||||
h->gate_mode = csi_collector_get_gate_mode();
|
||||
h->gate_seq_period = csi_collector_get_gate_seq_period();
|
||||
|
||||
/* The association link, NOT the CSI-derived rssi_median_dbm in
|
||||
* adaptive_controller.c. Nodes 5 and 6 show every symptom of a bad uplink
|
||||
* and this is the one call that can confirm or refute it. */
|
||||
wifi_ap_record_t ap;
|
||||
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
|
||||
h->ap_rssi_dbm = (int8_t)ap.rssi;
|
||||
h->channel = ap.primary;
|
||||
h->wifi_state = 1;
|
||||
}
|
||||
|
||||
h->free_heap_kib = (uint16_t)(esp_get_free_heap_size() / 1024);
|
||||
h->min_heap_kib = (uint16_t)(esp_get_minimum_free_heap_size() / 1024);
|
||||
h->csi_fps_x100 = csi_collector_get_pkt_yield_per_sec() * 100;
|
||||
h->tx_send_fail = csi_collector_get_send_fail_count();
|
||||
|
||||
h->disconnect_count = s_disconnect_count;
|
||||
h->last_disc_reason = s_last_disc_reason;
|
||||
h->last_disc_rssi = s_last_disc_rssi;
|
||||
|
||||
h->mesh_flags = (uint8_t)((c6_sync_espnow_is_valid() ? 1 : 0)
|
||||
| (c6_sync_espnow_is_leader() ? 2 : 0));
|
||||
int64_t off = c6_sync_espnow_get_offset_us();
|
||||
/* Saturate rather than wrap: a wrapped offset reads as a plausible small
|
||||
* number and would be believed. */
|
||||
if (off > INT32_MAX) h->mesh_offset_us = INT32_MAX;
|
||||
else if (off < INT32_MIN) h->mesh_offset_us = INT32_MIN;
|
||||
else h->mesh_offset_us = (int32_t)off;
|
||||
}
|
||||
|
||||
/* Append one record. Callers hold no lock; this takes it. */
|
||||
static void append(uint8_t type, const void *payload, size_t len)
|
||||
{
|
||||
if (!s_active || len > (NODE_LOG_RECORD_SIZE - sizeof(node_log_hdr_t))) {
|
||||
return;
|
||||
}
|
||||
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(200)) != pdTRUE) {
|
||||
return; /* never block a caller on the log */
|
||||
}
|
||||
|
||||
uint8_t rec[NODE_LOG_RECORD_SIZE];
|
||||
memset(rec, 0, sizeof(rec));
|
||||
node_log_hdr_t *h = (node_log_hdr_t *)rec;
|
||||
h->type = type;
|
||||
h->boot_id = s_boot_id;
|
||||
h->seq = ++s_seq;
|
||||
h->uptime_s = (uint32_t)(esp_timer_get_time() / 1000000);
|
||||
h->payload_len = (uint16_t)len;
|
||||
h->crc16 = 0;
|
||||
memcpy(rec + sizeof(*h), payload, len);
|
||||
h->crc16 = crc16(rec, sizeof(rec));
|
||||
|
||||
if (fseek(s_fp, (long)(s_head * NODE_LOG_RECORD_SIZE), SEEK_SET) == 0
|
||||
&& fwrite(rec, 1, sizeof(rec), s_fp) == sizeof(rec)) {
|
||||
/* fflush only pushes the C stdio buffer into the VFS; FATFS still holds
|
||||
* the directory entry and FAT chain in RAM. Without fsync a reboot
|
||||
* loses the file's size, and on the next open "r+b" sees a zero-length
|
||||
* file -- which is precisely what the node-3 pilot showed: boot 1's
|
||||
* records vanished while the NVS-backed prev_uptime survived. A log
|
||||
* that does not survive a power cycle is the exact thing this module
|
||||
* exists to prevent, so the cost of an fsync per record is accepted:
|
||||
* at one record per 60 s it is nothing, and the bounded write rate is
|
||||
* what keeps it that way. */
|
||||
fflush(s_fp);
|
||||
fsync(fileno(s_fp));
|
||||
s_head = (s_head + 1) % NODE_LOG_MAX_RECORDS;
|
||||
if (s_count < NODE_LOG_MAX_RECORDS) s_count++;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "record write failed; disabling to protect the volume");
|
||||
s_active = false;
|
||||
}
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
esp_err_t node_log_init(void)
|
||||
{
|
||||
if (s_active) return ESP_OK;
|
||||
|
||||
s_lock = xSemaphoreCreateMutex();
|
||||
if (!s_lock) return ESP_ERR_NO_MEM;
|
||||
|
||||
const esp_vfs_fat_mount_config_t cfg = {
|
||||
.format_if_mount_failed = true,
|
||||
.max_files = 2,
|
||||
.allocation_unit_size = CONFIG_WL_SECTOR_SIZE,
|
||||
};
|
||||
esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl(
|
||||
NODE_LOG_MOUNT, NODE_LOG_PARTITION, &cfg, &s_wl);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "FAT mount failed (%s); logging DISABLED, node unaffected",
|
||||
esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
/* boot_id orders records across reboots. Without an RTC it is the only
|
||||
* ordering that survives a power cycle. */
|
||||
nvs_handle_t nh;
|
||||
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
|
||||
uint16_t b = 0;
|
||||
nvs_get_u16(nh, "boot_id", &b);
|
||||
s_boot_id = (uint16_t)(b + 1);
|
||||
nvs_set_u16(nh, "boot_id", s_boot_id);
|
||||
nvs_commit(nh);
|
||||
nvs_close(nh);
|
||||
}
|
||||
|
||||
s_fp = fopen(NODE_LOG_PATH, "r+b");
|
||||
if (!s_fp) {
|
||||
/* First boot only. Create it, then fsync so the directory entry is on
|
||||
* flash before anything is written into it. */
|
||||
s_fp = fopen(NODE_LOG_PATH, "w+b");
|
||||
if (s_fp) {
|
||||
fflush(s_fp);
|
||||
fsync(fileno(s_fp));
|
||||
}
|
||||
}
|
||||
if (!s_fp) {
|
||||
ESP_LOGW(TAG, "cannot open %s; logging DISABLED", NODE_LOG_PATH);
|
||||
esp_vfs_fat_spiflash_unmount_rw_wl(NODE_LOG_MOUNT, s_wl);
|
||||
s_wl = WL_INVALID_HANDLE;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Recover head/seq by scanning. The ring has no superblock deliberately:
|
||||
* a superblock is one more thing a power cut can leave inconsistent, and
|
||||
* the scan is a one-off cost at boot. */
|
||||
uint8_t rec[NODE_LOG_RECORD_SIZE];
|
||||
uint32_t best_seq = 0;
|
||||
for (size_t i = 0; i < NODE_LOG_MAX_RECORDS; i++) {
|
||||
if (fseek(s_fp, (long)(i * NODE_LOG_RECORD_SIZE), SEEK_SET) != 0) break;
|
||||
if (fread(rec, 1, sizeof(rec), s_fp) != sizeof(rec)) break;
|
||||
node_log_hdr_t *h = (node_log_hdr_t *)rec;
|
||||
if (h->type == 0) continue;
|
||||
uint16_t got = h->crc16;
|
||||
h->crc16 = 0;
|
||||
if (crc16(rec, sizeof(rec)) != got) continue;
|
||||
s_count++;
|
||||
if (h->seq > best_seq) {
|
||||
best_seq = h->seq;
|
||||
s_head = (i + 1) % NODE_LOG_MAX_RECORDS;
|
||||
}
|
||||
}
|
||||
s_seq = best_seq;
|
||||
s_active = true;
|
||||
ESP_LOGI(TAG, "logging active: boot_id=%u recovered=%u records, head=%u",
|
||||
(unsigned)s_boot_id, (unsigned)s_count, (unsigned)s_head);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool node_log_is_active(void) { return s_active; }
|
||||
uint16_t node_log_boot_id(void) { return s_boot_id; }
|
||||
size_t node_log_count(void) { return s_count; }
|
||||
|
||||
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
|
||||
{
|
||||
node_log_boot_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.reset_reason = reset_reason;
|
||||
b.prev_uptime_s = prev_uptime_s;
|
||||
fill_health(&b.health);
|
||||
append(NODE_LOG_TYPE_BOOT, &b, sizeof(b));
|
||||
}
|
||||
|
||||
void node_log_periodic(void)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (s_last_periodic_us != 0
|
||||
&& (now - s_last_periodic_us) < (int64_t)NODE_LOG_MIN_PERIOD_S * 1000000) {
|
||||
return;
|
||||
}
|
||||
s_last_periodic_us = now;
|
||||
node_log_periodic_t h;
|
||||
fill_health(&h);
|
||||
append(NODE_LOG_TYPE_PERIODIC, &h, sizeof(h));
|
||||
}
|
||||
|
||||
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (s_last_event_us != 0
|
||||
&& (now - s_last_event_us) < (int64_t)NODE_LOG_MIN_EVENT_MS * 1000) {
|
||||
return;
|
||||
}
|
||||
s_last_event_us = now;
|
||||
node_log_event_t e;
|
||||
memset(&e, 0, sizeof(e));
|
||||
e.subtype = subtype;
|
||||
e.a = a;
|
||||
e.b = b;
|
||||
append(NODE_LOG_TYPE_EVENT, &e, sizeof(e));
|
||||
}
|
||||
|
||||
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
|
||||
{
|
||||
if (!s_active || !out || max_records == 0) return 0;
|
||||
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(500)) != pdTRUE) return 0;
|
||||
|
||||
size_t written = 0;
|
||||
for (size_t n = 0; n < s_count && written < max_records; n++) {
|
||||
if (n < skip) continue;
|
||||
/* newest-first: step back from head */
|
||||
size_t idx = (s_head + NODE_LOG_MAX_RECORDS - 1 - n) % NODE_LOG_MAX_RECORDS;
|
||||
if (fseek(s_fp, (long)(idx * NODE_LOG_RECORD_SIZE), SEEK_SET) != 0) break;
|
||||
if (fread(out + written * NODE_LOG_RECORD_SIZE, 1, NODE_LOG_RECORD_SIZE, s_fp)
|
||||
!= NODE_LOG_RECORD_SIZE) break;
|
||||
written++;
|
||||
}
|
||||
xSemaphoreGive(s_lock);
|
||||
return written;
|
||||
}
|
||||
|
||||
esp_err_t node_log_clear(void)
|
||||
{
|
||||
if (!s_active) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
esp_err_t err = ESP_OK;
|
||||
if (s_fp) { fclose(s_fp); s_fp = NULL; }
|
||||
s_fp = fopen(NODE_LOG_PATH, "w+b");
|
||||
if (!s_fp) { s_active = false; err = ESP_FAIL; }
|
||||
s_count = 0;
|
||||
s_head = 0;
|
||||
s_seq = 0;
|
||||
xSemaphoreGive(s_lock);
|
||||
return err;
|
||||
}
|
||||
|
||||
#else /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
|
||||
|
||||
esp_err_t node_log_init(void) { return ESP_ERR_NOT_SUPPORTED; }
|
||||
bool node_log_is_active(void) { return false; }
|
||||
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
|
||||
{
|
||||
(void)reset_reason;
|
||||
(void)prev_uptime_s;
|
||||
}
|
||||
void node_log_periodic(void) { }
|
||||
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
|
||||
{
|
||||
(void)subtype;
|
||||
(void)a;
|
||||
(void)b;
|
||||
}
|
||||
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
|
||||
{
|
||||
(void)reason;
|
||||
(void)rssi;
|
||||
}
|
||||
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
|
||||
{
|
||||
(void)out;
|
||||
(void)max_records;
|
||||
(void)skip;
|
||||
return 0;
|
||||
}
|
||||
size_t node_log_count(void) { return 0; }
|
||||
uint16_t node_log_boot_id(void) { return 0; }
|
||||
esp_err_t node_log_clear(void) { return ESP_ERR_NOT_SUPPORTED; }
|
||||
|
||||
#endif /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
|
||||
@@ -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
|
||||
@@ -109,7 +109,7 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
len = sizeof(buf);
|
||||
if (nvs_get_str(handle, "ssid", buf, &len) == ESP_OK && len > 1) {
|
||||
strlcpy(cfg->wifi_ssid, buf, sizeof(cfg->wifi_ssid));
|
||||
ESP_LOGI(TAG, "NVS override: ssid=%s", cfg->wifi_ssid);
|
||||
ESP_LOGI(TAG, "NVS override: ssid=<configured>");
|
||||
}
|
||||
|
||||
/* WiFi password */
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "serial_onboarding.h"
|
||||
|
||||
#include "serial_onboarding_protocol.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_efuse.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "nvs.h"
|
||||
#include "psa/crypto.h"
|
||||
|
||||
static const char *TAG = "serial_onboard";
|
||||
static nvs_config_t s_current_config;
|
||||
static char s_issued_nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1];
|
||||
static int64_t s_nonce_issued_at_us;
|
||||
|
||||
#define ONBOARDING_LINE_MAX 384
|
||||
#define ONBOARDING_NONCE_TTL_US (60LL * 1000LL * 1000LL)
|
||||
|
||||
static const char *chip_name(void)
|
||||
{
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
return "esp32c6";
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
|
||||
return "esp32s3";
|
||||
#else
|
||||
return "esp32";
|
||||
#endif
|
||||
}
|
||||
|
||||
static void device_digest(char output[17])
|
||||
{
|
||||
uint8_t base_mac[6] = {0};
|
||||
uint8_t digest[32] = {0};
|
||||
static const uint8_t domain[] = "ruview-device-v1";
|
||||
uint8_t digest_input[sizeof(domain) - 1 + sizeof(base_mac)];
|
||||
size_t digest_length = 0;
|
||||
memcpy(digest_input, domain, sizeof(domain) - 1);
|
||||
const esp_err_t mac_result = esp_efuse_mac_get_default(base_mac);
|
||||
if (mac_result == ESP_OK) {
|
||||
memcpy(digest_input + sizeof(domain) - 1, base_mac, sizeof(base_mac));
|
||||
}
|
||||
if (mac_result == ESP_OK &&
|
||||
psa_crypto_init() == PSA_SUCCESS &&
|
||||
psa_hash_compute(PSA_ALG_SHA_256, digest_input, sizeof(digest_input),
|
||||
digest, sizeof(digest), &digest_length) == PSA_SUCCESS &&
|
||||
digest_length == sizeof(digest)) {
|
||||
for (size_t index = 0; index < 8; index++) {
|
||||
snprintf(output + index * 2, 3, "%02x", digest[index]);
|
||||
}
|
||||
} else {
|
||||
memcpy(output, "0000000000000000", 17);
|
||||
}
|
||||
}
|
||||
|
||||
static void emit_hello(const char *nonce)
|
||||
{
|
||||
const esp_app_desc_t *description = esp_app_get_description();
|
||||
char digest[17] = {0};
|
||||
device_digest(digest);
|
||||
const bool configured = s_current_config.wifi_ssid[0] != '\0' &&
|
||||
s_current_config.target_ip[0] != '\0';
|
||||
printf("RUVIEW_HELLO_OK_V1 nonce=%s chip=%s version=%s node_id=%u "
|
||||
"target_ip=%s target_port=%u configured=%u device_digest=%s\n",
|
||||
nonce, chip_name(), description->version,
|
||||
(unsigned)s_current_config.node_id, s_current_config.target_ip,
|
||||
(unsigned)s_current_config.target_port, configured ? 1U : 0U, digest);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static esp_err_t commit_config(const ruview_onboarding_config_t *config)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
esp_err_t result = nvs_open("csi_cfg", NVS_READWRITE, &handle);
|
||||
if (result != ESP_OK) return result;
|
||||
if (!config->preserve_wifi) {
|
||||
result = nvs_set_str(handle, "ssid", config->ssid);
|
||||
if (result == ESP_OK) result = nvs_set_str(handle, "password", config->password);
|
||||
}
|
||||
if (result == ESP_OK) result = nvs_set_str(handle, "target_ip", config->target_ip);
|
||||
if (result == ESP_OK) result = nvs_set_u16(handle, "target_port", config->target_port);
|
||||
if (result == ESP_OK) result = nvs_set_u8(handle, "node_id", config->node_id);
|
||||
if (result == ESP_OK) result = nvs_commit(handle);
|
||||
nvs_close(handle);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void process_line(char *line)
|
||||
{
|
||||
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1] = {0};
|
||||
if (ruview_onboarding_parse_hello(line, nonce)) {
|
||||
memcpy(s_issued_nonce, nonce, sizeof(s_issued_nonce));
|
||||
s_nonce_issued_at_us = esp_timer_get_time();
|
||||
emit_hello(nonce);
|
||||
return;
|
||||
}
|
||||
if (strncmp(line, "RUVIEW_CONFIG_V1 ", 17) != 0) return;
|
||||
|
||||
ruview_onboarding_config_t request;
|
||||
const ruview_onboarding_result_t parsed = ruview_onboarding_parse_config(line, &request);
|
||||
if (parsed != RUVIEW_ONBOARDING_OK) {
|
||||
printf("RUVIEW_CONFIG_ERR_V1 reason=%s\n", ruview_onboarding_result_name(parsed));
|
||||
fflush(stdout);
|
||||
return;
|
||||
}
|
||||
const int64_t age_us = esp_timer_get_time() - s_nonce_issued_at_us;
|
||||
if (s_issued_nonce[0] == '\0' || strcmp(request.nonce, s_issued_nonce) != 0 ||
|
||||
age_us < 0 || age_us > ONBOARDING_NONCE_TTL_US) {
|
||||
printf("RUVIEW_CONFIG_ERR_V1 nonce=%s reason=claim_expired\n", request.nonce);
|
||||
fflush(stdout);
|
||||
return;
|
||||
}
|
||||
s_issued_nonce[0] = '\0';
|
||||
const esp_err_t result = commit_config(&request);
|
||||
if (result != ESP_OK) {
|
||||
printf("RUVIEW_CONFIG_ERR_V1 nonce=%s reason=nvs_commit code=%s\n",
|
||||
request.nonce, esp_err_to_name(result));
|
||||
fflush(stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
printf("RUVIEW_CONFIG_OK_V1 nonce=%s node_id=%u rebooting=1\n",
|
||||
request.nonce, (unsigned)request.node_id);
|
||||
fflush(stdout);
|
||||
vTaskDelay(pdMS_TO_TICKS(400));
|
||||
esp_restart();
|
||||
}
|
||||
|
||||
static void consume_bytes(const uint8_t *input, ssize_t count, char *line,
|
||||
size_t *length, bool *overflow)
|
||||
{
|
||||
for (ssize_t index = 0; index < count; index++) {
|
||||
const uint8_t byte = input[index];
|
||||
if (byte == '\r') continue;
|
||||
if (byte == '\n') {
|
||||
if (!*overflow && *length > 0) {
|
||||
line[*length] = '\0';
|
||||
process_line(line);
|
||||
}
|
||||
*length = 0;
|
||||
*overflow = false;
|
||||
continue;
|
||||
}
|
||||
if (byte < 0x20 || byte == 0x7f) continue;
|
||||
if (*length + 1 < ONBOARDING_LINE_MAX) line[(*length)++] = (char)byte;
|
||||
else *overflow = true;
|
||||
}
|
||||
}
|
||||
|
||||
static void serial_onboarding_task(void *argument)
|
||||
{
|
||||
(void)argument;
|
||||
const int original_flags = fcntl(STDIN_FILENO, F_GETFL, 0);
|
||||
if (original_flags >= 0) (void)fcntl(STDIN_FILENO, F_SETFL, original_flags | O_NONBLOCK);
|
||||
/* ESP-IDF secondary consoles mirror output but deliberately do not forward
|
||||
* input to STDIN. Native S3/C6 USB Serial/JTAG therefore needs a bounded
|
||||
* direct VFS reader while UART-console boards continue to use STDIN. */
|
||||
const int usb_fd = open("/dev/secondary", O_RDONLY | O_NONBLOCK);
|
||||
char primary_line[ONBOARDING_LINE_MAX];
|
||||
char usb_line[ONBOARDING_LINE_MAX];
|
||||
size_t primary_length = 0;
|
||||
size_t usb_length = 0;
|
||||
bool primary_overflow = false;
|
||||
bool usb_overflow = false;
|
||||
for (;;) {
|
||||
uint8_t input[64];
|
||||
const ssize_t count = read(STDIN_FILENO, input, sizeof(input));
|
||||
if (count > 0) {
|
||||
consume_bytes(input, count, primary_line, &primary_length, &primary_overflow);
|
||||
}
|
||||
else if (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
|
||||
ESP_LOGW(TAG, "serial input unavailable: errno=%d", errno);
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
}
|
||||
if (usb_fd >= 0) {
|
||||
const ssize_t usb_count = read(usb_fd, input, sizeof(input));
|
||||
if (usb_count > 0) {
|
||||
consume_bytes(input, usb_count, usb_line, &usb_length, &usb_overflow);
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t serial_onboarding_start(const nvs_config_t *config)
|
||||
{
|
||||
if (config == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memcpy(&s_current_config, config, sizeof(s_current_config));
|
||||
const BaseType_t created = xTaskCreate(serial_onboarding_task, "serial_onboard",
|
||||
4096, NULL, 4, NULL);
|
||||
if (created != pdPASS) return ESP_ERR_NO_MEM;
|
||||
ESP_LOGI(TAG, "bounded serial onboarding ready");
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#ifndef SERIAL_ONBOARDING_H
|
||||
#define SERIAL_ONBOARDING_H
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "nvs_config.h"
|
||||
|
||||
/** Start the bounded USB serial onboarding listener.
|
||||
*
|
||||
* The listener exposes device metadata and accepts one nonce-bound
|
||||
* configuration transaction. It never prints WiFi credentials and reboots
|
||||
* only after a successful atomic NVS commit.
|
||||
*/
|
||||
esp_err_t serial_onboarding_start(const nvs_config_t *config);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,214 @@
|
||||
#ifndef _POSIX_C_SOURCE
|
||||
#define _POSIX_C_SOURCE 200809L /* strtok_r under -std=c11 -pedantic on glibc */
|
||||
#endif
|
||||
#include "serial_onboarding_protocol.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define HELLO_COMMAND "RUVIEW_HELLO_V1"
|
||||
#define CONFIG_COMMAND "RUVIEW_CONFIG_V1"
|
||||
|
||||
static bool valid_nonce(const char *value)
|
||||
{
|
||||
if (value == NULL || strlen(value) != RUVIEW_ONBOARDING_NONCE_HEX_LEN) return false;
|
||||
for (size_t index = 0; index < RUVIEW_ONBOARDING_NONCE_HEX_LEN; index++) {
|
||||
const char c = value[index];
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
|
||||
(c >= 'A' && c <= 'F'))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parse_u32(const char *value, uint32_t maximum, uint32_t *output)
|
||||
{
|
||||
if (value == NULL || value[0] == '\0' || output == NULL) return false;
|
||||
uint32_t result = 0;
|
||||
for (const char *cursor = value; *cursor != '\0'; cursor++) {
|
||||
if (*cursor < '0' || *cursor > '9') return false;
|
||||
const uint32_t digit = (uint32_t)(*cursor - '0');
|
||||
if (result > (maximum - digit) / 10U) return false;
|
||||
result = result * 10U + digit;
|
||||
}
|
||||
*output = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool valid_ipv4(const char *value)
|
||||
{
|
||||
if (value == NULL || value[0] == '\0' || strlen(value) > 15) return false;
|
||||
unsigned octets = 0;
|
||||
const char *cursor = value;
|
||||
while (*cursor != '\0') {
|
||||
if (octets == 4) return false;
|
||||
if (*cursor == '.') return false;
|
||||
uint32_t octet = 0;
|
||||
unsigned digits = 0;
|
||||
while (*cursor != '\0' && *cursor != '.') {
|
||||
if (*cursor < '0' || *cursor > '9' || digits == 3) return false;
|
||||
octet = octet * 10U + (uint32_t)(*cursor - '0');
|
||||
if (octet > 255U) return false;
|
||||
digits++;
|
||||
cursor++;
|
||||
}
|
||||
if (digits == 0) return false;
|
||||
octets++;
|
||||
if (*cursor == '.') cursor++;
|
||||
}
|
||||
return octets == 4;
|
||||
}
|
||||
|
||||
static bool valid_private_ipv4(const char *value)
|
||||
{
|
||||
if (!valid_ipv4(value)) return false;
|
||||
unsigned first = 0;
|
||||
unsigned second = 0;
|
||||
if (sscanf(value, "%u.%u", &first, &second) != 2) return false;
|
||||
return first == 10U || (first == 172U && second >= 16U && second <= 31U) ||
|
||||
(first == 192U && second == 168U);
|
||||
}
|
||||
|
||||
static int base64_value(char c)
|
||||
{
|
||||
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static bool decode_base64(const char *encoded, uint8_t *output,
|
||||
size_t output_capacity, size_t *output_length)
|
||||
{
|
||||
if (encoded == NULL || output == NULL || output_length == NULL) return false;
|
||||
const size_t length = strlen(encoded);
|
||||
if (length == 0 || length % 4 != 0) return false;
|
||||
size_t written = 0;
|
||||
for (size_t index = 0; index < length; index += 4) {
|
||||
int values[4];
|
||||
unsigned padding = 0;
|
||||
for (unsigned offset = 0; offset < 4; offset++) {
|
||||
const char c = encoded[index + offset];
|
||||
if (c == '=') {
|
||||
if (offset < 2 || index + 4 != length) return false;
|
||||
values[offset] = 0;
|
||||
padding++;
|
||||
} else {
|
||||
if (padding != 0) return false;
|
||||
values[offset] = base64_value(c);
|
||||
if (values[offset] < 0) return false;
|
||||
}
|
||||
}
|
||||
if (padding > 2) return false;
|
||||
const uint32_t block = ((uint32_t)values[0] << 18) |
|
||||
((uint32_t)values[1] << 12) |
|
||||
((uint32_t)values[2] << 6) |
|
||||
(uint32_t)values[3];
|
||||
const unsigned bytes = 3U - padding;
|
||||
if (written + bytes > output_capacity) return false;
|
||||
output[written++] = (uint8_t)(block >> 16);
|
||||
if (bytes > 1) output[written++] = (uint8_t)(block >> 8);
|
||||
if (bytes > 2) output[written++] = (uint8_t)block;
|
||||
}
|
||||
*output_length = written;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool printable_secret(const uint8_t *bytes, size_t length)
|
||||
{
|
||||
if (bytes == NULL) return false;
|
||||
for (size_t index = 0; index < length; index++) {
|
||||
if (bytes[index] < 0x20 || bytes[index] == 0x7f) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int ruview_onboarding_parse_hello(
|
||||
const char *line,
|
||||
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1])
|
||||
{
|
||||
if (line == NULL || nonce == NULL) return 0;
|
||||
const size_t prefix_length = strlen(HELLO_COMMAND);
|
||||
if (strncmp(line, HELLO_COMMAND, prefix_length) != 0 || line[prefix_length] != ' ')
|
||||
return 0;
|
||||
const char *candidate = line + prefix_length + 1;
|
||||
if (!valid_nonce(candidate)) return 0;
|
||||
memcpy(nonce, candidate, RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
ruview_onboarding_result_t ruview_onboarding_parse_config(
|
||||
const char *line,
|
||||
ruview_onboarding_config_t *config)
|
||||
{
|
||||
if (line == NULL || config == NULL || strlen(line) >= 384) return RUVIEW_ONBOARDING_ERR_FORMAT;
|
||||
char copy[384];
|
||||
memcpy(copy, line, strlen(line) + 1);
|
||||
|
||||
char *tokens[7] = {0};
|
||||
size_t token_count = 0;
|
||||
char *save = NULL;
|
||||
for (char *token = strtok_r(copy, " ", &save); token != NULL;
|
||||
token = strtok_r(NULL, " ", &save)) {
|
||||
if (token_count == 7) return RUVIEW_ONBOARDING_ERR_FORMAT;
|
||||
tokens[token_count++] = token;
|
||||
}
|
||||
if (token_count != 7 || strcmp(tokens[0], CONFIG_COMMAND) != 0)
|
||||
return RUVIEW_ONBOARDING_ERR_FORMAT;
|
||||
if (!valid_nonce(tokens[1])) return RUVIEW_ONBOARDING_ERR_NONCE;
|
||||
|
||||
uint32_t node_id = 0;
|
||||
if (!parse_u32(tokens[2], 255, &node_id) || node_id == 0)
|
||||
return RUVIEW_ONBOARDING_ERR_NODE_ID;
|
||||
if (!valid_private_ipv4(tokens[3])) return RUVIEW_ONBOARDING_ERR_TARGET_IP;
|
||||
uint32_t target_port = 0;
|
||||
if (!parse_u32(tokens[4], 65535, &target_port) || target_port == 0)
|
||||
return RUVIEW_ONBOARDING_ERR_TARGET_PORT;
|
||||
|
||||
const bool preserve_wifi = strcmp(tokens[5], "KEEP") == 0 &&
|
||||
strcmp(tokens[6], "KEEP") == 0;
|
||||
if ((strcmp(tokens[5], "KEEP") == 0) != (strcmp(tokens[6], "KEEP") == 0))
|
||||
return RUVIEW_ONBOARDING_ERR_FORMAT;
|
||||
uint8_t ssid[RUVIEW_ONBOARDING_SSID_MAX_BYTES];
|
||||
size_t ssid_length = 0;
|
||||
if (!preserve_wifi && (!decode_base64(tokens[5], ssid, sizeof(ssid), &ssid_length) ||
|
||||
ssid_length == 0 || !printable_secret(ssid, ssid_length)))
|
||||
return RUVIEW_ONBOARDING_ERR_SSID;
|
||||
uint8_t password[RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES];
|
||||
size_t password_length = 0;
|
||||
if (!preserve_wifi && (!decode_base64(tokens[6], password, sizeof(password), &password_length) ||
|
||||
password_length < 8 || !printable_secret(password, password_length)))
|
||||
return RUVIEW_ONBOARDING_ERR_PASSWORD;
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
memcpy(config->nonce, tokens[1], RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1);
|
||||
config->node_id = (uint8_t)node_id;
|
||||
memcpy(config->target_ip, tokens[3], strlen(tokens[3]) + 1);
|
||||
config->target_port = (uint16_t)target_port;
|
||||
config->preserve_wifi = preserve_wifi;
|
||||
if (!preserve_wifi) {
|
||||
memcpy(config->ssid, ssid, ssid_length);
|
||||
config->ssid[ssid_length] = '\0';
|
||||
memcpy(config->password, password, password_length);
|
||||
config->password[password_length] = '\0';
|
||||
}
|
||||
return RUVIEW_ONBOARDING_OK;
|
||||
}
|
||||
|
||||
const char *ruview_onboarding_result_name(ruview_onboarding_result_t result)
|
||||
{
|
||||
switch (result) {
|
||||
case RUVIEW_ONBOARDING_OK: return "ok";
|
||||
case RUVIEW_ONBOARDING_ERR_FORMAT: return "format";
|
||||
case RUVIEW_ONBOARDING_ERR_NONCE: return "nonce";
|
||||
case RUVIEW_ONBOARDING_ERR_NODE_ID: return "node_id";
|
||||
case RUVIEW_ONBOARDING_ERR_TARGET_IP: return "target_ip";
|
||||
case RUVIEW_ONBOARDING_ERR_TARGET_PORT: return "target_port";
|
||||
case RUVIEW_ONBOARDING_ERR_SSID: return "ssid";
|
||||
case RUVIEW_ONBOARDING_ERR_PASSWORD: return "password";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#ifndef SERIAL_ONBOARDING_PROTOCOL_H
|
||||
#define SERIAL_ONBOARDING_PROTOCOL_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define RUVIEW_ONBOARDING_NONCE_HEX_LEN 32
|
||||
#define RUVIEW_ONBOARDING_SSID_MAX_BYTES 32
|
||||
#define RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES 63
|
||||
|
||||
typedef struct {
|
||||
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1];
|
||||
uint8_t node_id;
|
||||
char target_ip[16];
|
||||
uint16_t target_port;
|
||||
bool preserve_wifi;
|
||||
char ssid[RUVIEW_ONBOARDING_SSID_MAX_BYTES + 1];
|
||||
char password[RUVIEW_ONBOARDING_PASSWORD_MAX_BYTES + 1];
|
||||
} ruview_onboarding_config_t;
|
||||
|
||||
typedef enum {
|
||||
RUVIEW_ONBOARDING_OK = 0,
|
||||
RUVIEW_ONBOARDING_ERR_FORMAT,
|
||||
RUVIEW_ONBOARDING_ERR_NONCE,
|
||||
RUVIEW_ONBOARDING_ERR_NODE_ID,
|
||||
RUVIEW_ONBOARDING_ERR_TARGET_IP,
|
||||
RUVIEW_ONBOARDING_ERR_TARGET_PORT,
|
||||
RUVIEW_ONBOARDING_ERR_SSID,
|
||||
RUVIEW_ONBOARDING_ERR_PASSWORD,
|
||||
} ruview_onboarding_result_t;
|
||||
|
||||
int ruview_onboarding_parse_hello(const char *line,
|
||||
char nonce[RUVIEW_ONBOARDING_NONCE_HEX_LEN + 1]);
|
||||
|
||||
ruview_onboarding_result_t ruview_onboarding_parse_config(
|
||||
const char *line,
|
||||
ruview_onboarding_config_t *config);
|
||||
|
||||
const char *ruview_onboarding_result_name(ruview_onboarding_result_t result);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,25 @@
|
||||
# ESP32-C6 CSI Node - 16MB flash partition table
|
||||
# For boards with 16MB flash (e.g. ESP32-C6 DevKitC-1 N16).
|
||||
#
|
||||
# Two 4MB OTA slots. The app is ~1.04MB, so the slots are far larger than
|
||||
# needed for the binary alone -- the size is chosen so an OTA never has to
|
||||
# care how much the image has grown, and it leaves room for a coredump
|
||||
# partition and an 8MB FAT volume for on-node storage.
|
||||
#
|
||||
# The 4MB and display layouts do not fit this: their slots are 1.875MB and
|
||||
# 2MB, sized for 4MB and 8MB parts respectively.
|
||||
#
|
||||
# Usage: set in sdkconfig, or use the matching defaults file:
|
||||
# CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_16mb.csv"
|
||||
# idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.16mb" build
|
||||
#
|
||||
# Verify before flashing an already-deployed node: a smaller table written
|
||||
# over a 16MB node relocates NVS and destroys its provisioning.
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs,data,nvs,0x9000,24K,
|
||||
otadata,data,ota,0xf000,8K,
|
||||
phy_init,data,phy,0x11000,4K,
|
||||
ota_0,app,ota_0,0x20000,4M,
|
||||
ota_1,app,ota_1,0x420000,4M,
|
||||
coredump,data,coredump,0x820000,64K,
|
||||
storage,data,fat,0x830000,8000K,
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 44.
|
@@ -6,3 +6,4 @@ phy_init, data, phy, 0x11000, 0x1000,
|
||||
ota_0, app, ota_0, 0x20000, 0x200000,
|
||||
ota_1, app, ota_1, 0x220000, 0x200000,
|
||||
spiffs, data, spiffs, 0x420000, 0x1E0000,
|
||||
storage, data, fat, 0x600000, 0x200000,
|
||||
|
@@ -0,0 +1,4 @@
|
||||
126a4b9f305e207aa2e0db93caf09e27f77b3bee4b4f618a0cb963f10a4364bc bootloader.bin
|
||||
183c4557a93f6f0be1680f45988b7c7320130c8e0ed5d9c900add038d5cc84fb esp32-csi-node.bin
|
||||
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f ota_data_initial.bin
|
||||
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 partition-table.bin
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������Line truncated
|
||||
Binary file not shown.
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the gate factorial: U / M / S / MS, Latin-square blocked.
|
||||
|
||||
Arms (NVS gate_mode, applied live -- no reboot):
|
||||
0 U elapsed-time gate, as upstream ships it
|
||||
1 M mesh-aligned bucket gate (ours, 4b54ef8e)
|
||||
2 S rx_seq selection + elapsed underneath
|
||||
3 MS both stacked
|
||||
|
||||
Why it is built this way, and what each guard is for:
|
||||
|
||||
* Every arm is applied to the WHOLE fleet, never to a subset. The stopped
|
||||
2026-09-04 run compared four gated nodes against four different control
|
||||
nodes; nodes differ, and that comparison could not be read.
|
||||
|
||||
* After every switch the runner reads back `active.gate_mode` from EVERY node
|
||||
and refuses to sample unless all nine agree. "Config was stored" is not
|
||||
"gate is running" -- that gap is exactly what the previous run could not
|
||||
distinguish from a null result.
|
||||
|
||||
* Arms are short and blocked in a Latin square rather than run in long
|
||||
sequential batches, so slow drift lands on all arms equally instead of being
|
||||
aliased onto whichever arm ran first. Measured drift over 20 min windows is
|
||||
sd 0.0045; counting noise is +/-0.002.
|
||||
|
||||
* Metrics come from differenced cumulative counters, so the server is never
|
||||
restarted and the fleet stays in production throughout.
|
||||
|
||||
Output: arms_<label>.csv, one row per arm, in the fleet-baselines directory
|
||||
(outside git -- it carries presence information).
|
||||
"""
|
||||
import argparse, csv, json, os, sys, time, urllib.request
|
||||
|
||||
PORT = 8032
|
||||
SERVER = "http://127.0.0.1:3000"
|
||||
OUTDIR = "C:/temp/ruview/fleet-baselines/gate-experiment"
|
||||
|
||||
NODES = {0: "192.168.1.171", 1: "192.168.1.219", 2: "192.168.1.72",
|
||||
3: "192.168.1.112", 4: "192.168.1.144", 5: "192.168.1.198",
|
||||
6: "192.168.1.199", 7: "192.168.1.25", 8: "192.168.1.122"}
|
||||
|
||||
ARM_NAME = {0: "U_elapsed", 1: "M_mesh", 2: "S_seq", 3: "MS_both"}
|
||||
|
||||
# Latin square: every arm appears once per block, in a different position.
|
||||
BLOCKS = [[0, 1, 2, 3], [2, 3, 1, 0], [3, 0, 2, 1]]
|
||||
|
||||
|
||||
def key():
|
||||
p = os.environ.get("RUVIEW_OTA_PSK_FILE")
|
||||
if not p or not os.path.isfile(p):
|
||||
sys.exit("set RUVIEW_OTA_PSK_FILE to the psk file path")
|
||||
return open(p, encoding="utf-8").read().strip()
|
||||
|
||||
|
||||
def node_get(ip, path, k, timeout=10):
|
||||
r = urllib.request.Request(f"http://{ip}:{PORT}{path}")
|
||||
r.add_header("Authorization", "Bearer " + k)
|
||||
with urllib.request.urlopen(r, timeout=timeout) as resp:
|
||||
return json.load(resp)
|
||||
|
||||
|
||||
def node_post(ip, path, k, payload, timeout=15):
|
||||
body = json.dumps(payload).encode()
|
||||
r = urllib.request.Request(f"http://{ip}:{PORT}{path}", data=body)
|
||||
r.add_header("Authorization", "Bearer " + k)
|
||||
r.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(r, timeout=timeout) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def srv(path):
|
||||
with urllib.request.urlopen(SERVER + path, timeout=15) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def set_arm(mode, period, k):
|
||||
"""Apply an arm fleet-wide and PROVE every node is running it."""
|
||||
for nid, ip in sorted(NODES.items()):
|
||||
try:
|
||||
node_post(ip, "/config", k, {"gate_mode": mode, "gate_seq_period": period})
|
||||
except Exception as e:
|
||||
print(f" n{nid} set FAILED: {e}", file=sys.stderr)
|
||||
time.sleep(5)
|
||||
bad = []
|
||||
for nid, ip in sorted(NODES.items()):
|
||||
try:
|
||||
act = node_get(ip, "/config", k).get("active", {})
|
||||
if act.get("gate_mode") != mode or act.get("gate_seq_period") != period:
|
||||
bad.append((nid, act))
|
||||
except Exception as e:
|
||||
bad.append((nid, str(e)))
|
||||
return bad
|
||||
|
||||
|
||||
def snapshot():
|
||||
f = srv("/api/v1/fusion")
|
||||
m = srv("/api/v1/mesh")["nodes"]
|
||||
nodes = {}
|
||||
for k2, v in m.items():
|
||||
tx = v.get("health", {}).get("tx", {})
|
||||
nodes[k2] = {"acc": v.get("csi_fps_samples"), "ed": tx.get("early_drop"),
|
||||
"fps": v.get("csi_fps_ema"), "valid": v.get("is_valid")}
|
||||
pairs = {f"{p['a']}-{p['b']}": p["common"] for p in f.get("pairs", [])}
|
||||
return {"t": time.time(), "observations": f["observations"],
|
||||
"transmissions": f["transmissions"], "paired": f["paired"],
|
||||
"nodes": nodes, "pairs": pairs}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--label", default="factorial")
|
||||
ap.add_argument("--arm-minutes", type=float, default=20.0)
|
||||
ap.add_argument("--settle-minutes", type=float, default=3.0)
|
||||
ap.add_argument("--period", type=int, default=8)
|
||||
ap.add_argument("--blocks", type=int, default=3)
|
||||
a = ap.parse_args()
|
||||
|
||||
k = key()
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
out = os.path.join(OUTDIR, f"arms_{a.label}.csv")
|
||||
new = not os.path.exists(out)
|
||||
fh = open(out, "a", newline="", encoding="utf-8")
|
||||
w = csv.writer(fh)
|
||||
if new:
|
||||
w.writerow(["block", "arm", "arm_name", "period", "start_iso", "end_iso",
|
||||
"dur_s", "d_observations", "d_transmissions", "d_paired",
|
||||
"paired_fraction", "node_json", "pairs_json", "verified"])
|
||||
fh.flush()
|
||||
|
||||
print(f"gate factorial: {a.blocks} blocks x 4 arms x {a.arm_minutes:g} min "
|
||||
f"(+{a.settle_minutes:g} min settle), seq period {a.period}")
|
||||
print(f"est. total {a.blocks*4*(a.arm_minutes+a.settle_minutes)/60:.1f} h -> {out}\n")
|
||||
|
||||
for bi in range(a.blocks):
|
||||
order = BLOCKS[bi % len(BLOCKS)]
|
||||
print(f"--- block {bi}: {' '.join(ARM_NAME[m] for m in order)} ---")
|
||||
for mode in order:
|
||||
print(f"[{time.strftime('%H:%M:%S')}] arm {ARM_NAME[mode]} ...", flush=True)
|
||||
bad = set_arm(mode, a.period, k)
|
||||
if bad:
|
||||
print(f" WARNING not all nodes on arm: {bad}", file=sys.stderr)
|
||||
# Settle: the gate changes instantly, but csi_fps_ema and the
|
||||
# fusion pairing window carry the previous arm for a while.
|
||||
time.sleep(a.settle_minutes * 60)
|
||||
s0 = snapshot()
|
||||
time.sleep(a.arm_minutes * 60)
|
||||
s1 = snapshot()
|
||||
|
||||
dtr = s1["transmissions"] - s0["transmissions"]
|
||||
dp = s1["paired"] - s0["paired"]
|
||||
pf = dp / dtr if dtr > 0 else float("nan")
|
||||
nodes = {}
|
||||
for nid in s1["nodes"]:
|
||||
if nid in s0["nodes"]:
|
||||
dacc = (s1["nodes"][nid]["acc"] or 0) - (s0["nodes"][nid]["acc"] or 0)
|
||||
ded = (s1["nodes"][nid]["ed"] or 0) - (s0["nodes"][nid]["ed"] or 0)
|
||||
nodes[nid] = {"d_acc": dacc, "d_ed": ded,
|
||||
"R": round((dacc + ded) / dacc, 3) if dacc > 0 else None,
|
||||
"fps": round(s1["nodes"][nid]["fps"] or 0, 2),
|
||||
"valid": s1["nodes"][nid]["valid"]}
|
||||
dpairs = {kk: s1["pairs"].get(kk, 0) - s0["pairs"].get(kk, 0)
|
||||
for kk in s1["pairs"]}
|
||||
w.writerow([bi, mode, ARM_NAME[mode], a.period,
|
||||
time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(s0["t"])),
|
||||
time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(s1["t"])),
|
||||
round(s1["t"] - s0["t"]),
|
||||
s1["observations"] - s0["observations"], dtr, dp,
|
||||
f"{pf:.6f}",
|
||||
json.dumps(nodes, separators=(",", ":")),
|
||||
json.dumps(dpairs, separators=(",", ":")),
|
||||
"no" if bad else "yes"])
|
||||
fh.flush()
|
||||
meanR = [v["R"] for v in nodes.values() if v["R"]]
|
||||
print(f" paired_fraction={pf:.4f} transmissions={dtr} "
|
||||
f"meanR={sum(meanR)/len(meanR):.2f}" if meanR else "", flush=True)
|
||||
|
||||
fh.close()
|
||||
print("done")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,41 @@
|
||||
# ESP32-C6 CSI Node - 16MB Flash SDK Configuration
|
||||
# For boards with 16MB flash (e.g. ESP32-C6 DevKitC-1 N16).
|
||||
#
|
||||
# Layered on top of sdkconfig.defaults.esp32c6, which keeps a 4MB layout
|
||||
# because 4MB C6 devkits are the common case and must not be silently
|
||||
# repartitioned by inheriting this.
|
||||
#
|
||||
# Build:
|
||||
# idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.16mb" build
|
||||
#
|
||||
# Confirm before flashing a deployed node: /ota/status must report
|
||||
# max_size 4194304. Writing a smaller table over a 16MB node relocates NVS
|
||||
# and destroys its provisioning.
|
||||
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="16MB"
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_16mb.csv"
|
||||
|
||||
# Firmware rollback: an OTA'd image boots PENDING_VERIFY and must confirm
|
||||
# itself, or the bootloader reverts on the next boot. See ota_rollback_* in
|
||||
# ota_update.c for when confirmation happens and why.
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||
|
||||
# Coredump to flash. The 64K `coredump` partition has existed in
|
||||
# partitions_16mb.csv since the 16MB layout was written and has never held
|
||||
# anything, because the build defaulted to
|
||||
# CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y. A panic therefore left no stack trace
|
||||
# on a fleet whose faults are all remote.
|
||||
#
|
||||
# Complementary to the on-node log, not a substitute: a coredump captures a
|
||||
# CRASH, and the log's boot record captures a HANG followed by a watchdog
|
||||
# reset -- which is the failure this fleet actually sees.
|
||||
CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y
|
||||
CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y
|
||||
CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y
|
||||
CONFIG_ESP_COREDUMP_LOGS=y
|
||||
|
||||
# The `storage` FAT volume (8000K) that node_log.c mounts. Reserved in the
|
||||
# same partition table and likewise never used until now.
|
||||
CONFIG_WL_SECTOR_SIZE_4096=y
|
||||
@@ -0,0 +1,4 @@
|
||||
# Seeed XIAO ESP32-C6 external U.FL antenna test overlay.
|
||||
# Use only with the XIAO board; generic C6 boards may use GPIO3/GPIO14.
|
||||
CONFIG_C6_XIAO_ANTENNA_SELECT=y
|
||||
CONFIG_C6_XIAO_ANTENNA_EXTERNAL=y
|
||||
@@ -0,0 +1,4 @@
|
||||
# Seeed XIAO ESP32-C6 internal ceramic antenna test overlay.
|
||||
# Keeps RF-switch control enabled so the boot log proves the selected path.
|
||||
CONFIG_C6_XIAO_ANTENNA_SELECT=y
|
||||
CONFIG_C6_XIAO_ANTENNA_INTERNAL=y
|
||||
@@ -39,16 +39,45 @@ CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \
|
||||
STUBS_SRC = stubs/esp_stubs.c
|
||||
MAIN_DIR = ../main
|
||||
|
||||
# GNU ld and Apple's ld spell dead section elimination differently.
|
||||
# Keep the host sanitation test portable across the Linux CI runner and macOS.
|
||||
UNAME_S := $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
DEAD_STRIP_LDFLAGS := -Wl,-dead_strip
|
||||
else
|
||||
DEAD_STRIP_LDFLAGS := -Wl,--gc-sections
|
||||
endif
|
||||
|
||||
# Default fuzz duration (seconds) and jobs
|
||||
FUZZ_DURATION ?= 30
|
||||
FUZZ_JOBS ?= 1
|
||||
|
||||
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
|
||||
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
|
||||
test_thermal run_thermal
|
||||
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
|
||||
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
|
||||
# --- XIAO ESP32-C6 RF switch selection ---
|
||||
# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven
|
||||
# low by the device implementation before this level is applied.
|
||||
test_c6_antenna: test_c6_antenna_select.c $(MAIN_DIR)/c6_antenna_select.h
|
||||
cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $<
|
||||
|
||||
run_c6_antenna: test_c6_antenna
|
||||
./test_c6_antenna
|
||||
|
||||
# --- ESP-IDF invalid first-word sanitation ---
|
||||
# Links the production serializer. Function sections and dead stripping keep
|
||||
# unrelated device-only callback code out of this host executable.
|
||||
test_csi_sanitize: test_csi_sanitize.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC)
|
||||
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -ffunction-sections \
|
||||
$^ $(DEAD_STRIP_LDFLAGS) -o $@ -lm
|
||||
|
||||
run_csi_sanitize: test_csi_sanitize
|
||||
./test_csi_sanitize
|
||||
|
||||
# --- ADR-110 encoding unit tests ---
|
||||
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
|
||||
@@ -93,8 +122,19 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
|
||||
run_thermal: test_thermal
|
||||
./test_thermal
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal
|
||||
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135 + thermal)"
|
||||
# --- Native Mac USB onboarding protocol ---
|
||||
# Tests the production parser and secret validation without ESP-IDF. This pins
|
||||
# nonce shape, IPv4 and node bounds, base64 handling, and control-byte denial.
|
||||
test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.h
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic -I$(MAIN_DIR) -o $@ \
|
||||
test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c
|
||||
|
||||
run_serial_onboarding: test_serial_onboarding
|
||||
./test_serial_onboarding
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
|
||||
run_c6_antenna run_serial_onboarding
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding)"
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
@@ -130,5 +170,6 @@ run_nvs: fuzz_nvs
|
||||
run_all: run_serialize run_edge run_nvs
|
||||
|
||||
clean:
|
||||
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
|
||||
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
|
||||
@@ -90,6 +90,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
|
||||
info.rx_ctrl.sig_mode = legacy_inputs & 0x03;
|
||||
info.rx_ctrl.cwb = (legacy_inputs >> 2) & 0x01;
|
||||
info.rx_ctrl.stbc = (legacy_inputs >> 3) & 0x01;
|
||||
info.first_word_invalid = (test_case & 0x80) != 0;
|
||||
|
||||
/* Use remaining fuzz data as I/Q buffer content. */
|
||||
uint16_t iq_len;
|
||||
@@ -136,6 +137,17 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
|
||||
if (result > out_len) {
|
||||
__builtin_trap(); /* Buffer overflow detected. */
|
||||
}
|
||||
if (result >= CSI_HEADER_SIZE + iq_len && info.first_word_invalid && iq_len > 0) {
|
||||
size_t invalid_len = iq_len < 4 ? iq_len : 4;
|
||||
for (size_t i = 0; i < invalid_len; i++) {
|
||||
if (out_buf[CSI_HEADER_SIZE + i] != 0) {
|
||||
__builtin_trap(); /* Invalid hardware prefix escaped sanitation. */
|
||||
}
|
||||
}
|
||||
if ((out_buf[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0) {
|
||||
__builtin_trap(); /* Sanitized payload must carry provenance. */
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Test case 1: NULL info pointer --- */
|
||||
if (test_case & 0x01) {
|
||||
|
||||
@@ -90,6 +90,7 @@ typedef struct {
|
||||
typedef struct {
|
||||
wifi_pkt_rx_ctrl_t rx_ctrl;
|
||||
uint8_t mac[6];
|
||||
bool first_word_invalid;
|
||||
int16_t len; /**< Length of the I/Q buffer in bytes. */
|
||||
int8_t *buf; /**< Pointer to I/Q data. */
|
||||
} wifi_csi_info_t;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "c6_antenna_select.h"
|
||||
|
||||
int main(void)
|
||||
{
|
||||
assert(c6_xiao_antenna_gpio14_level(C6_XIAO_ANTENNA_INTERNAL) == 0);
|
||||
assert(c6_xiao_antenna_gpio14_level(C6_XIAO_ANTENNA_EXTERNAL) == 1);
|
||||
|
||||
puts("PASS: XIAO ESP32-C6 antenna selection truth table");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** Deterministic host test for ESP-IDF first_word_invalid sanitation. */
|
||||
|
||||
#include "esp_stubs.h"
|
||||
#include "nvs_config.h"
|
||||
#include "csi_collector.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
nvs_config_t g_nvs_config;
|
||||
|
||||
static void make_info(wifi_csi_info_t *info, int8_t *iq, uint16_t len, bool invalid)
|
||||
{
|
||||
memset(info, 0, sizeof(*info));
|
||||
info->rx_ctrl.channel = 6;
|
||||
info->rx_ctrl.rssi = -42;
|
||||
info->rx_ctrl.noise_floor = -91;
|
||||
info->len = (int16_t)len;
|
||||
info->buf = iq;
|
||||
info->first_word_invalid = invalid;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
int8_t iq[] = {1, 2, 3, 4, 5, 6, 7, 8};
|
||||
uint8_t frame[CSI_MAX_FRAME_SIZE];
|
||||
wifi_csi_info_t info;
|
||||
|
||||
make_info(&info, iq, sizeof(iq), true);
|
||||
size_t len = csi_serialize_frame(&info, frame, sizeof(frame));
|
||||
assert(len == CSI_HEADER_SIZE + sizeof(iq));
|
||||
assert(frame[CSI_HEADER_SIZE] == 0);
|
||||
assert(frame[CSI_HEADER_SIZE + 1] == 0);
|
||||
assert(frame[CSI_HEADER_SIZE + 2] == 0);
|
||||
assert(frame[CSI_HEADER_SIZE + 3] == 0);
|
||||
assert(frame[CSI_HEADER_SIZE + 4] == 5);
|
||||
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0);
|
||||
|
||||
make_info(&info, iq, sizeof(iq), false);
|
||||
len = csi_serialize_frame(&info, frame, sizeof(frame));
|
||||
assert(len == CSI_HEADER_SIZE + sizeof(iq));
|
||||
assert(memcmp(&frame[CSI_HEADER_SIZE], iq, sizeof(iq)) == 0);
|
||||
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) == 0);
|
||||
|
||||
int8_t short_iq[] = {9, 10};
|
||||
make_info(&info, short_iq, sizeof(short_iq), true);
|
||||
len = csi_serialize_frame(&info, frame, sizeof(frame));
|
||||
assert(len == CSI_HEADER_SIZE + sizeof(short_iq));
|
||||
assert(frame[CSI_HEADER_SIZE] == 0);
|
||||
assert(frame[CSI_HEADER_SIZE + 1] == 0);
|
||||
assert((frame[19] & CSI_FLAG_FIRST_WORD_SANITIZED) != 0);
|
||||
|
||||
puts("CSI invalid-prefix sanitation: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "../main/serial_onboarding_protocol.h"
|
||||
|
||||
static void test_hello(void)
|
||||
{
|
||||
char nonce[33] = {0};
|
||||
assert(ruview_onboarding_parse_hello(
|
||||
"RUVIEW_HELLO_V1 00112233445566778899aabbccddeeff", nonce));
|
||||
assert(strcmp(nonce, "00112233445566778899aabbccddeeff") == 0);
|
||||
assert(!ruview_onboarding_parse_hello("RUVIEW_HELLO_V1 bad", nonce));
|
||||
assert(!ruview_onboarding_parse_hello(
|
||||
"RUVIEW_HELLO_V1 00112233445566778899aabbccddeeff extra", nonce));
|
||||
}
|
||||
|
||||
static void test_config(void)
|
||||
{
|
||||
ruview_onboarding_config_t config;
|
||||
assert(ruview_onboarding_parse_config(
|
||||
"RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff 5 192.168.1.166 5005 "
|
||||
"UnVWaWV3IExhYg== U3Ryb25nUGFzc3dvcmQ=", &config) == RUVIEW_ONBOARDING_OK);
|
||||
assert(config.node_id == 5);
|
||||
assert(config.target_port == 5005);
|
||||
assert(strcmp(config.target_ip, "192.168.1.166") == 0);
|
||||
assert(strcmp(config.ssid, "RuView Lab") == 0);
|
||||
assert(strcmp(config.password, "StrongPassword") == 0);
|
||||
assert(!config.preserve_wifi);
|
||||
assert(ruview_onboarding_parse_config(
|
||||
"RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff 6 192.168.1.166 5005 KEEP KEEP",
|
||||
&config) == RUVIEW_ONBOARDING_OK);
|
||||
assert(config.preserve_wifi);
|
||||
}
|
||||
|
||||
static void test_rejections(void)
|
||||
{
|
||||
ruview_onboarding_config_t config;
|
||||
const char *prefix = "RUVIEW_CONFIG_V1 00112233445566778899aabbccddeeff";
|
||||
char line[512];
|
||||
snprintf(line, sizeof(line), "%s 0 192.168.1.2 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_NODE_ID);
|
||||
snprintf(line, sizeof(line), "%s 5 999.1.1.1 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP);
|
||||
snprintf(line, sizeof(line), "%s 5 8.8.8.8 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP);
|
||||
snprintf(line, sizeof(line), "%s 5 172.32.0.1 5005 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_IP);
|
||||
snprintf(line, sizeof(line), "%s 5 192.168.1.2 0 UnVWaWV3 U3Ryb25nUGFzc3dvcmQ=", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_TARGET_PORT);
|
||||
snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 !!!! U3Ryb25nUGFzc3dvcmQ=", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_SSID);
|
||||
snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 UnVWaWV3 c2hvcnQ=", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_PASSWORD);
|
||||
snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 UnVWaWV3 UGFzc3dvcmQxCg==", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_PASSWORD);
|
||||
snprintf(line, sizeof(line), "%s 5 192.168.1.2 5005 KEEP U3Ryb25nUGFzc3dvcmQ=", prefix);
|
||||
assert(ruview_onboarding_parse_config(line, &config) == RUVIEW_ONBOARDING_ERR_FORMAT);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_hello();
|
||||
test_config();
|
||||
test_rejections();
|
||||
puts("serial onboarding protocol tests passed");
|
||||
return 0;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.8.8
|
||||
0.8.12
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -234,9 +234,30 @@ impl StreamingEngine {
|
||||
/// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed
|
||||
/// trust cycles even after widening the guard elsewhere.
|
||||
///
|
||||
/// Rebuilds the fuser, so call before any frames are processed.
|
||||
/// Rebuilds the fuser, so call before any frames are processed. Preserves
|
||||
/// any node positions configured via [`Self::set_node_positions`] across
|
||||
/// the rebuild (issue #1870) — a caller that configures positions once at
|
||||
/// startup and calls this later (e.g. after deriving a TDM schedule) must
|
||||
/// not have that configuration silently discarded.
|
||||
pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) {
|
||||
let positions = self.fuser.node_positions_by_id().clone();
|
||||
self.fuser = MultistaticFuser::with_config(cfg);
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Configure node positions for the governed multistatic fuser, keyed by
|
||||
/// node id (issue #1870). Without this, `StreamingEngine`'s fuser — the
|
||||
/// one behind `fuse_scored_calibrated`, which feeds provenance and the
|
||||
/// witness — always fuses with every node at the origin, regardless of
|
||||
/// deployment configuration, because nothing ever called this on it. This
|
||||
/// is a separate `MultistaticFuser` instance from the one
|
||||
/// `wifi-densepose-sensing-server`'s `AppState` owns (fixed for that
|
||||
/// instance in #1860); each fuser instance needs positions set on it
|
||||
/// independently. Safe to call before or after [`Self::set_multistatic_config`] —
|
||||
/// order does not matter, since that method now preserves whatever
|
||||
/// positions are already set across its rebuild.
|
||||
pub fn set_node_positions(&mut self, positions: std::collections::HashMap<u8, [f32; 3]>) {
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
@@ -1359,4 +1380,64 @@ mod tests {
|
||||
"an extra evidence ref must not collide with a model_version that absorbs it"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Issue #1870: StreamingEngine's own fuser never received positions ----
|
||||
|
||||
/// The governed path (`fuse_scored_calibrated`, feeding provenance and the
|
||||
/// witness) must carry each node's configured position -- not the origin
|
||||
/// for every node, which is what happened before `set_node_positions`
|
||||
/// existed on `StreamingEngine` and nothing ever configured this fuser
|
||||
/// instance (distinct from the one `wifi-densepose-sensing-server`'s
|
||||
/// `AppState` owns, fixed separately in #1860).
|
||||
#[test]
|
||||
fn governed_fuse_carries_each_nodes_configured_position() {
|
||||
let (mut e, _room) = engine();
|
||||
let mut positions = std::collections::HashMap::new();
|
||||
positions.insert(0u8, [1.0, 2.0, 3.0]);
|
||||
positions.insert(1u8, [4.0, 5.0, 6.0]);
|
||||
e.set_node_positions(positions.clone());
|
||||
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
let (fused, _score) = e
|
||||
.fuser
|
||||
.fuse_scored_calibrated(&frames, &[None, None], StreamingEngine::DEFAULT_COHERENCE_ACCEPT)
|
||||
.expect("cohort fuses");
|
||||
|
||||
assert_eq!(fused.node_positions.len(), frames.len());
|
||||
for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) {
|
||||
let expected = positions
|
||||
.get(&frame.node_id)
|
||||
.copied()
|
||||
.expect("every configured node has a position in this test");
|
||||
assert_eq!(
|
||||
*position, expected,
|
||||
"node {} received the wrong position on the governed path",
|
||||
frame.node_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `set_multistatic_config` used to rebuild the fuser from scratch,
|
||||
/// silently discarding any positions configured before it. A caller that
|
||||
/// sets positions once at startup and derives/applies a TDM-schedule
|
||||
/// config later (a real, documented ordering per #1049/#1057) must not
|
||||
/// lose them.
|
||||
#[test]
|
||||
fn positions_survive_a_later_set_multistatic_config() {
|
||||
let (mut e, _room) = engine();
|
||||
let mut positions = std::collections::HashMap::new();
|
||||
positions.insert(0u8, [7.0, 8.0, 9.0]);
|
||||
e.set_node_positions(positions.clone());
|
||||
|
||||
e.set_multistatic_config(MultistaticConfig {
|
||||
guard_interval_us: 30_000,
|
||||
..MultistaticConfig::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
e.fuser.node_position(0),
|
||||
Some([7.0, 8.0, 9.0]),
|
||||
"set_multistatic_config must not discard previously-configured positions"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Benchmarks for ADR-323 Realtek RTL8721Dx (AmebaDplus) CSI encode/decode
|
||||
//! throughput, at the real hardware's own reported dimensions (52
|
||||
//! subcarriers, 16-bit tone precision — see `example/wifi/wifi_csi/README.md`
|
||||
//! in the vendor SDK).
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use wifi_densepose_hardware::realtek_csi::simulator::{RealtekCsiSimulator, SimulatorConfig};
|
||||
use wifi_densepose_hardware::realtek_csi::CsiFrame;
|
||||
|
||||
fn bench_to_bytes_8bit(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let frame = sim.next_frame();
|
||||
c.bench_function("realtek_csi_to_bytes_52sc_8bit", |b| {
|
||||
b.iter(|| black_box(frame.to_bytes().unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_from_bytes_8bit(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let wire = sim.next_frame().to_bytes().unwrap();
|
||||
c.bench_function("realtek_csi_from_bytes_52sc_8bit", |b| {
|
||||
b.iter(|| black_box(CsiFrame::from_bytes(black_box(&wire)).unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_to_bytes_16bit(c: &mut Criterion) {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let frame = sim.next_frame();
|
||||
c.bench_function("realtek_csi_to_bytes_52sc_16bit", |b| {
|
||||
b.iter(|| black_box(frame.to_bytes().unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_from_bytes_16bit(c: &mut Criterion) {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let wire = sim.next_frame().to_bytes().unwrap();
|
||||
c.bench_function("realtek_csi_from_bytes_52sc_16bit", |b| {
|
||||
b.iter(|| black_box(CsiFrame::from_bytes(black_box(&wire)).unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_round_trip_full_datagram(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
c.bench_function("realtek_csi_round_trip_52sc_8bit", |b| {
|
||||
b.iter(|| {
|
||||
let frame = sim.next_frame();
|
||||
let wire = black_box(frame.to_bytes().unwrap());
|
||||
black_box(CsiFrame::from_bytes(&wire).unwrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_to_bytes_8bit,
|
||||
bench_from_bytes_8bit,
|
||||
bench_to_bytes_16bit,
|
||||
bench_from_bytes_16bit,
|
||||
bench_round_trip_full_datagram,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Deterministic Realtek RTL8721Dx (AmebaDplus) 1x1 CSI simulator (ADR-323).
|
||||
|
||||
use clap::Parser;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
net::{SocketAddr, UdpSocket},
|
||||
path::PathBuf,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use wifi_densepose_hardware::realtek_csi::{
|
||||
simulator::{RealtekCsiSimulator, SimulatorConfig},
|
||||
CsiFrame, CsiMode,
|
||||
};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "realtek-csi-sim",
|
||||
about = "Emit synthetic ADR-323 Realtek RTL8721Dx CSI frames"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(long, default_value_t = 100)]
|
||||
frames: u32,
|
||||
#[arg(long, default_value = "0x5241433152544c31", value_parser=parse_u64)]
|
||||
seed: u64,
|
||||
#[arg(long, default_value_t = 6)]
|
||||
channel: u8,
|
||||
#[arg(long)]
|
||||
bandwidth_40mhz: bool,
|
||||
#[arg(long, default_value_t = 52)]
|
||||
subcarriers: u16,
|
||||
#[arg(long, default_value_t = 16)]
|
||||
tone_bits: u8,
|
||||
#[arg(long, default_value_t = 1)]
|
||||
decimation: u8,
|
||||
#[arg(long, default_value_t = 64)]
|
||||
interval_ms: u64,
|
||||
#[arg(long)]
|
||||
udp: Option<SocketAddr>,
|
||||
/// Replay: little-endian u32 length followed by one ADR-323 envelope.
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
realtime: bool,
|
||||
}
|
||||
|
||||
fn parse_u64(v: &str) -> Result<u64, String> {
|
||||
if let Some(h) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
|
||||
u64::from_str_radix(h, 16).map_err(|e| e.to_string())
|
||||
} else {
|
||||
v.parse()
|
||||
.map_err(|e: std::num::ParseIntError| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(
|
||||
frame: CsiFrame,
|
||||
socket: Option<&UdpSocket>,
|
||||
destination: Option<SocketAddr>,
|
||||
output: &mut Option<File>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let wire = frame.to_bytes()?;
|
||||
if let (Some(s), Some(d)) = (socket, destination) {
|
||||
if s.send_to(&wire, d)? != wire.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::WriteZero, "partial UDP datagram").into());
|
||||
}
|
||||
}
|
||||
if let Some(f) = output {
|
||||
f.write_all(&(wire.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&wire)?;
|
||||
}
|
||||
Ok(wire.len())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let a = Args::parse();
|
||||
if a.udp.is_none() && a.output.is_none() {
|
||||
return Err("select at least one sink with --udp or --output".into());
|
||||
}
|
||||
let cfg = SimulatorConfig {
|
||||
seed: a.seed,
|
||||
csi_mode: CsiMode::Passive,
|
||||
channel: a.channel,
|
||||
bandwidth: if a.bandwidth_40mhz { 1 } else { 0 },
|
||||
num_sub_carrier: a.subcarriers,
|
||||
num_bit_per_tone: a.tone_bits,
|
||||
decimation: a.decimation,
|
||||
frame_period_us: (a.interval_ms * 1000) as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg)?;
|
||||
let socket = a.udp.map(|_| UdpSocket::bind("0.0.0.0:0")).transpose()?;
|
||||
let mut output = a.output.as_ref().map(File::create).transpose()?;
|
||||
let mut bytes = 0usize;
|
||||
for _ in 0..a.frames {
|
||||
bytes += emit(sim.next_frame(), socket.as_ref(), a.udp, &mut output)?;
|
||||
if a.realtime {
|
||||
thread::sleep(Duration::from_millis(a.interval_ms));
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"emitted {} synthetic Realtek RTL8721Dx CSI frames ({} bytes, channel={}, subcarriers={}, tone_bits={}, seed={:#x})",
|
||||
a.frames, bytes, a.channel, a.subcarriers, a.tone_bits, a.seed
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -145,13 +145,16 @@ impl PpduType {
|
||||
/// bit 2 : STBC
|
||||
/// bit 3 : LDPC (reserved — not yet populated by firmware)
|
||||
/// bit 4 : 802.15.4 time-sync valid (C6 only)
|
||||
/// bit 5-7 : reserved
|
||||
/// bit 5 : ESP-IDF invalid first CSI word was zeroed by firmware
|
||||
/// bit 6-7 : reserved
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Adr018Flags {
|
||||
pub bw40: bool,
|
||||
pub stbc: bool,
|
||||
pub ldpc: bool,
|
||||
pub ieee802154_sync_valid: bool,
|
||||
#[serde(default)]
|
||||
pub first_word_sanitized: bool,
|
||||
}
|
||||
|
||||
impl Adr018Flags {
|
||||
@@ -161,6 +164,7 @@ impl Adr018Flags {
|
||||
stbc: (b & 0x04) != 0,
|
||||
ldpc: (b & 0x08) != 0,
|
||||
ieee802154_sync_valid: (b & 0x10) != 0,
|
||||
first_word_sanitized: (b & 0x20) != 0,
|
||||
}
|
||||
}
|
||||
pub fn to_byte(self) -> u8 {
|
||||
@@ -169,6 +173,7 @@ impl Adr018Flags {
|
||||
if self.stbc { b |= 0x04; }
|
||||
if self.ldpc { b |= 0x08; }
|
||||
if self.ieee802154_sync_valid { b |= 0x10; }
|
||||
if self.first_word_sanitized { b |= 0x20; }
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,15 +413,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn adr110_flags_round_trip_all_bits() {
|
||||
// All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
|
||||
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D);
|
||||
// All known flag bits set: bw40 + STBC + LDPC + sync + sanitized prefix = 0x3D.
|
||||
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x3D);
|
||||
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
assert!(frame.metadata.adr018_flags.bw40);
|
||||
assert!(frame.metadata.adr018_flags.stbc);
|
||||
assert!(frame.metadata.adr018_flags.ldpc);
|
||||
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
|
||||
assert!(frame.metadata.adr018_flags.first_word_sanitized);
|
||||
// Round-trip the encoder
|
||||
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D);
|
||||
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x3D);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,6 +62,9 @@ pub mod qualcomm_csi;
|
||||
/// ADR-264 host-side framing for Realtek RTL8720F CFR and FMCW radar reports.
|
||||
/// This module has no dependency on the vendor SDK.
|
||||
pub mod rtl8720f;
|
||||
/// ADR-323 vendor-neutral Realtek RTL8721Dx (AmebaDplus) 1x1 CSI framing and
|
||||
/// simulator. This module has no dependency on the vendor SDK.
|
||||
pub mod realtek_csi;
|
||||
|
||||
pub use bridge::CsiData;
|
||||
pub use csi_frame::{
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
//! Vendor-neutral Realtek RTL8721Dx (AmebaDplus) 1x1 CSI transport and
|
||||
//! deterministic simulator. This is not the vendor SDK's raw
|
||||
//! `rtw_event_csi_report_info` ABI (no CRC, C flexible-array payload); see
|
||||
//! ADR-323 for the field-by-field mapping and rationale.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
pub const RAC1_MAGIC: u32 = 0x3143_4152; // "RAC1" little endian
|
||||
pub const RAC1_VERSION: u8 = 1;
|
||||
pub const RAC1_HEADER_LEN: usize = 49;
|
||||
pub const RAC1_CRC_LEN: usize = 4;
|
||||
pub const RAC1_MAX_FRAME_LEN: usize = 65_507;
|
||||
/// Upper bound on subcarriers a 1x1 20/40 MHz OFDM/HT/VHT/HE capture can
|
||||
/// plausibly report; guards `to_bytes`/`from_bytes` against runaway sizes.
|
||||
pub const RAC1_MAX_SUBCARRIERS: usize = 4_096;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum CsiMode {
|
||||
Passive = 0,
|
||||
Active = 1,
|
||||
BoardToBoard = 2,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for CsiMode {
|
||||
type Error = CsiParseError;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Passive),
|
||||
1 => Ok(Self::Active),
|
||||
2 => Ok(Self::BoardToBoard),
|
||||
_ => Err(CsiParseError::UnknownCsiMode(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum ProtocolMode {
|
||||
Ofdm = 0,
|
||||
Ht = 1,
|
||||
Vht = 2,
|
||||
He = 3,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for ProtocolMode {
|
||||
type Error = CsiParseError;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Ofdm),
|
||||
1 => Ok(Self::Ht),
|
||||
2 => Ok(Self::Vht),
|
||||
3 => Ok(Self::He),
|
||||
_ => Err(CsiParseError::UnknownProtocolMode(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CsiFlags(pub u8);
|
||||
|
||||
impl CsiFlags {
|
||||
pub const SYNTHETIC: u8 = 1 << 0;
|
||||
pub fn contains(self, flag: u8) -> bool {
|
||||
self.0 & flag != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiFrame {
|
||||
/// Disambiguates multi-board illuminator/receiver deployments; 0 = unset.
|
||||
pub node_id: u8,
|
||||
pub csi_mode: CsiMode,
|
||||
pub sequence: u32,
|
||||
/// Hardware-clock microseconds; wraps roughly every 71 minutes. Consumers
|
||||
/// must reorder by `sequence`, not raw timestamp arithmetic, across a wrap.
|
||||
pub timestamp_us: u32,
|
||||
pub peer_mac: [u8; 6],
|
||||
pub trig_mac: [u8; 6],
|
||||
pub channel: u8,
|
||||
/// 0 = 20 MHz, 1 = 40 MHz.
|
||||
pub bandwidth: u8,
|
||||
pub rx_rate: u8,
|
||||
pub protocol_mode: ProtocolMode,
|
||||
pub num_sub_carrier: u16,
|
||||
/// 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q).
|
||||
pub num_bit_per_tone: u8,
|
||||
/// Tone group: 1/2/4/8 (every Nth tone reported).
|
||||
pub decimation: u8,
|
||||
/// Single RF chain (1x1 hardware).
|
||||
pub rssi_dbm: i8,
|
||||
pub rxsc: u8,
|
||||
pub csi_valid: bool,
|
||||
pub flags: CsiFlags,
|
||||
/// One (I, Q) pair per reported subcarrier, widened to i16 regardless of
|
||||
/// wire precision (an 8-bit wire value fits losslessly).
|
||||
pub payload: Vec<(i16, i16)>,
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
fn component_bytes(&self) -> Result<usize, CsiParseError> {
|
||||
match self.num_bit_per_tone {
|
||||
16 => Ok(1),
|
||||
32 => Ok(2),
|
||||
other => Err(CsiParseError::InvalidToneBits(other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, CsiParseError> {
|
||||
self.validate()?;
|
||||
let comp_bytes = self.component_bytes()?;
|
||||
let payload_len = self
|
||||
.payload
|
||||
.len()
|
||||
.checked_mul(comp_bytes * 2)
|
||||
.ok_or(CsiParseError::LengthOverflow)?;
|
||||
let frame_len = RAC1_HEADER_LEN
|
||||
.checked_add(payload_len)
|
||||
.and_then(|n| n.checked_add(RAC1_CRC_LEN))
|
||||
.ok_or(CsiParseError::LengthOverflow)?;
|
||||
if frame_len > RAC1_MAX_FRAME_LEN {
|
||||
return Err(CsiParseError::FrameTooLarge(frame_len));
|
||||
}
|
||||
let mut out = Vec::with_capacity(frame_len);
|
||||
out.extend_from_slice(&RAC1_MAGIC.to_le_bytes());
|
||||
out.push(RAC1_VERSION);
|
||||
out.extend_from_slice(&(RAC1_HEADER_LEN as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(frame_len as u32).to_le_bytes());
|
||||
out.push(self.node_id);
|
||||
out.push(self.csi_mode as u8);
|
||||
out.extend_from_slice(&self.sequence.to_le_bytes());
|
||||
out.extend_from_slice(&self.timestamp_us.to_le_bytes());
|
||||
out.extend_from_slice(&self.peer_mac);
|
||||
out.extend_from_slice(&self.trig_mac);
|
||||
out.push(self.channel);
|
||||
out.push(self.bandwidth);
|
||||
out.push(self.rx_rate);
|
||||
out.push(self.protocol_mode as u8);
|
||||
out.extend_from_slice(&self.num_sub_carrier.to_le_bytes());
|
||||
out.push(self.num_bit_per_tone);
|
||||
out.push(self.decimation);
|
||||
out.push(self.rssi_dbm as u8);
|
||||
out.push(self.rxsc);
|
||||
out.push(self.csi_valid as u8);
|
||||
out.push(self.flags.0);
|
||||
out.extend_from_slice(&(payload_len as u32).to_le_bytes());
|
||||
debug_assert_eq!(out.len(), RAC1_HEADER_LEN);
|
||||
for (i, q) in &self.payload {
|
||||
if comp_bytes == 1 {
|
||||
out.push(*i as i8 as u8);
|
||||
out.push(*q as i8 as u8);
|
||||
} else {
|
||||
out.extend_from_slice(&i.to_le_bytes());
|
||||
out.extend_from_slice(&q.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&crc32_ieee(&out).to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn from_bytes(input: &[u8]) -> Result<(Self, usize), CsiParseError> {
|
||||
if input.len() < RAC1_HEADER_LEN {
|
||||
return Err(CsiParseError::InsufficientData {
|
||||
needed: RAC1_HEADER_LEN,
|
||||
got: input.len(),
|
||||
});
|
||||
}
|
||||
let magic = u32_at(input, 0);
|
||||
if magic != RAC1_MAGIC {
|
||||
return Err(CsiParseError::InvalidMagic(magic));
|
||||
}
|
||||
if input[4] != RAC1_VERSION {
|
||||
return Err(CsiParseError::UnsupportedVersion(input[4]));
|
||||
}
|
||||
let header_len = u16_at(input, 5) as usize;
|
||||
if header_len != RAC1_HEADER_LEN {
|
||||
return Err(CsiParseError::InvalidHeaderLength(header_len));
|
||||
}
|
||||
let frame_len = u32_at(input, 7) as usize;
|
||||
if frame_len > RAC1_MAX_FRAME_LEN {
|
||||
return Err(CsiParseError::FrameTooLarge(frame_len));
|
||||
}
|
||||
if frame_len < header_len + RAC1_CRC_LEN {
|
||||
return Err(CsiParseError::InvalidFrameLength(frame_len));
|
||||
}
|
||||
if input.len() < frame_len {
|
||||
return Err(CsiParseError::InsufficientData {
|
||||
needed: frame_len,
|
||||
got: input.len(),
|
||||
});
|
||||
}
|
||||
let expected_crc = u32_at(input, frame_len - 4);
|
||||
let actual_crc = crc32_ieee(&input[..frame_len - 4]);
|
||||
if expected_crc != actual_crc {
|
||||
return Err(CsiParseError::CrcMismatch {
|
||||
expected: expected_crc,
|
||||
actual: actual_crc,
|
||||
});
|
||||
}
|
||||
|
||||
let node_id = input[11];
|
||||
let csi_mode = CsiMode::try_from(input[12])?;
|
||||
let sequence = u32_at(input, 13);
|
||||
let timestamp_us = u32_at(input, 17);
|
||||
let mut peer_mac = [0u8; 6];
|
||||
peer_mac.copy_from_slice(&input[21..27]);
|
||||
let mut trig_mac = [0u8; 6];
|
||||
trig_mac.copy_from_slice(&input[27..33]);
|
||||
let channel = input[33];
|
||||
let bandwidth = input[34];
|
||||
let rx_rate = input[35];
|
||||
let protocol_mode = ProtocolMode::try_from(input[36])?;
|
||||
let num_sub_carrier = u16_at(input, 37);
|
||||
let num_bit_per_tone = input[39];
|
||||
let decimation = input[40];
|
||||
let rssi_dbm = input[41] as i8;
|
||||
let rxsc = input[42];
|
||||
let csi_valid = input[43] != 0;
|
||||
let flags = CsiFlags(input[44]);
|
||||
let payload_len = u32_at(input, 45) as usize;
|
||||
|
||||
if header_len + payload_len + RAC1_CRC_LEN != frame_len {
|
||||
return Err(CsiParseError::PayloadLengthMismatch);
|
||||
}
|
||||
let comp_bytes = match num_bit_per_tone {
|
||||
16 => 1usize,
|
||||
32 => 2usize,
|
||||
other => return Err(CsiParseError::InvalidToneBits(other)),
|
||||
};
|
||||
let expected_payload_len = (num_sub_carrier as usize)
|
||||
.checked_mul(comp_bytes * 2)
|
||||
.ok_or(CsiParseError::LengthOverflow)?;
|
||||
if payload_len != expected_payload_len {
|
||||
return Err(CsiParseError::PayloadLengthMismatch);
|
||||
}
|
||||
let payload_bytes = &input[header_len..header_len + payload_len];
|
||||
let payload = if comp_bytes == 1 {
|
||||
payload_bytes
|
||||
.chunks_exact(2)
|
||||
.map(|b| (b[0] as i8 as i16, b[1] as i8 as i16))
|
||||
.collect()
|
||||
} else {
|
||||
payload_bytes
|
||||
.chunks_exact(4)
|
||||
.map(|b| {
|
||||
(
|
||||
i16::from_le_bytes([b[0], b[1]]),
|
||||
i16::from_le_bytes([b[2], b[3]]),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let frame = Self {
|
||||
node_id,
|
||||
csi_mode,
|
||||
sequence,
|
||||
timestamp_us,
|
||||
peer_mac,
|
||||
trig_mac,
|
||||
channel,
|
||||
bandwidth,
|
||||
rx_rate,
|
||||
protocol_mode,
|
||||
num_sub_carrier,
|
||||
num_bit_per_tone,
|
||||
decimation,
|
||||
rssi_dbm,
|
||||
rxsc,
|
||||
csi_valid,
|
||||
flags,
|
||||
payload,
|
||||
};
|
||||
frame.validate()?;
|
||||
Ok((frame, frame_len))
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), CsiParseError> {
|
||||
if !matches!(self.bandwidth, 0 | 1) {
|
||||
return Err(CsiParseError::InvalidBandwidth(self.bandwidth));
|
||||
}
|
||||
let comp_bytes = self.component_bytes()?;
|
||||
if self.num_sub_carrier == 0 || self.num_sub_carrier as usize > RAC1_MAX_SUBCARRIERS {
|
||||
return Err(CsiParseError::InvalidSubcarrierCount(self.num_sub_carrier));
|
||||
}
|
||||
if self.payload.len() != self.num_sub_carrier as usize {
|
||||
return Err(CsiParseError::PayloadLengthMismatch);
|
||||
}
|
||||
if comp_bytes == 1 {
|
||||
let in_range = |v: i16| (i8::MIN as i16..=i8::MAX as i16).contains(&v);
|
||||
if self.payload.iter().any(|(i, q)| !in_range(*i) || !in_range(*q)) {
|
||||
return Err(CsiParseError::ToneValueOutOfRange);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum CsiParseError {
|
||||
#[error("insufficient data: needed {needed}, got {got}")]
|
||||
InsufficientData { needed: usize, got: usize },
|
||||
#[error("invalid magic {0:#010x}")]
|
||||
InvalidMagic(u32),
|
||||
#[error("unsupported version {0}")]
|
||||
UnsupportedVersion(u8),
|
||||
#[error("unknown CSI mode {0}")]
|
||||
UnknownCsiMode(u8),
|
||||
#[error("unknown protocol mode {0}")]
|
||||
UnknownProtocolMode(u8),
|
||||
#[error("invalid header length {0}")]
|
||||
InvalidHeaderLength(usize),
|
||||
#[error("invalid frame length {0}")]
|
||||
InvalidFrameLength(usize),
|
||||
#[error("frame too large: {0}")]
|
||||
FrameTooLarge(usize),
|
||||
#[error("length arithmetic overflow")]
|
||||
LengthOverflow,
|
||||
#[error("payload length mismatch")]
|
||||
PayloadLengthMismatch,
|
||||
#[error("invalid num_bit_per_tone {0} (must be 16 or 32)")]
|
||||
InvalidToneBits(u8),
|
||||
#[error("invalid subcarrier count {0}")]
|
||||
InvalidSubcarrierCount(u16),
|
||||
#[error("invalid bandwidth code {0} (must be 0 or 1)")]
|
||||
InvalidBandwidth(u8),
|
||||
#[error("tone I/Q value out of range for 8-bit precision")]
|
||||
ToneValueOutOfRange,
|
||||
#[error("CRC mismatch: expected {expected:#010x}, actual {actual:#010x}")]
|
||||
CrcMismatch { expected: u32, actual: u32 },
|
||||
}
|
||||
|
||||
pub mod simulator {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimulatorConfig {
|
||||
pub seed: u64,
|
||||
pub node_id: u8,
|
||||
pub csi_mode: CsiMode,
|
||||
pub peer_mac: [u8; 6],
|
||||
pub trig_mac: [u8; 6],
|
||||
pub channel: u8,
|
||||
pub bandwidth: u8,
|
||||
pub rx_rate: u8,
|
||||
pub protocol_mode: ProtocolMode,
|
||||
pub num_sub_carrier: u16,
|
||||
pub num_bit_per_tone: u8,
|
||||
pub decimation: u8,
|
||||
pub rssi_dbm: i8,
|
||||
pub frame_period_us: u32,
|
||||
}
|
||||
|
||||
impl Default for SimulatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
seed: 0x5241_4331_5254_4c31, // "RAC1RTL1"-ish
|
||||
node_id: 1,
|
||||
csi_mode: CsiMode::Passive,
|
||||
peer_mac: [0xa4, 0x39, 0xb3, 0xa4, 0xbe, 0x2d],
|
||||
trig_mac: [0x00, 0xe0, 0x4c, 0x00, 0x0a, 0xa3],
|
||||
channel: 6,
|
||||
bandwidth: 0,
|
||||
rx_rate: 12, // RTW_RATE_6M
|
||||
protocol_mode: ProtocolMode::Ofdm,
|
||||
num_sub_carrier: 52,
|
||||
num_bit_per_tone: 16,
|
||||
decimation: 1,
|
||||
rssi_dbm: -45,
|
||||
frame_period_us: 64_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RealtekCsiSimulator {
|
||||
config: SimulatorConfig,
|
||||
rng: u64,
|
||||
sequence: u32,
|
||||
timestamp_us: u32,
|
||||
motion_phase: f32,
|
||||
}
|
||||
|
||||
impl RealtekCsiSimulator {
|
||||
pub fn new(config: SimulatorConfig) -> Result<Self, CsiParseError> {
|
||||
let s = Self {
|
||||
rng: config.seed,
|
||||
config,
|
||||
sequence: 0,
|
||||
timestamp_us: 0,
|
||||
motion_phase: 0.0,
|
||||
};
|
||||
s.csi_frame().validate()?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn next_frame(&mut self) -> CsiFrame {
|
||||
let frame = self.csi_frame();
|
||||
self.sequence = self.sequence.wrapping_add(1);
|
||||
self.timestamp_us = self.timestamp_us.wrapping_add(self.config.frame_period_us);
|
||||
self.motion_phase += 0.041;
|
||||
frame
|
||||
}
|
||||
|
||||
fn csi_frame(&self) -> CsiFrame {
|
||||
let mut rng = self.rng ^ self.sequence as u64;
|
||||
let comp_max = if self.config.num_bit_per_tone == 16 {
|
||||
110.0
|
||||
} else {
|
||||
1800.0
|
||||
};
|
||||
let payload = (0..self.config.num_sub_carrier as usize)
|
||||
.map(|tone| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 7;
|
||||
rng ^= rng << 17;
|
||||
let noise = ((rng >> 48) as i16 % 8) as f32;
|
||||
let phase = tone as f32 * 0.11 + self.motion_phase;
|
||||
(
|
||||
((phase.cos() * comp_max) + noise) as i16,
|
||||
((phase.sin() * comp_max) - noise) as i16,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
CsiFrame {
|
||||
node_id: self.config.node_id,
|
||||
csi_mode: self.config.csi_mode,
|
||||
sequence: self.sequence,
|
||||
timestamp_us: self.timestamp_us,
|
||||
peer_mac: self.config.peer_mac,
|
||||
trig_mac: self.config.trig_mac,
|
||||
channel: self.config.channel,
|
||||
bandwidth: self.config.bandwidth,
|
||||
rx_rate: self.config.rx_rate,
|
||||
protocol_mode: self.config.protocol_mode,
|
||||
num_sub_carrier: self.config.num_sub_carrier,
|
||||
num_bit_per_tone: self.config.num_bit_per_tone,
|
||||
decimation: self.config.decimation,
|
||||
rssi_dbm: self.config.rssi_dbm,
|
||||
rxsc: 0,
|
||||
csi_valid: true,
|
||||
flags: CsiFlags(CsiFlags::SYNTHETIC),
|
||||
payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], o: usize) -> u16 {
|
||||
u16::from_le_bytes([b[o], b[o + 1]])
|
||||
}
|
||||
fn u32_at(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
fn crc32_ieee(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for &byte in data {
|
||||
crc ^= byte as u32;
|
||||
for _ in 0..8 {
|
||||
crc = (crc >> 1) ^ ((0u32.wrapping_sub(crc & 1)) & 0xedb8_8320);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use simulator::*;
|
||||
|
||||
#[test]
|
||||
fn simulator_round_trip_is_deterministic() {
|
||||
let cfg = SimulatorConfig::default();
|
||||
let mut a = RealtekCsiSimulator::new(cfg.clone()).unwrap();
|
||||
let mut b = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let wa = a.next_frame().to_bytes().unwrap();
|
||||
assert_eq!(wa, b.next_frame().to_bytes().unwrap());
|
||||
let (decoded, n) = CsiFrame::from_bytes(&wa).unwrap();
|
||||
assert_eq!(n, wa.len());
|
||||
assert!(decoded.flags.contains(CsiFlags::SYNTHETIC));
|
||||
assert_eq!(decoded.payload.len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_precision_round_trip() {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let frame = sim.next_frame();
|
||||
let wire = frame.to_bytes().unwrap();
|
||||
let (decoded, n) = CsiFrame::from_bytes(&wire).unwrap();
|
||||
assert_eq!(n, wire.len());
|
||||
assert_eq!(decoded, frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_corruption_is_rejected() {
|
||||
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let mut w = s.next_frame().to_bytes().unwrap();
|
||||
let last = w.len() - 1;
|
||||
w[last] ^= 1;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_bytes(&w),
|
||||
Err(CsiParseError::CrcMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_is_rejected() {
|
||||
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let w = s.next_frame().to_bytes().unwrap();
|
||||
assert!(matches!(
|
||||
CsiFrame::from_bytes(&w[..w.len() - 1]),
|
||||
Err(CsiParseError::InsufficientData { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_subcarriers_is_rejected() {
|
||||
let cfg = SimulatorConfig {
|
||||
num_sub_carrier: 0,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
RealtekCsiSimulator::new(cfg),
|
||||
Err(CsiParseError::InvalidSubcarrierCount(0))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_tone_bits_is_rejected() {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 24,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
RealtekCsiSimulator::new(cfg),
|
||||
Err(CsiParseError::InvalidToneBits(24))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_never_panics_on_prefixes() {
|
||||
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let w = s.next_frame().to_bytes().unwrap();
|
||||
for end in 0..w.len() {
|
||||
let _ = CsiFrame::from_bytes(&w[..end]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_mismatch_is_rejected() {
|
||||
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let mut w = s.next_frame().to_bytes().unwrap();
|
||||
w[0] ^= 0xff;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_bytes(&w),
|
||||
Err(CsiParseError::InvalidMagic(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,39 @@ pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_
|
||||
]
|
||||
}
|
||||
|
||||
/// Preserve the fifth feature's historical meaning for models trained before
|
||||
/// the server began measuring temporal frequency from a frame window.
|
||||
///
|
||||
/// Version 1 models learned a proxy equal to the strongest subcarrier index
|
||||
/// multiplied by 0.05. Version 2 and later models consume the measured
|
||||
/// temporal frequency in hertz. Keeping this translation at the model boundary
|
||||
/// prevents a server upgrade from silently changing an existing model's input
|
||||
/// distribution.
|
||||
pub fn compatible_dominant_frequency(
|
||||
model_version: u32,
|
||||
temporal_frequency_hz: f64,
|
||||
amplitudes: &[f64],
|
||||
) -> f64 {
|
||||
if model_version >= 2 {
|
||||
return if temporal_frequency_hz.is_finite() {
|
||||
temporal_frequency_hz.max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
amplitudes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, amplitude)| amplitude.is_finite())
|
||||
.max_by(|(_, left), (_, right)| {
|
||||
left.partial_cmp(right)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(index, _)| index as f64 * 0.05)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Compute statistical features from raw subcarrier amplitudes.
|
||||
fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
|
||||
if amps.is_empty() {
|
||||
@@ -642,3 +675,21 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
|
||||
pub fn model_path() -> PathBuf {
|
||||
PathBuf::from("data/adaptive_model.json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compatibility_tests {
|
||||
use super::compatible_dominant_frequency;
|
||||
|
||||
#[test]
|
||||
fn version_one_keeps_legacy_subcarrier_proxy() {
|
||||
let amplitudes = [1.0, 2.0, 8.0, 3.0];
|
||||
assert_eq!(compatible_dominant_frequency(1, 1.25, &litudes), 0.10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_two_uses_measured_temporal_frequency() {
|
||||
let amplitudes = [1.0, 20.0, 2.0];
|
||||
assert_eq!(compatible_dominant_frequency(2, 1.25, &litudes), 1.25);
|
||||
assert_eq!(compatible_dominant_frequency(2, f64::NAN, &litudes), 0.0);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use wifi_densepose_signal::ruvsense::field_model::{
|
||||
FieldModel, FieldModelError, FieldModelSnapshotV1,
|
||||
};
|
||||
|
||||
pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.v1";
|
||||
pub const BOOTSTRAP_BASELINE_SCHEMA: &str = "ruview.bootstrap-empty-field-model.v2";
|
||||
pub const BOOTSTRAP_BASELINE_AUTHORITY: &str = "bootstrap_only";
|
||||
pub const BOOTSTRAP_VALIDATION_SAMPLES: usize = 12;
|
||||
pub const BOOTSTRAP_VALIDATION_MIN_EMPTY: usize = 10;
|
||||
@@ -24,13 +24,25 @@ pub const BOOTSTRAP_VALIDATION_MIN_SPACING_MS: u64 = 1_000;
|
||||
pub const BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS: u64 = 4_000;
|
||||
const BOOTSTRAP_MAX_FILE_BYTES: u64 = 1_048_576;
|
||||
|
||||
/// Exact CSI layout used to train the persisted empty room model.
|
||||
///
|
||||
/// Restoring code must require incoming frames to match this binding before
|
||||
/// the bootstrap image can influence inference.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BootstrapCsiGrid {
|
||||
pub n_subcarriers: u16,
|
||||
pub ppdu_type: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BootstrapPayloadV1 {
|
||||
struct BootstrapPayloadV2 {
|
||||
schema: String,
|
||||
authority: String,
|
||||
installation_binding_sha256: String,
|
||||
source_node_ids: Vec<u8>,
|
||||
source_grid: BootstrapCsiGrid,
|
||||
source_model_id: String,
|
||||
created_at_unix_ms: u64,
|
||||
expires_at_unix_ms: u64,
|
||||
@@ -39,14 +51,14 @@ struct BootstrapPayloadV1 {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BootstrapImageV1 {
|
||||
payload: BootstrapPayloadV1,
|
||||
struct BootstrapImageV2 {
|
||||
payload: BootstrapPayloadV2,
|
||||
content_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BootstrapImageRawV1 {
|
||||
struct BootstrapImageRawV2 {
|
||||
payload: Box<RawValue>,
|
||||
content_sha256: String,
|
||||
}
|
||||
@@ -55,6 +67,7 @@ struct BootstrapImageRawV1 {
|
||||
pub struct BootstrapBaselineMetadata {
|
||||
pub authority: &'static str,
|
||||
pub source_node_ids: Vec<u8>,
|
||||
pub source_grid: BootstrapCsiGrid,
|
||||
pub source_model_id: String,
|
||||
pub created_at_unix_ms: u64,
|
||||
pub expires_at_unix_ms: u64,
|
||||
@@ -139,6 +152,7 @@ pub fn store(
|
||||
path: &Path,
|
||||
installation_id: &str,
|
||||
source_node_ids: &[u8],
|
||||
source_grid: BootstrapCsiGrid,
|
||||
source_model_id: &str,
|
||||
created_at_unix_ms: u64,
|
||||
field_model: &FieldModel,
|
||||
@@ -146,11 +160,12 @@ pub fn store(
|
||||
let snapshot = field_model.export_snapshot()?;
|
||||
let calibrated_at_ms = snapshot.modes.calibrated_at_us / 1_000;
|
||||
let expiry_ms = (snapshot.config.baseline_expiry_s * 1_000.0) as u64;
|
||||
let payload = BootstrapPayloadV1 {
|
||||
let payload = BootstrapPayloadV2 {
|
||||
schema: BOOTSTRAP_BASELINE_SCHEMA.to_string(),
|
||||
authority: BOOTSTRAP_BASELINE_AUTHORITY.to_string(),
|
||||
installation_binding_sha256: installation_binding(installation_id)?,
|
||||
source_node_ids: source_node_ids.to_vec(),
|
||||
source_grid,
|
||||
source_model_id: source_model_id.to_string(),
|
||||
created_at_unix_ms,
|
||||
expires_at_unix_ms: calibrated_at_ms.saturating_add(expiry_ms),
|
||||
@@ -158,7 +173,7 @@ pub fn store(
|
||||
};
|
||||
validate_payload(&payload, installation_id, created_at_unix_ms)?;
|
||||
let content_sha256 = payload_digest(&payload)?;
|
||||
let image = BootstrapImageV1 {
|
||||
let image = BootstrapImageV2 {
|
||||
payload,
|
||||
content_sha256,
|
||||
};
|
||||
@@ -194,13 +209,13 @@ pub fn load(
|
||||
// Verify the exact payload bytes written by storage before decoding the
|
||||
// floating point model. Equivalent JSON number spellings may serialize
|
||||
// differently, so decode and reserialize is not an integrity contract.
|
||||
let raw_image: BootstrapImageRawV1 = serde_json::from_slice(&bytes)?;
|
||||
let raw_image: BootstrapImageRawV2 = serde_json::from_slice(&bytes)?;
|
||||
if hex_sha256(raw_image.payload.get().as_bytes()) != raw_image.content_sha256 {
|
||||
return Err(BootstrapBaselineError::DigestMismatch);
|
||||
}
|
||||
let payload: BootstrapPayloadV1 = serde_json::from_str(raw_image.payload.get())?;
|
||||
let payload: BootstrapPayloadV2 = serde_json::from_str(raw_image.payload.get())?;
|
||||
validate_payload(&payload, installation_id, current_unix_ms)?;
|
||||
let image = BootstrapImageV1 {
|
||||
let image = BootstrapImageV2 {
|
||||
payload,
|
||||
content_sha256: raw_image.content_sha256,
|
||||
};
|
||||
@@ -226,7 +241,7 @@ pub fn remove(path: &Path) -> Result<bool, BootstrapBaselineError> {
|
||||
}
|
||||
|
||||
fn validate_payload(
|
||||
payload: &BootstrapPayloadV1,
|
||||
payload: &BootstrapPayloadV2,
|
||||
installation_id: &str,
|
||||
current_unix_ms: u64,
|
||||
) -> Result<(), BootstrapBaselineError> {
|
||||
@@ -234,17 +249,14 @@ fn validate_payload(
|
||||
|| payload.authority != BOOTSTRAP_BASELINE_AUTHORITY
|
||||
|| payload.source_model_id.is_empty()
|
||||
|| payload.source_model_id.len() > 128
|
||||
|| payload.source_node_ids.is_empty()
|
||||
|| payload.source_node_ids.len() > 16
|
||||
|| payload
|
||||
.source_node_ids
|
||||
.windows(2)
|
||||
.any(|pair| pair[0] >= pair[1])
|
||||
|| payload.source_node_ids.len() != 1
|
||||
|| !(1..=2_048).contains(&payload.source_grid.n_subcarriers)
|
||||
|| payload.source_grid.ppdu_type > 3
|
||||
|| payload.created_at_unix_ms == 0
|
||||
|| payload.expires_at_unix_ms <= payload.created_at_unix_ms
|
||||
{
|
||||
return Err(BootstrapBaselineError::Malformed(
|
||||
"invalid schema, authority, identity, node set, or lifetime".into(),
|
||||
"invalid schema, authority, identity, node set, source grid, or lifetime".into(),
|
||||
));
|
||||
}
|
||||
if payload.installation_binding_sha256 != installation_binding(installation_id)? {
|
||||
@@ -256,14 +268,15 @@ fn validate_payload(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn payload_digest(payload: &BootstrapPayloadV1) -> Result<String, serde_json::Error> {
|
||||
fn payload_digest(payload: &BootstrapPayloadV2) -> Result<String, serde_json::Error> {
|
||||
Ok(hex_sha256(&serde_json::to_vec(payload)?))
|
||||
}
|
||||
|
||||
fn metadata(image: &BootstrapImageV1) -> BootstrapBaselineMetadata {
|
||||
fn metadata(image: &BootstrapImageV2) -> BootstrapBaselineMetadata {
|
||||
BootstrapBaselineMetadata {
|
||||
authority: BOOTSTRAP_BASELINE_AUTHORITY,
|
||||
source_node_ids: image.payload.source_node_ids.clone(),
|
||||
source_grid: image.payload.source_grid,
|
||||
source_model_id: image.payload.source_model_id.clone(),
|
||||
created_at_unix_ms: image.payload.created_at_unix_ms,
|
||||
expires_at_unix_ms: image.payload.expires_at_unix_ms,
|
||||
@@ -315,6 +328,11 @@ mod tests {
|
||||
use super::*;
|
||||
use wifi_densepose_signal::ruvsense::field_model::FieldModelConfig;
|
||||
|
||||
const TEST_GRID: BootstrapCsiGrid = BootstrapCsiGrid {
|
||||
n_subcarriers: 192,
|
||||
ppdu_type: 2,
|
||||
};
|
||||
|
||||
fn completed_model(now_us: u64) -> FieldModel {
|
||||
let mut model = FieldModel::new(FieldModelConfig {
|
||||
n_links: 1,
|
||||
@@ -340,10 +358,11 @@ mod tests {
|
||||
let path = path_in(dir.path());
|
||||
let now_ms = 1_000_000;
|
||||
let model = completed_model(now_ms * 1_000);
|
||||
let stored = store(&path, "home-a", &[5], "model", now_ms, &model).unwrap();
|
||||
let stored = store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
|
||||
let text = String::from_utf8(fs::read(&path).unwrap()).unwrap();
|
||||
assert!(!text.contains("home-a"));
|
||||
assert!(!text.contains("raw_csi"));
|
||||
assert_eq!(stored.source_grid, TEST_GRID);
|
||||
|
||||
let (restored, loaded) = load(&path, "home-a", now_ms + 1).unwrap();
|
||||
assert_eq!(stored, loaded);
|
||||
@@ -363,18 +382,18 @@ mod tests {
|
||||
let path = path_in(dir.path());
|
||||
let now_ms = 1_000_000;
|
||||
let model = completed_model(now_ms * 1_000);
|
||||
store(&path, "home-a", &[5], "model", now_ms, &model).unwrap();
|
||||
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
|
||||
|
||||
let mut image: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
image["payload"]["source_model_id"] = serde_json::json!("forged");
|
||||
image["payload"]["source_grid"]["n_subcarriers"] = serde_json::json!(64);
|
||||
fs::write(&path, serde_json::to_vec(&image).unwrap()).unwrap();
|
||||
assert!(matches!(
|
||||
load(&path, "home-a", now_ms + 1),
|
||||
Err(BootstrapBaselineError::DigestMismatch)
|
||||
));
|
||||
|
||||
store(&path, "home-a", &[5], "model", now_ms, &model).unwrap();
|
||||
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
|
||||
assert!(matches!(
|
||||
load(&path, "home-a", now_ms + 3_600_001),
|
||||
Err(BootstrapBaselineError::Expired)
|
||||
@@ -384,13 +403,90 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_rejects_invalid_source_grid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = path_in(dir.path());
|
||||
let now_ms = 1_000_000;
|
||||
let model = completed_model(now_ms * 1_000);
|
||||
|
||||
for source_grid in [
|
||||
BootstrapCsiGrid {
|
||||
n_subcarriers: 0,
|
||||
ppdu_type: 0,
|
||||
},
|
||||
BootstrapCsiGrid {
|
||||
n_subcarriers: 2_049,
|
||||
ppdu_type: 0,
|
||||
},
|
||||
BootstrapCsiGrid {
|
||||
n_subcarriers: 192,
|
||||
ppdu_type: 4,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
store(&path, "home-a", &[5], source_grid, "model", now_ms, &model,),
|
||||
Err(BootstrapBaselineError::Malformed(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_requires_exactly_one_source_node() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = path_in(dir.path());
|
||||
let now_ms = 1_000_000;
|
||||
let model = completed_model(now_ms * 1_000);
|
||||
|
||||
for source_node_ids in [&[][..], &[5, 7][..]] {
|
||||
assert!(matches!(
|
||||
store(
|
||||
&path,
|
||||
"home-a",
|
||||
source_node_ids,
|
||||
TEST_GRID,
|
||||
"model",
|
||||
now_ms,
|
||||
&model,
|
||||
),
|
||||
Err(BootstrapBaselineError::Malformed(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_grid_is_required_when_loading_a_v2_image() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = path_in(dir.path());
|
||||
let now_ms = 1_000_000;
|
||||
let model = completed_model(now_ms * 1_000);
|
||||
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
|
||||
|
||||
let mut image: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
image["payload"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("source_grid");
|
||||
let payload = serde_json::to_vec(&image["payload"]).unwrap();
|
||||
let rewritten = format!(
|
||||
"{{\"payload\":{},\"content_sha256\":\"{}\"}}",
|
||||
String::from_utf8(payload.clone()).unwrap(),
|
||||
hex_sha256(&payload)
|
||||
);
|
||||
fs::write(&path, rewritten).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
load(&path, "home-a", now_ms + 1),
|
||||
Err(BootstrapBaselineError::Json(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promotion_gate_requires_twelve_fresh_samples_and_zero_vitals() {
|
||||
assert_eq!(BOOTSTRAP_VALIDATION_MIN_SPACING_MS, 1_000);
|
||||
assert_eq!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS, 4_000);
|
||||
assert!(
|
||||
BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS
|
||||
);
|
||||
assert!(BOOTSTRAP_VALIDATION_SAMPLE_TIMEOUT_MS >= BOOTSTRAP_VALIDATION_MIN_SPACING_MS);
|
||||
let passing = vec![
|
||||
BootstrapValidationSample {
|
||||
fresh_tick: true,
|
||||
@@ -415,10 +511,10 @@ mod tests {
|
||||
let path = path_in(dir.path());
|
||||
let now_ms = 1_000_000;
|
||||
let model = completed_model(now_ms * 1_000);
|
||||
store(&path, "home-a", &[5], "model", now_ms, &model).unwrap();
|
||||
store(&path, "home-a", &[5], TEST_GRID, "model", now_ms, &model).unwrap();
|
||||
|
||||
let original = fs::read_to_string(&path).unwrap();
|
||||
let image: BootstrapImageRawV1 = serde_json::from_str(&original).unwrap();
|
||||
let image: BootstrapImageRawV2 = serde_json::from_str(&original).unwrap();
|
||||
let rewritten_payload = image.payload.get().replacen(
|
||||
"\"min_calibration_duration_s\":0.0",
|
||||
"\"min_calibration_duration_s\":0.00",
|
||||
|
||||
@@ -178,7 +178,12 @@ pub struct Args {
|
||||
#[arg(long, value_name = "TYPE")]
|
||||
pub build_index: Option<String>,
|
||||
|
||||
/// Node positions for multistatic fusion (format: "x,y,z;x,y,z;...")
|
||||
/// Node positions for multistatic fusion.
|
||||
///
|
||||
/// Two forms, mixable: "x,y,z;..." positional, where each entry's identity
|
||||
/// is its index, and "node_id:x,y,z;..." explicit. Prefer the explicit form
|
||||
/// unless the fleet is numbered 0,1,2,... — otherwise "the third entry" and
|
||||
/// "node 3" are different nodes and nothing in the string says which is meant.
|
||||
#[arg(long, env = "SENSING_NODE_POSITIONS")]
|
||||
pub node_positions: Option<String>,
|
||||
|
||||
|
||||
@@ -11,6 +11,141 @@ use crate::vital_signs::VitalSigns;
|
||||
|
||||
const EDGE_MAX_PERSONS: u8 = 4;
|
||||
|
||||
/// Estimate a real temporal motion frequency from recent CSI amplitude
|
||||
/// history. Each frame is reduced to its mean amplitude, detrended, Hann
|
||||
/// windowed, and evaluated on exact DFT bins.
|
||||
pub fn estimate_temporal_dominant_frequency_hz(
|
||||
frame_history: &VecDeque<Vec<f64>>,
|
||||
sample_rate_hz: f64,
|
||||
) -> f64 {
|
||||
if !sample_rate_hz.is_finite() || sample_rate_hz <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let recent_frames: Vec<&Vec<f64>> = frame_history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(128)
|
||||
.collect();
|
||||
if recent_frames
|
||||
.iter()
|
||||
.any(|frame| frame.is_empty() || frame.iter().any(|value| !value.is_finite()))
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
let samples: Vec<f64> = recent_frames
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|frame| frame.iter().sum::<f64>() / frame.len() as f64)
|
||||
.collect();
|
||||
let n = samples.len();
|
||||
if n < 16 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mean = samples.iter().sum::<f64>() / n as f64;
|
||||
let upper_hz = 4.0_f64.min(sample_rate_hz * 0.45);
|
||||
if upper_hz < 0.1 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut powers = Vec::new();
|
||||
for bin in 1..=(n / 2) {
|
||||
let frequency_hz = bin as f64 * sample_rate_hz / n as f64;
|
||||
if !(0.1..=upper_hz).contains(&frequency_hz) {
|
||||
continue;
|
||||
}
|
||||
let mut real = 0.0;
|
||||
let mut imag = 0.0;
|
||||
let angle_step = 2.0 * std::f64::consts::PI * bin as f64 / n as f64;
|
||||
let (step_sin, step_cos) = angle_step.sin_cos();
|
||||
let mut angle_cos = 1.0;
|
||||
let mut angle_sin = 0.0;
|
||||
for (index, sample) in samples.iter().enumerate() {
|
||||
let window =
|
||||
0.5 * (1.0 - (2.0 * std::f64::consts::PI * index as f64 / (n - 1) as f64).cos());
|
||||
let value = (sample - mean) * window;
|
||||
real += value * angle_cos;
|
||||
imag -= value * angle_sin;
|
||||
let next_cos = angle_cos * step_cos - angle_sin * step_sin;
|
||||
angle_sin = angle_sin * step_cos + angle_cos * step_sin;
|
||||
angle_cos = next_cos;
|
||||
}
|
||||
powers.push((frequency_hz, real * real + imag * imag));
|
||||
}
|
||||
if powers.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mean_power = powers.iter().map(|(_, power)| power).sum::<f64>() / powers.len() as f64;
|
||||
let (peak_hz, peak_power) = powers
|
||||
.into_iter()
|
||||
.max_by(|left, right| {
|
||||
left.1
|
||||
.partial_cmp(&right.1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.unwrap_or((0.0, 0.0));
|
||||
if mean_power <= f64::EPSILON || peak_power / mean_power < 2.0 {
|
||||
0.0
|
||||
} else {
|
||||
peak_hz
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod temporal_frequency_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recovers_temporal_frequency_in_hertz() {
|
||||
let sample_rate_hz = 20.0;
|
||||
let target_hz = 1.2;
|
||||
let mut history = VecDeque::new();
|
||||
for sample in 0..100 {
|
||||
let t = sample as f64 / sample_rate_hz;
|
||||
let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * target_hz * t).sin();
|
||||
history.push_back(vec![value; 56]);
|
||||
}
|
||||
let measured = estimate_temporal_dominant_frequency_hz(&history, sample_rate_hz);
|
||||
assert!((measured - target_hz).abs() <= 0.21, "measured {measured} Hz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstains_on_short_or_flat_history() {
|
||||
let short = VecDeque::from(vec![vec![20.0; 56]; 15]);
|
||||
assert_eq!(estimate_temporal_dominant_frequency_hz(&short, 20.0), 0.0);
|
||||
let flat = VecDeque::from(vec![vec![20.0; 56]; 100]);
|
||||
assert_eq!(estimate_temporal_dominant_frequency_hz(&flat, 20.0), 0.0);
|
||||
let mut corrupt = flat;
|
||||
corrupt[50][4] = f64::NAN;
|
||||
assert_eq!(estimate_temporal_dominant_frequency_hz(&corrupt, 20.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_frequency_p95_stays_below_server_budget() {
|
||||
let sample_rate_hz = 50.0;
|
||||
let mut history = VecDeque::new();
|
||||
for sample in 0..128 {
|
||||
let t = sample as f64 / sample_rate_hz;
|
||||
let value = 20.0 + 2.0 * (2.0 * std::f64::consts::PI * 1.2 * t).sin();
|
||||
history.push_back(vec![value; 256]);
|
||||
}
|
||||
let mut timings = Vec::with_capacity(1_000);
|
||||
for _ in 0..1_000 {
|
||||
let start = std::time::Instant::now();
|
||||
std::hint::black_box(estimate_temporal_dominant_frequency_hz(
|
||||
std::hint::black_box(&history),
|
||||
sample_rate_hz,
|
||||
));
|
||||
timings.push(start.elapsed());
|
||||
}
|
||||
timings.sort_unstable();
|
||||
let p95 = timings[949];
|
||||
eprintln!("temporal dominant frequency p95: {p95:?}");
|
||||
assert!(p95 < std::time::Duration::from_millis(10), "p95 {p95:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Person count is supporting evidence, never an independent occupancy claim.
|
||||
/// Return zero and mark invalid for contradictory or out-of-range firmware.
|
||||
fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) {
|
||||
@@ -469,14 +604,7 @@ pub fn extract_features_from_frame(
|
||||
0.0
|
||||
};
|
||||
|
||||
let peak_idx = frame
|
||||
.amplitudes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(0);
|
||||
let dominant_freq_hz = peak_idx as f64 * 0.05;
|
||||
let dominant_freq_hz = estimate_temporal_dominant_frequency_hz(frame_history, sample_rate_hz);
|
||||
|
||||
let threshold = mean_amp * 1.2;
|
||||
let change_points = frame
|
||||
@@ -485,7 +613,9 @@ pub fn extract_features_from_frame(
|
||||
.filter(|w| (w[0] < threshold) != (w[1] < threshold))
|
||||
.count();
|
||||
|
||||
let temporal_motion_score = if let Some(prev_frame) = frame_history.back() {
|
||||
// The caller appends the current frame before extraction, so the true
|
||||
// predecessor is the second item from the back.
|
||||
let temporal_motion_score = if let Some(prev_frame) = frame_history.iter().rev().nth(1) {
|
||||
let n_cmp = n_sub.min(prev_frame.len());
|
||||
if n_cmp > 0 {
|
||||
let diff_energy: f64 = (0..n_cmp)
|
||||
@@ -636,13 +766,18 @@ pub fn adaptive_override(
|
||||
.back()
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let dominant_frequency = adaptive_classifier::compatible_dominant_frequency(
|
||||
model.version,
|
||||
features.dominant_freq_hz,
|
||||
amps,
|
||||
);
|
||||
let feat_arr = adaptive_classifier::features_from_runtime(
|
||||
&serde_json::json!({
|
||||
"variance": features.variance,
|
||||
"motion_band_power": features.motion_band_power,
|
||||
"breathing_band_power": features.breathing_band_power,
|
||||
"spectral_power": features.spectral_power,
|
||||
"dominant_freq_hz": features.dominant_freq_hz,
|
||||
"dominant_freq_hz": dominant_frequency,
|
||||
"change_points": features.change_points,
|
||||
"mean_rssi": features.mean_rssi,
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,15 @@ use wifi_densepose_signal::ruvsense::field_model::{
|
||||
|
||||
use super::score_to_person_count;
|
||||
|
||||
/// Versioned schema for the immutable receipt emitted after field-model calibration.
|
||||
pub const CALIBRATION_MODEL_RECEIPT_SCHEMA: &str =
|
||||
"ruview.calibration.field-model-receipt.v1";
|
||||
|
||||
/// Versioned schema for per-frame calibrated occupancy evidence bound to a
|
||||
/// model receipt, published on the sensing stream.
|
||||
pub const CALIBRATED_PRESENCE_EVIDENCE_SCHEMA: &str =
|
||||
"ruview.calibration.calibrated-presence-evidence.v2";
|
||||
|
||||
/// Length-only canonicalizer for calibration frames (issue #1170 pattern,
|
||||
/// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the
|
||||
/// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is
|
||||
@@ -47,6 +56,9 @@ const MAX_SINGLE_LINK_OCCUPANCY: usize = 3;
|
||||
pub struct BootstrapBackgroundMatch {
|
||||
pub matches_empty: bool,
|
||||
pub score: Option<f64>,
|
||||
pub normalized_residual_z: Option<f64>,
|
||||
pub maturity: f64,
|
||||
pub reliable: bool,
|
||||
pub residual_energy: f64,
|
||||
pub residual_energy_threshold: f64,
|
||||
pub window_size: usize,
|
||||
@@ -146,6 +158,18 @@ pub fn single_link_config() -> FieldModelConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the model status at the observation wall clock. `FieldModel::status`
|
||||
/// records the state at collection or restore time and does not advance as a
|
||||
/// long-running server crosses the stale and expiry boundaries.
|
||||
pub fn calibration_status_at(field: &FieldModel, observed_at_us: u64) -> CalibrationStatus {
|
||||
match field.status() {
|
||||
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting => field.status(),
|
||||
CalibrationStatus::Fresh | CalibrationStatus::Stale | CalibrationStatus::Expired => {
|
||||
field.check_freshness(observed_at_us)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate occupancy using the FieldModel when calibrated, falling back
|
||||
/// to the score-based heuristic otherwise.
|
||||
///
|
||||
@@ -155,10 +179,11 @@ pub fn single_link_config() -> FieldModelConfig {
|
||||
pub fn occupancy_or_fallback(
|
||||
field: &FieldModel,
|
||||
frame_history: &VecDeque<Vec<f64>>,
|
||||
observed_at_us: u64,
|
||||
smoothed_score: f64,
|
||||
prev_count: usize,
|
||||
) -> usize {
|
||||
match field.status() {
|
||||
match calibration_status_at(field, observed_at_us) {
|
||||
CalibrationStatus::Fresh | CalibrationStatus::Stale => {
|
||||
let frames: Vec<Vec<f64>> = frame_history
|
||||
.iter()
|
||||
@@ -227,6 +252,9 @@ pub fn bootstrap_background_match(
|
||||
Some(BootstrapBackgroundMatch {
|
||||
matches_empty: result.matches_empty,
|
||||
score: result.score,
|
||||
normalized_residual_z: result.normalized_residual_z,
|
||||
maturity: result.maturity,
|
||||
reliable: result.reliable,
|
||||
residual_energy: result.residual_energy,
|
||||
residual_energy_threshold: result.residual_energy_threshold,
|
||||
window_size: result.window_size,
|
||||
@@ -264,62 +292,114 @@ pub fn calibrated_occupancy(
|
||||
})
|
||||
}
|
||||
|
||||
/// Feed the latest frame to the FieldModel during calibration collection.
|
||||
/// Feed the current CSI frame to the FieldModel during calibration.
|
||||
///
|
||||
/// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame
|
||||
/// flips a freshly-started (`Uncalibrated`) model to `Collecting` inside
|
||||
/// `feed_calibration`; without accepting the `Uncalibrated` state here the two
|
||||
/// gates deadlock and the frame count never leaves 0 (calibration/start yields
|
||||
/// an `Uncalibrated` model that nothing would ever advance). Wraps the latest
|
||||
/// frame as a single-link observation (n_links=1) and feeds it.
|
||||
pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<Vec<f64>>) {
|
||||
/// an `Uncalibrated` model that nothing would ever advance). Edge-vitals
|
||||
/// packets have no CSI observation and must never call this helper. The caller
|
||||
/// owns the stateful sequence admission gate. Wraps the current frame as a
|
||||
/// single-link observation (n_links=1) and feeds it.
|
||||
pub fn maybe_feed_calibration(field: &mut FieldModel, amplitudes: &[f64]) -> bool {
|
||||
if !matches!(
|
||||
field.status(),
|
||||
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting
|
||||
) {
|
||||
return;
|
||||
) || amplitudes.is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(latest) = frame_history.back() {
|
||||
// Resample the raw amplitude vector onto the FieldModel's canonical
|
||||
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
|
||||
// feeding those raw made every `feed_calibration` fail DimensionMismatch
|
||||
// (swallowed at debug level), pinning frame_count at 0 even after the
|
||||
// status-gate deadlock was fixed. Single-link observation: [1][56].
|
||||
let canonical = CALIB_NORMALIZER.resample_to_canonical(latest);
|
||||
let observations = vec![canonical];
|
||||
if let Err(e) = field.feed_calibration(&observations) {
|
||||
// Resample the raw amplitude vector onto the FieldModel's canonical
|
||||
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
|
||||
// feeding those raw made every `feed_calibration` fail DimensionMismatch
|
||||
// (swallowed at debug level), pinning frame_count at 0 even after the
|
||||
// status-gate deadlock was fixed. Single-link observation: [1][56].
|
||||
let canonical = CALIB_NORMALIZER.resample_to_canonical(amplitudes);
|
||||
let observations = vec![canonical];
|
||||
match field.feed_calibration(&observations) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::debug!("FieldModel calibration feed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse node positions from a semicolon-delimited string.
|
||||
/// One parsed `--node-positions` entry: a position, and the node id if the
|
||||
/// operator named one explicitly.
|
||||
///
|
||||
/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`.
|
||||
/// Malformed entries are skipped with a warning log.
|
||||
pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
|
||||
/// `node_id: None` means the entry was given positionally, so its identity is
|
||||
/// its index in the list — which is only the same thing as a node id on a fleet
|
||||
/// numbered `0, 1, 2, ...`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct NodePositionEntry {
|
||||
pub node_id: Option<u8>,
|
||||
pub position: [f32; 3],
|
||||
}
|
||||
|
||||
/// Parse node positions, keeping any explicit node ids.
|
||||
///
|
||||
/// Two accepted forms, per entry:
|
||||
///
|
||||
/// * `x,y,z` — positional; identity is the entry's index.
|
||||
/// * `node_id:x,y,z` — explicit; identity is the stated node id.
|
||||
///
|
||||
/// The explicit form exists because the positional one is only unambiguous on a
|
||||
/// fleet whose ids happen to run `0, 1, 2, ...`. Anywhere else, "the third
|
||||
/// entry" and "node 3" are different nodes, and nothing in the string says
|
||||
/// which was meant. Naming the id removes the guess rather than relocating it.
|
||||
///
|
||||
/// Both forms may be mixed; a positional entry keeps its index as its identity.
|
||||
/// Malformed entries are skipped with a warning rather than failing the boot —
|
||||
/// a typo in one triplet should not take the server down.
|
||||
pub fn parse_node_position_entries(input: &str) -> Vec<NodePositionEntry> {
|
||||
if input.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
input
|
||||
.split(';')
|
||||
.enumerate()
|
||||
.filter_map(|(idx, triplet)| {
|
||||
.filter_map(|(idx, raw)| {
|
||||
let entry = raw.trim();
|
||||
if entry.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// An explicit id is everything before the first ':'. Coordinates
|
||||
// never contain one, so this cannot be ambiguous.
|
||||
let (node_id, triplet) = match entry.split_once(':') {
|
||||
Some((id_str, rest)) => match id_str.trim().parse::<u8>() {
|
||||
Ok(id) => (Some(id), rest),
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"Skipping node position entry {idx}: '{entry}' has \
|
||||
an unparseable node id before ':' (expected \
|
||||
node_id:x,y,z with node_id in 0..=255)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
},
|
||||
None => (None, entry),
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = triplet.split(',').collect();
|
||||
if parts.len() != 3 {
|
||||
tracing::warn!(
|
||||
"Skipping malformed node position entry {idx}: '{triplet}' (expected x,y,z)"
|
||||
"Skipping malformed node position entry {idx}: '{entry}' (expected x,y,z)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
match (
|
||||
parts[0].parse::<f32>(),
|
||||
parts[1].parse::<f32>(),
|
||||
parts[2].parse::<f32>(),
|
||||
parts[0].trim().parse::<f32>(),
|
||||
parts[1].trim().parse::<f32>(),
|
||||
parts[2].trim().parse::<f32>(),
|
||||
) {
|
||||
(Ok(x), Ok(y), Ok(z)) => Some([x, y, z]),
|
||||
(Ok(x), Ok(y), Ok(z)) => Some(NodePositionEntry {
|
||||
node_id,
|
||||
position: [x, y, z],
|
||||
}),
|
||||
_ => {
|
||||
tracing::warn!("Skipping unparseable node position entry {idx}: '{triplet}'");
|
||||
tracing::warn!("Skipping unparseable node position entry {idx}: '{entry}'");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -327,6 +407,50 @@ pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the `node_id -> position` map the live `NodeInfo` output reads.
|
||||
///
|
||||
/// Identity is the explicit `node_id:` prefix when the operator gave one, and
|
||||
/// the entry's list index otherwise.
|
||||
///
|
||||
/// This exists as a function rather than a loop inside `main()` because the bug
|
||||
/// it fixes was invisible without a test: positions were inserted keyed by list
|
||||
/// index and read back by `node_id`, so on any fleet not numbered `0, 1, 2, ...`
|
||||
/// every lookup missed and silently fell through to the hardcoded
|
||||
/// `[2.0, 0.0, 1.5]` that `--node-positions` exists to replace. The config
|
||||
/// looked applied and did nothing.
|
||||
pub fn node_positions_by_id(
|
||||
entries: &[NodePositionEntry],
|
||||
) -> std::collections::HashMap<u8, [f32; 3]> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
for (idx, e) in entries.iter().enumerate() {
|
||||
let key = e.node_id.unwrap_or(idx as u8);
|
||||
if let Some(prev) = map.insert(key, e.position) {
|
||||
tracing::warn!(
|
||||
"node position for node {key} given twice ({prev:?} then {:?}); the later entry wins",
|
||||
e.position
|
||||
);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Parse node positions from a semicolon-delimited string.
|
||||
///
|
||||
/// Format: `"x,y,z;x,y,z;..."` where each coordinate is an `f32`.
|
||||
/// Malformed entries are skipped with a warning log.
|
||||
///
|
||||
/// Positional view, for consumers that index the list rather than key it by
|
||||
/// node id. Since issue #1866 the fusion path is no longer one of them: it
|
||||
/// takes the keyed map from [`node_positions_by_id`] via
|
||||
/// `MultistaticFuser::set_node_positions_by_id()`. Use
|
||||
/// [`parse_node_position_entries`] where identity is needed.
|
||||
pub fn parse_node_positions(input: &str) -> Vec<[f32; 3]> {
|
||||
parse_node_position_entries(input)
|
||||
.into_iter()
|
||||
.map(|e| e.position)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -387,6 +511,158 @@ mod tests {
|
||||
assert_eq!(positions[0], [3.0, 4.0, 5.0]);
|
||||
}
|
||||
|
||||
// ---- Explicit node ids (the indexing-mismatch regression) ----
|
||||
|
||||
/// Positional entries carry no id, so identity falls back to the index.
|
||||
/// This is the ONLY case the old behaviour handled correctly.
|
||||
#[test]
|
||||
fn positional_entries_report_no_explicit_id() {
|
||||
let e = parse_node_position_entries("0,0,1.5;3,0,1.5");
|
||||
assert_eq!(e.len(), 2);
|
||||
assert!(e.iter().all(|x| x.node_id.is_none()));
|
||||
assert_eq!(e[1].position, [3.0, 0.0, 1.5]);
|
||||
}
|
||||
|
||||
/// THE REGRESSION. Positions were stored keyed by list index and read back
|
||||
/// by node_id, so on any fleet not numbered 0,1,2,... every lookup missed
|
||||
/// and fell through to the hardcoded default the option exists to replace.
|
||||
/// Non-sequential ids are what catch that.
|
||||
#[test]
|
||||
fn non_sequential_node_ids_are_preserved() {
|
||||
let e = parse_node_position_entries("11:1,2,3;12:4,5,6;13:7,8,9");
|
||||
assert_eq!(e.len(), 3);
|
||||
assert_eq!(e[0].node_id, Some(11));
|
||||
assert_eq!(e[1].node_id, Some(12));
|
||||
assert_eq!(e[2].node_id, Some(13));
|
||||
assert_eq!(e[2].position, [7.0, 8.0, 9.0]);
|
||||
// The index would have been 0,1,2 -- none of which is a real node here.
|
||||
for (idx, entry) in e.iter().enumerate() {
|
||||
assert_ne!(
|
||||
entry.node_id,
|
||||
Some(idx as u8),
|
||||
"index and node id must not be conflated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-digit ids, and a fleet re-homed to even numbers -- the shape of the
|
||||
/// planned hardware check (nine boards moved from 0..8 to 0,2,4,...,16).
|
||||
#[test]
|
||||
fn two_digit_and_even_numbered_fleet_ids_parse() {
|
||||
let spec = "0:0,0,1;2:1,0,1;4:2,0,1;6:3,0,1;8:4,0,1;10:5,0,1;12:6,0,1;14:7,0,1;16:8,0,1";
|
||||
let e = parse_node_position_entries(spec);
|
||||
assert_eq!(e.len(), 9);
|
||||
let ids: Vec<u8> = e.iter().map(|x| x.node_id.unwrap()).collect();
|
||||
assert_eq!(ids, vec![0, 2, 4, 6, 8, 10, 12, 14, 16]);
|
||||
assert_eq!(e[8].position, [8.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// A non-zero starting index must work too -- nothing requires node 0.
|
||||
#[test]
|
||||
fn a_fleet_that_does_not_start_at_zero_parses() {
|
||||
let e = parse_node_position_entries("18:1.5,2.5,3.5");
|
||||
assert_eq!(e[0].node_id, Some(18));
|
||||
assert_eq!(e[0].position, [1.5, 2.5, 3.5]);
|
||||
}
|
||||
|
||||
/// Mixing is allowed: an unkeyed entry keeps its index.
|
||||
#[test]
|
||||
fn mixed_forms_keep_their_own_identity() {
|
||||
let e = parse_node_position_entries("0,0,1;7:9,9,9");
|
||||
assert_eq!(e[0].node_id, None);
|
||||
assert_eq!(e[1].node_id, Some(7));
|
||||
}
|
||||
|
||||
/// An unparseable id is skipped rather than silently treated as positional
|
||||
/// -- guessing there would reintroduce the exact ambiguity being removed.
|
||||
#[test]
|
||||
fn an_unparseable_node_id_is_skipped_not_guessed() {
|
||||
let e = parse_node_position_entries("abc:1,2,3;5:4,5,6");
|
||||
assert_eq!(e.len(), 1, "only the well-formed entry survives");
|
||||
assert_eq!(e[0].node_id, Some(5));
|
||||
}
|
||||
|
||||
/// 256 is out of range for a u8 node id and must not wrap to 0.
|
||||
#[test]
|
||||
fn an_out_of_range_node_id_is_rejected() {
|
||||
let e = parse_node_position_entries("256:1,2,3");
|
||||
assert!(e.is_empty(), "256 must not wrap to node 0");
|
||||
}
|
||||
|
||||
/// The positional view stays byte-compatible for the fuser, which indexes
|
||||
/// the list rather than keying it.
|
||||
#[test]
|
||||
fn the_positional_view_is_unchanged_by_explicit_ids() {
|
||||
let p = parse_node_positions("11:1,2,3;12:4,5,6");
|
||||
assert_eq!(p, vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
|
||||
}
|
||||
|
||||
/// The regression itself, at the map that `NodeInfo` actually reads.
|
||||
///
|
||||
/// The old code inserted keyed by list index and looked up by node_id. On a
|
||||
/// fleet numbered 11,12,13 that map holds keys 0,1,2, so every lookup misses
|
||||
/// and the node reports the hardcoded default. This asserts both halves:
|
||||
/// the real ids resolve, and the indices are absent.
|
||||
#[test]
|
||||
fn the_map_is_keyed_by_node_id_not_list_index() {
|
||||
let entries = parse_node_position_entries("11:1,2,3;12:4,5,6;13:7,8,9");
|
||||
let map = node_positions_by_id(&entries);
|
||||
|
||||
assert_eq!(map.get(&11), Some(&[1.0, 2.0, 3.0]));
|
||||
assert_eq!(map.get(&12), Some(&[4.0, 5.0, 6.0]));
|
||||
assert_eq!(map.get(&13), Some(&[7.0, 8.0, 9.0]));
|
||||
|
||||
for idx in 0u8..3 {
|
||||
assert!(
|
||||
map.get(&idx).is_none(),
|
||||
"list index {idx} must not be a key -- that is the bug: a lookup by node_id would miss and fall back to the hardcoded position"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Positional input keeps the old behaviour exactly: index becomes the key.
|
||||
/// This is what made the bug invisible on a 0..8 fleet.
|
||||
#[test]
|
||||
fn positional_input_still_keys_by_index() {
|
||||
let entries = parse_node_position_entries("0,0,1;3,0,1;6,0,1");
|
||||
let map = node_positions_by_id(&entries);
|
||||
assert_eq!(map.get(&0), Some(&[0.0, 0.0, 1.0]));
|
||||
assert_eq!(map.get(&1), Some(&[3.0, 0.0, 1.0]));
|
||||
assert_eq!(map.get(&2), Some(&[6.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// A re-homed fleet at 0,2,4,...,16 resolves on the real ids, and the odd
|
||||
/// indices in between are absent.
|
||||
#[test]
|
||||
fn an_even_numbered_fleet_resolves_by_id() {
|
||||
let spec = "0:0,0,1;2:1,0,1;4:2,0,1;6:3,0,1;8:4,0,1;10:5,0,1;12:6,0,1;14:7,0,1;16:8,0,1";
|
||||
let map = node_positions_by_id(&parse_node_position_entries(spec));
|
||||
assert_eq!(map.len(), 9);
|
||||
assert_eq!(map.get(&16), Some(&[8.0, 0.0, 1.0]));
|
||||
assert_eq!(map.get(&10), Some(&[5.0, 0.0, 1.0]));
|
||||
// Index-keyed would have produced 0..8; 1,3,5,7 are not real nodes here.
|
||||
for odd in [1u8, 3, 5, 7] {
|
||||
assert!(map.get(&odd).is_none(), "node {odd} does not exist in this fleet");
|
||||
}
|
||||
}
|
||||
|
||||
/// A duplicate id keeps the later entry rather than silently holding two.
|
||||
#[test]
|
||||
fn a_duplicate_node_id_keeps_the_later_entry() {
|
||||
let map = node_positions_by_id(&parse_node_position_entries("4:1,1,1;4:2,2,2"));
|
||||
assert_eq!(map.len(), 1);
|
||||
assert_eq!(map.get(&4), Some(&[2.0, 2.0, 2.0]));
|
||||
}
|
||||
|
||||
/// Whitespace around entries and coordinates is tolerated.
|
||||
#[test]
|
||||
fn whitespace_is_tolerated() {
|
||||
let e = parse_node_position_entries(" 3 : 1.0 , 2.0 , 3.0 ");
|
||||
assert_eq!(e.len(), 1);
|
||||
assert_eq!(e[0].node_id, Some(3));
|
||||
assert_eq!(e[0].position, [1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
/// Regression: a freshly-started (`Uncalibrated`) field model must begin
|
||||
/// collecting once frames arrive. Before the fix, `maybe_feed_calibration`
|
||||
/// only fed while already `Collecting`, but only `feed_calibration` sets
|
||||
@@ -399,10 +675,7 @@ mod tests {
|
||||
|
||||
// n_subcarriers defaults to 56; one single-link frame of that width.
|
||||
let frame = vec![0.5_f64; 56];
|
||||
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
|
||||
history.push_back(frame);
|
||||
|
||||
maybe_feed_calibration(&mut field, &history);
|
||||
assert!(maybe_feed_calibration(&mut field, &frame));
|
||||
|
||||
assert_eq!(
|
||||
field.status(),
|
||||
@@ -415,8 +688,8 @@ mod tests {
|
||||
"frame count must advance past 0"
|
||||
);
|
||||
|
||||
// Subsequent frames keep accumulating while Collecting.
|
||||
maybe_feed_calibration(&mut field, &history);
|
||||
// A subsequent unique frame keeps accumulating while Collecting.
|
||||
assert!(maybe_feed_calibration(&mut field, &frame));
|
||||
assert_eq!(field.calibration_frame_count(), 2);
|
||||
}
|
||||
|
||||
@@ -431,10 +704,7 @@ mod tests {
|
||||
|
||||
// 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw.
|
||||
let wide = vec![0.5_f64; 128];
|
||||
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
|
||||
history.push_back(wide);
|
||||
|
||||
maybe_feed_calibration(&mut field, &history);
|
||||
assert!(maybe_feed_calibration(&mut field, &wide));
|
||||
|
||||
assert_eq!(
|
||||
field.status(),
|
||||
@@ -448,6 +718,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_feed_calibration_rejects_an_empty_observation() {
|
||||
let mut field = FieldModel::new(single_link_config()).expect("field model");
|
||||
assert!(!maybe_feed_calibration(&mut field, &[]));
|
||||
assert_eq!(field.calibration_frame_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrated_occupancy_is_fresh_model_only_and_normalizes_runtime_frames() {
|
||||
let field = fresh_test_model();
|
||||
@@ -467,6 +744,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupancy_falls_back_after_expiry_without_a_process_restart() {
|
||||
let field = fresh_test_model();
|
||||
let baseline = field.modes().unwrap().baseline[0].clone();
|
||||
let history: VecDeque<Vec<f64>> =
|
||||
(0..OCCUPANCY_WINDOW).map(|_| baseline.clone()).collect();
|
||||
|
||||
assert_eq!(field.status(), CalibrationStatus::Fresh);
|
||||
assert_eq!(
|
||||
calibration_status_at(&field, 1_500_000),
|
||||
CalibrationStatus::Fresh
|
||||
);
|
||||
assert_eq!(
|
||||
occupancy_or_fallback(&field, &history, 1_500_000, 0.0, 0),
|
||||
0,
|
||||
"a fresh field model may resolve its learned baseline as empty"
|
||||
);
|
||||
|
||||
assert_eq!(field.status(), CalibrationStatus::Fresh);
|
||||
assert_eq!(
|
||||
calibration_status_at(&field, 4_000_000),
|
||||
CalibrationStatus::Expired
|
||||
);
|
||||
assert_eq!(
|
||||
occupancy_or_fallback(&field, &history, 4_000_000, 0.0, 0),
|
||||
score_to_person_count(0.0, 0),
|
||||
"an expired in-memory model must fall back even though its cached status is fresh"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_runtime_reference_scores_empty_and_rejects_shift() {
|
||||
let config = FieldModelConfig {
|
||||
@@ -502,6 +809,9 @@ mod tests {
|
||||
.expect("background score");
|
||||
assert!(background.matches_empty);
|
||||
assert_eq!(background.reference_window_count, 12);
|
||||
assert!(background.reliable);
|
||||
assert_eq!(background.maturity, 0.6);
|
||||
assert!(background.normalized_residual_z.is_some());
|
||||
assert!(background
|
||||
.score
|
||||
.is_some_and(|score| (0.5..=1.0).contains(&score)));
|
||||
@@ -530,5 +840,6 @@ mod tests {
|
||||
.expect("shifted background result");
|
||||
assert!(!shifted_background.matches_empty);
|
||||
assert_eq!(shifted_background.score, Some(0.0));
|
||||
assert!(shifted_background.reliable);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
//! Bounded summaries for ADR-323 Realtek RTL8721Dx (AmebaDplus) 1x1 CSI frames.
|
||||
|
||||
use serde::Serialize;
|
||||
use wifi_densepose_hardware::realtek_csi::{CsiFlags, CsiFrame};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub(crate) struct RealtekCsiSnapshot {
|
||||
pub event_type: &'static str,
|
||||
pub source: &'static str,
|
||||
pub csi_mode: &'static str,
|
||||
pub node_id: u8,
|
||||
pub sequence: u32,
|
||||
pub timestamp_us: u32,
|
||||
pub peer_mac: String,
|
||||
pub trig_mac: String,
|
||||
pub channel: u8,
|
||||
pub bandwidth_mhz: u16,
|
||||
pub rx_rate: u8,
|
||||
pub protocol_mode: String,
|
||||
pub num_sub_carrier: u16,
|
||||
pub num_bit_per_tone: u8,
|
||||
pub decimation: u8,
|
||||
pub rssi_dbm: i8,
|
||||
pub rxsc: u8,
|
||||
pub csi_valid: bool,
|
||||
pub synthetic: bool,
|
||||
pub mean_amplitude: Option<f32>,
|
||||
pub peak_amplitude: Option<f32>,
|
||||
}
|
||||
|
||||
impl RealtekCsiSnapshot {
|
||||
pub(crate) fn from_frame(frame: &CsiFrame) -> Self {
|
||||
let synthetic = frame.flags.contains(CsiFlags::SYNTHETIC);
|
||||
let (mean_amplitude, peak_amplitude) = amplitude_summary(frame);
|
||||
Self {
|
||||
event_type: "realtek_csi",
|
||||
source: if synthetic {
|
||||
"realtek_csi:simulated"
|
||||
} else {
|
||||
"realtek_csi"
|
||||
},
|
||||
csi_mode: match frame.csi_mode {
|
||||
wifi_densepose_hardware::realtek_csi::CsiMode::Passive => "passive",
|
||||
wifi_densepose_hardware::realtek_csi::CsiMode::Active => "active",
|
||||
wifi_densepose_hardware::realtek_csi::CsiMode::BoardToBoard => "board_to_board",
|
||||
},
|
||||
node_id: frame.node_id,
|
||||
sequence: frame.sequence,
|
||||
timestamp_us: frame.timestamp_us,
|
||||
peer_mac: mac_string(&frame.peer_mac),
|
||||
trig_mac: mac_string(&frame.trig_mac),
|
||||
channel: frame.channel,
|
||||
bandwidth_mhz: if frame.bandwidth == 1 { 40 } else { 20 },
|
||||
rx_rate: frame.rx_rate,
|
||||
protocol_mode: format!("{:?}", frame.protocol_mode).to_ascii_lowercase(),
|
||||
num_sub_carrier: frame.num_sub_carrier,
|
||||
num_bit_per_tone: frame.num_bit_per_tone,
|
||||
decimation: frame.decimation,
|
||||
rssi_dbm: frame.rssi_dbm,
|
||||
rxsc: frame.rxsc,
|
||||
csi_valid: frame.csi_valid,
|
||||
synthetic,
|
||||
mean_amplitude,
|
||||
peak_amplitude,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mac_string(mac: &[u8; 6]) -> String {
|
||||
mac.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
fn amplitude_summary(frame: &CsiFrame) -> (Option<f32>, Option<f32>) {
|
||||
if frame.payload.is_empty() {
|
||||
return (None, None);
|
||||
}
|
||||
let amplitudes: Vec<f32> = frame
|
||||
.payload
|
||||
.iter()
|
||||
.map(|(i, q)| (*i as f32).hypot(*q as f32))
|
||||
.collect();
|
||||
let mean = amplitudes.iter().sum::<f32>() / amplitudes.len() as f32;
|
||||
let peak = amplitudes.into_iter().max_by(f32::total_cmp);
|
||||
(Some(mean), peak)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wifi_densepose_hardware::realtek_csi::simulator::{RealtekCsiSimulator, SimulatorConfig};
|
||||
|
||||
#[test]
|
||||
fn simulator_summary_preserves_dimensions_and_provenance() {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
|
||||
assert_eq!(snapshot.source, "realtek_csi:simulated");
|
||||
assert_eq!(snapshot.num_sub_carrier, 52);
|
||||
assert_eq!(snapshot.csi_mode, "passive");
|
||||
assert!(snapshot.mean_amplitude.unwrap() > 0.0);
|
||||
assert!(snapshot.peak_amplitude.unwrap() >= snapshot.mean_amplitude.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_string_is_distinct_from_realtek_radar() {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
|
||||
// Must never collapse to the bare "realtek"/"realtek:simulated" source
|
||||
// strings already owned by the RTL8720F radar path (realtek_radar.rs) —
|
||||
// effective_source()'s prefix matching in main.rs depends on this.
|
||||
assert_ne!(snapshot.source, "realtek");
|
||||
assert_ne!(snapshot.source, "realtek:simulated");
|
||||
assert!(snapshot.source.starts_with("realtek_csi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_addresses_are_formatted_colon_separated() {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
|
||||
assert_eq!(snapshot.peer_mac, "a4:39:b3:a4:be:2d");
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,10 @@ impl AppStateInner {
|
||||
pub fn person_count(&self) -> usize {
|
||||
use crate::csi::score_to_person_count;
|
||||
use crate::field_bridge;
|
||||
let observed_at_us = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_micros() as u64)
|
||||
.unwrap_or(0);
|
||||
match self.field_model.as_ref() {
|
||||
Some(fm) => {
|
||||
let history = if !self.frame_history.is_empty() {
|
||||
@@ -559,6 +563,7 @@ impl AppStateInner {
|
||||
field_bridge::occupancy_or_fallback(
|
||||
fm,
|
||||
history,
|
||||
observed_at_us,
|
||||
self.smoothed_person_score,
|
||||
self.prev_person_count,
|
||||
)
|
||||
|
||||
@@ -346,6 +346,31 @@ impl VitalSignDetector {
|
||||
self.frame_count = 0;
|
||||
}
|
||||
|
||||
/// Retune temporal windows to an observed CSI clock. Changes smaller than
|
||||
/// twenty percent are ignored to avoid resetting on normal arrival jitter.
|
||||
/// A real clock change clears history so samples from different clocks are
|
||||
/// never interpreted in one spectrum.
|
||||
pub fn reconfigure_sample_rate(&mut self, sample_rate: f64) -> bool {
|
||||
if !sample_rate.is_finite() || sample_rate < 1.0 {
|
||||
return false;
|
||||
}
|
||||
let relative_change = (sample_rate - self.sample_rate).abs() / self.sample_rate.max(1.0);
|
||||
if relative_change < 0.20 {
|
||||
return false;
|
||||
}
|
||||
self.sample_rate = sample_rate;
|
||||
self.breathing_capacity =
|
||||
((sample_rate * self.breathing_window_secs) as usize).max(1);
|
||||
self.heartbeat_capacity =
|
||||
((sample_rate * self.heartbeat_window_secs) as usize).max(1);
|
||||
self.reset();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn sample_rate_hz(&self) -> f64 {
|
||||
self.sample_rate
|
||||
}
|
||||
|
||||
/// Current buffer fill levels for diagnostics.
|
||||
/// Returns (breathing_len, breathing_capacity, heartbeat_len, heartbeat_capacity).
|
||||
pub fn buffer_status(&self) -> (usize, usize, usize, usize) {
|
||||
@@ -842,6 +867,22 @@ mod tests {
|
||||
assert_eq!(hb_len, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_clock_reconfigures_once_and_clears_mixed_history() {
|
||||
let mut detector = VitalSignDetector::new(20.0);
|
||||
let amp = vec![10.0; 56];
|
||||
let phase = vec![0.0; 56];
|
||||
for _ in 0..32 {
|
||||
detector.process_frame(&, &phase);
|
||||
}
|
||||
assert!(!detector.reconfigure_sample_rate(18.0));
|
||||
assert_eq!(detector.sample_rate_hz(), 20.0);
|
||||
assert!(detector.reconfigure_sample_rate(12.0));
|
||||
assert_eq!(detector.sample_rate_hz(), 12.0);
|
||||
assert_eq!(detector.buffer_status(), (0, 360, 0, 180));
|
||||
assert!(!detector.reconfigure_sample_rate(f64::NAN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vital_signs_default() {
|
||||
let vs = VitalSigns::default();
|
||||
|
||||
@@ -57,6 +57,10 @@ pub const MIN_CALIBRATION_FRAMES: usize =
|
||||
RUNTIME_OCCUPANCY_WINDOW * MIN_BACKGROUND_REFERENCE_WINDOWS;
|
||||
const CALIBRATION_BACKGROUND_WINDOWS_MAX: usize = 512;
|
||||
const EMPTY_ROOM_RESIDUAL_MARGIN: f64 = 1.5;
|
||||
const EMPTY_ROOM_HELD_OUT_MARGIN: f64 = 1.10;
|
||||
const EMPTY_ROOM_REFINEMENT_MAX_LIFT: f64 = 1.25;
|
||||
const EMPTY_ROOM_REFINEMENT_MIN_SAMPLES: usize = 10;
|
||||
const EMPTY_ROOM_REFINEMENT_MAX_SAMPLES: usize = 64;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
@@ -362,6 +366,11 @@ pub struct FieldNormalMode {
|
||||
/// an empty room model into positive person evidence.
|
||||
#[serde(default)]
|
||||
pub empty_room_residual_energy_reference: Vec<f64>,
|
||||
/// Number of operator-confirmed held-out empty refinements. Version one
|
||||
/// permits one bounded refinement so repeated calls cannot ratchet the
|
||||
/// background boundary upward until a weak occupant is suppressed.
|
||||
#[serde(default)]
|
||||
pub empty_room_residual_refinement_count: u8,
|
||||
}
|
||||
|
||||
/// A bounded comparison between one runtime window and the learned empty room
|
||||
@@ -371,12 +380,28 @@ pub struct FieldNormalMode {
|
||||
pub struct EmptyRoomMatch {
|
||||
pub matches_empty: bool,
|
||||
pub score: Option<f64>,
|
||||
/// Robust residual distance from the quiet reference median.
|
||||
pub normalized_residual_z: Option<f64>,
|
||||
/// Reference coverage relative to the normal calibration target.
|
||||
pub maturity: f64,
|
||||
/// Whether the reference set is large and valid enough to suppress change.
|
||||
pub reliable: bool,
|
||||
pub residual_energy: f64,
|
||||
pub residual_energy_threshold: f64,
|
||||
pub window_size: usize,
|
||||
pub reference_window_count: usize,
|
||||
}
|
||||
|
||||
/// Receipt for one bounded, operator-confirmed empty-room refinement.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EmptyRoomRefinement {
|
||||
pub threshold_before: f64,
|
||||
pub threshold_after: f64,
|
||||
pub held_out_p95: f64,
|
||||
pub held_out_sample_count: usize,
|
||||
pub reference_window_count: usize,
|
||||
}
|
||||
|
||||
/// Body perturbation extracted from a CSI observation.
|
||||
///
|
||||
/// After subtracting the baseline and projecting out environmental
|
||||
@@ -625,6 +650,27 @@ fn percentile_95(mut values: Vec<f64>) -> Option<f64> {
|
||||
values.get(index).copied()
|
||||
}
|
||||
|
||||
fn robust_residual_z(reference: &[f64], residual: f64) -> Option<f64> {
|
||||
if !residual.is_finite() || reference.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<f64> = reference
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
.collect();
|
||||
if sorted.len() != reference.len() {
|
||||
return None;
|
||||
}
|
||||
sorted.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let median = sorted[sorted.len() / 2];
|
||||
let mut deviations: Vec<f64> = sorted.iter().map(|value| (value - median).abs()).collect();
|
||||
deviations.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mad = deviations[deviations.len() / 2];
|
||||
let robust_scale = (1.4826 * mad).max((median.abs() * 0.01).max(f64::EPSILON));
|
||||
Some((residual - median) / robust_scale)
|
||||
}
|
||||
|
||||
#[cfg(feature = "eigenvalue")]
|
||||
fn percentile_95_usize(mut values: Vec<usize>) -> Option<usize> {
|
||||
values.sort_unstable();
|
||||
@@ -692,6 +738,73 @@ impl FieldModel {
|
||||
.and_then(|modes| modes.empty_room_residual_energy_threshold)
|
||||
}
|
||||
|
||||
/// Refine a completed single-link empty-room boundary with scalar residuals
|
||||
/// measured during a separate operator-confirmed empty holdout.
|
||||
///
|
||||
/// This never ingests or persists raw CSI. The lift is capped at 25 percent
|
||||
/// and may happen only once per snapshot, preventing repeated calls from
|
||||
/// silently erasing weak occupied evidence.
|
||||
pub fn refine_empty_room_residual_boundary(
|
||||
&mut self,
|
||||
held_out_residuals: &[f64],
|
||||
) -> Result<EmptyRoomRefinement, FieldModelError> {
|
||||
if self.config.n_links != 1 || self.status != CalibrationStatus::Fresh {
|
||||
return Err(FieldModelError::NotCalibrated);
|
||||
}
|
||||
if !(EMPTY_ROOM_REFINEMENT_MIN_SAMPLES..=EMPTY_ROOM_REFINEMENT_MAX_SAMPLES)
|
||||
.contains(&held_out_residuals.len())
|
||||
|| held_out_residuals
|
||||
.iter()
|
||||
.any(|value| !value.is_finite() || *value < 0.0)
|
||||
{
|
||||
return Err(FieldModelError::InvalidConfig(
|
||||
"held-out empty residuals are missing, excessive, or non-finite".into(),
|
||||
));
|
||||
}
|
||||
let modes = self.modes.as_mut().ok_or(FieldModelError::NotCalibrated)?;
|
||||
if modes.empty_room_residual_refinement_count != 0 {
|
||||
return Err(FieldModelError::InvalidConfig(
|
||||
"the empty-room boundary already has its one permitted held-out refinement"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if modes.empty_room_residual_energy_reference.len() + held_out_residuals.len()
|
||||
> CALIBRATION_BACKGROUND_WINDOWS_MAX
|
||||
{
|
||||
return Err(FieldModelError::InvalidConfig(
|
||||
"the empty-room residual reference limit would be exceeded".into(),
|
||||
));
|
||||
}
|
||||
let threshold_before = modes
|
||||
.empty_room_residual_energy_threshold
|
||||
.ok_or(FieldModelError::NotCalibrated)?
|
||||
.max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX);
|
||||
let held_out_p95 = percentile_95(held_out_residuals.to_vec())
|
||||
.ok_or(FieldModelError::NotCalibrated)?;
|
||||
let proposed = held_out_p95 * EMPTY_ROOM_HELD_OUT_MARGIN;
|
||||
let threshold_after = threshold_before
|
||||
.max(proposed.min(threshold_before * EMPTY_ROOM_REFINEMENT_MAX_LIFT));
|
||||
|
||||
modes
|
||||
.empty_room_residual_energy_reference
|
||||
.extend_from_slice(held_out_residuals);
|
||||
modes
|
||||
.empty_room_residual_energy_reference
|
||||
.sort_by(|left, right| {
|
||||
left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
modes.empty_room_residual_energy_threshold = Some(threshold_after);
|
||||
modes.empty_room_residual_refinement_count = 1;
|
||||
|
||||
Ok(EmptyRoomRefinement {
|
||||
threshold_before,
|
||||
threshold_after,
|
||||
held_out_p95,
|
||||
held_out_sample_count: held_out_residuals.len(),
|
||||
reference_window_count: modes.empty_room_residual_energy_reference.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Export aggregate model state suitable for local restart persistence.
|
||||
/// Raw calibration observations and Welford accumulators are not exported.
|
||||
pub fn export_snapshot(&self) -> Result<FieldModelSnapshotV1, FieldModelError> {
|
||||
@@ -762,6 +875,7 @@ impl FieldModel {
|
||||
.is_none_or(|threshold| threshold.is_finite() && threshold >= 0.0)
|
||||
&& modes.empty_room_residual_energy_reference.len()
|
||||
<= CALIBRATION_BACKGROUND_WINDOWS_MAX
|
||||
&& modes.empty_room_residual_refinement_count <= 1
|
||||
&& modes
|
||||
.empty_room_residual_energy_reference
|
||||
.iter()
|
||||
@@ -1118,6 +1232,7 @@ impl FieldModel {
|
||||
baseline_runtime_window_size: None,
|
||||
empty_room_residual_energy_threshold: None,
|
||||
empty_room_residual_energy_reference: Vec::new(),
|
||||
empty_room_residual_refinement_count: 0,
|
||||
};
|
||||
|
||||
// The full calibration covariance and a fifty frame runtime covariance
|
||||
@@ -1227,12 +1342,20 @@ impl FieldModel {
|
||||
let residual_energy_threshold = modes
|
||||
.empty_room_residual_energy_threshold?
|
||||
.max(EMPTY_ROOM_RESIDUAL_ENERGY_MAX);
|
||||
let matches_empty = residual_energy <= residual_energy_threshold;
|
||||
let reference_window_count = modes.empty_room_residual_energy_reference.len();
|
||||
let score = if !matches_empty {
|
||||
Some(0.0)
|
||||
} else if reference_window_count == 0 {
|
||||
let maturity = (reference_window_count as f64
|
||||
/ MIN_BACKGROUND_REFERENCE_WINDOWS as f64)
|
||||
.clamp(0.0, 1.0);
|
||||
let reliable = reference_window_count >= 10
|
||||
&& modes
|
||||
.empty_room_residual_energy_reference
|
||||
.iter()
|
||||
.all(|value| value.is_finite() && *value >= 0.0);
|
||||
let matches_empty = reliable && residual_energy <= residual_energy_threshold;
|
||||
let score = if !reliable {
|
||||
None
|
||||
} else if !matches_empty {
|
||||
Some(0.0)
|
||||
} else {
|
||||
let at_or_below = modes
|
||||
.empty_room_residual_energy_reference
|
||||
@@ -1243,6 +1366,12 @@ impl FieldModel {
|
||||
Some(EmptyRoomMatch {
|
||||
matches_empty,
|
||||
score,
|
||||
normalized_residual_z: robust_residual_z(
|
||||
&modes.empty_room_residual_energy_reference,
|
||||
residual_energy,
|
||||
),
|
||||
maturity,
|
||||
reliable,
|
||||
residual_energy,
|
||||
residual_energy_threshold,
|
||||
window_size,
|
||||
@@ -2061,11 +2190,52 @@ mod tests {
|
||||
.expect("background match");
|
||||
assert!(background.matches_empty);
|
||||
assert_eq!(background.reference_window_count, 12);
|
||||
assert!(background.reliable);
|
||||
assert_eq!(background.maturity, 0.6);
|
||||
assert!(background.normalized_residual_z.is_some());
|
||||
assert!(background
|
||||
.score
|
||||
.is_some_and(|score| (0.5..=1.0).contains(&score)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_out_empty_refinement_is_bounded_and_single_use() {
|
||||
let config = FieldModelConfig {
|
||||
n_links: 1,
|
||||
n_subcarriers: 8,
|
||||
n_modes: 2,
|
||||
min_calibration_frames: 100,
|
||||
min_calibration_duration_s: 0.0,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
};
|
||||
let mut model = FieldModel::new(config).unwrap();
|
||||
for frame_index in 0..1_000 {
|
||||
let phase = frame_index as f64 * 0.071;
|
||||
let frame = (0..8)
|
||||
.map(|index| 20.0 + index as f64 * 0.1 + (phase + index as f64).sin())
|
||||
.collect();
|
||||
model.feed_calibration(&[frame]).unwrap();
|
||||
}
|
||||
model.finalize_calibration(1_000_000, 0).unwrap();
|
||||
|
||||
let before = model.empty_room_residual_energy_threshold().unwrap();
|
||||
let residuals = vec![before * 1.4; 12];
|
||||
let receipt = model
|
||||
.refine_empty_room_residual_boundary(&residuals)
|
||||
.unwrap();
|
||||
assert_eq!(receipt.threshold_before, before);
|
||||
assert!(receipt.threshold_after > before);
|
||||
assert!(receipt.threshold_after <= before * EMPTY_ROOM_REFINEMENT_MAX_LIFT);
|
||||
assert_eq!(receipt.held_out_sample_count, 12);
|
||||
assert_eq!(
|
||||
model.modes().unwrap().empty_room_residual_refinement_count,
|
||||
1
|
||||
);
|
||||
assert!(model
|
||||
.refine_empty_room_residual_boundary(&residuals)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "eigenvalue")]
|
||||
#[test]
|
||||
fn runtime_reference_rejects_shifted_held_out_window() {
|
||||
@@ -2111,6 +2281,51 @@ mod tests {
|
||||
let background = model.empty_room_match(&shifted).expect("shifted match");
|
||||
assert!(!background.matches_empty);
|
||||
assert_eq!(background.score, Some(0.0));
|
||||
assert!(background.reliable);
|
||||
assert!(background.normalized_residual_z.is_some());
|
||||
}
|
||||
|
||||
#[cfg(feature = "eigenvalue")]
|
||||
#[test]
|
||||
fn sparse_background_reference_cannot_suppress_runtime_change() {
|
||||
let config = FieldModelConfig {
|
||||
n_links: 1,
|
||||
n_subcarriers: 56,
|
||||
n_modes: 3,
|
||||
min_calibration_frames: 500,
|
||||
min_calibration_duration_s: 0.0,
|
||||
baseline_expiry_s: 86_400.0,
|
||||
};
|
||||
let mut model = FieldModel::new(config).unwrap();
|
||||
let mut calibration = Vec::new();
|
||||
for frame_index in 0..600 {
|
||||
let time = frame_index as f64 * 0.071;
|
||||
let frame: Vec<f64> = (0..56)
|
||||
.map(|subcarrier| {
|
||||
let carrier = subcarrier as f64;
|
||||
18.0 + carrier * 0.08
|
||||
+ (time + carrier * 0.13).sin() * 0.8
|
||||
+ (time * 0.37 + carrier * 0.031).cos() * 0.35
|
||||
})
|
||||
.collect();
|
||||
model.feed_calibration(&[frame.clone()]).unwrap();
|
||||
calibration.push(frame);
|
||||
}
|
||||
model.finalize_calibration(1_000_000, 0).unwrap();
|
||||
model
|
||||
.modes
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.empty_room_residual_energy_reference
|
||||
.truncate(9);
|
||||
|
||||
let result = model
|
||||
.empty_room_match(&calibration[550..600])
|
||||
.expect("background comparison");
|
||||
assert!(!result.reliable);
|
||||
assert!(!result.matches_empty);
|
||||
assert_eq!(result.score, None);
|
||||
assert_eq!(result.maturity, 0.45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
//! - `ruvector-attn-mincut` for cross-node spectrogram attention gating
|
||||
//! - `ruvector-mincut` for person separation (DynamicMinCut)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::cir::{CirConfig, CirEstimator};
|
||||
@@ -72,6 +73,12 @@ pub struct FusedSensingFrame {
|
||||
/// Per-node multi-band frames (preserved for geometry computations).
|
||||
pub node_frames: Vec<MultiBandCsiFrame>,
|
||||
/// Node positions (x, y, z) in meters from deployment configuration.
|
||||
///
|
||||
/// Parallel to [`Self::node_frames`]: `node_positions[i]` is the
|
||||
/// configured position of `node_frames[i].node_id`, resolved by identity
|
||||
/// rather than by rank (issue #1866). Nodes with no configured position
|
||||
/// appear at the origin. Index `i` is this cycle's cohort rank, which is
|
||||
/// *not* a node id -- read the id from the paired frame.
|
||||
pub node_positions: Vec<[f32; 3]>,
|
||||
/// Number of active nodes contributing to this frame.
|
||||
pub active_nodes: usize,
|
||||
@@ -147,8 +154,31 @@ impl MultistaticConfig {
|
||||
/// maximum legitimate spread between two paired node frames in one cycle is
|
||||
/// the full cycle length `tdm_total_slots × slot_duration_us` (last slot vs
|
||||
/// first slot). The hard guard is set to that cycle length plus 20% jitter
|
||||
/// headroom; the soft guard to ~⅓ of the cycle (a normal adjacent-slot pair
|
||||
/// fuses cleanly, a near-full-cycle spread is flagged as loose alignment).
|
||||
/// headroom; the soft guard to the schedule's own **designed stagger**,
|
||||
/// `(slots - 1) * slot_duration_us` -- the offset between the first and
|
||||
/// last slot, which is the spread a complete, healthy cycle produces.
|
||||
///
|
||||
/// # Why not ~⅓ of the cycle (issue #1866-adjacent, corrected)
|
||||
///
|
||||
/// The soft guard was previously `cycle_us / 3`, reasoned about as "a
|
||||
/// normal *adjacent-slot pair* fuses cleanly". But [`MultistaticFuser::fuse_scored`]
|
||||
/// does not compare pairs -- it takes `max(timestamp) - min(timestamp)`
|
||||
/// across the **whole cohort**, so the spread it measures is always the
|
||||
/// first-to-last offset, `(N - 1)` slots. Against that:
|
||||
///
|
||||
/// ```text
|
||||
/// soft < stagger <=> N*slot/3 < (N-1)*slot <=> N/3 < N-1 <=> N > 1.5
|
||||
/// ```
|
||||
///
|
||||
/// which holds for **every** `N >= 2`. The derived soft guard therefore
|
||||
/// never covered the schedule it was derived from, at any fleet size, and
|
||||
/// a complete healthy cycle was flagged as a `TimestampMismatch`
|
||||
/// contradiction -- which demotes the BFLD privacy class one step
|
||||
/// (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs.
|
||||
///
|
||||
/// The doc example below is the smallest demonstration: the 2-slot mesh
|
||||
/// whose **measured** 18,194 µs spread motivated this constructor derived
|
||||
/// a 12,000 µs soft guard, below its own motivating measurement.
|
||||
///
|
||||
/// `tdm_total_slots` is clamped to ≥ 1. All other fields take their
|
||||
/// [`Default`] values.
|
||||
@@ -160,6 +190,9 @@ impl MultistaticConfig {
|
||||
/// // reported 18,194 µs 2-slot spread.
|
||||
/// let cfg = MultistaticConfig::for_tdm_schedule(2, 18_000);
|
||||
/// assert!(cfg.guard_interval_us >= 18_194);
|
||||
/// // ...and the soft guard now covers it too, so a healthy 2-slot cycle
|
||||
/// // is not recorded as a contradiction.
|
||||
/// assert!(cfg.soft_guard_us >= 18_194);
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn for_tdm_schedule(tdm_total_slots: usize, slot_duration_us: u64) -> Self {
|
||||
@@ -167,8 +200,19 @@ impl MultistaticConfig {
|
||||
let cycle_us = slots.saturating_mul(slot_duration_us);
|
||||
// +20% jitter headroom on the full cycle.
|
||||
let guard_interval_us = cycle_us.saturating_add(cycle_us / 5).max(1);
|
||||
// Soft band at ~⅓ cycle, kept strictly below the hard guard.
|
||||
let soft_guard_us = (cycle_us / 3).clamp(1, guard_interval_us.saturating_sub(1).max(1));
|
||||
// Soft band at the schedule's designed stagger -- the first-to-last
|
||||
// slot offset, which is what `fuse_scored` measures. Kept strictly
|
||||
// below the hard guard, so a spread approaching a full cycle is still
|
||||
// flagged as loose alignment.
|
||||
// +20% jitter headroom, mirroring the hard guard: the *nominal* stagger
|
||||
// is not the observed one. The 2-slot mesh below has an 18,000 us
|
||||
// nominal stagger but a MEASURED 18,194 us spread, so a bare nominal
|
||||
// bound would flag the very cycle it was derived from. Always strictly
|
||||
// below the hard guard, since stagger < cycle.
|
||||
let stagger_us = slots.saturating_sub(1).saturating_mul(slot_duration_us);
|
||||
let soft_guard_us = stagger_us
|
||||
.saturating_add(stagger_us / 5)
|
||||
.clamp(1, guard_interval_us.saturating_sub(1).max(1));
|
||||
Self {
|
||||
guard_interval_us,
|
||||
soft_guard_us,
|
||||
@@ -191,8 +235,16 @@ impl MultistaticConfig {
|
||||
/// disable the CIR path and keep the legacy frequency-domain coherence only.
|
||||
pub struct MultistaticFuser {
|
||||
config: MultistaticConfig,
|
||||
/// Node positions in 3D space (meters).
|
||||
node_positions: Vec<[f32; 3]>,
|
||||
/// Node positions in 3D space (meters), keyed by node id (issue #1866).
|
||||
///
|
||||
/// Keyed rather than indexed because [`Self::fuse`] receives a *compacted*
|
||||
/// cohort: `node_frames_from_states_with_guard` drops nodes that are
|
||||
/// stale, outside the timestamp guard, or have an empty history. Under
|
||||
/// index addressing, one node missing a single beacon re-ranks every node
|
||||
/// above it and silently hands them their neighbour's coordinates. Each
|
||||
/// `MultiBandCsiFrame` carries its own `node_id`, so the position is
|
||||
/// looked up by identity and a silent node moves nobody.
|
||||
node_positions: HashMap<u8, [f32; 3]>,
|
||||
/// Optional shared CIR estimator (ADR-134). `None` = legacy path only.
|
||||
cir_estimator: Option<Arc<CirEstimator>>,
|
||||
}
|
||||
@@ -212,7 +264,7 @@ impl MultistaticFuser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: MultistaticConfig::default(),
|
||||
node_positions: Vec::new(),
|
||||
node_positions: HashMap::new(),
|
||||
cir_estimator: None,
|
||||
}
|
||||
}
|
||||
@@ -221,7 +273,7 @@ impl MultistaticFuser {
|
||||
pub fn with_config(config: MultistaticConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
node_positions: Vec::new(),
|
||||
node_positions: HashMap::new(),
|
||||
cir_estimator: None,
|
||||
}
|
||||
}
|
||||
@@ -267,11 +319,39 @@ impl MultistaticFuser {
|
||||
fuser
|
||||
}
|
||||
|
||||
/// Set node positions for geometric diversity computations.
|
||||
/// Set node positions from a positional list (**legacy compatibility**).
|
||||
///
|
||||
/// The list index is the node id: `positions[i]` belongs to node `i`. That
|
||||
/// is the meaning the `--node-positions` flag has always carried when its
|
||||
/// entries are given without an explicit `node_id:` prefix, so this
|
||||
/// mapping is preserved exactly rather than reinterpreted.
|
||||
///
|
||||
/// This is only correct for fleets whose ids are dense and zero-based.
|
||||
/// Prefer [`Self::set_node_positions_by_id`], which states the identity
|
||||
/// instead of inferring it from ordering.
|
||||
pub fn set_node_positions(&mut self, positions: Vec<[f32; 3]>) {
|
||||
self.node_positions = positions
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(idx, position)| (idx as u8, position))
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Set node positions keyed by node id (issue #1866, preferred).
|
||||
///
|
||||
/// The position travels with the board it was measured for. Nodes absent
|
||||
/// from the map fuse at the origin, exactly as before; nodes present but
|
||||
/// not currently reporting cost nothing and shift nobody.
|
||||
pub fn set_node_positions_by_id(&mut self, positions: HashMap<u8, [f32; 3]>) {
|
||||
self.node_positions = positions;
|
||||
}
|
||||
|
||||
/// Return the configured position for one node id, if any.
|
||||
#[must_use]
|
||||
pub fn node_position(&self, node_id: u8) -> Option<[f32; 3]> {
|
||||
self.node_positions.get(&node_id).copied()
|
||||
}
|
||||
|
||||
/// Return the configured hard timestamp guard in microseconds.
|
||||
///
|
||||
/// Callers that assemble frames before invoking [`Self::fuse`] use this
|
||||
@@ -283,11 +363,33 @@ impl MultistaticFuser {
|
||||
self.config.guard_interval_us
|
||||
}
|
||||
|
||||
/// Return the current node positions.
|
||||
pub fn node_positions(&self) -> &[[f32; 3]] {
|
||||
/// Return the configured positions keyed by node id.
|
||||
#[must_use]
|
||||
pub fn node_positions_by_id(&self) -> &HashMap<u8, [f32; 3]> {
|
||||
&self.node_positions
|
||||
}
|
||||
|
||||
/// Return the configured positions as a dense id-indexed list
|
||||
/// (**legacy compatibility view**).
|
||||
///
|
||||
/// Entry `i` is node `i`'s position, with unconfigured ids filled at the
|
||||
/// origin, so this round-trips [`Self::set_node_positions`]. It is a view
|
||||
/// of the configuration, *not* of what any particular fusion cycle used —
|
||||
/// a cycle's cohort is compacted, so read
|
||||
/// [`FusedSensingFrame::node_positions`] alongside its `node_frames` for
|
||||
/// that.
|
||||
#[must_use]
|
||||
pub fn node_positions(&self) -> Vec<[f32; 3]> {
|
||||
let Some(&max_id) = self.node_positions.keys().max() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut dense = vec![[0.0_f32, 0.0, 0.0]; max_id as usize + 1];
|
||||
for (&id, &position) in &self.node_positions {
|
||||
dense[id as usize] = position;
|
||||
}
|
||||
dense
|
||||
}
|
||||
|
||||
/// Fuse multiple node frames into a single `FusedSensingFrame`.
|
||||
///
|
||||
/// When only one node is provided, falls back to single-node mode
|
||||
@@ -360,11 +462,19 @@ impl MultistaticFuser {
|
||||
timestamps.sort_unstable();
|
||||
let timestamp_us = timestamps[timestamps.len() / 2];
|
||||
|
||||
// Build node positions list, filling with origin for unknown nodes
|
||||
let positions: Vec<[f32; 3]> = (0..n_nodes)
|
||||
.map(|i| {
|
||||
// Build the node positions list parallel to `node_frames`, resolving
|
||||
// each entry by that frame's own `node_id` (issue #1866). The cohort
|
||||
// handed to `fuse` is compacted -- stale, out-of-guard and
|
||||
// empty-history nodes are already dropped -- so a frame's index here
|
||||
// is its rank in this cycle, not its identity. Addressing by index
|
||||
// gave every node above a gap its neighbour's coordinates whenever a
|
||||
// single board missed a beacon. Unknown nodes still fill with the
|
||||
// origin, unchanged.
|
||||
let positions: Vec<[f32; 3]> = node_frames
|
||||
.iter()
|
||||
.map(|frame| {
|
||||
self.node_positions
|
||||
.get(i)
|
||||
.get(&frame.node_id)
|
||||
.copied()
|
||||
.unwrap_or([0.0, 0.0, 0.0])
|
||||
})
|
||||
@@ -964,6 +1074,88 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A derived schedule must not flag its own healthy cycle as a
|
||||
/// contradiction.
|
||||
///
|
||||
/// Every prior assertion on `soft_guard_us` was *relational* (`soft <
|
||||
/// hard`, `soft >= 1`), so nothing compared it to a spread the schedule
|
||||
/// actually produces -- which is how `cycle/3` survived. `fuse_scored`
|
||||
/// measures `max - min` across the whole cohort, i.e. `(N-1)` slots, so
|
||||
/// that is what the soft guard must cover.
|
||||
#[test]
|
||||
fn derived_soft_guard_covers_the_schedules_own_stagger() {
|
||||
for (slots, slot_us) in [
|
||||
(2_usize, 18_000_u64), // the measured 2-slot mesh in the doc comment
|
||||
(3, 27_000),
|
||||
(4, 12_500),
|
||||
(6, 33_300),
|
||||
(9, 35_600), // a nine-node fleet: 320 ms cycle
|
||||
(13, 36_900),
|
||||
] {
|
||||
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
|
||||
// Nominal stagger; the derived guard must clear it WITH headroom,
|
||||
// because observed spread runs slightly over nominal (18,194 us
|
||||
// measured against an 18,000 us nominal 2-slot stagger).
|
||||
let stagger = (slots as u64 - 1) * slot_us;
|
||||
assert!(
|
||||
cfg.soft_guard_us > stagger,
|
||||
"{slots} slots x {slot_us} us: soft guard {} us is below the \
|
||||
schedule's own first-to-last stagger of {stagger} us, so a \
|
||||
complete healthy cycle is recorded as a TimestampMismatch \
|
||||
contradiction and demotes the privacy class",
|
||||
cfg.soft_guard_us
|
||||
);
|
||||
assert!(cfg.soft_guard_us < cfg.guard_interval_us);
|
||||
}
|
||||
}
|
||||
|
||||
/// End to end: a full, evenly-staggered cycle fuses with **no**
|
||||
/// contradiction under its own derived config.
|
||||
#[test]
|
||||
fn a_healthy_full_cycle_raises_no_contradiction() {
|
||||
let (slots, slot_us) = (9_usize, 35_600_u64);
|
||||
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
|
||||
let fuser = MultistaticFuser::with_config(cfg.clone());
|
||||
|
||||
// Node k transmits in slot k -- the schedule working exactly as designed.
|
||||
let frames: Vec<MultiBandCsiFrame> = (0..slots)
|
||||
.map(|k| make_node_frame(k as u8, k as u64 * slot_us, 56, 1.0))
|
||||
.collect();
|
||||
|
||||
let (_fused, score) = fuser
|
||||
.fuse_scored(&frames, 0.0)
|
||||
.expect("a healthy cycle must fuse");
|
||||
assert!(
|
||||
!score.forces_privacy_demotion(),
|
||||
"a complete {slots}-slot cycle is the schedule operating normally \
|
||||
and must not demote the privacy class; flags: {:?}",
|
||||
score.contradiction_flags
|
||||
);
|
||||
}
|
||||
|
||||
/// The soft band still does its job: a spread beyond the designed stagger
|
||||
/// is flagged.
|
||||
#[test]
|
||||
fn a_spread_beyond_the_stagger_is_still_flagged() {
|
||||
let (slots, slot_us) = (9_usize, 35_600_u64);
|
||||
let cfg = MultistaticConfig::for_tdm_schedule(slots, slot_us);
|
||||
let hard = cfg.guard_interval_us;
|
||||
let fuser = MultistaticFuser::with_config(cfg);
|
||||
|
||||
// Two nodes, inside the hard guard but wider than a full cycle's stagger.
|
||||
let late = hard - 1;
|
||||
let frames = vec![
|
||||
make_node_frame(0, 0, 56, 1.0),
|
||||
make_node_frame(1, late, 56, 1.0),
|
||||
];
|
||||
let (_f, score) = fuser.fuse_scored(&frames, 0.0).expect("within hard guard");
|
||||
assert!(
|
||||
score.forces_privacy_demotion(),
|
||||
"a spread of {late} us exceeds the designed stagger and must still \
|
||||
be recorded as loose alignment"
|
||||
);
|
||||
}
|
||||
|
||||
/// The derived soft guard stays strictly below the hard guard, and a
|
||||
/// degenerate (0-slot) schedule clamps to a usable config.
|
||||
#[test]
|
||||
@@ -1064,10 +1256,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn node_positions_set_and_retrieved() {
|
||||
// The legacy positional setter round-trips through the dense view:
|
||||
// index i in, index i out. Storage is keyed by id underneath, and
|
||||
// `positions[i]` is documented to mean node i.
|
||||
let mut fuser = MultistaticFuser::new();
|
||||
let positions = vec![[0.0, 0.0, 1.0], [3.0, 0.0, 1.0]];
|
||||
fuser.set_node_positions(positions.clone());
|
||||
assert_eq!(fuser.node_positions(), &positions[..]);
|
||||
assert_eq!(fuser.node_positions(), positions);
|
||||
assert_eq!(fuser.node_position(0), Some([0.0, 0.0, 1.0]));
|
||||
assert_eq!(fuser.node_position(1), Some([3.0, 0.0, 1.0]));
|
||||
assert_eq!(fuser.node_position(2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1083,6 +1281,255 @@ mod tests {
|
||||
assert_eq!(fused.node_positions[1], [0.0, 0.0, 0.0]); // default
|
||||
}
|
||||
|
||||
/// Issue #1866 regressions: a fused contribution must carry the position
|
||||
/// belonging to its own `node_id`, under every cohort shape the live
|
||||
/// collector can produce.
|
||||
///
|
||||
/// `node_frames_from_states_with_guard` hands `fuse` a *compacted* cohort:
|
||||
/// stale, out-of-guard and empty-history nodes are already gone. So the
|
||||
/// index of a frame is its rank in this cycle, never its identity.
|
||||
mod positions_keyed_by_node_id {
|
||||
use super::*;
|
||||
|
||||
fn positions(entries: &[(u8, [f32; 3])]) -> HashMap<u8, [f32; 3]> {
|
||||
entries.iter().copied().collect()
|
||||
}
|
||||
|
||||
/// Every frame gets its own node's coordinates -- the core assertion.
|
||||
fn assert_each_frame_owns_its_position(
|
||||
configured: &HashMap<u8, [f32; 3]>,
|
||||
frames: &[MultiBandCsiFrame],
|
||||
) {
|
||||
let mut fuser = MultistaticFuser::new();
|
||||
fuser.set_node_positions_by_id(configured.clone());
|
||||
let fused = fuser.fuse(frames).expect("cohort fuses");
|
||||
|
||||
assert_eq!(fused.node_positions.len(), frames.len());
|
||||
for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) {
|
||||
let expected = configured
|
||||
.get(&frame.node_id)
|
||||
.copied()
|
||||
.unwrap_or([0.0, 0.0, 0.0]);
|
||||
assert_eq!(
|
||||
*position, expected,
|
||||
"node {} received the wrong position",
|
||||
frame.node_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_node_ids_each_get_their_own_position() {
|
||||
// Real fleets use logical ids like 11/12/13. Under rank addressing
|
||||
// every one of these resolved to the origin, because indices
|
||||
// 11..13 do not exist in a 3-entry list.
|
||||
let configured = positions(&[
|
||||
(11, [1.0, 0.0, 0.0]),
|
||||
(12, [2.0, 0.0, 0.0]),
|
||||
(13, [3.0, 0.0, 0.0]),
|
||||
]);
|
||||
let frames = vec![
|
||||
make_node_frame(11, 100, 56, 1.0),
|
||||
make_node_frame(12, 101, 56, 1.0),
|
||||
make_node_frame(13, 102, 56, 1.0),
|
||||
];
|
||||
assert_each_frame_owns_its_position(&configured, &frames);
|
||||
|
||||
let mut fuser = MultistaticFuser::new();
|
||||
fuser.set_node_positions_by_id(configured);
|
||||
let fused = fuser.fuse(&frames).expect("cohort fuses");
|
||||
assert_eq!(fused.node_positions[0], [1.0, 0.0, 0.0]);
|
||||
assert_eq!(fused.node_positions[2], [3.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_order_does_not_change_any_position() {
|
||||
// Insertion order must be irrelevant: identity is stated, not
|
||||
// inferred from where an entry sat in the config.
|
||||
let ascending = positions(&[
|
||||
(3, [3.0, 0.0, 0.0]),
|
||||
(5, [5.0, 0.0, 0.0]),
|
||||
(9, [9.0, 0.0, 0.0]),
|
||||
]);
|
||||
let reversed = positions(&[
|
||||
(9, [9.0, 0.0, 0.0]),
|
||||
(5, [5.0, 0.0, 0.0]),
|
||||
(3, [3.0, 0.0, 0.0]),
|
||||
]);
|
||||
let frames = vec![
|
||||
make_node_frame(3, 100, 56, 1.0),
|
||||
make_node_frame(5, 101, 56, 1.0),
|
||||
make_node_frame(9, 102, 56, 1.0),
|
||||
];
|
||||
|
||||
let mut a = MultistaticFuser::new();
|
||||
a.set_node_positions_by_id(ascending);
|
||||
let mut b = MultistaticFuser::new();
|
||||
b.set_node_positions_by_id(reversed);
|
||||
|
||||
assert_eq!(
|
||||
a.fuse(&frames).expect("fuses").node_positions,
|
||||
b.fuse(&frames).expect("fuses").node_positions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_node_does_not_move_the_others() {
|
||||
// THE REASON THIS IS KEYED BY ID. Node 2 misses a beacon and drops
|
||||
// out of the cohort. Under rank addressing nodes 3 and 4 shift
|
||||
// down one slot and silently inherit their neighbours' coordinates
|
||||
// -- a fleet-wide position error caused by one dropped frame.
|
||||
let configured = positions(&[
|
||||
(1, [1.0, 0.0, 0.0]),
|
||||
(2, [2.0, 0.0, 0.0]),
|
||||
(3, [3.0, 0.0, 0.0]),
|
||||
(4, [4.0, 0.0, 0.0]),
|
||||
]);
|
||||
let mut fuser = MultistaticFuser::new();
|
||||
fuser.set_node_positions_by_id(configured);
|
||||
|
||||
let all_up = vec![
|
||||
make_node_frame(1, 100, 56, 1.0),
|
||||
make_node_frame(2, 101, 56, 1.0),
|
||||
make_node_frame(3, 102, 56, 1.0),
|
||||
make_node_frame(4, 103, 56, 1.0),
|
||||
];
|
||||
let two_silent = vec![
|
||||
make_node_frame(1, 100, 56, 1.0),
|
||||
make_node_frame(3, 102, 56, 1.0),
|
||||
make_node_frame(4, 103, 56, 1.0),
|
||||
];
|
||||
|
||||
let full = fuser.fuse(&all_up).expect("fuses");
|
||||
let gapped = fuser.fuse(&two_silent).expect("fuses");
|
||||
|
||||
assert_eq!(full.node_positions[2], [3.0, 0.0, 0.0]);
|
||||
assert_eq!(full.node_positions[3], [4.0, 0.0, 0.0]);
|
||||
// Node 3 is now at rank 1 and node 4 at rank 2, but both keep
|
||||
// their own coordinates.
|
||||
assert_eq!(gapped.node_positions[0], [1.0, 0.0, 0.0]);
|
||||
assert_eq!(gapped.node_positions[1], [3.0, 0.0, 0.0]);
|
||||
assert_eq!(gapped.node_positions[2], [4.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_and_leave_churn_never_reassigns_a_position() {
|
||||
// Walk a node through leave -> rejoin. Every cohort shape must
|
||||
// still pair each frame with its own configured coordinates.
|
||||
let configured = positions(&[
|
||||
(0, [0.0, 0.0, 0.0]),
|
||||
(1, [1.0, 1.0, 0.0]),
|
||||
(2, [2.0, 2.0, 0.0]),
|
||||
(3, [3.0, 3.0, 0.0]),
|
||||
]);
|
||||
let cohorts: Vec<Vec<u8>> = vec![
|
||||
vec![0, 1, 2, 3],
|
||||
vec![0, 2, 3],
|
||||
vec![2, 3],
|
||||
vec![3],
|
||||
vec![0, 3],
|
||||
vec![0, 1, 2, 3],
|
||||
];
|
||||
for ids in cohorts {
|
||||
let frames: Vec<MultiBandCsiFrame> = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &id)| make_node_frame(id, 100 + i as u64, 56, 1.0))
|
||||
.collect();
|
||||
assert_each_frame_owns_its_position(&configured, &frames);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_configured_but_absent_node_costs_nothing() {
|
||||
// Positions may be configured for boards that are not reporting.
|
||||
// They must not consume a slot or displace anyone.
|
||||
let configured = positions(&[
|
||||
(1, [1.0, 0.0, 0.0]),
|
||||
(7, [7.0, 0.0, 0.0]),
|
||||
(42, [42.0, 0.0, 0.0]),
|
||||
]);
|
||||
let frames = vec![
|
||||
make_node_frame(1, 100, 56, 1.0),
|
||||
make_node_frame(7, 101, 56, 1.0),
|
||||
];
|
||||
assert_each_frame_owns_its_position(&configured, &frames);
|
||||
|
||||
let mut fuser = MultistaticFuser::new();
|
||||
fuser.set_node_positions_by_id(configured);
|
||||
let fused = fuser.fuse(&frames).expect("fuses");
|
||||
assert_eq!(fused.node_positions.len(), 2);
|
||||
assert_eq!(fused.node_positions[1], [7.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unconfigured_node_still_fuses_at_the_origin() {
|
||||
// Unchanged fallback behaviour: unknown node -> origin, and it
|
||||
// must not disturb its neighbours.
|
||||
let configured = positions(&[(1, [1.0, 0.0, 0.0]), (3, [3.0, 0.0, 0.0])]);
|
||||
let frames = vec![
|
||||
make_node_frame(1, 100, 56, 1.0),
|
||||
make_node_frame(2, 101, 56, 1.0),
|
||||
make_node_frame(3, 102, 56, 1.0),
|
||||
];
|
||||
assert_each_frame_owns_its_position(&configured, &frames);
|
||||
|
||||
let mut fuser = MultistaticFuser::new();
|
||||
fuser.set_node_positions_by_id(configured);
|
||||
let fused = fuser.fuse(&frames).expect("fuses");
|
||||
assert_eq!(fused.node_positions[1], [0.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_positional_setter_still_means_index_equals_node_id() {
|
||||
// Documented compatibility mapping: a bare `--node-positions`
|
||||
// list has always meant "the i-th entry is node i". Preserved
|
||||
// exactly, so existing deployments are unaffected.
|
||||
let mut fuser = MultistaticFuser::new();
|
||||
fuser.set_node_positions(vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
]);
|
||||
let frames = vec![
|
||||
make_node_frame(0, 100, 56, 1.0),
|
||||
make_node_frame(1, 101, 56, 1.0),
|
||||
make_node_frame(2, 102, 56, 1.0),
|
||||
];
|
||||
let fused = fuser.fuse(&frames).expect("fuses");
|
||||
assert_eq!(fused.node_positions[0], [0.0, 0.0, 0.0]);
|
||||
assert_eq!(fused.node_positions[1], [1.0, 0.0, 0.0]);
|
||||
assert_eq!(fused.node_positions[2], [2.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Negative control: the pre-fix behaviour must actually be gone.
|
||||
///
|
||||
/// Rank addressing over a dense zero-based config only diverges from
|
||||
/// id addressing once the cohort is compacted, so this pins the exact
|
||||
/// case the old code got wrong rather than restating the happy path.
|
||||
#[test]
|
||||
fn rank_addressing_would_have_failed_this_cohort() {
|
||||
let configured = positions(&[
|
||||
(0, [0.0, 0.0, 0.0]),
|
||||
(1, [1.0, 0.0, 0.0]),
|
||||
(2, [2.0, 0.0, 0.0]),
|
||||
]);
|
||||
let mut fuser = MultistaticFuser::new();
|
||||
fuser.set_node_positions_by_id(configured);
|
||||
|
||||
// Node 1 is silent; the cohort is [0, 2].
|
||||
let frames = vec![
|
||||
make_node_frame(0, 100, 56, 1.0),
|
||||
make_node_frame(2, 101, 56, 1.0),
|
||||
];
|
||||
let fused = fuser.fuse(&frames).expect("fuses");
|
||||
|
||||
// Rank addressing would hand rank 1 the position of node 1.
|
||||
assert_ne!(fused.node_positions[1], [1.0, 0.0, 0.0]);
|
||||
assert_eq!(fused.node_positions[1], [2.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometric_diversity_single_node() {
|
||||
assert_eq!(geometric_diversity(&[[0.0, 0.0, 0.0]]), 0.0);
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
//!
|
||||
//! macOS only. Gated behind `#[cfg(target_os = "macos")]` at the module level.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||
use crate::error::WifiScanError;
|
||||
@@ -64,11 +64,14 @@ impl MacosCoreWlanScanner {
|
||||
|
||||
/// Run the Swift helper and parse the output synchronously.
|
||||
///
|
||||
/// Returns one [`BssidObservation`] per BSSID seen in the scan.
|
||||
/// Returns one [`BssidObservation`] for the connected link.
|
||||
/// Helpers that fail to exit within five seconds are killed and reaped.
|
||||
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
let output = Command::new(&self.helper_path)
|
||||
let mut child = Command::new(&self.helper_path)
|
||||
.arg("--scan-once")
|
||||
.output()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
WifiScanError::ProcessError(format!(
|
||||
"failed to run mac_wifi helper ({}): {e}",
|
||||
@@ -76,6 +79,29 @@ impl MacosCoreWlanScanner {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Older helpers ignore --scan-once and stream forever. Bound the
|
||||
// wait so an outdated installation cannot hang capture or auto-detect.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
status => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(WifiScanError::ProcessError(match status {
|
||||
Err(e) => format!("failed to wait for mac_wifi: {e}"),
|
||||
_ => "mac_wifi --scan-once timed out; rebuild the Swift helper".into(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = child.wait_with_output().map_err(|e| {
|
||||
WifiScanError::ProcessError(format!("failed to read mac_wifi output: {e}"))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WifiScanError::ScanFailed {
|
||||
@@ -160,7 +186,8 @@ fn parse_json_line(line: &str, timestamp: Instant) -> Option<BssidObservation> {
|
||||
/// Resolve a BSSID string to a [`BssidId`].
|
||||
///
|
||||
/// If the MAC is all-zeros (macOS redaction), generate a synthetic
|
||||
/// locally-administered MAC from `SHA-256(ssid:channel)`.
|
||||
/// locally-administered MAC from the SSID and channel. Abstain when the
|
||||
/// real BSSID is unavailable and the SSID is blank.
|
||||
fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
|
||||
// Try parsing the real BSSID first.
|
||||
if let Ok(id) = BssidId::parse(bssid_str) {
|
||||
@@ -170,7 +197,13 @@ fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate synthetic BSSID: SHA-256(ssid:channel), take first 6 bytes,
|
||||
// Without either identity, unrelated networks on the same channel would
|
||||
// collapse to one synthetic BSSID. Do not emit an observation in that case.
|
||||
if ssid.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Generate synthetic BSSID from SSID and channel, take first 6 bytes,
|
||||
// set locally-administered + unicast bits (byte 0: bit 1 set, bit 0 clear).
|
||||
Some(synthetic_bssid(ssid, channel))
|
||||
}
|
||||
@@ -276,6 +309,32 @@ mod tests {
|
||||
{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn helper_process_errors_are_reported() {
|
||||
assert!(matches!(
|
||||
MacosCoreWlanScanner::with_path("/usr/bin/false").scan_sync(),
|
||||
Err(WifiScanError::ScanFailed { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
MacosCoreWlanScanner::with_path("/dev/null/mac_wifi").scan_sync(),
|
||||
Err(WifiScanError::ProcessError(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_helper_is_timed_out() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = std::env::temp_dir().join(format!("mac-wifi-timeout-{}", std::process::id()));
|
||||
// exec preserves the helper PID, so killing it cannot orphan sleep.
|
||||
std::fs::write(&path, "#!/bin/sh\nexec /bin/sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let start = Instant::now();
|
||||
let result = MacosCoreWlanScanner::with_path(path.to_string_lossy()).scan_sync();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(matches!(result, Err(WifiScanError::ProcessError(ref e)) if e.contains("timed out")));
|
||||
assert!(start.elapsed() < Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_output() {
|
||||
let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap();
|
||||
@@ -304,6 +363,29 @@ mod tests {
|
||||
assert_eq!(obs[2].bssid.0[0] & 0x01, 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_bssid_without_ssid_abstains() {
|
||||
for bssid in ["00:00:00:00:00:00", "", "invalid"] {
|
||||
for ssid in ["", " "] {
|
||||
let output = format!(
|
||||
r#"{{"ssid":"{ssid}","bssid":"{bssid}","rssi":-65,"noise":-88,"channel":36}}"#
|
||||
);
|
||||
assert!(parse_macos_scan_output(&output).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_bssid_without_ssid_is_preserved() {
|
||||
let output = r#"{"ssid":"","bssid":"aa:bb:cc:dd:ee:ff","rssi":-65,"noise":-88,"channel":36}"#;
|
||||
let obs = parse_macos_scan_output(output).unwrap();
|
||||
assert_eq!(obs.len(), 1);
|
||||
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
|
||||
assert!(obs[0].ssid.is_empty());
|
||||
assert_eq!(obs[0].rssi_dbm, -65.0);
|
||||
assert_eq!(obs[0].channel, 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_bssid_is_deterministic() {
|
||||
let a = synthetic_bssid("TestNet", 36);
|
||||
|
||||
Reference in new issue
Block a user