mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-20 13:50:07 -05:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da2ec430ce | ||
|
|
5ef001b4fe | ||
|
|
6524712900 | ||
|
|
dd02efe2fe | ||
|
|
7d15e9fb4a | ||
|
|
69106970dd | ||
|
|
686e4bad00 | ||
|
|
b713a23cac | ||
|
|
0c1efda425 | ||
|
|
b1be91478c | ||
|
|
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 | ||
|
|
c4021f9884 | ||
|
|
2227b42841 | ||
|
|
767c36ad12 | ||
|
|
2e9e87c65c | ||
|
|
8d1a67d31b | ||
|
|
c19b846394 | ||
|
|
cf6e955196 | ||
|
|
dddc214a25 | ||
|
|
5da64c6fc5 | ||
|
|
1c088e67c2 | ||
|
|
1231088427 | ||
|
|
4d857a908f | ||
|
|
d6347fe12e | ||
|
|
9b595b8333 | ||
|
|
944b7bea77 | ||
|
|
7f9df37380 | ||
|
|
f574450516 | ||
|
|
3621a211bd | ||
|
|
5eb8ddde12 | ||
|
|
fbecae4e02 | ||
|
|
de099c1f2d | ||
|
|
01460b98b0 | ||
|
|
26f444138e | ||
|
|
00737441c4 | ||
|
|
25d7be3c25 | ||
|
|
29b3968731 | ||
|
|
ed006abd58 | ||
|
|
5419bff2a8 | ||
|
|
7cdf5714e8 | ||
|
|
f1ea275d5d | ||
|
|
5c50fa8588 | ||
|
|
f19938a7b6 | ||
|
|
db5a898372 | ||
|
|
b11422459e |
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,54 @@ 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"
|
||||
|
||||
publish-release-assets:
|
||||
name: Publish firmware release assets
|
||||
if: github.ref_type == 'tag'
|
||||
needs: [version-guard, build, build-c6-idf6]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download the exact-run firmware artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
pattern: esp32-csi-node-firmware-*
|
||||
path: release-downloads
|
||||
|
||||
- name: Package variants without filename collisions
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
for directory in release-downloads/esp32-csi-node-firmware-*; do
|
||||
variant="${directory##*/}"
|
||||
tar -C "$directory" -czf "release-assets/${variant}.tar.gz" .
|
||||
done
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS.txt)
|
||||
|
||||
- name: Publish the downloadable firmware bundles
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
|
||||
with:
|
||||
name: ESP32 firmware ${{ github.ref_name }}
|
||||
body: |
|
||||
Exact-run ESP32-S3 8 MB, ESP32-S3 4 MB, and ESP32-C6 4 MB firmware bundles.
|
||||
Verify each archive against `SHA256SUMS.txt` before flashing.
|
||||
files: release-assets/*
|
||||
@@ -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({
|
||||
|
||||
+16
-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
|
||||
|
||||
@@ -311,3 +316,9 @@ v2/crates/wifi-densepose-sensing-server/data/
|
||||
# ADR-324: wasm-bindgen output for ruview-offaxis is generated locally
|
||||
# (see the crate README); never commit generated artifacts.
|
||||
v2/crates/ruview-offaxis/pkg/
|
||||
|
||||
# fal.ai visual-teacher LoRA training data/weights -- regenerable, never
|
||||
# commit training images or model weights (ADR-353)
|
||||
scripts/fal-visual-teacher/training-data/
|
||||
scripts/fal-visual-teacher/training-data.zip
|
||||
scripts/fal-visual-teacher/output/
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cognitum.one/seed">
|
||||
<img src="assets/ruview-seed.png" alt="RuView - WiFi DensePose" width="100%">
|
||||
<img src="assets/ruview-hero-h3-v3.gif" alt="RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -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,7 @@
|
||||
{
|
||||
"endpoint": "fal-ai/flux-lora",
|
||||
"lora_url": "https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors",
|
||||
"prompt": "ruviewstyle room visualization, a living room with a glowing cyan pose skeleton overlay, wifi signal arcs radiating across the space, a vitals readout panel, dark tech aesthetic, synthetic sensor visualization",
|
||||
"scale": 1.0,
|
||||
"source_image_url": "https://v3b.fal.media/files/b/0aa8cf67/JH1qyC3GCKQ9ojkrAUXe9.png"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 585 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
@@ -0,0 +1,218 @@
|
||||
# ADR-323: RTL8721Dx (Realtek AmebaDplus) CSI Wire Protocol
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-09-15
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: realtek, ameba, amebadplus, rtl8721dx, csi, protocol, rust, udp
|
||||
|
||||
## Context
|
||||
|
||||
Unlike the MediaTek/Qualcomm CSI paths (ADR-266..269), which are simulator-first
|
||||
because no supported public vendor interface exports complex CSI, Realtek's
|
||||
official Ameba RTOS SDK (`Ameba-AIoT/ameba-rtos`, Apache-2.0) documents and
|
||||
exposes a real, callback-driven CSI API for the RTL8721Dx ("AmebaDplus")
|
||||
family: `wifi_csi_config()` plus the `RTW_EVENT_CSI_DONE` event delivering a
|
||||
`struct rtw_event_csi_report_info` (per-tone signed I/Q, 8-bit or 16-bit
|
||||
precision, selectable tone decimation, passive or active/triggered capture).
|
||||
This has been confirmed directly against SDK source
|
||||
(`component/wifi/api/wifi_api_event.h`) and against the official
|
||||
`example/wifi/wifi_csi` example, and validated on real PKM8721DAF-C13-F10
|
||||
hardware (build+flash+boot confirmed on branch `feat/rtl8721dx-csi-bringup`).
|
||||
|
||||
The RTL8721Dx is a 1x1 WiFi 4 part — single TX/RX chain, not MIMO — so the
|
||||
MediaTek/Qualcomm wire format (Tx/Rx dimensioned matrices, chipset chain
|
||||
counts) does not fit. The vendor SDK's raw struct is also not a safe host
|
||||
wire format as-is: it has no CRC, a C flexible-array-member payload, and
|
||||
fields sized for firmware convenience (e.g. a wrapping `u32` microsecond
|
||||
timestamp) rather than network robustness.
|
||||
|
||||
Per the existing repo convention (ESP32/ADR-018, MediaTek/ADR-266-267,
|
||||
Qualcomm/ADR-268-269, RTL8720F radar/ADR-263-264), each vendor CSI/radar
|
||||
source gets its own self-delimiting envelope and its own Rust module pair
|
||||
(`wifi-densepose-hardware` for framing+codec+simulator,
|
||||
`wifi-densepose-sensing-server` for the bounded JSON-safe snapshot), never a
|
||||
shared "generic vendor CSI" struct that would either lose vendor-specific
|
||||
fields or invite magic-space collisions. `SourceKind::Realtek` (value 2) is
|
||||
already assigned to the RTL8720F FMCW radar path (ADR-263/264) and must not
|
||||
be reused for CSI.
|
||||
|
||||
## Decision
|
||||
|
||||
Define `RAC1` ("Realtek Ameba CSI", version 1) as a little-endian,
|
||||
self-delimiting envelope, magic `0x3143_4152` (distinct from `MTC1`
|
||||
`0x3143544d`, `QCS1` 0x31535143, `RTR1` 0x31525452, and all ADR-018
|
||||
`0xC511000x` siblings):
|
||||
|
||||
Fixed 49-byte header (explicit field-by-field serialization, no `#[repr(C)]`
|
||||
padding, matching the MediaTek/Qualcomm modules' style):
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| magic | u32 LE | `0x3143_4152` |
|
||||
| version | u8 | 1 |
|
||||
| header_len | u16 LE | 49 |
|
||||
| frame_len | u32 LE | header + payload + 4-byte CRC |
|
||||
| node_id | u8 | disambiguates multi-board illuminator/receiver deployments; 0 = unset |
|
||||
| csi_mode | u8 | 0=passive, 1=active, 2=board-to-board (`CsiMode`) |
|
||||
| sequence | u32 LE | from firmware `csi_sequence` |
|
||||
| timestamp_us | u32 LE | from firmware `hw_assigned_timestamp` (hardware-clock microseconds, wraps ~71 min — consumers must not assume monotonic across a capture longer than that) |
|
||||
| peer_mac | [u8; 6] | firmware `mac_addr` |
|
||||
| trig_mac | [u8; 6] | firmware `trig_addr` |
|
||||
| channel | u8 | operating channel |
|
||||
| bandwidth | u8 | 0=20MHz, 1=40MHz |
|
||||
| rx_rate | u8 | firmware `rx_rate` |
|
||||
| protocol_mode | u8 | 0=OFDM,1=HT,2=VHT,3=HE (`ProtocolMode`) |
|
||||
| num_sub_carrier | u16 LE | tone count in payload |
|
||||
| num_bit_per_tone | u8 | 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q) |
|
||||
| decimation | u8 | tone group (1/2/4/8 — every Nth tone) |
|
||||
| rssi_dbm | i8 | single RF chain (1x1 hardware) |
|
||||
| rxsc | u8 | sub-20 MHz channel used |
|
||||
| csi_valid | u8 | 0/1 from firmware |
|
||||
| flags | u8 | bit 0 = SYNTHETIC (simulator provenance, never clearable downstream) |
|
||||
| payload_len | u32 LE | bytes, == `num_sub_carrier * (num_bit_per_tone / 8)` |
|
||||
|
||||
Payload: `num_sub_carrier` consecutive (I, Q) tone pairs, each
|
||||
`num_bit_per_tone / 16` bytes per component (1 byte/component when
|
||||
`num_bit_per_tone == 16`, 2 bytes/component little-endian signed when `== 32`)
|
||||
— i.e. exactly the byte layout the firmware already produces in
|
||||
`csi_data[]`, just framed. CRC-32/IEEE over header+payload, final 4
|
||||
little-endian bytes. One envelope per UDP datagram.
|
||||
|
||||
Parsers reject: unknown version, wrong `header_len`, `frame_len` exceeding
|
||||
the IPv4 UDP payload cap (65,507 bytes), `num_bit_per_tone` outside
|
||||
`{16, 32}`, `payload_len` inconsistent with `num_sub_carrier *
|
||||
(num_bit_per_tone/8)`, trailing bytes, and bad CRC — mirroring the
|
||||
MediaTek/Qualcomm parser contract exactly.
|
||||
|
||||
Host side: add `realtek_csi.rs` to both
|
||||
`wifi-densepose-hardware` (frame + codec + simulator, mirroring
|
||||
`mediatek_csi.rs`/`qualcomm_csi.rs`) and `wifi-densepose-sensing-server`
|
||||
(bounded `RealtekCsiSnapshot`, mirroring `QualcommCsiSnapshot`), wired into
|
||||
the same UDP listener loop and `/api/v1/csi/realtek/latest` route as the
|
||||
existing vendor sources. The initial Mac compatibility port keeps Realtek CSI
|
||||
as a separate bounded diagnostic stream. Fresh ESP32 frames remain the primary
|
||||
sensing source. This change does not add `SourceKind::RealtekCsi` to a signed
|
||||
ingest contract; that requires a separate reviewed identity-bound hardware
|
||||
proof. RAC1 CRC checks corruption, not origin.
|
||||
|
||||
Firmware side (`example/wifi/wifi_csi` derivative, built against the
|
||||
official Ameba RTOS SDK, tracked outside this repository per the existing
|
||||
`CLAUDE.local.md` convention — the vendor SDK itself is never vendored into
|
||||
this repo): join the board to the deployment's WiFi AP in STA mode
|
||||
(passive CSI against the real AP as peer, since only one physical board is
|
||||
available in this bring-up), read the AP's BSSID at runtime after
|
||||
association (never hardcode a MAC into firmware source), pack each
|
||||
`RTW_EVENT_CSI_DONE` report into one `RAC1` datagram, and `sendto()` it over
|
||||
UDP to the host sensing-server's existing CSI port.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- CSI is real, MEASURED hardware output from day one — no simulator-first
|
||||
bridge phase is needed the way MediaTek/Qualcomm required, because the
|
||||
vendor SDK genuinely exposes the capability and we have the board in hand.
|
||||
- One board is sufficient for a first useful signal (passive CSI against the
|
||||
home AP); active and board-to-board modes are additive later without a
|
||||
wire-format break (`csi_mode` already reserves the values).
|
||||
- Reuses the bounded UDP parser and status-route pattern without changing
|
||||
existing ESP32 sensing. Unsigned RAC1 diagnostics are not authorized as
|
||||
calibrated presence, person count, pose, or vital evidence.
|
||||
|
||||
### Negative
|
||||
|
||||
- 1x1 hardware means no spatial (multi-antenna) diversity from a single
|
||||
board; multi-board deployment is required for the differential/localization
|
||||
use cases described in the original RTL8721Dx capability analysis.
|
||||
- The 71-minute microsecond timestamp wraparound (inherited from the
|
||||
firmware's `u32` field) means long unattended captures need host-side
|
||||
sequence-based reordering, not raw timestamp arithmetic, across a wrap.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The RAC1 envelope intentionally does not carry the SDK's raw
|
||||
`csi_signature` (0xABCD) marker — our own `magic`/CRC supersede it as the
|
||||
framing integrity check.
|
||||
|
||||
## Hardware gates
|
||||
|
||||
- [x] Confirm SDK source exposes `RTW_EVENT_CSI_DONE` / `rtw_event_csi_report_info` (done — `component/wifi/api/wifi_api_event.h`).
|
||||
- [x] Build and flash a baseline image on real PKM8721DAF-C13-F10 hardware, confirm boot over serial (done — `feat/rtl8721dx-csi-bringup`).
|
||||
- [x] Flash the CSI-streaming firmware and confirm `RAC1` datagrams parse on the host from the real board (done, 2026-09-15 — see below).
|
||||
- [x] Record MEASURED throughput/latency/jitter from the real running firmware (done, 2026-09-15 — see below).
|
||||
- [ ] A human walk-test across the link (amplitude perturbation vs. static baseline) is deferred — requires the operator physically present; not claimed until performed.
|
||||
|
||||
### MEASURED: real RAC1 capture and sustained throughput (2026-09-15)
|
||||
|
||||
Passive CSI (`RTW_CSI_MODE_NORMAL`, `RTW_CSI_TRIG_BEACON | RTW_CSI_TRIG_DATA | RTW_CSI_TRIG_QOS_DATA`)
|
||||
against the deployment AP. A single `RAC1` datagram was first
|
||||
cross-checked field-by-field against the firmware's own serial log of the
|
||||
same report — every byte matched: magic `0x31434152`, RSSI byte exact match,
|
||||
channel/bandwidth/protocol/subcarrier-count exact match, 157-byte total frame
|
||||
(49-byte header + 104-byte payload + 4-byte CRC, exactly as specified above).
|
||||
|
||||
`RTW_CSI_MODE_RX_RESP` (estimate CSI from ACKs to the STA's own transmissions)
|
||||
never fired `RTW_EVENT_CSI_DONE` across two full test runs with confirmed
|
||||
real traffic (not a hang) — this mode does not work for this hardware/AP
|
||||
combination and `RTW_CSI_MODE_NORMAL` with beacon+data triggers is the
|
||||
working configuration.
|
||||
|
||||
**An earlier note here claimed `NORMAL` mode fires only once per CSI enable
|
||||
— that was wrong**, caused by a debug log line gated to print only every
|
||||
50th frame (`frame_count % 50 == 1`), which was misread as "only one report
|
||||
total." A follow-up capture with a UDP listener running concurrently with a
|
||||
board reset measured **277 real RAC1 datagrams in 40 seconds (43,489 bytes)**,
|
||||
with `csi_sequence` climbing continuously and monotonically in the firmware's
|
||||
own serial log across six separate 50-frame log checkpoints in one 45s
|
||||
session — CSI reporting is continuous, roughly 7-12 fps, not sparse.
|
||||
|
||||
### MEASURED: throughput optimization pass (2026-09-15)
|
||||
|
||||
Formalized as a repeatable tool: `scripts/benchmark-rtl8721dx-csi.py` (listens
|
||||
on the sensing-server's CSI UDP port, reports fps/throughput/RSSI/inter-frame
|
||||
interval/sequence-gap stats from real received `RAC1` datagrams).
|
||||
|
||||
**Baseline** (`trig_period=200` [64ms], `trig_frame_ctrl=0`,
|
||||
`CSI_REPORT_BUF_NUM=1`), 30s capture:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| RAC1 fps | 14.67 |
|
||||
| Throughput | 2,302.7 bytes/s |
|
||||
| Mean inter-frame interval | 68.3 ms |
|
||||
| RSSI | mean -34.4 dBm |
|
||||
|
||||
`trig_period` (units of 320µs) was hypothesized as the rate-limiting
|
||||
throttle — the observed 68.3ms mean interval tracks the 64ms configured
|
||||
period almost exactly. Confirmed by tuning:
|
||||
|
||||
1. `trig_period`: 200 → 15 (the SDK doc's stated minimum recommended value,
|
||||
~4.8ms)
|
||||
2. `trig_frame_ctrl`: 0 → `RTW_CSI_TRIG_ACK` (second trigger source: every
|
||||
link-layer ACK the AP sends for the heartbeat's own outgoing traffic, on
|
||||
top of the existing beacon trigger)
|
||||
3. `CSI_REPORT_BUF_NUM`: 1 → 4 (a "lack of csi buf!" drop was observed even
|
||||
at the slower baseline rate; more headroom needed at a higher rate)
|
||||
|
||||
**Tuned**, 30s capture:
|
||||
|
||||
| Metric | Value | vs. baseline |
|
||||
|---|---|---|
|
||||
| RAC1 fps | 66.40 | **4.5x** |
|
||||
| Throughput | 10,424.8 bytes/s | **4.5x** |
|
||||
| Mean inter-frame interval | 14.9 ms | 4.6x lower |
|
||||
| Sequence gaps | 78 / ~2036 (~3.8%) | new (0 observed at the slower baseline rate) |
|
||||
| RSSI | mean -31.1 dBm | (different session/position, not a controlled variable here) |
|
||||
|
||||
The `trig_period` hypothesis is confirmed as the primary lever. The ~3.8%
|
||||
frame-loss rate at 66 fps is a reasonable place to stop for now — it is
|
||||
UDP-over-WiFi loss, not a firmware defect, and is small enough not to block
|
||||
downstream sensing use; revisit only if a use case needs stronger delivery
|
||||
guarantees (sequence-gap-triggered re-request, TCP fallback, etc.).
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-266](ADR-266-mediatek-filogic-csi-platform.md) / [ADR-267](ADR-267-mediatek-mimo-csi-wire-protocol.md) — sibling vendor CSI ADR pair (simulator-first precedent)
|
||||
- [ADR-268](ADR-268-qualcomm-atheros-csi-platform.md) / [ADR-269](ADR-269-qualcomm-csi-wire-protocol.md) — sibling vendor CSI ADR pair
|
||||
- [ADR-263](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md) / [ADR-264](ADR-264-rtl8720f-radar-wire-protocol.md) — existing Realtek (RTL8720F radar) path; `SourceKind::Realtek` owner
|
||||
- `CLAUDE.local.md` — RTL8721Dx toolchain setup, board/COM port, first bring-up log
|
||||
@@ -0,0 +1,193 @@
|
||||
# ADR-345: Per-link CSI attribution and node-to-node ranging
|
||||
|
||||
- **Status**: Proposed — measurement layer implemented and validated on hardware; localization not built
|
||||
- **Date**: 2026-08-28
|
||||
- **Deciders**: Joe
|
||||
- **Tags**: csi, wire-format, esp32-c6, localization, adr-018, adr-060, adr-110
|
||||
|
||||
## Context
|
||||
|
||||
Every attempt at device-free position estimation in this repository has failed
|
||||
against the same wall, and the wall has now been measured twice from different
|
||||
directions.
|
||||
|
||||
**Phase is unusable.** The bistatic tier needed a signed per-link radial
|
||||
velocity, which requires the common-mode component of CSI phase. Measured
|
||||
2026-08-28 across all three ESP32-C6 nodes over ~40,000 frames, the
|
||||
frame-to-frame change in common-mode phase is statistically indistinguishable
|
||||
from uniform random — resultant length `R` = 0.011–0.032 against a 0.000
|
||||
reference, standard deviation 1.78–1.81 against `pi/sqrt(3)` = 1.814, and no
|
||||
lag-1 autocorrelation. A second, unwrap-free estimator agreed. The mechanism is
|
||||
packet-detection timing quantization: common-mode phase is `-2*pi*f_c*tau`, and
|
||||
one ADC sample of jitter at 20 MHz is `2*pi * 2.412e9 * 50e-9` ~= 758 rad, about
|
||||
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
|
||||
is default-off as a result (`bistatic_tier_enabled`).
|
||||
|
||||
**Amplitude works but had no parallax.** The three AP -> node links leave a
|
||||
single distant transmitter within a 36.5 degree fan and converge on the node
|
||||
cluster. Bearings computed from `room_config.json`: 198.5, 221.2, 235.0 degrees.
|
||||
Scalar per-node energy weighting was closed with a clean negative result on
|
||||
2026-08-27 — magnitude responds to proximity, direction does not.
|
||||
|
||||
**The receivers were hearing far more than we recorded.** A node in promiscuous
|
||||
MGMT+DATA mode with no `filter_mac` produces CSI for every transmitter on the
|
||||
channel. Measured on node 2: the AP accounted for only **34%** of frames; the
|
||||
two peer nodes supplied 39% and 27%. Those peer frames are valid measurements
|
||||
of *node-to-node* channels — but ADR-018's wire format carries no transmitter
|
||||
address, so the sink could not attribute them. They interleaved into one
|
||||
`frame_history` with mixed geometry, and the only available remedy
|
||||
(`filter_mac`, ADR-060) fixes the mixing by discarding ~75% of the data.
|
||||
|
||||
The node-to-node links between the same three boards cross the room from three
|
||||
sides at **108.7 degrees** of angular spread (bearings 0.0, 73.2, 108.7) versus
|
||||
36.5 for the AP links — roughly 3x the parallax, with both endpoints known,
|
||||
fixed, and under our control rather than wherever the router happens to live.
|
||||
Link count also grows as `N*(N-1)/2`: 3 nodes give 3 links, 6 give 15, 13 give
|
||||
78.
|
||||
|
||||
## Decision
|
||||
|
||||
Carry the transmitter MAC on the CSI wire and model CSI per **link**
|
||||
(receiver, transmitter) rather than per **node**, as a layer alongside the
|
||||
existing per-node model.
|
||||
|
||||
### 1. Wire format v2
|
||||
|
||||
`CSI_MAGIC_V2 = 0xC5110008`, header 26 bytes: the v1 header unchanged through
|
||||
byte 19, then the transmitter address (`info->mac`, 802.11 addr2) at bytes
|
||||
20..25, with I/Q from byte 26.
|
||||
|
||||
`0xC5110002`..`0xC5110007` were already claimed by the vitals, feature and other
|
||||
edge packets, so v2 takes the next free value rather than an adjacent one — see
|
||||
`issue_928_magic_collision_tests` for why that matters. The parser accepts both
|
||||
magics, so a mixed fleet keeps working and v1 nodes simply report no
|
||||
transmitter.
|
||||
|
||||
### 2. Per-link state (`links.rs`)
|
||||
|
||||
`LinkTable` keyed by `LinkId { rx_node, tx_mac }`, each link holding its own
|
||||
amplitude history, RSSI EMA, learned resting baseline and motion metric.
|
||||
|
||||
The metric is the mean over subcarriers of the temporal standard deviation of
|
||||
**AGC-normalized** amplitude. Removing each frame's own mean across subcarriers
|
||||
is essential rather than cosmetic: the ESP32 applies automatic gain control per
|
||||
packet, so absolute amplitude jumps between packets for reasons unrelated to the
|
||||
channel, and any statistic built on the raw level measures the radio's gain
|
||||
control instead of the room.
|
||||
|
||||
Amplitude only, deliberately — nothing here can depend on phase coherence.
|
||||
|
||||
### 3. Beacon rate
|
||||
|
||||
`BEACON_PERIOD_MS` 100 -> 20. At 10 Hz a peer samples a node-to-node link with a
|
||||
5 Hz Nyquist while motion Doppler lives at 1–20 Hz; it aliases before it starts.
|
||||
20 ms matches both the receiver's own `CSI_MIN_PROCESS_INTERVAL_US` ceiling and
|
||||
the gateway self-ping already used for the AP link.
|
||||
|
||||
### 4. `GET /api/v1/links` and a live UI panel
|
||||
|
||||
The link table was initially reachable only through a 30-second log line, which
|
||||
is the wrong medium for a quantity whose value is watching it respond while you
|
||||
walk across the room. `GET /api/v1/links` returns one row per link — `motion`,
|
||||
`raw_motion`, `rssi_dbm`, `frames`, `kind` — and `LinkMeshPanel` renders them in
|
||||
the Sensing tab at 1 Hz. `raw_motion` is shown beside `motion` deliberately: a
|
||||
link whose baseline has not settled reports a healthy raw value and a near-zero
|
||||
motion, and with only the one number that is indistinguishable from a dead link.
|
||||
|
||||
Node identity is **inferred, not measured**. Nothing in the system records a
|
||||
node's own MAC — a node reports the addresses it hears, never its own. But a
|
||||
radio does not receive its own transmissions, so given the set of receivers
|
||||
currently reporting, a transmitter heard by all of them except exactly one is
|
||||
that one receiver's own board; a transmitter heard by every receiver is external
|
||||
infrastructure. `links::infer_transmitting_node` declines (returns `None`) with
|
||||
fewer than three receivers, where "heard by all but one" and "heard by exactly
|
||||
one" are the same observation, and whenever more than one receiver is missing,
|
||||
which a peer out of range produces indistinguishably. The field is named
|
||||
`tx_node_inferred` and the UI draws the peer id with a trailing `?`.
|
||||
|
||||
Checked against the known MAC map on 2026-08-28 the inference labelled all four
|
||||
transmitters correctly — nodes 0, 1 and 2 by address, and the AP as external —
|
||||
with no access to that map. A wrong answer here mislabels a display row; it
|
||||
never reaches a metric.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Validated on hardware, 2026-08-28
|
||||
|
||||
All three nodes on wire v2 at 20 ms produced the full mesh: **9 links** (3 AP +
|
||||
6 directional node-to-node).
|
||||
|
||||
Reciprocity — each physical channel measured independently from both ends:
|
||||
|
||||
| channel | direction A | direction B | ratio |
|
||||
|---|---|---|---|
|
||||
| 0 <-> 1 | 2.229 | 1.930 | 1.15 |
|
||||
| 0 <-> 2 | 1.353 | 1.000 | 1.35 |
|
||||
| 1 <-> 2 | 1.398 | 1.486 | 1.06 |
|
||||
|
||||
Nothing in the implementation enforces this. Three independent radio pairs
|
||||
agreeing within 6–35% is the strongest available evidence that the per-link
|
||||
separation is real rather than an artifact of frame bucketing.
|
||||
|
||||
Link quality is also far more uniform than the AP links: node-to-node raw motion
|
||||
spans 1.0–2.2 across all six, versus 3.8–17.8 for the three AP links (which
|
||||
track RSSI: -67, -72, -80). Uniform link quality is desirable for tomographic
|
||||
localization — no single link dominates the solution.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **Peer links are ~10x weaker.** A 16-byte ESP-NOW beacon offers far less
|
||||
energy to estimate a channel response from than a full data frame. Whether
|
||||
1.0–2.2 perturbation is sufficient to localize with is **unknown and untested**.
|
||||
- **Three links is below threshold.** Tomographic localization needs many
|
||||
crossing links; 3 will not localize regardless of quality. This ADR delivers
|
||||
the measurement layer, not localization.
|
||||
- **Localization is not implemented.** No position tier consumes `LinkTable`.
|
||||
The live position estimate remains `doppler_centroid`.
|
||||
- **Per-node path regression.** Removing `filter_mac` to admit peer frames
|
||||
returned the *per-node* `frame_history` to a mixed-transmitter stream, since
|
||||
it still consumes every frame regardless of source. Node 2's classification
|
||||
signal is noisier as a result. The fix is to select a link to feed the
|
||||
per-node path — software filtering that keeps the other links — and it is not
|
||||
yet done.
|
||||
|
||||
### Scaling limits to settle before more nodes
|
||||
|
||||
- **Airtime.** Every node broadcasts, so N nodes put N/period frames per second
|
||||
on the channel being sensed. 3 nodes at 20 ms is 150 pps; 13 would be 650 pps.
|
||||
`BEACON_PERIOD_MS` should become an NVS setting scaled to fleet size.
|
||||
- **`MAX_LINKS` is 64.** 13 nodes give 78 receiver-transmitter pairs plus the AP
|
||||
plus neighbours, which overflows. Raise deliberately, with the memory implied.
|
||||
- **Placement.** Links must *cross* the target area. Nodes spread through a
|
||||
house give roughly one link per room, each attenuated by walls and perturbed
|
||||
by anyone anywhere along it — whole-house coarse presence, not position.
|
||||
Perimeter placement around a single room is the geometry that works.
|
||||
|
||||
## Related bugs found and fixed while implementing
|
||||
|
||||
- **ADR-060 MAC filtering cost ~75% of capture rate.** The 50 Hz rate gate ran
|
||||
*before* the MAC filter and stamped `s_last_process_us` before the check, so a
|
||||
frame from any other transmitter consumed the slot and was then discarded.
|
||||
Enabling `filter_mac` dropped yield from ~42 pps to 6–13. Filtering first
|
||||
restores it (measured 31–37 pps). A 6-byte `memcmp` is negligible ISR work next
|
||||
to the CSI processing the gate guards.
|
||||
- **The entire edge pipeline was dead on ESP32-C6.** `EDGE_MAX_SUBCARRIERS` was
|
||||
128, an S3-era assumption; a C6 on an HE-capable AP delivers HE20 frames with
|
||||
256 bins, and `process_frame` rejected every one. ADR-110 onboarded the C6 to
|
||||
the capture path but not this one. Vitals, presence, fall detection and
|
||||
per-slot counting had never run on these boards. Now target-conditional on
|
||||
`CONFIG_SOC_WIFI_HE_SUPPORT`, with the four subcarrier-sized arrays hoisted to
|
||||
static so peak stack is *lower* than it was at 128.
|
||||
- **A target-conditional constant that failed silently.** `edge_processing.h`
|
||||
had no `#include` at all, so `CONFIG_SOC_WIFI_HE_SUPPORT` was undefined and
|
||||
`#if` evaluated it as 0 — selecting the wrong size with no warning. An
|
||||
undefined identifier in `#if` is 0 in C. This is the same failure mode as the
|
||||
bug it was fixing, one layer up.
|
||||
|
||||
## References
|
||||
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/links.rs` — per-link state, 8 tests
|
||||
- `v2/crates/wifi-densepose-sensing-server/src/phase_diag.rs` — the phase measurement
|
||||
- `firmware/esp32-csi-node/main/csi_collector.c` — wire v2 serialization, filter ordering
|
||||
- `firmware/esp32-csi-node/main/edge_processing.h` — `EDGE_MAX_SUBCARRIERS`
|
||||
- ADR-018 (wire format), ADR-060 (MAC filtering), ADR-110 (C6 onboarding)
|
||||
@@ -0,0 +1,162 @@
|
||||
# ADR-353: fal.ai synthetic visual teacher LoRA
|
||||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-09-02
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView visualization / neural-styling maintainers
|
||||
- **Tags**: fal, lora, synthetic-visualization, display-only, cost-control
|
||||
- **Parent**: None
|
||||
- **Extends**: None
|
||||
- **Supersedes**: None
|
||||
|
||||
## Decision
|
||||
|
||||
RuView will use one real, fal.ai-trained LoRA fine-tune (a "synthetic visual
|
||||
teacher") to steer fal.ai image/video generation toward a consistent
|
||||
"RuView-style" room visualization — glowing pose overlays, WiFi signal arcs,
|
||||
vitals readout panels, dark tech aesthetic — instead of relying on ad hoc
|
||||
prompt engineering against a stock model.
|
||||
|
||||
**The architecture boundary is non-negotiable:**
|
||||
|
||||
> The implementation boundary is now clear: fal.ai will train an optional
|
||||
> synthetic visual teacher, while RuView's measured RF, pose, vitals,
|
||||
> identity, and confidence remain local and authoritative.
|
||||
|
||||
Concretely:
|
||||
|
||||
- The trained model produced here is ONLY ever allowed to generate
|
||||
supplementary synthetic visual/pixel output for display or broadcast.
|
||||
- It must NEVER be a source of, or influence, any measured/derived value: RF/
|
||||
CSI measurements, pose estimates, vitals (heart rate/breathing rate),
|
||||
identity, or confidence/quality scores. Those stay local, deterministic,
|
||||
and authoritative — the existing `wifi-densepose-engine` /
|
||||
`ruview-twin` / `ruview-witness` stack — completely untouched by this work.
|
||||
- Any output this model produces must be clearly framed as SYNTHETIC per this
|
||||
repo's rule: "Never present WiFi sensing as camera-grade."
|
||||
- This model has no read or write path into the RF/pose/vitals/identity
|
||||
pipeline. It consumes only a text prompt (and optionally a reference
|
||||
image) and returns pixels for display.
|
||||
|
||||
**Evidence status:** the training data extraction, training submission
|
||||
script, and validation script are real and committed. The training run
|
||||
completed and validation succeeded — trained model id, actual spend, and the
|
||||
validation sample are recorded in "Result" below. Actual-vs-billed cost
|
||||
reconciliation against the fal.ai account dashboard remains UNMEASURED (the
|
||||
figure below is a documented-pricing estimate).
|
||||
|
||||
## Context
|
||||
|
||||
A concurrent workstream (`ruview-fal-room-viz` / `ruview-room-viz-h3-upgrade`)
|
||||
generated real fal.ai room-visualization videos across several style presets
|
||||
(architectural, abstract, branded, and an "h3" refresh: architectural,
|
||||
cinematic, minimal, abstract) using stock fal.ai video models driven by
|
||||
prompt engineering alone. A separate concurrently-running agent
|
||||
("realtime-dual-renderer") is building a neural-styling layer that needs a
|
||||
*specific* trained model id to call, rather than continuing to rely on prompt
|
||||
engineering against a generic model.
|
||||
|
||||
This ADR's job is narrow: turn the real captured style presets into a real
|
||||
trained fal.ai LoRA, verify it actually works, and hand off the model id. It
|
||||
does not implement the renderer integration — that is
|
||||
`realtime-dual-renderer`'s scope — and it does not touch the measured
|
||||
RF/pose/vitals/identity/confidence pipeline in any way.
|
||||
|
||||
## Training data
|
||||
|
||||
Source: the real fal.ai-generated room-visualization MP4s already captured
|
||||
this session (read from `/tmp/room-viz-*/room-viz.mp4` on `ruvultra`, one per
|
||||
style preset — not committed to the repo; the repo has no camera or person
|
||||
data in these clips, they are synthetic renders). `scripts/fal-visual-teacher/extract_frames.py`
|
||||
extracts one JPEG frame every 1.5 seconds from each of the 7 real style-preset
|
||||
videos (architectural, abstract, branded, h3-architectural, h3-cinematic,
|
||||
h3-minimal, h3-abstract), producing 27 frames total, each paired with a
|
||||
caption `.txt` describing the shared RuView visual motifs. Frames and the
|
||||
training zip are gitignored (`scripts/fal-visual-teacher/training-data/`,
|
||||
`training-data.zip`) — they are regenerable from the source MP4s and are not
|
||||
committed, consistent with the repo's no-generated-artifacts rule.
|
||||
|
||||
## Training endpoint and pricing (verified 2026-09-02)
|
||||
|
||||
- **Endpoint**: `fal-ai/flux-lora-fast-training` — a real, currently-listed
|
||||
fal.ai LoRA training endpoint for FLUX.1, verified against
|
||||
`https://fal.ai/models/fal-ai/flux-lora-fast-training/api`.
|
||||
- **Pricing**: $2 per training run at the default 1000 steps, scaling
|
||||
linearly with steps (verified against fal.ai's own pricing text on the
|
||||
model page).
|
||||
- **Mode**: submitted with `is_style: true` (style LoRA, not
|
||||
subject/character LoRA) and explicit per-frame caption files, so fal.ai's
|
||||
auto-captioning/segmentation is skipped in favor of our own captions.
|
||||
- **Inference endpoint** (validation only): `fal-ai/flux-lora`, $0.035 per
|
||||
megapixel, accepting `loras: [{ path, scale }]` pointing at the trained
|
||||
`diffusers_lora_file` URL.
|
||||
|
||||
## Budget
|
||||
|
||||
- **Original cap**: $15 total (this task's cap only; not shared with other
|
||||
concurrent tasks' budgets this session).
|
||||
- **Raised cap**: the user explicitly raised this task's cap to **$100** on
|
||||
2026-09-02, mid-run, as real headroom for a larger/better run or retries —
|
||||
not an instruction to spend all of it.
|
||||
- **Planned spend**: ~$2 for the training run (1000 steps, default) + ~$0.04
|
||||
per validation inference image.
|
||||
- **Actual spend**: ~$2.04 (one 1000-step training run at $2/run + one
|
||||
landscape_16_9 validation image at $0.035/megapixel, rounded up to 1
|
||||
megapixel). This is a documented-pricing estimate (`CLAIMED`), not yet
|
||||
reconciled against the fal.ai account billing dashboard (`MEASURED`).
|
||||
Comfortably under both the original $15 cap and the raised $100 cap.
|
||||
- **Why no larger follow-up run**: the first validation image already showed
|
||||
a clean, on-style result (glowing pose overlay, WiFi arcs, vitals panels,
|
||||
dark aesthetic, all present and legible) on the first attempt. Spending
|
||||
further into the raised budget for marginal improvement was judged not
|
||||
worth it against a result that already meets the bar; the fallback path in
|
||||
"Consequences" was not needed.
|
||||
|
||||
## Result
|
||||
|
||||
- **Trained model id / LoRA weights URL**:
|
||||
`https://v3b.fal.media/files/b/0aa8c60d/OjaBs6Gi8pMhZqMo79iym_pytorch_lora_weights.safetensors`
|
||||
(131 MB `diffusers_lora_file`, paired `config.json` at
|
||||
`https://v3b.fal.media/files/b/0aa8c60d/-GU6dd_2ZFqq18I0CuPOB_config.json`).
|
||||
Use with `fal-ai/flux-lora` (or any fal.ai FLUX.1 endpoint accepting a
|
||||
`loras: [{ path, scale }]` array) — trigger word `ruviewstyle`. Full
|
||||
training record: `scripts/fal-visual-teacher/output/training-result.json`
|
||||
(gitignored — regenerate by re-running `train_lora.py`, or copy the URL
|
||||
above).
|
||||
- **Training request id**: `01a060b2-c940-7972-abf9-ea48847eb2ae` (fal.ai
|
||||
`fal-ai/flux-lora-fast-training`, 1000 steps, `is_style: true`, 27 training
|
||||
images, completed in 376s on an H100/H200 worker).
|
||||
- **Actual spend**: ~$2.04 (see Budget above).
|
||||
- **Validation sample**: `assets/fal-visual-teacher-sample.png` (+ sibling
|
||||
`assets/fal-visual-teacher-sample.json` with the exact prompt, LoRA scale
|
||||
1.0, and source fal.ai URL) — a real `fal-ai/flux-lora` inference call
|
||||
using the trained LoRA, generated 2026-09-02. The image shows a glowing
|
||||
cyan pose skeleton, WiFi signal arcs, vitals-style readout panels, and a
|
||||
HUD-style TV overlay in a dark living room — confirming the LoRA learned
|
||||
and reproduces the intended RuView visual motifs.
|
||||
- **Consumer**: `realtime-dual-renderer` (separate worktree
|
||||
`~/projects/ruview-worktrees/realtime-render`) — notified directly with the
|
||||
model id above.
|
||||
|
||||
## Consequences
|
||||
|
||||
A real trained LoRA gives the display layer a specific, repeatable style
|
||||
target instead of prompt engineering against a stock model, at a bounded and
|
||||
small real cost ($2 training + cents per generated image). Because the model
|
||||
only ever produces pixels for a prompt, and has no schema path into any
|
||||
measured/derived RuView value, it cannot silently become a second source of
|
||||
truth for pose, vitals, identity, or confidence — that boundary is structural
|
||||
(no shared types, no shared data path), not just documented convention.
|
||||
|
||||
If the trained LoRA does not meaningfully improve style consistency over the
|
||||
stock model (a genuine risk with 27 training images and default steps), the
|
||||
fallback is to keep using prompt engineering against the stock model and note
|
||||
that outcome honestly here rather than claim a training win that isn't real.
|
||||
|
||||
## References
|
||||
|
||||
- `scripts/fal-visual-teacher/extract_frames.py` — real frame extraction
|
||||
- `scripts/fal-visual-teacher/train_lora.py` — real training submission
|
||||
- `scripts/fal-visual-teacher/validate.py` — real validation inference
|
||||
- [fal.ai Flux LoRA Fast Training](https://fal.ai/models/fal-ai/flux-lora-fast-training)
|
||||
- [fal.ai FLUX.1 with LoRAs (inference)](https://fal.ai/models/fal-ai/flux-lora)
|
||||
@@ -0,0 +1,89 @@
|
||||
# ADR-354: fal.ai forecast governance and spend enforcement now real, in the vendored RuForecast submodule
|
||||
|
||||
- **Status**: Accepted
|
||||
- **Date**: 2026-09-02
|
||||
- **Deciders**: ruv
|
||||
- **Owners**: RuView forecast training, security, and release maintainers
|
||||
- **Tags**: forecasting, training, fal, governance, submodule, cost-control
|
||||
- **Parent**: ADR-349
|
||||
- **Extends**: ADR-348, ADR-349
|
||||
- **Supersedes**: None
|
||||
|
||||
## Decision
|
||||
|
||||
ADR-349 specified two governance controls for RuView Forecast's fal.ai
|
||||
hosted path: operator-verified dataset/schema/policy/recipe authorization,
|
||||
and an explicit local spend-approval record bound to the request digest and
|
||||
maximum units. A 9-agent code review of the training stack merged as PR
|
||||
#1766 found that neither control was actually wired into the code path that
|
||||
builds a submittable, fal-destined training contract — `FalGovernanceVerifier`
|
||||
existed with zero callers outside its own tests, and no spend-approval type
|
||||
existed anywhere in the codebase.
|
||||
|
||||
That code no longer lives directly in this repository. Between the review
|
||||
and this fix, `v2/crates/ruview-forecast-{core,model,train}` was extracted
|
||||
into its own repository, `github.com/ruvnet/RuForecast`, and is now vendored
|
||||
back into `v2/crates/ruforecast` as a git submodule (crates renamed to
|
||||
`ruforecast-{core,model,train}`). This ADR records two things at the RuView
|
||||
level:
|
||||
|
||||
1. **The governance and spend-approval gaps are fixed**, in
|
||||
`ruvnet/RuForecast` PR #2 (branch `fix/governance-and-spend-enforcement`).
|
||||
The full technical decision record — what changed, why, and the
|
||||
requirements/acceptance table — lives in that repository's own
|
||||
[ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md),
|
||||
which this ADR defers to rather than duplicating. In summary: a
|
||||
fal-destined `TrainSpec` can no longer be constructed without a real,
|
||||
operator-signed, single-use-verified governance receipt; a hosted
|
||||
reservation can no longer be built without a real, operator-signed spend
|
||||
approval bound to the exact request digest and cost ceiling; and several
|
||||
related correctness/resilience gaps on the same trust boundary
|
||||
(unauthenticated Direct Server webhook, no execution timeout, mismatched
|
||||
local/hosted artifact budgets, cancellation permanently wedging a job)
|
||||
were fixed in the same pass.
|
||||
2. **The submodule pointer in this repository is bumped** to the commit
|
||||
containing that fix, once `ruvnet/RuForecast`'s own CI is green on that
|
||||
PR and it merges. This RuView-side change is deliberately small: a
|
||||
pointer bump plus this ADR, not a re-implementation or re-review of code
|
||||
that now lives, is tested, and is reviewed in its own repository.
|
||||
|
||||
## Context
|
||||
|
||||
RuView's own copies of ADR-348 and ADR-349 predate the extraction and still
|
||||
describe the crates as if they lived directly under `v2/crates/`. They are
|
||||
left as-is here (not corrected path-by-path) since the canonical, actively
|
||||
maintained copies of the forecast-specific ADRs now live in
|
||||
`ruvnet/RuForecast`'s own `docs/adr/`, which was seeded with copies of
|
||||
ADR-348/349 at extraction time and now also carries ADR-350. Consult that
|
||||
repository for the forecast subsystem's own decision history going forward;
|
||||
this repository's ADR series should record RuView-level integration
|
||||
decisions about the vendored submodule (like this one), not attempt to
|
||||
re-host the submodule's internal history.
|
||||
|
||||
This repo's CLAUDE.md requires an ADR for load-bearing upstream changes.
|
||||
The governance-enforcement fix is load-bearing for RuView even though none
|
||||
of its source lines are: it changes what RuView's vendored forecast
|
||||
subsystem actually requires before it will reach real, billable fal.ai
|
||||
infrastructure on behalf of a RuView deployment.
|
||||
|
||||
## Consequences
|
||||
|
||||
Once the submodule pointer here is bumped, any RuView-side code, CLI
|
||||
wrapper, or documentation that assumes the old, unenforced fal.ai
|
||||
submission path (a bare `SourceState::synthetic` claim with no verified
|
||||
governance receipt or spend approval) will no longer compile or run against
|
||||
the vendored crate — `TrainSpec::new_fal_synthetic` and
|
||||
`ReservedSyntheticSubmission::reserve` both gained new required parameters.
|
||||
A repository-wide search found no such RuView-side caller today; this ADR
|
||||
records the change in case one is added later without checking the
|
||||
submodule's current API first.
|
||||
|
||||
This pointer bump must not merge before `ruvnet/RuForecast` PR #2 merges and
|
||||
its CI is green — see that PR for status.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR-349](./ADR-349-governed-local-and-fal-forecast-training.md) (this repository's copy; canonical copy is in `ruvnet/RuForecast`)
|
||||
- [ruvnet/RuForecast PR #2](https://github.com/ruvnet/RuForecast/pull/2)
|
||||
- [ruvnet/RuForecast ADR-350](https://github.com/ruvnet/RuForecast/blob/main/docs/adr/ADR-350-fal-governance-and-spend-enforcement.md)
|
||||
- [`v2/crates/ruforecast`](../../v2/crates/ruforecast) (submodule)
|
||||
@@ -0,0 +1,134 @@
|
||||
# ADR 355: Local empty room bootstrap prior
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-09-04
|
||||
|
||||
## Context
|
||||
|
||||
Most installations are occupied when RuView first starts. An uncalibrated
|
||||
signal heuristic can interpret HVAC, clock error, neighboring motion, or a
|
||||
short covariance window as a person. It can also surface plausible numeric
|
||||
vitals before a room model establishes occupant attribution.
|
||||
|
||||
One empty home is not representative training data for all homes and contains
|
||||
no positive human examples. It is useful only as a recent negative prior for
|
||||
the same installation. It cannot define a universal human signal model.
|
||||
|
||||
The field model was memory only. The server also used a relative `data/`
|
||||
directory, which is unreliable for embedded application hosts.
|
||||
|
||||
## Decision
|
||||
|
||||
RuView may persist a completed empty room field model as a local bootstrap
|
||||
prior only after two server controlled stages:
|
||||
|
||||
1. Normal calibration reaches both 1,000 accepted frames and 600 seconds.
|
||||
The frame target represents twenty complete fifty frame runtime windows.
|
||||
A caller may select one currently live ESP32 source. The server checks its
|
||||
freshness before replacing an active bootstrap prior, binds collection to
|
||||
that source, and rejects frames from other radios. When no source is
|
||||
selected, the first accepted ESP32 source preserves legacy behavior.
|
||||
2. Promotion observes 12 new samples spaced by at least one second. Each
|
||||
sample has a four second bounded acquisition timeout. At least 10 must
|
||||
resolve empty, all ticks must be fresh, and none may publish numeric vital
|
||||
signs.
|
||||
|
||||
The stored image contains aggregate means, environmental modes, mode energies,
|
||||
noise statistics, sorted scalar residual references, configuration, one source
|
||||
node ID, and a server generated model ID. It excludes raw CSI, waveforms,
|
||||
device addresses, room names, pose, identity, credentials, and positive human
|
||||
labels. Runtime matching compares a fifty frame mean with the learned residual
|
||||
distribution. Its score is empirical background conformance, not an absence
|
||||
probability.
|
||||
|
||||
The image is bound to a SHA 256 digest of the stable installation ID, carries
|
||||
an integrity digest over the exact stored payload bytes, is capped at 1 MiB,
|
||||
and inherits the field model expiry.
|
||||
Files with an invalid schema, dimensions, values, node set, installation
|
||||
binding, digest, size, or lifetime fail closed.
|
||||
|
||||
Restoring an image creates a field model in `bootstrap_only` mode. It may
|
||||
reduce an uncalibrated false person count, but it cannot authorize calibrated
|
||||
evidence or numeric heart and breathing rates. Explicit calibration replaces
|
||||
it with fresh statistics. Cancelling an unfinished capture restores a valid
|
||||
prior. Administrator scoped reset removes it.
|
||||
|
||||
A restored image may receive one operator confirmed held out empty refinement.
|
||||
The server measures 12 fresh, source bound runtime residuals at one second
|
||||
spacing and requires zero stale samples and zero numeric vital samples. It
|
||||
persists only the scalar residuals. The new boundary is the larger of the
|
||||
existing threshold and 110 percent of the held out 95th percentile, capped at
|
||||
125 percent of the prior threshold. The one use marker persists in the model,
|
||||
so repeated requests cannot ratchet the threshold upward.
|
||||
|
||||
The status endpoint reports the learned runtime window, residual threshold,
|
||||
reference count, current background conformance, and the negative only
|
||||
authority boundary. A restored model never resumes collection and never
|
||||
persists raw calibration frames.
|
||||
|
||||
The server accepts `--data-dir` or `RUVIEW_DATA_DIR`, allowing an application
|
||||
host to provide a protected state directory.
|
||||
|
||||
## Consequences
|
||||
|
||||
Startup can use recent measured background structure instead of only generic
|
||||
heuristics. No accuracy lift is claimed until occupied and empty held out data
|
||||
from multiple installations is recorded.
|
||||
|
||||
The largest failure mode is overfitting to a temporarily quiet home and
|
||||
suppressing a weak occupant. Controls are low authority, 24 hour expiry,
|
||||
installation binding, vital abstention, explicit recalibration, and reset.
|
||||
|
||||
A human signal model still requires positive, consented, leakage separated
|
||||
occupied sequences. Empty data is never relabeled as human training.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. `wifi-densepose-signal::ruvsense::field_model` exports and validates
|
||||
`FieldModelSnapshotV1`.
|
||||
2. `wifi-densepose-sensing-server::bootstrap_baseline` owns schema, integrity,
|
||||
storage, lifetime, and the held out gate.
|
||||
3. `POST /api/v1/calibration/bootstrap/promote` measures the validation window
|
||||
and stores the image only on pass.
|
||||
4. `GET /api/v1/calibration/status` reports bootstrap authority and explicitly
|
||||
denies calibrated evidence and numeric vital authority.
|
||||
5. Numeric vitals fail closed unless an explicit fresh calibration resolves
|
||||
exactly one occupant at sufficient signal quality and confidence.
|
||||
6. The legacy `edge_vitals` WebSocket message applies the same publication
|
||||
gate as `sensing_update`. When the bootstrap background matches, it emits
|
||||
absent state, zero people, null numeric rates, and an explicit abstention
|
||||
reason instead of forwarding raw edge candidates.
|
||||
7. `POST /api/v1/calibration/start?source_node_id=N` selects a live source for
|
||||
deterministic single link capture. A missing or stale selection fails
|
||||
without disabling the active bootstrap prior.
|
||||
8. `POST /api/v1/calibration/bootstrap/refine?confirmed_empty=true&source_node_id=N`
|
||||
performs the bounded one time held out refinement. It requires administrator
|
||||
authority when transport authentication is enabled.
|
||||
|
||||
## Measured installation evidence
|
||||
|
||||
One physical empty home run on source Node 5 collected 1,762 accepted frames
|
||||
over 655 seconds and produced 35 privacy reduced reference windows. Promotion
|
||||
observed 12 of 12 fresh empty samples, zero stale samples, and zero numeric
|
||||
vital samples. After process restart, 12 of 12 fresh samples matched the empty
|
||||
background and contained no numeric vital signs. Background conformance scores
|
||||
ranged from 50.0 to 64.3 percent with a median of 54.3 percent.
|
||||
|
||||
This is measured negative evidence for one installation. No occupied positive
|
||||
holdout was recorded, so it does not prove person recall, person counting, or
|
||||
cross home accuracy improvement.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
With a physically empty room, complete calibration on stable nodes, promote
|
||||
the single source model, and restart the server with the same installation ID
|
||||
and data directory. Status must report `binding_mode=bootstrap_only`, zero
|
||||
collection frames, and false authority for calibrated evidence and numeric
|
||||
vitals. Twelve fresh empty samples must contain no numeric vital signs.
|
||||
Starting a new room calibration must replace the prior and begin at frame zero.
|
||||
When several radios are live, selecting Node 5 must leave Nodes 3 and 7 out of
|
||||
the source set for the entire capture.
|
||||
After restart, a confirmed empty holdout may refine the restored boundary once.
|
||||
The stored snapshot must reject a second refinement and must continue to deny
|
||||
calibrated evidence and numeric vital authority.
|
||||
@@ -0,0 +1,151 @@
|
||||
# ADR 356: Rate truthful CSI and reliability gated room adaptation
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-09-05
|
||||
|
||||
## Context
|
||||
|
||||
RuView mixed three different notions of time. ESP32 firmware requested a packet
|
||||
rate, the server observed a delivered rate, and several server paths still used
|
||||
fixed 2 Hz, 10 Hz, or 20 Hz assumptions. One feature named
|
||||
`dominant_freq_hz` was not a temporal frequency at all. It was the index of the
|
||||
strongest subcarrier multiplied by 0.05. A second defect compared a newly
|
||||
inserted frame with itself, forcing that motion delta to zero.
|
||||
|
||||
These errors can move a spectral peak into the breathing or heartbeat band and
|
||||
produce plausible but false values. They also make models sensitive to WiFi
|
||||
traffic and processor scheduling rather than physical motion.
|
||||
|
||||
ESP-IDF documents that `wifi_csi_info_t.first_word_invalid` marks the first four
|
||||
CSI bytes invalid because of a hardware limitation. It also documents that the
|
||||
CSI receive callback runs in the WiFi task and should hand work to a lower
|
||||
priority task rather than perform lengthy processing. RuView serialized the
|
||||
invalid bytes unchanged and fed the original buffer to edge processing.
|
||||
|
||||
Recent work suggests four useful directions with different maturity:
|
||||
|
||||
1. OpenCSI represents each link as a dimensionless residual against its own
|
||||
quiet period temporal standard deviation and exposes baseline maturity. Its
|
||||
reported transfer results are limited to binary presence.
|
||||
2. MORIC derives motion centered delay and Doppler views to reduce dependence
|
||||
on static environment specific CSI structure.
|
||||
3. Parameter efficient CSI crowd counting combines self supervised pretraining,
|
||||
small room adapters, and a stateful counting machine.
|
||||
4. WiFi JEPA masks complete radio links and predicts their latent embeddings,
|
||||
using real and ray traced CSI for pose representation learning.
|
||||
|
||||
The papers report promising external results. None is measured RuView evidence,
|
||||
and none authorizes deployment without leakage free occupied and empty held out
|
||||
evaluation.
|
||||
|
||||
## Decision
|
||||
|
||||
### Firmware ingestion
|
||||
|
||||
1. When ESP-IDF marks the first CSI word invalid, zero the first four serialized
|
||||
CSI bytes while retaining the fixed packet shape.
|
||||
2. Set ADR-018 byte 19 bit 5 to record that sanitation occurred.
|
||||
3. Feed edge DSP from the sanitized serialized payload, never the original
|
||||
invalid driver buffer.
|
||||
4. Keep callback work bounded to sanitation, serialization, and the existing
|
||||
queue handoff. Do not add transforms to the WiFi task.
|
||||
|
||||
### Server time model
|
||||
|
||||
1. Estimate delivered CSI rate from accepted monotonic frame timestamps after
|
||||
five samples. Use 20 Hz only during startup.
|
||||
2. Cap the physical input rate at the firmware contract of 50 Hz so a burst or
|
||||
timestamp artifact cannot retune downstream filters above their supported
|
||||
range.
|
||||
3. Reconfigure the vital detector only after a 20 percent rate change. Clear
|
||||
temporal and smoothing state whenever its clock changes. Samples calculated
|
||||
with different clocks must never be blended.
|
||||
4. Compute temporal dominant frequency from the recent mean amplitude series
|
||||
with demeaning, a Hann window, and bounded direct Fourier bins from 0.1 Hz to
|
||||
the smaller of 4 Hz or 45 percent of sample rate.
|
||||
5. Return zero for short, flat, nonfinite, or spectrally ambiguous windows.
|
||||
6. Compare the current frame with the prior frame, not itself, for motion delta.
|
||||
7. Export zero sample rate until the estimate is mature. Never present a startup
|
||||
fallback as a measured node property.
|
||||
|
||||
### Model compatibility and room reliability
|
||||
|
||||
1. Preserve the historical fifth input feature for version 1 adaptive models at
|
||||
the model boundary. Version 2 and later models consume the true temporal
|
||||
frequency. This prevents an existing model from silently receiving a
|
||||
different input distribution after a server update.
|
||||
2. A background model is unreliable until it contains at least ten finite,
|
||||
nonnegative residual references. An unreliable model cannot suppress a
|
||||
runtime change and exposes no conformance score.
|
||||
3. Expose background maturity, reliability, and a robust residual Z score based
|
||||
on the median and median absolute deviation.
|
||||
4. Keep the persisted empty home prior negative only. It cannot authorize
|
||||
calibrated presence, person count, pose, or numeric vitals.
|
||||
|
||||
### Model roadmap
|
||||
|
||||
The following work remains proposal only:
|
||||
|
||||
1. Evaluate an OpenCSI style per link quiet residual normalization for binary
|
||||
presence without replacing absolute magnitude features used for motion.
|
||||
2. Evaluate delay and Doppler views after phase sanitation for activity.
|
||||
3. Pretrain a frozen multilink encoder and adapt small room specific modules for
|
||||
counting.
|
||||
4. Evaluate link masked self supervision for pose only when at least three
|
||||
spatially distinct links and synchronized positive pose labels exist.
|
||||
|
||||
Every candidate uses immutable manifests and splits by room, subject,
|
||||
recording, and time. It is compared with the frozen baseline under identical
|
||||
seeds. Counting includes confusion matrices and mean absolute error. Pose
|
||||
includes PCK at 20 centimetres, MPJPE, and the mean pose baseline. A shuffled
|
||||
label control must fail.
|
||||
|
||||
Promotion requires at least 10 percent held out lift, less than 10 milliseconds
|
||||
added p95 inference latency, no more than 20 percent peak memory overhead, zero
|
||||
leakage or secret findings, frozen anchor retention, and no regression in empty
|
||||
rejection or occupied recall. Promotion remains an explicit maintainer action.
|
||||
|
||||
## Consequences
|
||||
|
||||
The firmware no longer preserves a known invalid prefix as sensor evidence. The
|
||||
server uses an observed physical clock for spectral features and vital filters.
|
||||
Existing version 1 adaptive models remain compatible. Sparse background samples
|
||||
fail open to observation and fail closed for suppression.
|
||||
|
||||
The direct Fourier estimator is intentionally small and bounded to 128 temporal
|
||||
samples. It is portable and deterministic, but it is not a replacement for a
|
||||
phase sanitized delay and Doppler pipeline.
|
||||
|
||||
The largest remaining uncertainty is human recall. Empty home evidence can
|
||||
measure false presence but cannot show whether weak occupied signals are being
|
||||
suppressed. The fix path is a consented, leakage separated occupied holdout with
|
||||
multiple spatial links and synchronized reference labels.
|
||||
|
||||
## Research basis
|
||||
|
||||
1. Espressif ESP-IDF WiFi CSI programming guide:
|
||||
https://docs.espressif.com/projects/esp-idf/en/v5.5.4/esp32/api-guides/wifi.html
|
||||
2. Espressif ESP CSI reference implementation:
|
||||
https://github.com/espressif/esp-csi
|
||||
3. OpenCSI:
|
||||
https://arxiv.org/abs/2607.26665
|
||||
4. MORIC:
|
||||
https://arxiv.org/abs/2506.12997
|
||||
5. Parameter efficient CSI crowd counting:
|
||||
https://arxiv.org/abs/2601.02203
|
||||
6. WiFi JEPA:
|
||||
https://arxiv.org/abs/2607.11064
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Run the firmware serializer host tests and the focused hardware, signal, and
|
||||
sensing server Rust suites. The deterministic serializer test must prove the
|
||||
first four bytes are zero only when ESP-IDF marks them invalid and bit 5 must
|
||||
round trip through the Rust parser. The temporal estimator must recover a
|
||||
1.2 Hz synthetic signal within 0.21 Hz, abstain on short and flat inputs, and
|
||||
remain below 10 milliseconds p95 on the target Mac. Existing version 1 model
|
||||
input must retain the legacy proxy. A background model with nine references
|
||||
must never report a match or suppress a runtime change. Physical firmware is
|
||||
not qualified until an S3 and C6 boot and stream the sanitized flag without
|
||||
watchdog, memory, or transport regressions during a five minute burn in.
|
||||
@@ -0,0 +1,143 @@
|
||||
# ADR 357: Raw CSI calibration sample integrity
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Date: 2026-09-06
|
||||
|
||||
## Context
|
||||
|
||||
The empty room field model requires 1,000 accepted observations over at least
|
||||
600 seconds. The sensing server previously invoked calibration from both the
|
||||
raw CSI path and the one second edge vital path. Edge vital packets did not add
|
||||
a CSI observation, so that path repeatedly fed the existing history tail. A
|
||||
counter could therefore satisfy the frame gate without representing 1,000
|
||||
independent CSI packets.
|
||||
|
||||
Node liveness also combined edge vital and CSI traffic. A source emitting only
|
||||
edge vital summaries could appear live enough to start calibration even though
|
||||
it had no fresh raw CSI. Finally, cloning the complete per node history before
|
||||
each feed copied as much as 204,800 bytes for a 100 frame, 256 subcarrier
|
||||
window even though calibration consumes only the current observation.
|
||||
|
||||
The earlier single installation result recorded in ADR 355 predates this
|
||||
admission boundary. Its frame count is not accepted as evidence of independent
|
||||
CSI coverage. It remains useful only as diagnosis of the failure mode and must
|
||||
not be promoted as a production bootstrap prior.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Only a packet on the frozen, model specific CSI grid may advance field
|
||||
calibration. Edge vital packets update their own health and presentation
|
||||
state but never feed the field model. The independent live inference grid
|
||||
remains unchanged.
|
||||
2. Each calibration session stores the last admitted wrapping sequence for
|
||||
every contributing node. Duplicate sequences are ignored. Normal forward
|
||||
progress and the `u32::MAX` to zero wrap are accepted.
|
||||
3. A packet one through sixteen sequence values behind the current high water mark
|
||||
is bounded UDP reordering. It is recorded and dropped without feeding the
|
||||
model or moving the high water mark. A backward depth above sixteen, or an
|
||||
ambiguous half range sequence, latches that node as faulted for the remainder
|
||||
of the session. Status exposes the policy, reorder window, per node reorder
|
||||
count, maximum observed depth, and `sequence_fault_node_ids`. Finalization
|
||||
returns `calibration_sequence_discontinuity` after a fault. The operator must
|
||||
cancel and restart the empty room capture.
|
||||
4. A requested source may start calibration only after a header only, bounded
|
||||
observation window identifies one stable grid. The selected grid needs at
|
||||
least ten seconds of evidence, a measured rate of at least 2 Hz, a latest
|
||||
sample newer than five seconds, and no observed gap of five seconds or more.
|
||||
Calibration status reports the immutable grid, selection evidence, current
|
||||
grid age, and `last_sequence_by_node` without exposing CSI values.
|
||||
5. Start, cancel, reset, and process initialization clear sequence admission
|
||||
state. A failed feed does not advance the sequence cursor, so the same
|
||||
packet may be retried after a transient model input failure.
|
||||
6. The field bridge receives the current amplitude slice rather than a cloned
|
||||
history. A separate bounded runtime history retains only that same frozen
|
||||
grid, so the model is never trained on one symbol layout and evaluated on a
|
||||
different layout. Canonical grid normalization remains unchanged.
|
||||
7. The exact source grid is stored inside the digest protected bootstrap v2
|
||||
payload. A restored model rebuilds only its matching runtime history. An
|
||||
unbound legacy image cannot become an active startup prior.
|
||||
8. Successful finalization clears the selected grid runtime history. Bootstrap
|
||||
promotion waits up to 35 seconds for a complete fresh runtime window before
|
||||
scoring twelve additional samples. This covers a 50 frame window at the 2 Hz
|
||||
admission floor plus ten seconds of bounded jitter. Calibration observations
|
||||
therefore cannot enter the held out decision window.
|
||||
9. Stop and promotion require the frozen grid to remain fresh and reject any
|
||||
latched sequence fault immediately before persistence. Bootstrap v2 accepts
|
||||
exactly one source node because the current runtime supports one grid bound
|
||||
field model.
|
||||
10. The legacy `--calibrate` startup switch fails closed before opening ports.
|
||||
Grid evidence does not exist at process start, so operators start the server,
|
||||
wait ten seconds, then call the explicit single source calibration API.
|
||||
|
||||
## Consequences
|
||||
|
||||
Calibration frame count now means unique, forward raw CSI observations within
|
||||
one uninterrupted sequence epoch. A vitals only node cannot produce a
|
||||
bootstrap model. Late packets never become calibration evidence.
|
||||
|
||||
One physical Node 5 capture observed sequence 4,119,566 followed by 4,119,563,
|
||||
4,119,564, and 4,119,565 in one receive batch. A second independent 90 second
|
||||
capture observed bounded late packets at depths 9, 10, and 11. The window of
|
||||
sixteen covers the measured maximum with five positions of margin. At the
|
||||
firmware acceptance ceiling of 50 Hz, the window represents no more than 320
|
||||
milliseconds. Late packets are always dropped. Larger regressions remain
|
||||
failed closed.
|
||||
|
||||
The same 90 second capture measured three interleaved HT grids on Node 5. The
|
||||
64 subcarrier stream delivered 436 frames at 4.84 Hz with a 1.56 second maximum
|
||||
gap. The 128 stream delivered 100 frames at 1.11 Hz with a 5.05 second maximum
|
||||
gap. The 192 stream delivered 49 frames at 0.54 Hz with an 11.53 second maximum
|
||||
gap. Only the 64 grid exceeded the 2 Hz admission floor and stayed inside the
|
||||
five second freshness boundary, so the final field model binds to that grid.
|
||||
|
||||
The current wire format still has no authenticated boot epoch or stable device
|
||||
identity. A restart very early in a session or a same endpoint adversary cannot
|
||||
be distinguished cryptographically. A future ADR 018 revision must carry an
|
||||
authenticated device identifier and a random per boot identifier before the
|
||||
server claims exact restart or identity collision detection.
|
||||
|
||||
No new raw CSI is persisted. Sequence cursors and fault markers remain bounded
|
||||
in memory to active calibration sources. The bootstrap schema advances to v2
|
||||
to protect the grid identity while its negative only authority remains
|
||||
unchanged.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. `NodeState` records header only recent grid evidence and a separate bounded
|
||||
field model history.
|
||||
2. `AppStateInner::maybe_feed_calibration_frame` owns source binding, sequence
|
||||
ordering, fault latching, model feed, and cursor commit.
|
||||
3. `field_bridge::maybe_feed_calibration` accepts exactly one current amplitude
|
||||
slice and rejects an empty observation.
|
||||
4. Calibration start, status, stop, cancel, reset, promotion, refinement, and
|
||||
restart enforce the frozen source and grid contract.
|
||||
5. `bootstrap_baseline` persists the source grid inside the verified v2 image.
|
||||
6. Finalization clears training history, and promotion blocks until the model
|
||||
has a complete fresh scoring window plus twelve separately spaced samples.
|
||||
7. Startup calibration without measured grid evidence exits with a typed
|
||||
operator instruction instead of creating a model that can never receive a
|
||||
frame.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Unit tests must prove stable grid selection, rate and gap rejection, first
|
||||
packet acceptance, forward progress, duplicate rejection, source and grid
|
||||
isolation, maximum counter wrap, empty input retry, measured depth eleven
|
||||
reordering without a model feed, depth sixteen acceptance, depth seventeen
|
||||
latching, half range rejection, session clear, receipt visibility, protected
|
||||
grid persistence, and typed stop refusal.
|
||||
Tests must also prove startup flag refusal, missing and stale grid refusal,
|
||||
complete holdout isolation, and sequence fault rejection during holdout.
|
||||
|
||||
On physical hardware, begin a Node 5 empty room capture and observe a fresh
|
||||
sixty second interval. Calibration frame growth must match unique 64 subcarrier
|
||||
Node 5 sequences while the independent global inference grid remains unchanged.
|
||||
Edge vital packets and other grids must produce zero growth. The bound grid
|
||||
must remain active, sequence faults must remain empty, and numeric vital output
|
||||
must remain absent. Any bounded late packet must appear in the reorder receipts
|
||||
while leaving the model count unchanged. Complete both the 1,000 frame and 600
|
||||
second gates, then run the twelve sample server controlled bootstrap holdout.
|
||||
Restart and prove that only the persisted grid rebuilds the runtime history.
|
||||
Do not claim human detection improvement until a separate consented occupied
|
||||
holdout demonstrates retained recall.
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-361: Point the whole fleet at one transmitter, to remove selection ambiguity
|
||||
|
||||
**Status:** Proposed — built, deployed fleet-wide, A-B-A measured. **Hypothesis CONFIRMED, adoption NOT justified.** The pairing *fraction* rises sharply; paired observations per second fall.
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** ADR-358 (falsified), ADR-359 (mechanism confirmed, not adopted), ADR-360 (falsified).
|
||||
|
||||
## The hypothesis, recorded long before it could be tested
|
||||
|
||||
From `docs/BACKLOG.md`, "Next: the actual pairing constraint":
|
||||
|
||||
> Two nodes can therefore fire in the same aligned window and still pick frames
|
||||
> from *different* transmitters, which can never pair under the
|
||||
> `(tx_mac, rx_seq)` key. Cheapest test and likely fix: **point the whole fleet
|
||||
> at one strong transmitter with the existing ADR-060 MAC filter.**
|
||||
|
||||
It had never been run, and the reason was mechanical rather than scientific:
|
||||
**`filter_mac` was writable only by `provision.py` over USB.** It is a 6-byte
|
||||
blob, absent from the `cfg_key_t` scalar table the config API validates
|
||||
against, so the test would have required nine physical visits. Exposing it over
|
||||
the network (see the firmware change accompanying this ADR) reduced the whole
|
||||
experiment to two config pushes.
|
||||
|
||||
## What was measured
|
||||
|
||||
The associated AP was chosen on measurement, not assumption. Five transmitters
|
||||
reach all nine receivers; only one carries a usable rate:
|
||||
|
||||
```
|
||||
transmitter n_rx total_fps mean_rssi
|
||||
8c:30:66:86:a4:21 9 109.81 -66.6 <- the AP, 26x the next
|
||||
96:30:66:86:a4:21 9 4.24 -65.9
|
||||
08:36:c9:71:ff:52 9 0.18 -75.9
|
||||
64:16:66:cb:17:07 9 0.08 -81.7
|
||||
10:a5:62:24:bb:30 9 0.06 -78.2
|
||||
```
|
||||
|
||||
A-B-A, ten 30 s windows per arm. `/api/v1/fusion` counters are cumulative since
|
||||
sink start, so they are **differenced over each window** and never divided as
|
||||
lifetime totals — the trap already recorded for `early_drop`/`csi_fps_samples`.
|
||||
The filter applied live on all nine nodes with **no restart**, so no reboot
|
||||
confounds any arm.
|
||||
|
||||
```
|
||||
arm n paired_fraction tx/s paired/s
|
||||
A1 control 10 0.5668 +/- 0.0107 73.0 41.4
|
||||
B filtered 10 0.7287 +/- 0.0352 33.9 24.7
|
||||
A2 control 10 0.5836 +/- 0.0120 81.5 47.6
|
||||
```
|
||||
|
||||
**Pairing fraction +0.1535 against the pooled control (+26.7% relative),
|
||||
Welch t = 13.26, df = 10.5, p < 0.0001.** Reversible: A2 returned to control,
|
||||
+0.0168 against A1.
|
||||
|
||||
Per-node confirmation that the filter did what it claims, taken on node 3 while
|
||||
eight nodes were still unfiltered:
|
||||
|
||||
```
|
||||
node 3 (filtered) AP fps 13.56 non-AP fps 0.28 non-AP share 2.0%
|
||||
node 8 (control) AP fps 12.18 non-AP fps 5.66 non-AP share 31.7%
|
||||
```
|
||||
|
||||
The AP rate is untouched; peer traffic falls to residue.
|
||||
|
||||
## The hypothesis is confirmed. The fix is not.
|
||||
|
||||
Transmitter ambiguity **was** suppressing pairing, and removing it lifts the
|
||||
fraction by a quarter. That settles a question that had been open since
|
||||
2026-09-07 and had resisted three other experiments.
|
||||
|
||||
But the backlog called this the "likely fix", and that half does not survive.
|
||||
It predicted yield would fall to ~13 fps and that "the frames that remain are
|
||||
pairable". Both are true, and neither is sufficient:
|
||||
|
||||
- **tx/s fell 56%** (77.3 -> 33.9)
|
||||
- **paired observations per second fell 44%** (44.4 -> 24.7)
|
||||
|
||||
A higher fraction of a much smaller pie. The quantity downstream sensing
|
||||
actually consumes is paired observations per second, and that went **down**.
|
||||
Fraction was the wrong figure of merit, and choosing it is what made this look
|
||||
like a fix rather than a diagnosis.
|
||||
|
||||
## Decision
|
||||
|
||||
**Do not adopt. Keep the branch unmerged.** Production stays unfiltered,
|
||||
`gate_mode=2`, `gate_seq_period=8`.
|
||||
|
||||
`filter_mac` **is** kept in the config API. It costs nothing when unset, it is
|
||||
now the cheapest available probe for "is this node's pairing transmitter-
|
||||
limited?", and the ADR-060 filter had been effectively unreachable for the
|
||||
fleet's whole life.
|
||||
|
||||
## What this opens
|
||||
|
||||
A **partial** filter is the obvious next question and is NOT what was tested
|
||||
here. Restricting to a handful of widely-heard transmitters rather than exactly
|
||||
one would trade less yield for some of the gain, and there is no reason to think
|
||||
the optimum is at either extreme. The five 9-receiver transmitters above are the
|
||||
natural candidate set, though four of them carry almost no rate.
|
||||
|
||||
That is a new experiment with a different independent variable, not a variant
|
||||
of this one. It should be sized on **paired observations per second**, which is
|
||||
the lesson this ADR paid for.
|
||||
|
||||
## Honest scope
|
||||
|
||||
Software-only. One house, one AP, nine nodes, ~15 minutes per arm, at 05:00
|
||||
local with the house quiet. The effect is far larger than the within-arm
|
||||
scatter and it reversed cleanly, but it has not been repeated across times of
|
||||
day, and pairing is known to move with the transmitter population (see
|
||||
"Transmitter population instability", 2026-09-02).
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR-362: Reading the on-node log is on-demand, never streamed
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** the on-node log engine (persistent 128 B-record ring + coredump-to-flash)
|
||||
|
||||
## Context
|
||||
|
||||
The firmware now keeps a persistent log on the node's `storage` volume and can
|
||||
write a coredump to flash. Nothing reads either one. The log is a new concept
|
||||
in this tree, so there is no existing consumer to extend and the access
|
||||
pattern is genuinely open.
|
||||
|
||||
Two shapes were considered:
|
||||
|
||||
1. **Ingest** — the server polls every node on a schedule, drains new records,
|
||||
and keeps its own copy that the UI reads.
|
||||
2. **On-demand** — the server fetches a node's log only when a human asks for
|
||||
it, and keeps nothing.
|
||||
|
||||
## Decision
|
||||
|
||||
**On-demand only. No streaming, no periodic polling, no server-side copy, no
|
||||
alerting.**
|
||||
|
||||
The log is pulled after a board hangs or crashes, or while troubleshooting a
|
||||
specific node. That is the whole access pattern.
|
||||
|
||||
## Why
|
||||
|
||||
- **The persistence already lives on the node.** The ring holds ~208 days at
|
||||
128 B per 300 s, and surviving a power cycle is the entire reason the module
|
||||
exists — a power cycle is how a wedged node gets recovered. A second copy on
|
||||
the server duplicates a durability property that is already satisfied.
|
||||
- **Airtime is this fleet's binding constraint, and it is the thing being
|
||||
diagnosed.** Two nodes uplinking at 1 Mbps were measured consuming 91% of
|
||||
fleet airtime. A periodic log drain across nine nodes would spend exactly the
|
||||
resource whose exhaustion is the most common thing you would open the log to
|
||||
investigate. An instrument must not perturb what it measures.
|
||||
- **Coredumps are 64 KB.** They are only ever worth moving when something has
|
||||
actually crashed.
|
||||
- **The use is diagnostic, not observational.** Nobody watches nine logs. You
|
||||
open one node's history when that node misbehaves.
|
||||
|
||||
## What this implies gets built
|
||||
|
||||
- A server route that proxies to the node on request — `GET
|
||||
/api/v1/nodes/{id}/log`, and the same for `/coredump` — authenticating with
|
||||
the OTA PSK the firmware endpoints already sit behind.
|
||||
- A page under the existing Tools menu to view one node's records.
|
||||
- **No** scheduler, **no** new storage, **no** retention policy on the server.
|
||||
|
||||
## Open question, deliberately not settled here
|
||||
|
||||
Whether the server decodes the 128 B records into JSON, or proxies raw bytes
|
||||
and lets the decoder at the edge (browser, or `node_log_read.py`) interpret
|
||||
them.
|
||||
|
||||
Decoding server-side means the record layout is defined in two places — C and
|
||||
Rust — and can drift silently. The pilot already paid for a version of this:
|
||||
a field whose sentinel the host had to learn to render as `n/a`, where a
|
||||
plausible wrong value would have been worse than an obvious gap. That argues
|
||||
for the raw proxy and a single definition of the format.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Real-time monitoring, log streaming, fleet-wide log aggregation, and alerting
|
||||
on log contents are all explicitly out of scope. If a future need genuinely
|
||||
requires one of them, it should revisit this ADR rather than grow into it.
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR 363: Native macOS USB node onboarding
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and physically qualified with one attached ESP32 S3 and one attached
|
||||
ESP32 C6 through an unsigned Mac Catalyst development build. Signed
|
||||
distribution remains a release gate.
|
||||
|
||||
## Context
|
||||
|
||||
RuView operators currently provision ESP32 nodes with command line tooling.
|
||||
The macOS app can list server accepted node identities, but it cannot establish
|
||||
that an attached serial device is RuView firmware, assign a collision free node
|
||||
identity, preserve its WiFi configuration, or prove that it joined the sensing
|
||||
server. A UI that merely stores a friendly name would create false success.
|
||||
|
||||
Generic shell access from the app would also expand authority beyond the task.
|
||||
Serial logs can contain operational data, and WiFi credentials must not enter
|
||||
application diagnostics or provisioning receipts.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Firmware 0.8.12 provides a bounded line protocol over the physical USB serial
|
||||
channel. A discovery request carries a random 128 bit nonce. The device
|
||||
response binds that nonce to chip family, firmware version, current node
|
||||
identity, private server target, configuration state, and a pseudonymous
|
||||
64 bit device digest derived from the base MAC under a RuView domain.
|
||||
|
||||
2. A configuration request is accepted only for the most recently issued
|
||||
nonce within 60 seconds. The nonce is single use. Node identity is limited
|
||||
to 1 through 255, the target is limited to RFC 1918 IPv4, and the target port
|
||||
is limited to 1 through 65535.
|
||||
|
||||
3. Existing WiFi configuration can be preserved. New credentials use strict
|
||||
base64 framing, bounded lengths, and control character rejection. Firmware
|
||||
commits NVS before acknowledging and rebooting. Credentials are never
|
||||
returned, logged, or included in the receipt.
|
||||
|
||||
4. The macOS client uses a native serial bridge. It allowlists exact character
|
||||
device prefixes, configures the port directly, and does not invoke a shell,
|
||||
Python, or an external process. It rechecks the device digest immediately
|
||||
before configuration to reduce port substitution risk.
|
||||
|
||||
5. The app suggests the lowest unused server node identity and the Mac private
|
||||
LAN address. Success is shown only after the configured node appears in the
|
||||
server inventory as active with evidence no older than three seconds.
|
||||
|
||||
6. Old firmware is reported as requiring an update. The app must not claim it
|
||||
provisioned a device based only on generic RuView serial logs.
|
||||
|
||||
7. Automatic firmware flashing is outside this decision. Flashing requires an
|
||||
exact chip and port check, a private backup, a matching image, and captured
|
||||
boot evidence. Release publication remains a separate maintainer action.
|
||||
|
||||
8. The device digest uses the PSA Crypto SHA 256 interface on ESP IDF 5.4 and
|
||||
6.0. This keeps the stable domain separated identity while avoiding the
|
||||
legacy Mbed TLS hash API removed by ESP IDF 6.
|
||||
|
||||
9. ESP32 C6 mmWave UART defaults use GPIO 4 and GPIO 5. Startup rejects any
|
||||
configured mmWave pin pair that overlaps the active console RX or TX pins.
|
||||
This preserves the onboarding input path after a failed mmWave probe.
|
||||
|
||||
## Security and privacy consequences
|
||||
|
||||
The app gains Mac App Sandbox USB and serial device access, but not general file
|
||||
or shell authority. The protocol does not authenticate a device with a hardware
|
||||
root of trust; the digest is stable identification, not a cryptographic device
|
||||
certificate. Physical USB possession is therefore the trust boundary for this
|
||||
version. An authenticated fleet identity remains ADR 305 scope.
|
||||
|
||||
The largest failure mode is a successful NVS write followed by failure to join
|
||||
the LAN. The user receives an explicit bounded verification failure instead of
|
||||
a success screen, while the board retains its committed settings and can be
|
||||
rescanned over USB.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Connect an ESP32 with firmware 0.8.12 to a Mac that is already receiving RuView
|
||||
nodes. Use only the macOS Add Sensor workflow to preserve WiFi, assign the
|
||||
lowest unused identity, and set the Mac private address. Pass when the UI
|
||||
reports the expected chip and firmware, the reassigned node remains fresh for
|
||||
five minutes, the stale collided identity expires, app restart preserves the
|
||||
profile, no server health, send, watchdog, panic, or reboot evidence appears,
|
||||
and no credential appears in app logs or the provisioning receipt.
|
||||
|
||||
The 2026-08-31 physical run passed on an ESP32 S3. The measured witness is in
|
||||
`docs/validation/2026-08-31-macos-usb-node-onboarding.md`.
|
||||
|
||||
The 2026-09-11 physical run passed on an ESP32 C6 over
|
||||
`cu.usbserial-3120`. The Mac app flashed the image, received the nonce bound
|
||||
hello receipt, preserved node identity 3, committed the server route, and
|
||||
verified fresh server evidence at minus 34 dBm. The run also exposed and fixed
|
||||
the GPIO 17 console RX conflict described above.
|
||||
@@ -92,9 +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
|
||||
|
||||
@@ -111,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 |
|
||||
|
||||
@@ -378,3 +378,157 @@ Reproducer: `v2/crates/ruforecast/crates/ruforecast-train/examples/bidmc_prepare
|
||||
one 76-row (context 64 + horizon 12) window per eligible patient, and writes
|
||||
governed `train.jsonl`/`test.jsonl`/`train-local.toml` per judge split. Raw
|
||||
data cached at `/tmp/bidmc-raw/` on the training host only.
|
||||
|
||||
### Zero-shot pretrained-checkpoint comparison (2026-09-02)
|
||||
|
||||
Every RuForecast result above this line is a **from-scratch-trained** model
|
||||
(RuForecast's own Burn/Rust architecture, trained on a tiny real or synthetic
|
||||
corpus) evaluated against trivial baselines. All of them lost. A literature
|
||||
pass after the BIDMC result above (arXiv:2505.11349, "context parroting";
|
||||
arXiv:2607.20027, zero-shot HRV forecasting with TimesFM/Chronos/MOIRAI on
|
||||
wearables) converged on an explanation: published foundation-model wins over
|
||||
naive baselines on short-horizon, smooth physiological signals come from
|
||||
**massive real pretraining corpora** (Chronos: ~10M real+augmented series;
|
||||
TimesFM: ~100B real time points), not from architecture alone — and
|
||||
RuForecast's from-scratch training used a real corpus many orders of
|
||||
magnitude smaller (tens to low hundreds of windows), so re-implementing
|
||||
these architectures from scratch and training small was never going to
|
||||
replicate their wins.
|
||||
|
||||
This test checks the other half of that hypothesis directly: does a **real,
|
||||
public, pretrained checkpoint**, run **zero-shot** (no fine-tuning, no
|
||||
training of any kind on this repo's data) on the **exact same BIDMC
|
||||
cross-entity test windows and metrics** as the BIDMC result above, actually
|
||||
beat the naive baselines? If the massive-pretraining-corpus explanation is
|
||||
right, it should — unlike every from-scratch attempt this session.
|
||||
|
||||
**Setup (kept identical to the BIDMC test above for a fair comparison):**
|
||||
same `/tmp/bidmc-raw/` cache (53 real ICU patients, PhysioNet BIDMC, Open
|
||||
Data Commons Attribution License v1.0), same window construction (context
|
||||
64s + horizon 12s, 1 Hz, variates HR/RESP/SpO2, one window per patient from
|
||||
that patient's first 76 rows), same two disjoint patient-partition judges (A:
|
||||
train 1-37/test 38-53 contiguous; B: train odd/test even interleaved — train
|
||||
patients are irrelevant here since nothing is trained, only the *test*
|
||||
partition is used), same quantile set
|
||||
`[0.05,0.10,0.25,0.50,0.75,0.90,0.95]`, same seasonal period (12), and the
|
||||
same WQL formula (`2*sum(pinball)/(numQuantiles*sum|actual|)`) and MAE
|
||||
definition as `ruforecast-core::metrics.rs`. Baselines were independently
|
||||
re-derived from `ruforecast-core::baseline.rs`'s exact logic in Python and
|
||||
cross-checked against the BIDMC section above: reproduced last-value WQL was
|
||||
0.01171 (A) / 0.01013 (B) versus the Rust CLI's originally reported
|
||||
0.01159 / 0.01002 — a ~1% gap attributable to f32 (Rust) vs f64 (Python)
|
||||
accumulation, not a methodology difference, so this reproduction is treated
|
||||
as validated.
|
||||
|
||||
Four real, public, pretrained-checkpoint models were run zero-shot on GPU
|
||||
(RTX 5080, `ruvultra`), per-variate univariate (none of these checkpoints has
|
||||
a native multivariate mode for these two families, so HR/RESP/SpO2 were
|
||||
forecast independently and recombined into the same window-level WQL/MAE as
|
||||
the baselines and the from-scratch RuForecast model):
|
||||
|
||||
- **Chronos-Bolt base/small** (`amazon/chronos-bolt-base`,
|
||||
`amazon/chronos-bolt-small`) — direct quantile-regression heads,
|
||||
deterministic given input (no sampling).
|
||||
- **Chronos (T5) small** (`amazon/chronos-t5-small`) — the original
|
||||
sampling-based Chronos family; `predict_quantiles` internally draws
|
||||
samples (default `num_samples`), so this one model's numbers below carry
|
||||
run-to-run sampling variance the other three don't.
|
||||
- **TimesFM 2.5 (200M, PyTorch)** (`google/timesfm-2.5-200m-pytorch`) —
|
||||
quantile output uses 10 channels; per the installed package's source
|
||||
(`timesfm_2p5_base.py`, `decode_index: int = 5`) and empirical
|
||||
verification across multiple windows/variates, channel 5 is the point
|
||||
forecast and channels 1-9 are the native quantile levels [0.1..0.9] in
|
||||
order (channel 0 is an unused duplicate, not a real quantile). Levels 0.05
|
||||
and 0.95 were clamped to the nearest native level (0.10/0.90); 0.25 and
|
||||
0.75 were linearly interpolated between the adjacent native levels — the
|
||||
same class of approximation Chronos-Bolt's own pipeline applies
|
||||
internally when asked for quantile levels outside its trained range (a
|
||||
warning it printed for 0.05/0.95 on every call).
|
||||
|
||||
| Judge | Model | Test windows | MAE | WQL | WQL vs last-value |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| A | last-value (baseline) | 16 | 0.7882 | 0.01171 | — |
|
||||
| A | seasonal-naive (baseline) | 16 | 1.0885 | 0.01617 | worse |
|
||||
| A | Chronos-Bolt-base (zero-shot) | 16 | 0.7802 | 0.00756 | **-35.4%** |
|
||||
| A | Chronos-Bolt-small (zero-shot) | 16 | 0.7212 | 0.00684 | **-41.6%** |
|
||||
| A | Chronos-T5-small (zero-shot) | 16 | 0.9487 | 0.00955 | **-18.4%** |
|
||||
| A | TimesFM-2.5-200M (zero-shot) | 16 | 0.7490 | 0.00731 | **-37.6%** |
|
||||
| B | last-value (baseline) | 26 | 0.6944 | 0.01013 | — |
|
||||
| B | seasonal-naive (baseline) | 26 | 0.8697 | 0.01269 | worse |
|
||||
| B | Chronos-Bolt-base (zero-shot) | 26 | 0.7096 | 0.00719 | **-29.0%** |
|
||||
| B | Chronos-Bolt-small (zero-shot) | 26 | 0.6852 | 0.00676 | **-33.3%** |
|
||||
| B | Chronos-T5-small (zero-shot) | 26 | 0.8254 | 0.00886 | **-12.5%** |
|
||||
| B | TimesFM-2.5-200M (zero-shot) | 26 | 0.6511 | 0.00698 | **-31.1%** |
|
||||
|
||||
**Honest result: this is the first test all session where a model beats the
|
||||
trivial baselines, and it does so decisively and consistently** — all four
|
||||
pretrained checkpoints beat both last-value and seasonal-naive on WQL, on
|
||||
both independently-partitioned judges, by double-digit-to-40% margins. This
|
||||
is a materially larger and more consistent margin than the closest published
|
||||
direct analog found in the literature pass (zero-shot HRV forecasting,
|
||||
arXiv:2607.20027: Chronos beat EWMA by ~3%, MOIRAI showed no significant
|
||||
advantage over a mean baseline, p=0.953) — plausibly because BIDMC's 1 Hz
|
||||
ICU HR/RESP/SpO2 signals are smoother and more autocorrelated than the HRV
|
||||
series in that paper, which is itself a fair, disclosed difference between
|
||||
the two comparisons rather than a claim of a stronger general result.
|
||||
|
||||
On MAE (point accuracy) the picture is more mixed: Chronos-Bolt-small and
|
||||
TimesFM beat last-value's MAE on both judges, but Chronos-Bolt-base and
|
||||
Chronos-T5-small do not consistently (e.g. Chronos-Bolt-base's MAE is
|
||||
essentially tied with last-value on judge A, worse on judge B, despite a
|
||||
much better WQL there) — the pretrained models' advantage is concentrated in
|
||||
calibrated quantile spread (what WQL scores), not in shifting the central
|
||||
point forecast much past what last-value already gets from strong
|
||||
autocorrelation in these signals.
|
||||
|
||||
**This directly confirms the massive-pretraining-corpus hypothesis from the
|
||||
literature pass**: the same short-horizon, smooth physiological-signal task
|
||||
on which every from-scratch RuForecast configuration this session lost to
|
||||
naive baselines is won, cleanly, by checkpoints that carry a pretraining
|
||||
corpus many orders of magnitude larger than any real dataset available to
|
||||
this project. This is evidence *for* zero-shot/foundation-model inference
|
||||
(or later, fine-tuning a pretrained checkpoint) as the credible next lever
|
||||
for RuForecast-style short-horizon vitals forecasting — not for continuing
|
||||
to scale from-scratch training on this project's own small real corpora,
|
||||
which the BIDMC and household tests above already showed does not close
|
||||
this gap even with genuine, cross-entity real data.
|
||||
|
||||
**Scope and caveats, stated plainly:**
|
||||
- This is zero-shot inference only — no fine-tuning was attempted on any
|
||||
checkpoint. A fine-tuned comparison (e.g. LoRA-adapting Chronos-Bolt on
|
||||
RuForecast's own training windows) is a natural, not-yet-run follow-up.
|
||||
- Univariate-per-variate forecasting was used for all four models because
|
||||
neither Chronos nor TimesFM in these releases has a native multivariate
|
||||
mode matching RuForecast's joint HR/RESP/SpO2 modeling; a true
|
||||
multivariate foundation model (e.g. MOIRAI, which does support cross-variate
|
||||
attention) was not tested here and is a natural next step.
|
||||
- Chronos-T5-small's `predict_quantiles` samples internally (unlike
|
||||
Chronos-Bolt's direct quantile heads or TimesFM's quantile head); its
|
||||
numbers above are one run and carry unquantified sampling variance the
|
||||
other three models' numbers don't.
|
||||
- No inference-cost/latency comparison against RuForecast's own (much
|
||||
smaller, ~tens-of-KB) model is included; all four pretrained checkpoints
|
||||
are orders of magnitude larger (small-to-200M+ parameters) and ran on a
|
||||
discrete GPU (RTX 5080), not the ESP32-class edge target RuForecast's own
|
||||
architecture is designed for. A model that wins on WQL here is not
|
||||
automatically deployable to the same edge budget RuForecast targets — that
|
||||
tradeoff is unresolved by this test and is itself a reason zero-shot
|
||||
inference (call out to a hosted/cloud checkpoint) and from-scratch
|
||||
small-model training remain different tools for different deployment
|
||||
constraints, not a strict replacement of one by the other.
|
||||
|
||||
Reproducer: `scripts/ruforecast-zeroshot/bidmc_windows.py` (window builder,
|
||||
baselines, and metrics — ported line-for-line from
|
||||
`ruforecast-train/examples/bidmc_prepare.rs` and
|
||||
`ruforecast-core/src/{baseline,metrics}.rs`, verified against this document's
|
||||
own BIDMC last-value/seasonal-naive numbers) and
|
||||
`scripts/ruforecast-zeroshot/run_zeroshot_eval.py` (zero-shot inference and
|
||||
scoring), both in this worktree (`research/ruforecast-zeroshot-eval`). Run
|
||||
against `/tmp/bidmc-raw/` (downloaded fresh from PhysioNet if not already
|
||||
cached) with `python run_zeroshot_eval.py --models chronos-bolt-base
|
||||
chronos-bolt-small chronos-t5-small timesfm-2.5-200m --judges a b` inside a
|
||||
venv with `chronos-forecasting`, `timesfm`, and a CUDA-enabled `torch`
|
||||
installed (`amazon/chronos-*` and `google/timesfm-2.5-200m-pytorch` download
|
||||
automatically from HuggingFace Hub on first run; no HF token was required).
|
||||
Model weights and the raw BIDMC CSVs are not committed — `/tmp/bidmc-raw/`
|
||||
and the HuggingFace cache live only on the host that ran this evaluation.
|
||||
@@ -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,63 @@
|
||||
# WiFlow Reproduction Protocol
|
||||
|
||||
Status: Proposed research protocol
|
||||
|
||||
Tracks issue #1786.
|
||||
|
||||
## Objective
|
||||
|
||||
Determine whether a CSI optical flow style representation improves reusable camera free motion intelligence in RuView rather than only improving a proxy flow metric.
|
||||
|
||||
## Required capture metadata
|
||||
|
||||
Every run records hardware family, firmware, antenna count and geometry, channel width, center frequency, packet rate, timestamp source, synchronization method, room identity, participant identity class, label source, calibration, packet loss, missing subcarriers, preprocessing version, model commit, accelerator, software versions, power mode, and random seeds.
|
||||
|
||||
## Baselines
|
||||
|
||||
1. current RuView CSI preprocessing plus strongest task specific sensing head
|
||||
2. WiFlow style motion field representation
|
||||
3. simpler temporal derivative or phase and amplitude motion baseline
|
||||
|
||||
All conditions use identical train, validation, and held out environment splits.
|
||||
|
||||
## Metrics
|
||||
|
||||
Primary intermediate metrics:
|
||||
|
||||
* endpoint error or equivalent flow error
|
||||
* directional consistency
|
||||
* temporal stability
|
||||
* uncertainty calibration
|
||||
|
||||
Required downstream metrics:
|
||||
|
||||
* tracking quality
|
||||
* occupancy or presence quality
|
||||
* pose or activity metric where available
|
||||
* spatial state consistency in RuField or WorldGraph
|
||||
|
||||
Systems metrics:
|
||||
|
||||
* p50 and p95 inference latency
|
||||
* peak memory
|
||||
* CPU, GPU, or accelerator utilization
|
||||
* energy per inference where measurable
|
||||
* bytes per observation and sustained bandwidth
|
||||
|
||||
## Stress tests
|
||||
|
||||
Test unseen rooms, unseen participants, changed antenna geometry, a second RF hardware family, packet loss, burst loss, missing subcarriers, lower packet rate, clock jitter, and partial calibration failure.
|
||||
|
||||
## Promotion gate
|
||||
|
||||
The representation graduates only when the same learned representation improves at least two downstream RuView tasks on held out environments, or reaches equivalent downstream quality with materially lower edge cost.
|
||||
|
||||
A better optical flow proxy score alone is insufficient.
|
||||
|
||||
## Security and privacy
|
||||
|
||||
Visual or LiDAR labels may be used as privileged training evidence but must not become a runtime dependency for camera free deployment claims. Dataset provenance and consent requirements remain explicit.
|
||||
|
||||
## Rollback
|
||||
|
||||
The experiment is isolated behind a representation interface. Existing RuView sensing heads remain the production fallback.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Native macOS USB onboarding firmware witness
|
||||
|
||||
## Evidence class
|
||||
|
||||
MEASURED on 2026-08-31 with one physical ESP32 S3. This is not an ESP32 C6 or
|
||||
release distribution qualification.
|
||||
|
||||
## Hardware and firmware
|
||||
|
||||
The board was identified by the flashing tool as ESP32 S3 QFN56 revision 0.2
|
||||
with 2 MB embedded PSRAM and 8 MB flash. Firmware 0.8.9 was built for ESP32 S3
|
||||
with the displayless DevKitC defaults. The private configuration region was
|
||||
backed up before flashing. The flash operation omitted NVS, preserving the
|
||||
existing WiFi configuration.
|
||||
|
||||
No base MAC, WiFi credential, device digest, or raw CSI is retained in this
|
||||
witness.
|
||||
|
||||
## Results
|
||||
|
||||
1. The initial native USB scan failed because the ESP-IDF secondary USB
|
||||
Serial/JTAG console mirrors output but does not forward input to STDIN.
|
||||
2. Firmware was corrected to read the bounded onboarding protocol from the
|
||||
secondary VFS while retaining STDIN for UART console boards.
|
||||
3. A direct nonce challenge then returned ESP32 S3, firmware 0.8.9, configured
|
||||
state, the current node identity, and the private server route.
|
||||
4. The Mac app changed the collided identity from 1 to unused identity 5 while
|
||||
preserving WiFi. It showed success only after receiving fresh server
|
||||
evidence at minus 56 dBm.
|
||||
5. A five minute monitor collected 299 samples with zero node 5 missing events,
|
||||
zero node 5 stale events, zero server health failures, minus 57 to minus 50
|
||||
dBm RSSI, and 216 ms maximum packet age.
|
||||
6. Firmware uptime exceeded 376 seconds. Captured logs advanced from CSI
|
||||
callback 10,600 through 11,000, emitted a node 5 synchronization packet, and
|
||||
showed ESP NOW transmit failures equal to zero without reboot, watchdog, or
|
||||
panic evidence.
|
||||
7. The final channel isolation hardening image was flashed and challenged
|
||||
again. A following 60 sample check had zero missing, stale, or health failure
|
||||
events, a maximum packet age of 100 ms, and minus 57 to minus 50 dBm RSSI.
|
||||
|
||||
## Software gates
|
||||
|
||||
The nonce and configuration parser host test passed, the complete ESP32 S3
|
||||
firmware image built under ESP-IDF 5.4, and the application image occupied
|
||||
941,056 bytes with 55 percent of the smallest application partition free.
|
||||
|
||||
## Remaining gates
|
||||
|
||||
New WiFi credential entry, signed Mac distribution, and cross platform serial
|
||||
bridges remain unmeasured.
|
||||
|
||||
## ESP32 C6 follow up
|
||||
|
||||
On 2026-09-11, an ESP32 C6 on `cu.usbserial-3120` completed the Mac app flash,
|
||||
hello, configure, and verify sequence with firmware 0.8.12. The app preserved
|
||||
the existing node identity 3 and verified fresh sensing server evidence at
|
||||
minus 34 dBm. The board returned `RUVIEW_HELLO_OK_V1` within the bounded serial
|
||||
exchange after the C6 mmWave defaults moved from the UART0 RX conflict on GPIO
|
||||
17 to GPIO 4 and GPIO 5. This closes only the physical C6 onboarding gate. It
|
||||
does not qualify new WiFi credential entry or signed distribution.
|
||||
@@ -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)
|
||||
|
||||
```
|
||||
@@ -312,6 +362,14 @@ The `MSYS_NO_PATHCONV=1` prefix prevents Git Bash from mangling the `/project` p
|
||||
- `build/partition_table/partition-table.bin` -- flash partition layout
|
||||
- `build/esp32-csi-node.bin` -- application firmware
|
||||
|
||||
### Release bundles
|
||||
|
||||
Firmware downloads are published from dedicated `vX.Y.Z-esp32` tags after all
|
||||
S3 and C6 matrix builds pass. Each release contains separate S3 8 MB, S3 4 MB,
|
||||
and C6 4 MB archives plus an archive checksum manifest. The repository's
|
||||
automated `vNNNN` server releases (for example `v2655`) are not firmware tags;
|
||||
do not use their presence or absence of assets as firmware provenance.
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
To change Kconfig settings before building:
|
||||
|
||||
@@ -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,5 +1,7 @@
|
||||
set(SRCS
|
||||
"main.c" "csi_collector.c" "stream_sender.c" "nvs_config.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"
|
||||
@@ -13,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)
|
||||
@@ -30,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
|
||||
@@ -38,6 +44,8 @@ set(REQUIRES
|
||||
driver
|
||||
lwip
|
||||
mbedtls
|
||||
efuse
|
||||
led_strip
|
||||
)
|
||||
|
||||
# ADR-110: C6-only components — pulled in when building for esp32c6.
|
||||
@@ -47,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
|
||||
@@ -517,3 +539,154 @@ menu "Onboard LED (ADR-183)"
|
||||
colormap, in milli-units (250 = 0.25). Lower = more sensitive
|
||||
(reaches yellow with less motion).
|
||||
endmenu
|
||||
|
||||
|
||||
menu "Thermal monitoring"
|
||||
|
||||
config THERMAL_MONITOR
|
||||
bool "Read the on-chip temperature sensor and throttle the radio"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
default y
|
||||
help
|
||||
The C6 has a temperature sensor this firmware otherwise never reads.
|
||||
Harmless on a bare bench board; not once a node is sealed in a printed
|
||||
enclosure on a wall with the radio transmitting continuously.
|
||||
|
||||
What is being protected is the ENCLOSURE, not the silicon. The part is
|
||||
rated far past anything it sees here, but PLA softens at its glass
|
||||
transition (~55-60 C) and sags slowly under its own weight, which never
|
||||
announces itself.
|
||||
|
||||
config THERMAL_THROTTLE
|
||||
bool "Act on the temperature by reducing transmit power"
|
||||
depends on THERMAL_MONITOR
|
||||
default n
|
||||
help
|
||||
Separate from monitoring on purpose. Reading the sensor is inert;
|
||||
reducing transmit power moves every RSSI the node has, and doing that
|
||||
mid-experiment gives any surprise two candidate causes.
|
||||
|
||||
Leave off until the fleet is stable and the logs show a node genuinely
|
||||
running hot. If none ever does -- which is the likely outcome -- this
|
||||
never needs enabling, and the thresholds below cost nothing.
|
||||
|
||||
config THERMAL_WARN_C
|
||||
int "Warn above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 40 100
|
||||
default 65
|
||||
|
||||
config THERMAL_THROTTLE_C
|
||||
int "Reduce transmit power above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 45 105
|
||||
default 72
|
||||
help
|
||||
Die temperature, not enclosure temperature -- the sensor is on the chip
|
||||
and runs hotter than the plastic around it. ~72 C on the die in a small
|
||||
vented box is roughly a 50-55 C enclosure, which is where PLA begins to
|
||||
matter. Setting this at 55 would throttle a perfectly healthy node.
|
||||
|
||||
PETG (glass transition ~80 C) removes the concern; raise this if the
|
||||
whole fleet is printed in it.
|
||||
|
||||
config THERMAL_CRITICAL_C
|
||||
int "Minimum transmit power above this die temperature (C)"
|
||||
depends on THERMAL_MONITOR
|
||||
range 50 110
|
||||
default 80
|
||||
|
||||
config THERMAL_HYSTERESIS_C
|
||||
int "Degrees of cooling required before stepping down a thermal state"
|
||||
depends on THERMAL_MONITOR
|
||||
range 1 20
|
||||
default 8
|
||||
help
|
||||
Recovering at the same threshold that tripped would oscillate, and every
|
||||
oscillation is a visible RSSI step on every link the node has.
|
||||
|
||||
Applies to EVERY downward transition, each relative to the threshold that
|
||||
was crossed on the way up: leaving CRITICAL needs CRITICAL_C minus this,
|
||||
leaving THROTTLED needs THROTTLE_C minus this, leaving WARN needs WARN_C
|
||||
minus this. Rising transitions are immediate at the plain thresholds --
|
||||
heat is a reason to act now.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Network supervision"
|
||||
|
||||
config UPLINK_WATCHDOG
|
||||
bool "Restart the node if the CSI uplink stops for too long"
|
||||
default y
|
||||
help
|
||||
Bounds how long a node can sit powered, capturing, and unable to reach
|
||||
the network.
|
||||
|
||||
MEASURED 2026-08-30: an AP-side event at 03:37 EDT disconnected all three
|
||||
wall-mounted nodes within three minutes of each other, and every one of
|
||||
them stayed dead for 5.5-7.5 hours until it was physically unplugged. The
|
||||
specific cause of that outage (a reconnect counter that gave up
|
||||
permanently) is fixed in main.c, but this exists so the NEXT unknown
|
||||
wedge costs minutes instead of a night of data.
|
||||
|
||||
The watchdog counts a successful sendto(), which on UDP means the network
|
||||
stack accepted the datagram -- not that the aggregator received it. That
|
||||
is deliberate: stopping the server does NOT reboot the fleet, because
|
||||
sendto keeps succeeding into a socket whose peer is gone. It fires only
|
||||
when the node's own network path is broken.
|
||||
|
||||
It arms on the first successful send rather than at boot, so a node
|
||||
powered on while the AP is down does not reboot-loop; the unbounded
|
||||
reconnect handles that case instead.
|
||||
|
||||
config UPLINK_WATCHDOG_TIMEOUT_S
|
||||
int "Restart after this many seconds with no successful send"
|
||||
depends on UPLINK_WATCHDOG
|
||||
range 30 3600
|
||||
default 1200
|
||||
help
|
||||
Must sit above the worst SELF-HEALING gap, or the watchdog reboots nodes
|
||||
that were about to recover on their own -- which is worse than the fault
|
||||
it guards against, because every reboot re-runs ESP-NOW leader election
|
||||
and discards the mesh time-sync state.
|
||||
|
||||
This was 120 s on the reasoning that the ENOMEM backoff caps at 2 s so a
|
||||
healthy node is never quiet for long. A 58-minute soak on 2026-08-30
|
||||
falsified that: nodes 0 and 2 each went silent repeatedly with a median
|
||||
gap of 210 s and a maximum of 225 s, and recovered every time without
|
||||
intervention. 120 s would have rebooted them every few minutes forever.
|
||||
(The cause of those 210 s gaps is still unknown -- it is not the ENOMEM
|
||||
backoff, which cannot produce a gap that long.)
|
||||
|
||||
Sized from the completed 112-minute soak, not the partial one: 30
|
||||
silences across two nodes, median 225 s, p90 225 s, and a single 510 s
|
||||
outlier that looks like two cycles running together. A third such merge
|
||||
would reach ~735 s, so 600 s was too tight -- it left only 90 s of margin
|
||||
over an event already observed once.
|
||||
|
||||
1200 s is 2.35x the observed maximum while still bounding a true wedge to
|
||||
twenty minutes rather than the 7.5 hours measured the same day. Revisit
|
||||
it if the ~225 s flapping is ever explained and fixed; until then do not
|
||||
lower it below ~900 s.
|
||||
|
||||
If nodes do restart unexpectedly, check the reset reason in the sync
|
||||
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.
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
#include "adaptive_controller.h"
|
||||
#include "thermal.h"
|
||||
#include "rv_radio_ops.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_mesh.h"
|
||||
@@ -331,6 +332,10 @@ static void emit_feature_state(void)
|
||||
static void slow_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* Sample the die and apply the thermal policy. The slow loop (30 s) is the
|
||||
* right cadence: the die does not change quickly, and reading it more
|
||||
* often buys nothing but noise. No-op unless CONFIG_THERMAL_MONITOR. */
|
||||
thermal_tick();
|
||||
/* ADR-081 L3: publish a HEALTH mesh message every slow tick
|
||||
* (default 30 s). The coordinator uses these to track liveness and
|
||||
* detect sync-error drift. */
|
||||
|
||||
@@ -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 */
|
||||
@@ -33,7 +33,31 @@
|
||||
#define EDGE_MAX_IQ_BYTES 1024 /**< Max I/Q payload per slot. */
|
||||
#define EDGE_PHASE_HISTORY_LEN 256 /**< Phase history buffer depth. */
|
||||
#define EDGE_TOP_K 8 /**< Top-K subcarriers to track. */
|
||||
#define EDGE_MAX_SUBCARRIERS 128 /**< Max subcarriers per frame. */
|
||||
/**
|
||||
* Max subcarriers per frame the edge pipeline will process.
|
||||
*
|
||||
* Target-conditional since 2026-08-28. The original 128 was an ESP32-S3
|
||||
* assumption: pre-HE chips report at most 128 CSI bins. ADR-110 onboarded the
|
||||
* ESP32-C6 to the *capture* path but not to this one, and a C6 associated to an
|
||||
* HE-capable AP delivers HE20 frames with 256 bins (iq_len = 512 bytes).
|
||||
*
|
||||
* `process_frame()` guards with `n_subcarriers > EDGE_MAX_SUBCARRIERS -> return`,
|
||||
* so on C6 every frame was rejected and the whole edge pipeline — vitals,
|
||||
* presence, fall detection, per-slot counting — silently did nothing. The Edge
|
||||
* DSP task started, logged its banner, and never processed a frame. Confirmed
|
||||
* on hardware: no edge_proc log past init and no vitals packet ever reaching
|
||||
* the sink.
|
||||
*
|
||||
* HE-capable parts get 256; pre-HE parts keep 128 so they don't pay ~3.5 KB of
|
||||
* .bss they can never use. CONFIG_SOC_WIFI_HE_SUPPORT is the same switch
|
||||
* csi_collector.c uses to pick its rx_ctrl layout.
|
||||
*/
|
||||
#if CONFIG_SOC_WIFI_HE_SUPPORT
|
||||
#define EDGE_MAX_SUBCARRIERS 256 /**< HE20 carries 256 CSI bins (C6/C5). */
|
||||
#else
|
||||
#define EDGE_MAX_SUBCARRIERS 128 /**< Pre-HE parts (S3 etc). */
|
||||
#endif
|
||||
|
||||
|
||||
/* ---- Measured sample-rate tracking ----
|
||||
*
|
||||
|
||||
@@ -21,8 +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"
|
||||
@@ -36,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
|
||||
@@ -59,7 +63,52 @@ nvs_config_t g_nvs_config;
|
||||
|
||||
static EventGroupHandle_t s_wifi_event_group;
|
||||
static int s_retry_num = 0;
|
||||
#define MAX_RETRY 10
|
||||
|
||||
/* WiFi reconnection.
|
||||
*
|
||||
* This used to be `#define MAX_RETRY 10` with the disconnect handler giving up
|
||||
* permanently once s_retry_num reached it: it set WIFI_FAIL_BIT and never
|
||||
* called esp_wifi_connect() again. s_retry_num is only cleared on a successful
|
||||
* IP_EVENT_STA_GOT_IP, so it accumulated over the node's entire uptime -- ten
|
||||
* disconnects spread across days left the node permanently off the network
|
||||
* with nothing but a power cycle to clear it.
|
||||
*
|
||||
* MEASURED 2026-08-30: a single AP-side event at 03:37:03 EDT disconnected all
|
||||
* three wall-mounted nodes within three minutes of each other. Every one of
|
||||
* them latched off and stayed off for 5.5-7.5 hours, until it was physically
|
||||
* unplugged. CSI capture kept running throughout (it is driver-level and needs
|
||||
* no IP), so the boards looked alive on the bench and dead to the server.
|
||||
*
|
||||
* Retry is now unbounded with exponential backoff. Two things that were
|
||||
* conflated are now separate: WIFI_BOOT_WAIT_ATTEMPTS only releases app_main
|
||||
* from its startup wait so the rest of the node can boot; it does not stop the
|
||||
* node trying to reconnect. Nothing stops the node trying to reconnect. */
|
||||
#define WIFI_BOOT_WAIT_ATTEMPTS 10
|
||||
#define WIFI_RETRY_BASE_MS 500
|
||||
#define WIFI_RETRY_MAX_MS 30000
|
||||
|
||||
static esp_timer_handle_t s_reconnect_timer;
|
||||
|
||||
/* 500 ms doubling to a 30 s ceiling. Backoff matters because the failure this
|
||||
* guards against is an AP that is down or rebooting: hammering
|
||||
* esp_wifi_connect() at full rate for the minutes an AP takes to come back
|
||||
* wastes power and floods the log without reconnecting any sooner. */
|
||||
static uint32_t wifi_retry_delay_ms(int attempt)
|
||||
{
|
||||
int shift = attempt - 1;
|
||||
if (shift < 0) shift = 0;
|
||||
if (shift > 6) shift = 6;
|
||||
uint32_t d = (uint32_t)WIFI_RETRY_BASE_MS << shift;
|
||||
return d > WIFI_RETRY_MAX_MS ? WIFI_RETRY_MAX_MS : d;
|
||||
}
|
||||
|
||||
/* Runs on the esp_timer task, not the event loop task -- esp_wifi_connect()
|
||||
* must not be reached through a delay inside the event handler itself. */
|
||||
static void wifi_reconnect_cb(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
esp_wifi_connect();
|
||||
}
|
||||
|
||||
static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
int32_t event_id, void *event_data)
|
||||
@@ -69,13 +118,26 @@ 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);
|
||||
if (s_retry_num < MAX_RETRY) {
|
||||
esp_wifi_connect();
|
||||
s_retry_num++;
|
||||
ESP_LOGI(TAG, "Retrying WiFi connection (%d/%d)", s_retry_num, MAX_RETRY);
|
||||
} else {
|
||||
/* 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
|
||||
* app_main. Retrying continues regardless. */
|
||||
if (s_retry_num == WIFI_BOOT_WAIT_ATTEMPTS) {
|
||||
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
|
||||
}
|
||||
uint32_t delay_ms = wifi_retry_delay_ms(s_retry_num);
|
||||
if (s_reconnect_timer != NULL) {
|
||||
esp_timer_stop(s_reconnect_timer); /* may not be running; harmless */
|
||||
esp_timer_start_once(s_reconnect_timer, (uint64_t)delay_ms * 1000);
|
||||
} else {
|
||||
esp_wifi_connect(); /* timer unavailable: retry now */
|
||||
}
|
||||
ESP_LOGI(TAG, "Reconnecting in %lu ms (attempt %d)",
|
||||
(unsigned long)delay_ms, s_retry_num);
|
||||
} else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
|
||||
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
||||
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
|
||||
@@ -84,6 +146,41 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef CONFIG_UPLINK_WATCHDOG
|
||||
/* Uplink supervision.
|
||||
*
|
||||
* The reconnect fix above addresses the one wedge whose mechanism we have
|
||||
* proven. This catches the ones we have not: any state in which the node is
|
||||
* powered, capturing, and unable to put a packet on the wire. It bounds such a
|
||||
* state to CONFIG_UPLINK_WATCHDOG_TIMEOUT_S instead of the 7.5 hours measured
|
||||
* on 2026-08-30.
|
||||
*
|
||||
* The signal is a successful sendto(), which on UDP means the stack accepted
|
||||
* the datagram -- NOT that the server received it. That distinction is what
|
||||
* makes this safe: stopping the aggregator does not reboot the fleet, because
|
||||
* sendto keeps succeeding into a socket whose peer is gone. It fires only when
|
||||
* the node's own network path is broken.
|
||||
*
|
||||
* It arms on the first successful send rather than at boot, so a node that has
|
||||
* never reached the network does not reboot-loop while the AP is down; the
|
||||
* unbounded reconnect above owns that case. */
|
||||
static void uplink_watchdog_tick(void)
|
||||
{
|
||||
int64_t last = stream_sender_last_success_us();
|
||||
if (last == 0) {
|
||||
return; /* never delivered -- not armed yet */
|
||||
}
|
||||
int64_t idle_s = (esp_timer_get_time() - last) / 1000000;
|
||||
if (idle_s < CONFIG_UPLINK_WATCHDOG_TIMEOUT_S) {
|
||||
return;
|
||||
}
|
||||
ESP_LOGE(TAG, "uplink watchdog: no successful send for %lld s "
|
||||
"(limit %d s) -- restarting",
|
||||
(long long)idle_s, CONFIG_UPLINK_WATCHDOG_TIMEOUT_S);
|
||||
esp_restart();
|
||||
}
|
||||
#endif /* CONFIG_UPLINK_WATCHDOG */
|
||||
|
||||
static void wifi_init_sta(void)
|
||||
{
|
||||
s_wifi_event_group = xEventGroupCreate();
|
||||
@@ -135,6 +232,17 @@ static void wifi_init_sta(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
const esp_timer_create_args_t reconnect_args = {
|
||||
.callback = &wifi_reconnect_cb,
|
||||
.name = "wifi_reconnect",
|
||||
};
|
||||
esp_err_t rc_ret = esp_timer_create(&reconnect_args, &s_reconnect_timer);
|
||||
if (rc_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "reconnect timer create failed: %s (falling back to "
|
||||
"immediate retry)", esp_err_to_name(rc_ret));
|
||||
s_reconnect_timer = NULL;
|
||||
}
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid);
|
||||
@@ -147,7 +255,9 @@ static void wifi_init_sta(void)
|
||||
if (bits & WIFI_CONNECTED_BIT) {
|
||||
ESP_LOGI(TAG, "Connected to WiFi");
|
||||
} else if (bits & WIFI_FAIL_BIT) {
|
||||
ESP_LOGE(TAG, "Failed to connect to WiFi after %d retries", MAX_RETRY);
|
||||
ESP_LOGW(TAG, "Not connected after %d attempts -- continuing boot; "
|
||||
"reconnection keeps retrying in the background",
|
||||
WIFI_BOOT_WAIT_ATTEMPTS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,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.
|
||||
@@ -318,6 +441,54 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "Mock CSI active (scenario=%d)", CONFIG_CSI_MOCK_SCENARIO);
|
||||
}
|
||||
#else
|
||||
/* Why did we just start? Nothing recorded this before, and it is the
|
||||
* single most useful line in a fleet log: a node that reboots tells you
|
||||
* almost nothing, but a node that reboots with ESP_RST_BROWNOUT tells you
|
||||
* its supply sagged, which on shared outlets is a wiring problem rather
|
||||
* than a firmware one. Three nodes dropping together on 2026-08-28 would
|
||||
* have been answered by this line. */
|
||||
{
|
||||
esp_reset_reason_t rr = esp_reset_reason();
|
||||
const char *why =
|
||||
rr == ESP_RST_POWERON ? "power-on" :
|
||||
rr == ESP_RST_EXT ? "external reset" :
|
||||
rr == ESP_RST_SW ? "software restart" :
|
||||
rr == ESP_RST_PANIC ? "PANIC (crash)" :
|
||||
rr == ESP_RST_INT_WDT ? "interrupt watchdog" :
|
||||
rr == ESP_RST_TASK_WDT ? "task watchdog" :
|
||||
rr == ESP_RST_WDT ? "other watchdog" :
|
||||
rr == ESP_RST_BROWNOUT ? "BROWNOUT (supply sagged)" :
|
||||
rr == ESP_RST_DEEPSLEEP? "deep-sleep wake" : "unknown";
|
||||
if (rr == ESP_RST_PANIC || rr == ESP_RST_BROWNOUT ||
|
||||
rr == ESP_RST_INT_WDT || rr == ESP_RST_TASK_WDT) {
|
||||
ESP_LOGW(TAG, "reset reason: %s (%d) <-- not a clean start", why, (int)rr);
|
||||
} 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
|
||||
* reading is taken against a known-idle baseline rather than mid-burst. */
|
||||
thermal_init();
|
||||
|
||||
csi_collector_init();
|
||||
|
||||
/* ADR-073: Start multi-frequency channel hopping if configured in NVS. */
|
||||
@@ -510,8 +681,30 @@ void app_main(void)
|
||||
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off",
|
||||
(adapt_ret == ESP_OK) ? "on" : "off");
|
||||
|
||||
/* Main loop — keep alive */
|
||||
/* Main loop — keep alive, and supervise the uplink. */
|
||||
while (1) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10000));
|
||||
#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,191 @@
|
||||
#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);
|
||||
char primary_line[ONBOARDING_LINE_MAX];
|
||||
size_t primary_length = 0;
|
||||
bool primary_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));
|
||||
}
|
||||
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
|
||||
@@ -37,6 +37,12 @@ static uint32_t s_enomem_suppressed = 0;
|
||||
* succeeds. */
|
||||
static uint32_t s_enomem_streak = 0;
|
||||
|
||||
/* Timestamp of the last sendto() the stack accepted, on either path; 0 until
|
||||
* the node has ever delivered anything. Read by the uplink watchdog in main.c
|
||||
* -- see the rationale there for why "accepted by the stack" is deliberately
|
||||
* not "received by the server". */
|
||||
static int64_t s_last_success_us = 0;
|
||||
|
||||
static int sender_init_internal(const char *ip, uint16_t port)
|
||||
{
|
||||
s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
||||
@@ -118,6 +124,7 @@ int stream_sender_send(const uint8_t *data, size_t len)
|
||||
|
||||
/* A send got through — buffer pressure cleared; reset the backoff streak. */
|
||||
s_enomem_streak = 0;
|
||||
s_last_success_us = esp_timer_get_time();
|
||||
return sent;
|
||||
}
|
||||
|
||||
@@ -146,9 +153,18 @@ int stream_sender_send_priority(const uint8_t *data, size_t len)
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
/* Counts for the watchdog: the mesh sync packet rides this path at ~1 Hz,
|
||||
* and its getting through proves the node's network path is intact even
|
||||
* when the bulk CSI stream is being suppressed by the ENOMEM backoff. */
|
||||
s_last_success_us = esp_timer_get_time();
|
||||
return sent;
|
||||
}
|
||||
|
||||
int64_t stream_sender_last_success_us(void)
|
||||
{
|
||||
return s_last_success_us;
|
||||
}
|
||||
|
||||
void stream_sender_deinit(void)
|
||||
{
|
||||
if (s_sock >= 0) {
|
||||
|
||||
@@ -50,6 +50,18 @@ int stream_sender_send(const uint8_t *data, size_t len);
|
||||
*/
|
||||
int stream_sender_send_priority(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* esp_timer timestamp (microseconds) of the last sendto() the network stack
|
||||
* accepted, on either the bulk or the priority path. Returns 0 if the node has
|
||||
* not successfully sent anything since boot.
|
||||
*
|
||||
* This reports that the datagram was accepted for transmission, NOT that the
|
||||
* aggregator received it -- UDP gives no such signal. Used by the uplink
|
||||
* watchdog to detect a node that is powered and capturing but cannot reach the
|
||||
* network.
|
||||
*/
|
||||
int64_t stream_sender_last_success_us(void);
|
||||
|
||||
/**
|
||||
* Close the UDP sender socket.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/* On-chip thermal monitoring and radio throttling.
|
||||
*
|
||||
* The ESP32-C6 carries a temperature sensor (SOC_TEMP_SENSOR_SUPPORTED) that
|
||||
* nothing in this firmware has ever read. That was tolerable on a bare board
|
||||
* on a bench; it is not once nine nodes are sealed in printed enclosures and
|
||||
* left on a wall for months with the radio transmitting continuously.
|
||||
*
|
||||
* What we are protecting is the enclosure, not the silicon. The part is rated
|
||||
* well past anything it will see here, but PLA goes soft at its glass
|
||||
* transition around 55-60 C -- and that is sagging under its own weight over
|
||||
* weeks, not a dramatic failure, so it would never announce itself.
|
||||
*
|
||||
* The sensor reads DIE temperature, which runs hotter than the plastic around
|
||||
* it. Thresholds are therefore set against the die with that offset in mind:
|
||||
* a die at ~72 C in a small vented box is roughly a 50-55 C enclosure, which
|
||||
* is where PLA starts to matter. They are deliberately not set at 55 C -- that
|
||||
* would throttle a perfectly healthy node.
|
||||
*
|
||||
* Throttling reduces WiFi transmit power, which is the dominant heat source
|
||||
* and degrades gracefully: links weaken but keep working, where cutting the
|
||||
* capture rate would blind the node entirely.
|
||||
*
|
||||
* IMPORTANT for anyone reading link diagnostics: a throttled node's RSSI drops
|
||||
* at both ends, which looks exactly like an obstruction. `thermal_state()` and
|
||||
* `thermal_tx_dbm()` exist so that can be distinguished from a cage or a wall
|
||||
* rather than misdiagnosed as one.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "thermal.h"
|
||||
|
||||
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
|
||||
#include "driver/temperature_sensor.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_heap_caps.h"
|
||||
|
||||
static const char *TAG = "thermal";
|
||||
|
||||
static temperature_sensor_handle_t s_sensor;
|
||||
static bool s_ready;
|
||||
static float s_last_c = -273.0f;
|
||||
static float s_peak_c = -273.0f;
|
||||
static thermal_state_t s_state = THERMAL_OK;
|
||||
|
||||
/* esp_wifi_set_max_tx_power() takes 0.25 dBm units. 80 = 20 dBm, the default
|
||||
* on this part; each step down is 3 dB, which halves radiated power. */
|
||||
#define TXP_FULL 80 /* 20 dBm */
|
||||
#define TXP_STEP1 68 /* 17 dBm */
|
||||
#define TXP_STEP2 56 /* 14 dBm */
|
||||
#define TXP_MIN 44 /* 11 dBm */
|
||||
|
||||
static int8_t s_txp = TXP_FULL;
|
||||
|
||||
#ifdef CONFIG_THERMAL_THROTTLE
|
||||
/* Only referenced from the throttling block in thermal_tick(). Guarded so a
|
||||
* monitor-only build (the default, and what is on the wall) does not warn
|
||||
* about an unused function. */
|
||||
static void apply_tx_power(int8_t q)
|
||||
{
|
||||
if (q == s_txp) {
|
||||
return;
|
||||
}
|
||||
esp_err_t r = esp_wifi_set_max_tx_power(q);
|
||||
if (r == ESP_OK) {
|
||||
ESP_LOGW(TAG, "tx power %d.%02d dBm -> %d.%02d dBm (thermal)",
|
||||
s_txp / 4, (s_txp % 4) * 25, q / 4, (q % 4) * 25);
|
||||
s_txp = q;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "esp_wifi_set_max_tx_power(%d) failed: %s",
|
||||
q, esp_err_to_name(r));
|
||||
}
|
||||
}
|
||||
#endif /* CONFIG_THERMAL_THROTTLE */
|
||||
|
||||
esp_err_t thermal_init(void)
|
||||
{
|
||||
/* The requested span must fit ENTIRELY inside one of the sensor's
|
||||
* predefined measurement ranges -- the driver walks a table and takes the
|
||||
* first that contains it, so a span crossing two boundaries is rejected
|
||||
* with ESP_ERR_INVALID_ARG rather than clamped. (-10,110) looks reasonable
|
||||
* and is invalid for exactly that reason; it failed on real hardware.
|
||||
*
|
||||
* (20,100) is the right first choice here: the warn/throttle/critical
|
||||
* thresholds all sit comfortably inside it, and indoor ambient is
|
||||
* essentially never below 20 C. The wider fallbacks exist so an unheated
|
||||
* room, or a different chip revision with a different table, degrades to a
|
||||
* usable range instead of leaving the node unmonitored. */
|
||||
static const struct { int lo, hi; } spans[] = {
|
||||
{20, 100}, {-10, 80}, {-30, 50}, {50, 125},
|
||||
};
|
||||
esp_err_t r = ESP_ERR_INVALID_ARG;
|
||||
for (size_t i = 0; i < sizeof(spans) / sizeof(spans[0]); i++) {
|
||||
temperature_sensor_config_t cfg =
|
||||
TEMPERATURE_SENSOR_CONFIG_DEFAULT(spans[i].lo, spans[i].hi);
|
||||
r = temperature_sensor_install(&cfg, &s_sensor);
|
||||
if (r == ESP_OK) {
|
||||
ESP_LOGI(TAG, "sensor range %d..%d C", spans[i].lo, spans[i].hi);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (r != ESP_OK) {
|
||||
ESP_LOGW(TAG, "sensor install failed on every range: %s "
|
||||
"(continuing unmonitored)", esp_err_to_name(r));
|
||||
return r;
|
||||
}
|
||||
r = temperature_sensor_enable(s_sensor);
|
||||
if (r != ESP_OK) {
|
||||
ESP_LOGW(TAG, "sensor enable failed: %s", esp_err_to_name(r));
|
||||
return r;
|
||||
}
|
||||
s_ready = true;
|
||||
ESP_LOGI(TAG, "monitoring on: warn %d C, throttle %d C, critical %d C "
|
||||
"(die temperature; protecting the enclosure, not the chip)",
|
||||
CONFIG_THERMAL_WARN_C, CONFIG_THERMAL_THROTTLE_C,
|
||||
CONFIG_THERMAL_CRITICAL_C);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void thermal_tick(void)
|
||||
{
|
||||
if (!s_ready) {
|
||||
return;
|
||||
}
|
||||
float c;
|
||||
if (temperature_sensor_get_celsius(s_sensor, &c) != ESP_OK) {
|
||||
return;
|
||||
}
|
||||
s_last_c = c;
|
||||
if (c > s_peak_c) {
|
||||
s_peak_c = c;
|
||||
}
|
||||
|
||||
/* Hysteresis on the way down: recovering at the same threshold that
|
||||
* tripped would oscillate, and each oscillation is a visible RSSI step on
|
||||
* every link this node has. The decision itself lives in thermal.h as a
|
||||
* pure function so a host test can exercise the same code this runs. */
|
||||
const thermal_state_t next = thermal_next_state(s_state, c,
|
||||
CONFIG_THERMAL_WARN_C,
|
||||
CONFIG_THERMAL_THROTTLE_C,
|
||||
CONFIG_THERMAL_CRITICAL_C,
|
||||
CONFIG_THERMAL_HYSTERESIS_C);
|
||||
|
||||
if (next != s_state) {
|
||||
ESP_LOGW(TAG, "%.1f C: %s -> %s",
|
||||
c, thermal_state_name(s_state), thermal_state_name(next));
|
||||
s_state = next;
|
||||
}
|
||||
|
||||
#ifdef CONFIG_THERMAL_THROTTLE
|
||||
/* Acting on the reading is separate from taking it. Measuring is inert;
|
||||
* changing transmit power mid-experiment moves every RSSI this node has,
|
||||
* which is not something to introduce during a fleet bring-up when any
|
||||
* surprise needs a single candidate cause. Turn this on once the fleet is
|
||||
* stable AND the logs show a node actually gets hot -- if none ever does,
|
||||
* it never needs turning on at all. */
|
||||
switch (s_state) {
|
||||
case THERMAL_CRITICAL: apply_tx_power(TXP_MIN); break;
|
||||
case THERMAL_THROTTLED: apply_tx_power(TXP_STEP2); break;
|
||||
case THERMAL_WARN: apply_tx_power(TXP_STEP1); break;
|
||||
case THERMAL_OK: apply_tx_power(TXP_FULL); break;
|
||||
}
|
||||
#endif
|
||||
|
||||
static int64_t s_last_log_us;
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (now - s_last_log_us > 60LL * 1000 * 1000) {
|
||||
s_last_log_us = now;
|
||||
/* Heap rides along because it is the other thing that fails slowly
|
||||
* and silently over weeks. A node that reboots mysteriously after ten
|
||||
* days looks identical to one that overheated, unless the minimum
|
||||
* free heap has been visibly walking downwards the whole time. */
|
||||
ESP_LOGI(TAG, "die %.1f C (peak %.1f C) state=%s tx=%d.%02d dBm "
|
||||
"heap %u min %u",
|
||||
s_last_c, s_peak_c, thermal_state_name(s_state),
|
||||
s_txp / 4, (s_txp % 4) * 25,
|
||||
(unsigned)esp_get_free_heap_size(),
|
||||
(unsigned)esp_get_minimum_free_heap_size());
|
||||
}
|
||||
}
|
||||
|
||||
float thermal_celsius(void) { return s_last_c; }
|
||||
float thermal_peak_celsius(void) { return s_peak_c; }
|
||||
thermal_state_t thermal_state(void) { return s_state; }
|
||||
int8_t thermal_tx_dbm(void) { return (int8_t)(s_txp / 4); }
|
||||
|
||||
const char *thermal_state_name(thermal_state_t s)
|
||||
{
|
||||
switch (s) {
|
||||
case THERMAL_OK: return "ok";
|
||||
case THERMAL_WARN: return "warn";
|
||||
case THERMAL_THROTTLED: return "throttled";
|
||||
case THERMAL_CRITICAL: return "critical";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
#endif /* CONFIG_THERMAL_MONITOR && ESP32C6 */
|
||||
@@ -0,0 +1,106 @@
|
||||
/* On-chip thermal monitoring and radio throttling. See thermal.c for why the
|
||||
* thresholds are set against die temperature rather than the plastic. */
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdint.h>
|
||||
|
||||
typedef enum {
|
||||
THERMAL_OK = 0,
|
||||
THERMAL_WARN,
|
||||
THERMAL_THROTTLED,
|
||||
THERMAL_CRITICAL,
|
||||
} thermal_state_t;
|
||||
|
||||
/**
|
||||
* Decide the next thermal state from a reading and the current state.
|
||||
*
|
||||
* Pure: no globals, no hardware. Kept in the public header so a host test can
|
||||
* exercise the exact function the device build runs, rather than a copy of it
|
||||
* that is free to drift.
|
||||
*
|
||||
* Rising edges act immediately -- heat is a reason to react now. EVERY falling
|
||||
* edge requires cooling a full `hyst_c` below the threshold that was crossed on
|
||||
* the way up, because recovering at the same point that tripped oscillates, and
|
||||
* each oscillation steps this node's transmit power, which moves the RSSI of
|
||||
* every link it has. A reader cannot distinguish that from a wall moving.
|
||||
*
|
||||
* The CRITICAL -> THROTTLED edge is the reason this exists as a function. It
|
||||
* used to be unconditional: any reading below `critical_c` dropped the state
|
||||
* immediately, so a node sitting on the critical boundary flapped between
|
||||
* minimum and step-2 transmit power indefinitely -- precisely the oscillation
|
||||
* the hysteresis elsewhere was added to prevent. With defaults (critical 80,
|
||||
* hysteresis 8) leaving CRITICAL now needs 72 C, which is exactly the THROTTLED
|
||||
* entry point.
|
||||
*
|
||||
* @param cur Current state.
|
||||
* @param c Die temperature, degrees C.
|
||||
* @param warn_c Warn threshold.
|
||||
* @param throttle_c Throttle threshold.
|
||||
* @param critical_c Critical threshold.
|
||||
* @param hyst_c Degrees of cooling required before any step down.
|
||||
* @return The state to adopt.
|
||||
*/
|
||||
static inline thermal_state_t thermal_next_state(thermal_state_t cur, float c,
|
||||
int warn_c, int throttle_c,
|
||||
int critical_c, int hyst_c)
|
||||
{
|
||||
const float h = (float)hyst_c;
|
||||
|
||||
/* Rising: immediate, at the plain thresholds. */
|
||||
if (c >= (float)critical_c) return THERMAL_CRITICAL;
|
||||
|
||||
/* Falling out of CRITICAL needs a full hysteresis band, like every other
|
||||
* downward step. Without this the state tracks `c` across critical_c with
|
||||
* no damping at all. */
|
||||
if (cur == THERMAL_CRITICAL && c >= (float)critical_c - h) {
|
||||
return THERMAL_CRITICAL;
|
||||
}
|
||||
|
||||
if (c >= (float)throttle_c) return THERMAL_THROTTLED;
|
||||
|
||||
if (cur == THERMAL_THROTTLED && c >= (float)throttle_c - h) {
|
||||
return THERMAL_THROTTLED;
|
||||
}
|
||||
|
||||
if (c >= (float)warn_c) return THERMAL_WARN;
|
||||
|
||||
if (cur == THERMAL_WARN && c >= (float)warn_c - h) {
|
||||
return THERMAL_WARN;
|
||||
}
|
||||
|
||||
return THERMAL_OK;
|
||||
}
|
||||
|
||||
#if defined(CONFIG_THERMAL_MONITOR) && defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
|
||||
esp_err_t thermal_init(void);
|
||||
|
||||
/* Sample and apply policy. Call from a slow loop -- the die does not change
|
||||
* fast, and reading it more often buys nothing. */
|
||||
void thermal_tick(void);
|
||||
|
||||
float thermal_celsius(void);
|
||||
float thermal_peak_celsius(void);
|
||||
thermal_state_t thermal_state(void);
|
||||
|
||||
/* Current WiFi transmit ceiling in whole dBm.
|
||||
*
|
||||
* Exposed because a throttled node's RSSI falls at both ends, which is
|
||||
* indistinguishable from an obstruction in the link diagnostics. Anything
|
||||
* reading link strength should check this before concluding a wall moved. */
|
||||
int8_t thermal_tx_dbm(void);
|
||||
|
||||
const char *thermal_state_name(thermal_state_t s);
|
||||
|
||||
#else /* stubs: S3 build and when monitoring is off */
|
||||
|
||||
static inline esp_err_t thermal_init(void) { return ESP_OK; }
|
||||
static inline void thermal_tick(void) {}
|
||||
static inline float thermal_celsius(void) { return -273.0f; }
|
||||
static inline float thermal_peak_celsius(void) { return -273.0f; }
|
||||
static inline thermal_state_t thermal_state(void) { return THERMAL_OK; }
|
||||
static inline int8_t thermal_tx_dbm(void) { return 20; }
|
||||
static inline const char *thermal_state_name(thermal_state_t s) { (void)s; return "n/a"; }
|
||||
|
||||
#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 @@
|
||||
12e9fe5bd0533c3eb8ebb1cc36f775668cc4dd39f334ea65e26529b87211dc4c bootloader.bin
|
||||
baff3940ede0ce1658f2f5c3ba458cc9007753eb1519231d67b1ffa724382824 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
|
||||
@@ -19,6 +19,12 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_4mb.csv"
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
|
||||
|
||||
# Native USB/JTAG is the interactive onboarding console on C6 boards. ESP-IDF
|
||||
# secondary consoles mirror output only and cannot receive RUVIEW_HELLO_V1.
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set
|
||||
# CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG is not set
|
||||
|
||||
# ── CSI (required) ──
|
||||
CONFIG_ESP_WIFI_CSI_ENABLED=y
|
||||
|
||||
@@ -77,3 +83,22 @@ CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
|
||||
# ── Power: keep CPU at max 160 MHz (C6 ceiling) for DSP throughput ──
|
||||
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y
|
||||
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=160
|
||||
|
||||
# ADR-110 D1 / ruvnet/RuView#762: the 802.15.4 RX path never delivers a
|
||||
# protocol frame on IDF v5.4. Upstream tested five configurations -- channel
|
||||
# 15 and 26, OpenThread on and off, promiscuous on and off, PAN-matched and
|
||||
# broadcast -- and every one gave rx=0 with TX at 100% reliability. ESP-NOW
|
||||
# shipped as the workaround and is what this firmware actually syncs on
|
||||
# (measured 99.56% cross-board delivery, 104 us smoothed offset).
|
||||
#
|
||||
# The C6 has a single 2.4 GHz transceiver shared by WiFi, BLE and 802.15.4,
|
||||
# so leaving the 15.4 stack running costs coexistence time-slicing against
|
||||
# the CSI capture that is the entire point of the node -- beaconing every
|
||||
# 100 ms and listening on a radio that provably returns nothing. Disable it
|
||||
# and give the airtime back to WiFi, which matters most at nine nodes where
|
||||
# the 50 Hz receive gate is already the binding constraint.
|
||||
#
|
||||
# Safe to turn off: the ADR-018 byte 19 bit 4 "sync valid" flag is ORed from
|
||||
# both transports and the 15.4 branch is #ifdef-gated on this symbol, so the
|
||||
# working ESP-NOW path keeps setting it.
|
||||
CONFIG_C6_TIMESYNC_ENABLE=n
|
||||
@@ -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,14 +39,46 @@ 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_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
|
||||
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
|
||||
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding \
|
||||
run_delivery_contract
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
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
|
||||
@@ -80,8 +112,33 @@ test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
|
||||
run_mmwave_detect: test_mmwave_detect
|
||||
./test_mmwave_detect
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect
|
||||
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
|
||||
# --- Thermal state hysteresis ---
|
||||
# Host-side. Exercises thermal_next_state() from ../main/thermal.h directly --
|
||||
# the same function the device build runs -- so the test and the firmware cannot
|
||||
# drift. Pins every edge in both directions, including the CRITICAL -> THROTTLED
|
||||
# flap that had no hysteresis at all.
|
||||
test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
|
||||
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -o $@ $<
|
||||
|
||||
run_thermal: test_thermal
|
||||
./test_thermal
|
||||
|
||||
# --- Native Mac USB onboarding protocol ---
|
||||
# Tests the production parser and secret validation without ESP-IDF. This pins
|
||||
# nonce shape, IPv4 and node bounds, base64 handling, and control-byte denial.
|
||||
test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.h
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic -I$(MAIN_DIR) -o $@ \
|
||||
test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c
|
||||
|
||||
run_serial_onboarding: test_serial_onboarding
|
||||
./test_serial_onboarding
|
||||
|
||||
run_delivery_contract:
|
||||
python3 test_delivery_contract.py
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
|
||||
run_c6_antenna run_serial_onboarding run_delivery_contract
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract)"
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
@@ -117,5 +174,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,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static delivery contracts that do not require an ESP-IDF toolchain."""
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
FIRMWARE_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = FIRMWARE_ROOT.parents[1]
|
||||
|
||||
|
||||
class FirmwareDeliveryContractTests(unittest.TestCase):
|
||||
def test_c6_uses_native_usb_serial_jtag_as_primary_console(self) -> None:
|
||||
config = (FIRMWARE_ROOT / "sdkconfig.defaults.esp32c6").read_text()
|
||||
self.assertIn("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y", config)
|
||||
self.assertNotIn("CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y", config)
|
||||
|
||||
onboarding = (FIRMWARE_ROOT / "main/serial_onboarding.c").read_text()
|
||||
self.assertNotIn('open("/dev/secondary"', onboarding)
|
||||
|
||||
def test_tracked_c6_onboarding_app_matches_version_txt(self) -> None:
|
||||
expected = (FIRMWARE_ROOT / "version.txt").read_text().strip().encode()
|
||||
app = (FIRMWARE_ROOT / "release_bins/c6-onboarding/esp32-csi-node.bin").read_bytes()
|
||||
self.assertTrue(
|
||||
expected + b"\0" in app,
|
||||
f"tracked C6 app does not embed version {expected.decode()}",
|
||||
)
|
||||
|
||||
def test_firmware_tags_publish_downloadable_release_assets(self) -> None:
|
||||
workflow = (REPOSITORY_ROOT / ".github/workflows/firmware-ci.yml").read_text()
|
||||
self.assertIn("publish-release-assets:", workflow)
|
||||
self.assertIn("esp32-csi-node-firmware-*", workflow)
|
||||
self.assertIn("softprops/action-gh-release@", workflow)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @file test_thermal_hysteresis.c
|
||||
* @brief Pins every thermal transition, in both directions.
|
||||
*
|
||||
* Exercises `thermal_next_state()` from ../main/thermal.h directly -- the same
|
||||
* function the device build runs, not a copy -- so the test and the firmware
|
||||
* cannot drift apart.
|
||||
*
|
||||
* The regression that motivated this: the CRITICAL -> THROTTLED edge was
|
||||
* unconditional. Any reading below `critical_c` dropped the state at once, so a
|
||||
* node sitting on the critical boundary flapped between minimum and step-2
|
||||
* transmit power forever. Every oscillation moves the RSSI of every link that
|
||||
* node has, at both ends, which a reader cannot tell apart from an obstruction
|
||||
* -- exactly the failure the hysteresis on the other edges exists to prevent.
|
||||
*
|
||||
* Defaults under test: warn 65, throttle 72, critical 80, hysteresis 8.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
#include "thermal.h"
|
||||
|
||||
#define WARN_C 65
|
||||
#define THROTTLE_C 72
|
||||
#define CRITICAL_C 80
|
||||
#define HYST_C 8
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
static const char *name(thermal_state_t s)
|
||||
{
|
||||
switch (s) {
|
||||
case THERMAL_OK: return "OK";
|
||||
case THERMAL_WARN: return "WARN";
|
||||
case THERMAL_THROTTLED: return "THROTTLED";
|
||||
case THERMAL_CRITICAL: return "CRITICAL";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
static thermal_state_t step(thermal_state_t cur, float c)
|
||||
{
|
||||
return thermal_next_state(cur, c, WARN_C, THROTTLE_C, CRITICAL_C, HYST_C);
|
||||
}
|
||||
|
||||
static void expect(thermal_state_t cur, float c, thermal_state_t want,
|
||||
const char *why)
|
||||
{
|
||||
thermal_state_t got = step(cur, c);
|
||||
if (got != want) {
|
||||
printf(" FAIL: from %-9s at %5.1f C -> %s, expected %s\n %s\n",
|
||||
name(cur), c, name(got), name(want), why);
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
printf("thermal hysteresis (warn %d, throttle %d, critical %d, hyst %d)\n",
|
||||
WARN_C, THROTTLE_C, CRITICAL_C, HYST_C);
|
||||
|
||||
/* ---- Rising edges are immediate. Heat is a reason to act now. ---- */
|
||||
expect(THERMAL_OK, 64.9f, THERMAL_OK, "below warn");
|
||||
expect(THERMAL_OK, 65.0f, THERMAL_WARN, "warn trips at the threshold");
|
||||
expect(THERMAL_WARN, 72.0f, THERMAL_THROTTLED, "throttle trips at the threshold");
|
||||
expect(THERMAL_THROTTLED, 80.0f, THERMAL_CRITICAL, "critical trips at the threshold");
|
||||
/* A jump straight past intermediate bands must not be damped on the way up. */
|
||||
expect(THERMAL_OK, 95.0f, THERMAL_CRITICAL, "a large rise skips straight to critical");
|
||||
|
||||
/* ---- THE REGRESSION: falling out of CRITICAL. ----
|
||||
*
|
||||
* Before the fix, 79.9 C dropped the state to THROTTLED immediately, so a
|
||||
* node hovering at 80 alternated TXP_MIN and TXP_STEP2 indefinitely. */
|
||||
expect(THERMAL_CRITICAL, 79.9f, THERMAL_CRITICAL,
|
||||
"0.1 C below critical must NOT step down -- this is the flap");
|
||||
expect(THERMAL_CRITICAL, 75.0f, THERMAL_CRITICAL,
|
||||
"still inside the hysteresis band");
|
||||
expect(THERMAL_CRITICAL, 72.1f, THERMAL_CRITICAL,
|
||||
"just inside the band (critical 80 - hyst 8 = 72)");
|
||||
/* Once it has cooled a full band it adopts the band it is ACTUALLY in,
|
||||
* which under these defaults is WARN, not THROTTLED: the critical exit
|
||||
* point (80 - 8 = 72) coincides exactly with the throttle threshold, so
|
||||
* anything cool enough to leave CRITICAL is already below THROTTLED's
|
||||
* entry. THROTTLED is therefore unreachable descending from CRITICAL at
|
||||
* hysteresis 8 -- a consequence of the chosen constants, not of the logic.
|
||||
* With a smaller hysteresis the intermediate step reappears, which the
|
||||
* next assertion pins. */
|
||||
expect(THERMAL_CRITICAL, 71.9f, THERMAL_WARN,
|
||||
"a full band below critical, and 71.9 is in the warn band");
|
||||
|
||||
/* Same descent with hysteresis 4: the exit point is 76, which lands inside
|
||||
* THROTTLED's band, so the intermediate power step is used. */
|
||||
{
|
||||
thermal_state_t got = thermal_next_state(THERMAL_CRITICAL, 75.9f,
|
||||
WARN_C, THROTTLE_C, CRITICAL_C, 4);
|
||||
if (got != THERMAL_THROTTLED) {
|
||||
printf(" FAIL: from CRITICAL at 75.9 C with hyst 4 -> %s, "
|
||||
"expected THROTTLED\n", name(got));
|
||||
g_failures++;
|
||||
}
|
||||
thermal_state_t held = thermal_next_state(THERMAL_CRITICAL, 76.1f,
|
||||
WARN_C, THROTTLE_C, CRITICAL_C, 4);
|
||||
if (held != THERMAL_CRITICAL) {
|
||||
printf(" FAIL: from CRITICAL at 76.1 C with hyst 4 -> %s, "
|
||||
"expected CRITICAL\n", name(held));
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prove the flap is gone by walking across the boundary repeatedly: a node
|
||||
* dithering either side of 80 must not change state on the down-swings. */
|
||||
{
|
||||
thermal_state_t s = THERMAL_CRITICAL;
|
||||
int changes = 0;
|
||||
const float dither[] = {80.2f, 79.8f, 80.1f, 79.7f, 80.3f, 79.9f};
|
||||
for (unsigned i = 0; i < sizeof(dither) / sizeof(dither[0]); i++) {
|
||||
thermal_state_t n = step(s, dither[i]);
|
||||
if (n != s) changes++;
|
||||
s = n;
|
||||
}
|
||||
if (changes != 0) {
|
||||
printf(" FAIL: dithering around the critical boundary produced %d "
|
||||
"state change(s); each one steps transmit power\n", changes);
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Falling out of THROTTLED. Behaviour preserved from before. ---- */
|
||||
expect(THERMAL_THROTTLED, 71.9f, THERMAL_THROTTLED, "just below the throttle point holds");
|
||||
expect(THERMAL_THROTTLED, 64.1f, THERMAL_THROTTLED, "still inside the band");
|
||||
expect(THERMAL_THROTTLED, 63.9f, THERMAL_OK, "throttle 72 - hyst 8 = 64 releases");
|
||||
|
||||
/* ---- Falling out of WARN. ---- */
|
||||
expect(THERMAL_WARN, 64.9f, THERMAL_WARN, "just below warn holds");
|
||||
expect(THERMAL_WARN, 57.1f, THERMAL_WARN, "still inside the band");
|
||||
expect(THERMAL_WARN, 56.9f, THERMAL_OK, "warn 65 - hyst 8 = 57 releases");
|
||||
|
||||
/* ---- OK is stable well below everything. ---- */
|
||||
expect(THERMAL_OK, 30.0f, THERMAL_OK, "a cold node stays OK");
|
||||
|
||||
if (g_failures == 0) {
|
||||
printf(" PASS\n");
|
||||
return 0;
|
||||
}
|
||||
printf(" %d check(s) FAILED\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
@@ -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())
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract training stills from the real fal.ai room-visualization MP4s.
|
||||
|
||||
Reads the source videos (real fal.ai-generated room visualizations, one per
|
||||
style preset) and writes JPEG frames + a caption .txt per frame into a local
|
||||
(gitignored) training-data directory, ready to be zipped for fal.ai LoRA
|
||||
training. Frame interval is time-based (not frame-count-based) so each
|
||||
source contributes visual diversity proportional to its length.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# (source mp4 path, style tag, caption) -- sources are real fal.ai output,
|
||||
# captured earlier this session by the fal-room-viz / room-viz-h3-upgrade
|
||||
# agents. Caption describes the RuView visual style so the LoRA learns the
|
||||
# recurring motifs (glowing pose overlay, WiFi signal arcs, vitals readout,
|
||||
# dark tech aesthetic) rather than any single room layout.
|
||||
SOURCES = [
|
||||
("/tmp/room-viz-architectural/room-viz.mp4", "architectural",
|
||||
"ruview style room visualization, architectural wireframe overlay, "
|
||||
"glowing cyan pose skeleton, wifi signal arcs, dark tech aesthetic"),
|
||||
("/tmp/room-viz-abstract/room-viz.mp4", "abstract",
|
||||
"ruview style room visualization, abstract particle field, "
|
||||
"glowing pose overlay, wifi signal arcs, dark tech aesthetic"),
|
||||
("/tmp/room-viz-branded/room-viz.mp4", "branded",
|
||||
"ruview style room visualization, branded hud overlay, "
|
||||
"glowing cyan and emerald pose skeleton, vitals readout, dark tech aesthetic"),
|
||||
("/tmp/room-viz-h3-architectural/room-viz.mp4", "h3-architectural",
|
||||
"ruview style room visualization, architectural wireframe overlay, "
|
||||
"glowing pose skeleton, wifi signal arcs, vitals readout, dark tech aesthetic"),
|
||||
("/tmp/room-viz-h3-cinematic/room-viz.mp4", "h3-cinematic",
|
||||
"ruview style room visualization, cinematic lighting, "
|
||||
"glowing pose overlay, wifi signal arcs, vitals readout, dark tech aesthetic"),
|
||||
("/tmp/room-viz-h3-minimal/room-viz.mp4", "h3-minimal",
|
||||
"ruview style room visualization, minimal clean overlay, "
|
||||
"glowing pose skeleton, subtle wifi signal arcs, dark tech aesthetic"),
|
||||
("/tmp/room-viz-h3-abstract/room-viz.mp4", "h3-abstract",
|
||||
"ruview style room visualization, abstract particle field, "
|
||||
"glowing pose overlay, wifi signal arcs, dark tech aesthetic"),
|
||||
]
|
||||
|
||||
FRAME_INTERVAL_SEC = 1.5
|
||||
|
||||
|
||||
def extract(src: Path, style: str, caption: str, out_dir: Path) -> int:
|
||||
pattern = out_dir / f"{style}_%03d.jpg"
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-i", str(src),
|
||||
"-vf", f"fps=1/{FRAME_INTERVAL_SEC}",
|
||||
"-q:v", "2",
|
||||
str(pattern),
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
frames = sorted(out_dir.glob(f"{style}_*.jpg"))
|
||||
for frame in frames:
|
||||
frame.with_suffix(".txt").write_text(caption, encoding="utf-8")
|
||||
return len(frames)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--out", default=str(REPO_ROOT / "scripts" / "fal-visual-teacher" / "training-data"),
|
||||
help="Output directory for extracted frames + captions (gitignored)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
out_dir = Path(args.out)
|
||||
if out_dir.exists():
|
||||
shutil.rmtree(out_dir)
|
||||
out_dir.mkdir(parents=True)
|
||||
|
||||
total = 0
|
||||
for src_str, style, caption in SOURCES:
|
||||
src = Path(src_str)
|
||||
if not src.exists():
|
||||
print(f"SKIP (missing): {src}", file=sys.stderr)
|
||||
continue
|
||||
n = extract(src, style, caption, out_dir)
|
||||
print(f"{style}: {n} frames from {src}")
|
||||
total += n
|
||||
|
||||
print(f"\nTotal frames extracted: {total}")
|
||||
print(f"Training data dir: {out_dir}")
|
||||
if total < 4:
|
||||
print("ERROR: fal.ai training needs at least 4 images", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Submit a real fal.ai LoRA training job for the RuView synthetic visual teacher.
|
||||
|
||||
Zips the extracted training-data directory, uploads it to fal.ai storage,
|
||||
submits a training job to fal-ai/flux-lora-fast-training, polls until it
|
||||
completes, and writes the resulting trained-model reference (diffusers LoRA
|
||||
weights URL + config URL) to a local (gitignored) JSON file.
|
||||
|
||||
Architecture boundary (ADR-353): the model trained here is ONLY ever a
|
||||
source of supplementary synthetic visual/pixel output. It must never
|
||||
influence, or be treated as a source of, measured/derived RF, pose, vitals,
|
||||
identity, or confidence values.
|
||||
|
||||
Requires FAL_KEY in the environment (fetch from GCP Secret Manager,
|
||||
project cognitum-20260110, secret MINIMAX_MUSIC_FAL_KEY, before running).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
TRAINING_ENDPOINT = "fal-ai/flux-lora-fast-training"
|
||||
|
||||
|
||||
def zip_training_data(src_dir: Path, zip_path: Path) -> None:
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for f in sorted(src_dir.iterdir()):
|
||||
if f.is_file():
|
||||
zf.write(f, arcname=f.name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--training-data", default=str(SCRIPT_DIR / "training-data"),
|
||||
help="Directory of extracted frames + captions (from extract_frames.py)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--steps", type=int, default=1000,
|
||||
help="Training steps (fal-ai/flux-lora-fast-training default is 1000, ~$2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trigger-word", default="ruviewstyle",
|
||||
help="Trigger token to prepend so the LoRA is invocable at inference time",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out", default=str(SCRIPT_DIR / "output" / "training-result.json"),
|
||||
help="Where to write the trained model reference JSON (gitignored)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.environ.get("FAL_KEY"):
|
||||
print("ERROR: FAL_KEY not set in environment", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import fal_client # imported after the FAL_KEY check for a clearer error
|
||||
|
||||
training_dir = Path(args.training_data)
|
||||
if not training_dir.exists():
|
||||
print(f"ERROR: {training_dir} not found -- run extract_frames.py first", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
zip_path = training_dir.parent / "training-data.zip"
|
||||
print(f"Zipping {training_dir} -> {zip_path}")
|
||||
zip_training_data(training_dir, zip_path)
|
||||
print(f"Zip size: {zip_path.stat().st_size / 1024:.1f} KB")
|
||||
|
||||
print("Uploading training zip to fal.ai storage...")
|
||||
images_data_url = fal_client.upload_file(str(zip_path))
|
||||
print(f"Uploaded: {images_data_url}")
|
||||
|
||||
training_args = {
|
||||
"images_data_url": images_data_url,
|
||||
"steps": args.steps,
|
||||
"trigger_word": args.trigger_word,
|
||||
# This is a style LoRA (recurring visual aesthetic across varied room
|
||||
# layouts), not a subject/character LoRA -- fal.ai's is_style flag
|
||||
# disables segmentation/auto-captioning so our own caption .txt files
|
||||
# (one per frame, describing the shared style) are used as-is.
|
||||
"is_style": True,
|
||||
}
|
||||
print(f"Submitting training job to {TRAINING_ENDPOINT} (steps={args.steps})...")
|
||||
handle = fal_client.submit(TRAINING_ENDPOINT, arguments=training_args)
|
||||
print(f"request_id: {handle.request_id}")
|
||||
|
||||
start = time.time()
|
||||
for event in handle.iter_events(with_logs=True, interval=5.0):
|
||||
print(f"[{time.time() - start:.0f}s] {event}")
|
||||
|
||||
result = handle.get()
|
||||
elapsed = time.time() - start
|
||||
print(f"Training completed in {elapsed:.0f}s")
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = {
|
||||
"endpoint": TRAINING_ENDPOINT,
|
||||
"request_id": handle.request_id,
|
||||
"steps": args.steps,
|
||||
"trigger_word": args.trigger_word,
|
||||
"training_images": sum(1 for f in training_dir.glob("*.jpg")),
|
||||
"elapsed_seconds": round(elapsed),
|
||||
"result": result,
|
||||
}
|
||||
out_path.write_text(json.dumps(record, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
lora_file = (result or {}).get("diffusers_lora_file", {}).get("url")
|
||||
if lora_file:
|
||||
print(f"\nTrained LoRA weights URL: {lora_file}")
|
||||
else:
|
||||
print("\nWARNING: no diffusers_lora_file.url in result -- inspect the JSON", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a real validation inference call against the trained visual-teacher LoRA.
|
||||
|
||||
Loads the trained model reference written by train_lora.py, calls
|
||||
fal-ai/flux-lora with the trained LoRA weights, and saves the generated
|
||||
image as evidence that training actually produced a working style LoRA.
|
||||
|
||||
This inference call, and the LoRA it exercises, produce SYNTHETIC display
|
||||
imagery only (ADR-353) -- never a source of measured RF/pose/vitals/identity
|
||||
or confidence values.
|
||||
|
||||
Requires FAL_KEY in the environment.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
INFERENCE_ENDPOINT = "fal-ai/flux-lora"
|
||||
|
||||
DEFAULT_PROMPT = (
|
||||
"ruviewstyle room visualization, a living room with a glowing cyan pose "
|
||||
"skeleton overlay, wifi signal arcs radiating across the space, a vitals "
|
||||
"readout panel, dark tech aesthetic, synthetic sensor visualization"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--training-result", default=str(SCRIPT_DIR / "output" / "training-result.json"),
|
||||
)
|
||||
parser.add_argument("--prompt", default=DEFAULT_PROMPT)
|
||||
parser.add_argument("--scale", type=float, default=1.0, help="LoRA application strength")
|
||||
parser.add_argument(
|
||||
"--out", default=str(REPO_ROOT / "assets" / "fal-visual-teacher-sample.png"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.environ.get("FAL_KEY"):
|
||||
print("ERROR: FAL_KEY not set in environment", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
import fal_client
|
||||
|
||||
training_result_path = Path(args.training_result)
|
||||
record = json.loads(training_result_path.read_text(encoding="utf-8"))
|
||||
lora_url = record["result"]["diffusers_lora_file"]["url"]
|
||||
print(f"Using trained LoRA: {lora_url}")
|
||||
|
||||
print(f"Submitting validation inference to {INFERENCE_ENDPOINT}...")
|
||||
result = fal_client.subscribe(
|
||||
INFERENCE_ENDPOINT,
|
||||
arguments={
|
||||
"prompt": args.prompt,
|
||||
"loras": [{"path": lora_url, "scale": args.scale}],
|
||||
"image_size": "landscape_16_9",
|
||||
"num_images": 1,
|
||||
"output_format": "png",
|
||||
},
|
||||
with_logs=True,
|
||||
)
|
||||
|
||||
images = result.get("images", [])
|
||||
if not images:
|
||||
print(f"ERROR: no images in result: {result}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
image_url = images[0]["url"]
|
||||
print(f"Generated image: {image_url}")
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
urllib.request.urlretrieve(image_url, out_path)
|
||||
print(f"Saved validation sample to {out_path}")
|
||||
|
||||
meta_path = out_path.with_suffix(".json")
|
||||
meta_path.write_text(json.dumps({
|
||||
"endpoint": INFERENCE_ENDPOINT,
|
||||
"lora_url": lora_url,
|
||||
"prompt": args.prompt,
|
||||
"scale": args.scale,
|
||||
"source_image_url": image_url,
|
||||
}, indent=2), encoding="utf-8")
|
||||
print(f"Saved validation metadata to {meta_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Faithful Python port of the window/baseline/metric definitions in
|
||||
v2/crates/ruforecast/crates/ruforecast-train/examples/bidmc_prepare.rs and
|
||||
v2/crates/ruforecast/crates/ruforecast-core/src/{baseline,metrics}.rs so the
|
||||
zero-shot pretrained-model comparison is apples-to-apples with RuForecast's
|
||||
own real-data evaluation.
|
||||
|
||||
CONTEXT=64, HORIZON=12, VARIATES=3 (HR, RESP, SpO2), quantiles as in
|
||||
ForecastModelConfig::tiny_ci(), seasonal_period=12 (the `evaluate` CLI default).
|
||||
"""
|
||||
import csv
|
||||
import math
|
||||
import os
|
||||
|
||||
CONTEXT = 64
|
||||
HORIZON = 12
|
||||
WINDOW_ROWS = CONTEXT + HORIZON
|
||||
VARIATE_NAMES = ["HR", "RESP", "SpO2"]
|
||||
QUANTILES = [0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95]
|
||||
SEASONAL_PERIOD = 12
|
||||
RAW_DIR = "/tmp/bidmc-raw"
|
||||
|
||||
|
||||
def read_patient(pid: int):
|
||||
path = os.path.join(RAW_DIR, f"bidmc_{pid:02d}_Numerics.csv")
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
rows = []
|
||||
with open(path, newline="") as f:
|
||||
reader = csv.reader(f)
|
||||
next(reader) # header
|
||||
for parts in reader:
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
parts = [p.strip() for p in parts]
|
||||
try:
|
||||
hr = float(parts[1])
|
||||
resp = float(parts[3])
|
||||
spo2 = float(parts[4])
|
||||
except ValueError:
|
||||
return None # malformed row -> treat like the Rust `?` early-return
|
||||
rows.append((hr, resp, spo2))
|
||||
return rows
|
||||
|
||||
|
||||
def build_window(pid: int):
|
||||
"""One window per patient: first CONTEXT rows are context, next HORIZON
|
||||
rows are targets. Mirrors build_window() in bidmc_prepare.rs exactly
|
||||
(context_start = row 0, no scanning for a "best" window)."""
|
||||
rows = read_patient(pid)
|
||||
if rows is None or len(rows) < WINDOW_ROWS:
|
||||
return None
|
||||
first = rows[:WINDOW_ROWS]
|
||||
for row in first:
|
||||
for v in row:
|
||||
if not math.isfinite(v):
|
||||
return None
|
||||
context = first[:CONTEXT] # list of (hr, resp, spo2), CONTEXT rows
|
||||
targets = first[CONTEXT:WINDOW_ROWS] # HORIZON rows
|
||||
return {"patient": pid, "context": context, "targets": targets}
|
||||
|
||||
|
||||
def judge_split(judge: str):
|
||||
if judge == "a":
|
||||
train_ids = list(range(1, 38))
|
||||
test_ids = list(range(38, 54))
|
||||
elif judge == "b":
|
||||
train_ids = [i for i in range(1, 54) if i % 2 == 1]
|
||||
test_ids = [i for i in range(1, 54) if i % 2 == 0]
|
||||
else:
|
||||
raise ValueError(judge)
|
||||
return train_ids, test_ids
|
||||
|
||||
|
||||
def build_test_windows(judge: str):
|
||||
_, test_ids = judge_split(judge)
|
||||
windows = []
|
||||
excluded = []
|
||||
for pid in test_ids:
|
||||
w = build_window(pid)
|
||||
if w is None:
|
||||
excluded.append(pid)
|
||||
else:
|
||||
windows.append(w)
|
||||
return windows, excluded
|
||||
|
||||
|
||||
# ---- baselines (mirror ruforecast-core::baseline.rs) ----
|
||||
|
||||
def last_value_forecast(context):
|
||||
"""Repeat the last observed context row for every future step (every
|
||||
quantile gets the same point value -> degenerate distribution)."""
|
||||
last = context[-1]
|
||||
return [[last[v] for _ in QUANTILES] for _ in range(HORIZON) for v in range(len(last))]
|
||||
# NOTE: shape handled explicitly in metric functions below instead; see
|
||||
# last_value_predictions() for the actually-used [horizon][variate] form.
|
||||
|
||||
|
||||
def last_value_predictions(context):
|
||||
last = context[-1]
|
||||
return [[last[v] for v in range(len(last))] for _ in range(HORIZON)]
|
||||
|
||||
|
||||
def seasonal_naive_predictions(context, period=SEASONAL_PERIOD):
|
||||
"""context has CONTEXT=64 rows; base = len(context) - period; for step in
|
||||
0..HORIZON: row = base + step % period."""
|
||||
base = len(context) - period
|
||||
preds = []
|
||||
for step in range(HORIZON):
|
||||
row = base + (step % period)
|
||||
preds.append(list(context[row]))
|
||||
return preds
|
||||
|
||||
|
||||
# ---- metrics (mirror ruforecast-core::metrics.rs) ----
|
||||
|
||||
def mae(actual_flat, predicted_flat):
|
||||
total, count = 0.0, 0
|
||||
for a, p in zip(actual_flat, predicted_flat):
|
||||
total += abs(a - p)
|
||||
count += 1
|
||||
return total / count if count else float("nan")
|
||||
|
||||
|
||||
def weighted_quantile_loss(actual_flat, quantile_preds_flat):
|
||||
"""actual_flat: list of HORIZON*VARIATES actuals in step-major order
|
||||
(step0-var0,step0-var1,step0-var2, step1-var0, ...).
|
||||
quantile_preds_flat: same order, each entry is a list over QUANTILES.
|
||||
Matches weighted_quantile_loss() in metrics.rs: sum pinball over every
|
||||
(cell, quantile), normalize by 2 / (num_quantiles * sum(|actual|))."""
|
||||
loss = 0.0
|
||||
scale = 0.0
|
||||
for actual, preds in zip(actual_flat, quantile_preds_flat):
|
||||
scale += abs(actual)
|
||||
for q, pred in zip(QUANTILES, preds):
|
||||
residual = actual - pred
|
||||
loss += q * residual if residual >= 0 else (q - 1.0) * residual
|
||||
if scale == 0.0:
|
||||
raise ValueError("weighted quantile loss undefined: all-zero actual targets")
|
||||
return 2.0 * loss / (len(QUANTILES) * scale)
|
||||
|
||||
|
||||
def flatten_targets_step_major(targets):
|
||||
"""targets: HORIZON rows of VARIATES values -> flat step-major list."""
|
||||
out = []
|
||||
for row in targets:
|
||||
out.extend(row)
|
||||
return out
|
||||
|
||||
|
||||
def point_to_quantile_preds(point_preds):
|
||||
"""point_preds: HORIZON rows of VARIATES point values (deterministic
|
||||
baseline) -> step-major flat list where every quantile equals the point
|
||||
value, matching how LastValue/SeasonalNaive fill every quantile slot
|
||||
with the same repeated value."""
|
||||
out = []
|
||||
for row in point_preds:
|
||||
for v in row:
|
||||
out.append([v for _ in QUANTILES])
|
||||
return out
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Real zero-shot pretrained-checkpoint comparison for RuForecast (see
|
||||
docs/benchmarks/ruforecast.md). Runs Amazon Chronos-Bolt (and TimesFM, if
|
||||
installed) zero-shot inference against the SAME BIDMC test windows,
|
||||
baselines, and metrics RuForecast's own `evaluate` CLI uses, and prints a
|
||||
markdown table.
|
||||
|
||||
No fine-tuning. No training. Every "model" forecast below comes from a
|
||||
public pretrained checkpoint's zero-shot .predict()/.forecast() call.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/tmp") # bidmc_windows.py copied there
|
||||
from bidmc_windows import (
|
||||
CONTEXT, HORIZON, VARIATE_NAMES, QUANTILES, SEASONAL_PERIOD,
|
||||
build_test_windows, judge_split,
|
||||
last_value_predictions, seasonal_naive_predictions,
|
||||
mae, weighted_quantile_loss, flatten_targets_step_major, point_to_quantile_preds,
|
||||
)
|
||||
|
||||
|
||||
def eval_baselines(windows):
|
||||
"""Returns dict: name -> (mae, wql) aggregated over ALL test windows and
|
||||
variates, matching how the Rust evaluate CLI aggregates (one running
|
||||
mean/scale accumulation across the whole test set, not averaged per
|
||||
window)."""
|
||||
results = {}
|
||||
for name, fn in [("last-value", last_value_predictions),
|
||||
("seasonal-naive", lambda ctx: seasonal_naive_predictions(ctx, SEASONAL_PERIOD))]:
|
||||
all_actual, all_point_pred, all_qpred = [], [], []
|
||||
for w in windows:
|
||||
preds = fn(w["context"]) # HORIZON rows x VARIATES
|
||||
actual_flat = flatten_targets_step_major(w["targets"])
|
||||
point_flat = flatten_targets_step_major(preds)
|
||||
qpred_flat = point_to_quantile_preds(preds)
|
||||
all_actual.extend(actual_flat)
|
||||
all_point_pred.extend(point_flat)
|
||||
all_qpred.extend(qpred_flat)
|
||||
results[name] = (mae(all_actual, all_point_pred), weighted_quantile_loss(all_actual, all_qpred))
|
||||
return results
|
||||
|
||||
|
||||
def chronos_zero_shot_predictions(windows, model_name="amazon/chronos-bolt-base", device="cuda"):
|
||||
"""Per-variate univariate zero-shot forecast (Chronos has no native
|
||||
multivariate mode) via ChronosPipeline. Returns list parallel to
|
||||
`windows`, each entry: HORIZON rows x VARIATES quantile-lists."""
|
||||
from chronos import BaseChronosPipeline
|
||||
pipeline = BaseChronosPipeline.from_pretrained(model_name, device_map=device, torch_dtype="bfloat16" if device == "cuda" else "float32")
|
||||
import torch
|
||||
|
||||
all_preds = []
|
||||
for w in windows:
|
||||
ctx = w["context"] # HORIZON... actually CONTEXT rows x VARIATES
|
||||
per_variate_q = []
|
||||
for v in range(len(VARIATE_NAMES)):
|
||||
series = torch.tensor([row[v] for row in ctx], dtype=torch.float32)
|
||||
quantiles, _mean = pipeline.predict_quantiles(
|
||||
inputs=series,
|
||||
prediction_length=HORIZON,
|
||||
quantile_levels=QUANTILES,
|
||||
)
|
||||
# quantiles shape: [1, HORIZON, len(QUANTILES)]
|
||||
per_variate_q.append(quantiles[0].numpy()) # [HORIZON, Q]
|
||||
# reshape to HORIZON rows, each VARIATES entries of Q-length lists
|
||||
rows = []
|
||||
for h in range(HORIZON):
|
||||
row = [per_variate_q[v][h].tolist() for v in range(len(VARIATE_NAMES))]
|
||||
rows.append(row)
|
||||
all_preds.append(rows)
|
||||
return all_preds
|
||||
|
||||
|
||||
def timesfm_zero_shot_predictions(windows, model_repo="google/timesfm-2.5-200m-pytorch"):
|
||||
"""Per-variate univariate zero-shot forecast via TimesFM 2.5 200M torch.
|
||||
Quantile output has 10 columns; per the installed package's source
|
||||
(timesfm_2p5_base.py: `decode_index: int = 5`), column 5 is the
|
||||
point/mean forecast and columns 1..9 are quantiles [0.1..0.9] in order
|
||||
(verified empirically: columns 1-9 are monotonic and column 5 exactly
|
||||
equals the returned point forecast on every sampled window/variate;
|
||||
column 0 is an unused duplicate channel, not a real quantile level).
|
||||
TimesFM has no 0.05/0.95 levels (clamped to 0.1/0.9, same treatment
|
||||
Chronos-Bolt applies for out-of-range levels) and no native 0.25/0.75
|
||||
(linearly interpolated between the adjacent 0.1-spaced levels it does
|
||||
have)."""
|
||||
import timesfm
|
||||
import numpy as np
|
||||
|
||||
m = timesfm.TimesFM_2p5_200M_torch.from_pretrained(model_repo)
|
||||
m.compile(timesfm.ForecastConfig(
|
||||
max_context=CONTEXT, max_horizon=HORIZON, normalize_inputs=True,
|
||||
use_continuous_quantile_head=True, fix_quantile_crossing=True,
|
||||
))
|
||||
native_levels = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9] # columns 1..9
|
||||
|
||||
def sample_at(row10, level):
|
||||
if level <= native_levels[0]:
|
||||
return row10[1]
|
||||
if level >= native_levels[-1]:
|
||||
return row10[9]
|
||||
for i in range(len(native_levels) - 1):
|
||||
lo, hi = native_levels[i], native_levels[i + 1]
|
||||
if lo <= level <= hi:
|
||||
col_lo, col_hi = 1 + i, 1 + i + 1
|
||||
if hi == lo:
|
||||
return row10[col_lo]
|
||||
frac = (level - lo) / (hi - lo)
|
||||
return row10[col_lo] + frac * (row10[col_hi] - row10[col_lo])
|
||||
raise ValueError(level)
|
||||
|
||||
all_preds = []
|
||||
for w in windows:
|
||||
ctx = w["context"]
|
||||
per_variate_rows = []
|
||||
for v in range(len(VARIATE_NAMES)):
|
||||
series = np.array([row[v] for row in ctx], dtype=np.float32)
|
||||
_point, quant = m.forecast(horizon=HORIZON, inputs=[series])
|
||||
per_variate_rows.append(quant[0]) # [HORIZON, 10]
|
||||
rows = []
|
||||
for h in range(HORIZON):
|
||||
row = []
|
||||
for v in range(len(VARIATE_NAMES)):
|
||||
row10 = per_variate_rows[v][h]
|
||||
row.append([sample_at(row10, q) for q in QUANTILES])
|
||||
rows.append(row)
|
||||
all_preds.append(rows)
|
||||
return all_preds
|
||||
|
||||
|
||||
def eval_pretrained(name, predict_fn, windows):
|
||||
t0 = time.time()
|
||||
preds = predict_fn(windows)
|
||||
elapsed = time.time() - t0
|
||||
all_actual, all_point_pred, all_qpred = [], [], []
|
||||
median_idx = QUANTILES.index(0.50)
|
||||
for w, pred_rows in zip(windows, preds):
|
||||
actual_flat = flatten_targets_step_major(w["targets"])
|
||||
qpred_flat = []
|
||||
point_flat = []
|
||||
for row in pred_rows: # HORIZON rows, each VARIATES quantile-lists
|
||||
for var_qs in row:
|
||||
qpred_flat.append(list(var_qs))
|
||||
point_flat.append(var_qs[median_idx])
|
||||
all_actual.extend(actual_flat)
|
||||
all_point_pred.extend(point_flat)
|
||||
all_qpred.extend(qpred_flat)
|
||||
m = mae(all_actual, all_point_pred)
|
||||
wql = weighted_quantile_loss(all_actual, all_qpred)
|
||||
return m, wql, elapsed
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--models", nargs="+", default=["chronos-bolt-base"])
|
||||
parser.add_argument("--judges", nargs="+", default=["a", "b"])
|
||||
args = parser.parse_args()
|
||||
|
||||
print("| Judge | Model | Test windows | MAE | WQL | Wall time (s) |")
|
||||
print("|---|---|---:|---:|---:|---:|")
|
||||
|
||||
for judge in args.judges:
|
||||
windows, excluded = build_test_windows(judge)
|
||||
train_ids, test_ids = judge_split(judge)
|
||||
print(f"<!-- judge {judge}: {len(windows)} test windows from {len(test_ids)} candidate patients, excluded={excluded} -->", file=sys.stderr)
|
||||
|
||||
baselines = eval_baselines(windows)
|
||||
for name, (m, wql) in baselines.items():
|
||||
print(f"| {judge} | {name} (baseline) | {len(windows)} | {m:.4f} | {wql:.5f} | - |")
|
||||
|
||||
for model in args.models:
|
||||
if model == "chronos-bolt-base":
|
||||
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-bolt-base")
|
||||
elif model == "chronos-bolt-small":
|
||||
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-bolt-small")
|
||||
elif model == "chronos-t5-small":
|
||||
fn = lambda w: chronos_zero_shot_predictions(w, "amazon/chronos-t5-small")
|
||||
elif model == "timesfm-2.5-200m":
|
||||
fn = timesfm_zero_shot_predictions
|
||||
else:
|
||||
print(f"unknown model {model}", file=sys.stderr)
|
||||
continue
|
||||
try:
|
||||
m, wql, elapsed = eval_pretrained(model, fn, windows)
|
||||
print(f"| {judge} | {model} (zero-shot) | {len(windows)} | {m:.4f} | {wql:.5f} | {elapsed:.1f} |")
|
||||
except Exception as e:
|
||||
print(f"| {judge} | {model} (zero-shot) | {len(windows)} | FAILED | FAILED | - |")
|
||||
print(f"ERROR running {model} on judge {judge}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { i18n } from './utils/i18n.js';
|
||||
import { ScreenshotTool } from './utils/screenshot.js';
|
||||
import { UptimeClock } from './utils/uptime-clock.js';
|
||||
import { QuickSettings } from './utils/quick-settings.js';
|
||||
import { dataSourceBanner } from './utils/data-source-banner.js';
|
||||
|
||||
class WiFiDensePoseApp {
|
||||
constructor() {
|
||||
@@ -98,6 +99,10 @@ class WiFiDensePoseApp {
|
||||
this.showBackendStatus('Backend unavailable — start sensing-server', 'warning');
|
||||
}
|
||||
|
||||
// Mount the global provenance bar before the service starts, so the very
|
||||
// first non-live state is visible on whichever tab is showing.
|
||||
dataSourceBanner.init();
|
||||
|
||||
// Start the sensing WebSocket service early so the dashboard and
|
||||
// live-demo tabs can show the correct data-source status immediately.
|
||||
sensingService.start();
|
||||
@@ -360,6 +365,22 @@ class WiFiDensePoseApp {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
// `beforeunload` above tears down every component and disconnects the
|
||||
// sensing WebSocket. If the browser restores this page from bfcache on
|
||||
// Back/Forward instead of doing a fresh navigation, that torn-down state
|
||||
// comes back frozen exactly as-is — nothing re-runs DOMContentLoaded, so
|
||||
// sensingService.start() never fires again and disposed components (e.g.
|
||||
// Room Builder) never get recreated. `pageshow`'s `event.persisted` is
|
||||
// the standard signal for "this is a bfcache restore, not a fresh load";
|
||||
// forcing a real reload here is simpler and more robust than trying to
|
||||
// hand-reinitialize every component/connection back to a clean state.
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) {
|
||||
console.log('Page restored from bfcache after cleanup() — forcing reload');
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle window resize
|
||||
|
||||
@@ -95,7 +95,8 @@ export class DashboardTab {
|
||||
'live': { text: 'ESP32', status: 'healthy', msg: 'Real hardware connected' },
|
||||
'server-simulated': { text: 'SIMULATED', status: 'warning', msg: 'Server running without hardware' },
|
||||
'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' },
|
||||
'simulated': { text: 'OFFLINE', status: 'unhealthy', msg: 'Server unreachable, local fallback' },
|
||||
'unreachable': { text: 'NO DATA', status: 'unhealthy', msg: 'Server unreachable — readings below are stale' },
|
||||
'simulated': { text: 'INVENTED', status: 'unhealthy', msg: 'Browser-generated data, not measured' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
el.className = `component-status status-${cfg.status}`;
|
||||
|
||||
@@ -1318,7 +1318,8 @@ export class LiveDemoTab {
|
||||
const ds = sensingService.dataSource;
|
||||
if (ds === 'live') return 'Active \u2014 ESP32 Live';
|
||||
if (ds === 'server-simulated') return 'Active \u2014 Simulated Data';
|
||||
if (ds === 'simulated') return 'Active \u2014 Offline Simulation';
|
||||
if (ds === 'unreachable') return 'No Data \u2014 Server Unreachable';
|
||||
if (ds === 'simulated') return 'INVENTED DATA \u2014 Not Measured';
|
||||
return 'Connecting...';
|
||||
}
|
||||
|
||||
@@ -1331,7 +1332,8 @@ export class LiveDemoTab {
|
||||
'live': { text: 'LIVE \u2014 ESP32 Hardware Connected', cls: 'demo-source-live' },
|
||||
'server-simulated': { text: 'SIMULATED DATA \u2014 No Hardware Detected', cls: 'demo-source-sim' },
|
||||
'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' },
|
||||
'simulated': { text: 'OFFLINE \u2014 Server Unreachable, Local Sim', cls: 'demo-source-offline' },
|
||||
'unreachable': { text: 'NO DATA \u2014 Server Unreachable, Display Is Stale', cls: 'demo-source-offline' },
|
||||
'simulated': { text: 'INVENTED DATA \u2014 Generated By Your Browser, Not Measured', cls: 'demo-source-offline' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
banner.textContent = cfg.text;
|
||||
|
||||
@@ -228,7 +228,8 @@ export class SensingTab {
|
||||
'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' },
|
||||
'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' },
|
||||
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
|
||||
'simulated': { text: 'OFFLINE \u2014 CLIENT SIMULATION', cls: 'sensing-source-simulated' },
|
||||
'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' },
|
||||
'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' },
|
||||
};
|
||||
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
|
||||
banner.textContent = cfg.text;
|
||||
|
||||
+122
-12
@@ -37,6 +37,39 @@ const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const SIM_FALLBACK_AFTER_ATTEMPTS = 5;
|
||||
const SIMULATION_INTERVAL = 500; // ms
|
||||
|
||||
export const SIM_FALLBACK_STORAGE_KEY = 'ruview-client-simulation';
|
||||
|
||||
/**
|
||||
* Whether the client may invent sensing frames when the server is unreachable.
|
||||
*
|
||||
* Default **off**, deliberately. The generated frames are plausible — presence
|
||||
* derived from `Math.sin(t * 0.3)` crossing a fixed threshold produces a steady
|
||||
* absent/present_still alternation that reads exactly like a real classifier
|
||||
* struggling at its decision boundary. A reader with no reason to suspect the
|
||||
* server had dropped will diagnose the sensing pipeline, and be wrong. That is
|
||||
* not a hypothetical: it cost a full debugging session on 2026-08-28, chasing a
|
||||
* "flip-flop" that was this function all along while the server sat at a
|
||||
* perfectly steady presence=true.
|
||||
*
|
||||
* For an instrument, "no signal" is the honest reading and a useful one.
|
||||
* Synthetic motion is only appropriate for a demo, so it must be asked for:
|
||||
* `?simulate=1`, or `localStorage['ruview-client-simulation'] = 'true'`.
|
||||
*
|
||||
* Repository rule: never present synthetic output as measurement (CLAUDE.md).
|
||||
*/
|
||||
function clientSimulationAllowed() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('simulate') === '1') return true;
|
||||
if (params.get('simulate') === '0') return false;
|
||||
} catch { /* no window/location — non-browser test context */ }
|
||||
try {
|
||||
return localStorage.getItem(SIM_FALLBACK_STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class SensingService {
|
||||
constructor() {
|
||||
/** @type {WebSocket|null} */
|
||||
@@ -46,6 +79,11 @@ class SensingService {
|
||||
this._reconnectAttempt = 0;
|
||||
this._reconnectTimer = null;
|
||||
this._simTimer = null;
|
||||
// Guards the await window inside `_connect()` (see the comment there).
|
||||
this._connectInFlight = false;
|
||||
// Bumped per connect attempt and by `stop()`; lets a socket recognise that
|
||||
// it has been abandoned and stop driving reconnect state.
|
||||
this._connectEpoch = 0;
|
||||
// Connection state: disconnected | connecting | connected | reconnecting | simulated
|
||||
this._state = 'disconnected';
|
||||
// Data-source label exposed to the UI:
|
||||
@@ -72,6 +110,10 @@ class SensingService {
|
||||
|
||||
/** Stop the service entirely. */
|
||||
stop() {
|
||||
// Invalidate any attempt still inside its await window, so a socket opened
|
||||
// after this call does not resurrect the service.
|
||||
this._connectEpoch++;
|
||||
this._connectInFlight = false;
|
||||
this._clearTimers();
|
||||
if (this._ws) {
|
||||
this._ws.close(1000, 'client stop');
|
||||
@@ -114,7 +156,10 @@ class SensingService {
|
||||
* Current data source label.
|
||||
* "live" — frames are arriving from the real ESP32 over WebSocket
|
||||
* "reconnecting" — WebSocket disconnected; actively retrying, no frames emitted
|
||||
* "simulated" — max reconnect attempts exhausted; emitting synthetic frames
|
||||
* "unreachable" — retries exhausted, client simulation off; NO frames emitted
|
||||
* and whatever is on screen is the last live reading, stale
|
||||
* "simulated" — client simulation explicitly enabled and running; every
|
||||
* frame on screen is invented
|
||||
*/
|
||||
get dataSource() {
|
||||
return this._dataSource;
|
||||
@@ -129,6 +174,22 @@ class SensingService {
|
||||
// reconnects would fail on the second attempt.
|
||||
async _connect() {
|
||||
if (this._ws && this._ws.readyState <= WebSocket.OPEN) return;
|
||||
// The readyState guard above cannot stand alone: this function awaits
|
||||
// ticket minting before it assigns `this._ws`, so two callers entering
|
||||
// during that window BOTH pass the guard and BOTH open a socket. The
|
||||
// second assignment orphans the first — a socket nobody references, still
|
||||
// open on the server, whose `onclose` fires later and calls
|
||||
// `_scheduleReconnect()` even though a healthy socket is live. That walks
|
||||
// `_reconnectAttempt` upward with no outage and eventually trips the
|
||||
// simulation fallback. Observed on the server as connect/disconnect churn
|
||||
// on every page load.
|
||||
if (this._connectInFlight) return;
|
||||
this._connectInFlight = true;
|
||||
|
||||
// Identifies this attempt for the lifetime of its socket. `stop()` and any
|
||||
// superseding attempt bump it, so a late event from an abandoned socket is
|
||||
// recognised and ignored rather than driving reconnect state.
|
||||
const epoch = ++this._connectEpoch;
|
||||
|
||||
this._setState('connecting');
|
||||
|
||||
@@ -140,15 +201,34 @@ class SensingService {
|
||||
// predating ADR-272, connecting without a ticket is correct.
|
||||
}
|
||||
|
||||
if (epoch !== this._connectEpoch) {
|
||||
// Superseded or stopped while minting. The ticket is single-use and now
|
||||
// spent; the current attempt mints its own.
|
||||
this._connectInFlight = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let ws;
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
ws = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.warn('[Sensing] WebSocket constructor failed:', err.message);
|
||||
this._connectInFlight = false;
|
||||
this._fallbackToSimulation();
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.onopen = () => {
|
||||
this._ws = ws;
|
||||
this._connectInFlight = false;
|
||||
|
||||
/** True while `ws` is still the socket this service is driving. */
|
||||
const isCurrent = () => epoch === this._connectEpoch && this._ws === ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isCurrent()) {
|
||||
ws.close(1000, 'superseded');
|
||||
return;
|
||||
}
|
||||
console.info('[Sensing] Connected to', SENSING_WS_URL);
|
||||
this._reconnectAttempt = 0;
|
||||
this._stopSimulation();
|
||||
@@ -158,7 +238,8 @@ class SensingService {
|
||||
this._detectServerSource();
|
||||
};
|
||||
|
||||
this._ws.onmessage = (evt) => {
|
||||
ws.onmessage = (evt) => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const data = JSON.parse(evt.data);
|
||||
this._handleData(data);
|
||||
@@ -167,11 +248,17 @@ class SensingService {
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
ws.onerror = () => {
|
||||
// onerror is always followed by onclose, so we handle reconnect there
|
||||
};
|
||||
|
||||
this._ws.onclose = (evt) => {
|
||||
ws.onclose = (evt) => {
|
||||
if (!isCurrent()) {
|
||||
// An abandoned socket closing is not an outage. Reconnect state
|
||||
// belongs to whichever attempt is current.
|
||||
console.debug('[Sensing] Ignoring close from superseded socket');
|
||||
return;
|
||||
}
|
||||
console.info('[Sensing] Connection closed (code=%d)', evt.code);
|
||||
this._ws = null;
|
||||
if (evt.code !== 1000) {
|
||||
@@ -184,18 +271,31 @@ class SensingService {
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
// A retry is already pending; stacking timers would multiply the attempt
|
||||
// count for a single outage.
|
||||
if (this._reconnectTimer) return;
|
||||
|
||||
if (this._reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
|
||||
this._fallbackToSimulation();
|
||||
return;
|
||||
if (clientSimulationAllowed()) {
|
||||
console.warn('[Sensing] Max reconnect attempts (%d) reached, switching to simulation', MAX_RECONNECT_ATTEMPTS);
|
||||
this._fallbackToSimulation();
|
||||
return;
|
||||
}
|
||||
// With simulation off there is nothing to give up *for*: keep retrying at
|
||||
// the ceiling delay so the UI recovers by itself when the server returns,
|
||||
// rather than requiring a hard refresh.
|
||||
this._setDataSource('unreachable');
|
||||
}
|
||||
|
||||
const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
||||
this._reconnectAttempt++;
|
||||
// Hold at the cap rather than counting into the thousands over a long
|
||||
// outage; the delay is already saturated and the number is only read by a
|
||||
// human in the console.
|
||||
this._reconnectAttempt = Math.min(this._reconnectAttempt + 1, MAX_RECONNECT_ATTEMPTS);
|
||||
console.info('[Sensing] Reconnecting in %dms (attempt %d/%d)', delay, this._reconnectAttempt, MAX_RECONNECT_ATTEMPTS);
|
||||
|
||||
this._setState('reconnecting');
|
||||
this._setDataSource('reconnecting');
|
||||
if (this._dataSource !== 'unreachable') this._setDataSource('reconnecting');
|
||||
|
||||
this._reconnectTimer = setTimeout(() => {
|
||||
this._reconnectTimer = null;
|
||||
@@ -205,13 +305,23 @@ class SensingService {
|
||||
// Only start simulation after several failed attempts so a brief hiccup
|
||||
// does not immediately switch the UI to "SIMULATED DATA".
|
||||
if (this._reconnectAttempt >= SIM_FALLBACK_AFTER_ATTEMPTS && this._state !== 'simulated') {
|
||||
this._fallbackToSimulation();
|
||||
if (clientSimulationAllowed()) {
|
||||
this._fallbackToSimulation();
|
||||
} else {
|
||||
// Say "the server is gone" instead of inventing a room.
|
||||
this._setDataSource('unreachable');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Simulation fallback -----------------------------------------------
|
||||
|
||||
_fallbackToSimulation() {
|
||||
if (!clientSimulationAllowed()) {
|
||||
this._setDataSource('unreachable');
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this._setState('simulated');
|
||||
this._setDataSource('simulated');
|
||||
if (this._simTimer) return; // already running
|
||||
|
||||
@@ -1804,7 +1804,11 @@ canvas {
|
||||
flex-direction: column;
|
||||
gap: var(--space-12);
|
||||
overflow-y: auto;
|
||||
/* Without this, `overflow-y: auto` alone still resolves the x axis to auto,
|
||||
so anything marginally too wide scrolls sideways instead of wrapping. */
|
||||
overflow-x: hidden;
|
||||
max-height: 600px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sensing-card {
|
||||
@@ -1812,6 +1816,10 @@ canvas {
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-12);
|
||||
/* Flex items default to min-width:auto, which refuses to shrink below their
|
||||
content — one wide child then widens the whole 320px column and puts a
|
||||
horizontal scrollbar on the panel. Cards must shrink to the column. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sensing-card-title {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Global data-source banner.
|
||||
//
|
||||
// The per-tab banner in SensingTab was correct and still invisible where it
|
||||
// mattered: a reader on Dashboard or Live Demo never saw it, so a screen full
|
||||
// of client-invented sensing frames was indistinguishable from measurement.
|
||||
// This bar is fixed to the viewport and present on every tab, so the provenance
|
||||
// of what is on screen is never more than a glance away.
|
||||
//
|
||||
// It shows nothing at all while data is live. An always-on badge becomes
|
||||
// wallpaper; appearing only when the reading is not trustworthy keeps it
|
||||
// meaningful.
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
const STYLE_ID = 'ruview-data-source-banner-style';
|
||||
const BAR_ID = 'ruview-data-source-banner';
|
||||
|
||||
const CONFIG = {
|
||||
// 'live' is intentionally absent: no banner is shown for trustworthy data.
|
||||
'server-simulated': {
|
||||
text: 'SYNTHETIC DATA — the server is generating this, no hardware is being read',
|
||||
bg: '#7c3a00',
|
||||
fg: '#ffd9a0',
|
||||
},
|
||||
reconnecting: {
|
||||
text: 'RECONNECTING — the display is frozen at the last live reading',
|
||||
bg: '#33302a',
|
||||
fg: '#e8dcc0',
|
||||
},
|
||||
unreachable: {
|
||||
text: 'SERVER UNREACHABLE — nothing on this screen is current',
|
||||
bg: '#5c1010',
|
||||
fg: '#ffc9c9',
|
||||
},
|
||||
simulated: {
|
||||
text: 'SIMULATED — every value on this screen is invented by your browser, not measured',
|
||||
bg: '#6b0f6b',
|
||||
fg: '#ffc9ff',
|
||||
},
|
||||
};
|
||||
|
||||
const CSS = `
|
||||
#${BAR_ID} {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100000;
|
||||
display: none;
|
||||
padding: 7px 14px;
|
||||
font: 600 13px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.45);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
#${BAR_ID}.is-visible { display: block; }
|
||||
/* Simulated data is the dangerous case — it looks real. Make the bar move so
|
||||
it cannot be mistaken for a static decoration. */
|
||||
#${BAR_ID}.is-simulated { animation: ruview-dsb-pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes ruview-dsb-pulse { 50% { filter: brightness(1.45); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#${BAR_ID}.is-simulated { animation: none; }
|
||||
}
|
||||
body.ruview-has-data-source-banner { padding-top: 30px; }
|
||||
`;
|
||||
|
||||
export class DataSourceBanner {
|
||||
constructor() {
|
||||
this._bar = null;
|
||||
this._unsubscribe = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
this._bar = document.getElementById(BAR_ID);
|
||||
if (!this._bar) {
|
||||
this._bar = document.createElement('div');
|
||||
this._bar.id = BAR_ID;
|
||||
this._bar.setAttribute('role', 'status');
|
||||
// Announce changes, but don't interrupt: the reader is mid-task and the
|
||||
// message describes context, not an error to act on right now.
|
||||
this._bar.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this._bar);
|
||||
}
|
||||
|
||||
// dataSource changes are published on the state-listener channel.
|
||||
this._unsubscribe = sensingService.onStateChange(() => this.render());
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._bar) return;
|
||||
const cfg = CONFIG[sensingService.dataSource];
|
||||
|
||||
if (!cfg) {
|
||||
this._bar.classList.remove('is-visible', 'is-simulated');
|
||||
document.body.classList.remove('ruview-has-data-source-banner');
|
||||
return;
|
||||
}
|
||||
|
||||
this._bar.textContent = cfg.text;
|
||||
this._bar.style.background = cfg.bg;
|
||||
this._bar.style.color = cfg.fg;
|
||||
this._bar.classList.add('is-visible');
|
||||
this._bar.classList.toggle('is-simulated', sensingService.dataSource === 'simulated');
|
||||
document.body.classList.add('ruview-has-data-source-banner');
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._unsubscribe) {
|
||||
this._unsubscribe();
|
||||
this._unsubscribe = null;
|
||||
}
|
||||
if (this._bar && this._bar.parentNode) {
|
||||
this._bar.parentNode.removeChild(this._bar);
|
||||
}
|
||||
this._bar = null;
|
||||
document.body.classList.remove('ruview-has-data-source-banner');
|
||||
}
|
||||
}
|
||||
|
||||
export const dataSourceBanner = new DataSourceBanner();
|
||||
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",
|
||||
|
||||
+1
-1
Submodule v2/crates/ruforecast updated: 448c035cc1...a802067f04.
@@ -234,9 +234,30 @@ impl StreamingEngine {
|
||||
/// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed
|
||||
/// trust cycles even after widening the guard elsewhere.
|
||||
///
|
||||
/// Rebuilds the fuser, so call before any frames are processed.
|
||||
/// Rebuilds the fuser, so call before any frames are processed. Preserves
|
||||
/// any node positions configured via [`Self::set_node_positions`] across
|
||||
/// the rebuild (issue #1870) — a caller that configures positions once at
|
||||
/// startup and calls this later (e.g. after deriving a TDM schedule) must
|
||||
/// not have that configuration silently discarded.
|
||||
pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) {
|
||||
let positions = self.fuser.node_positions_by_id().clone();
|
||||
self.fuser = MultistaticFuser::with_config(cfg);
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Configure node positions for the governed multistatic fuser, keyed by
|
||||
/// node id (issue #1870). Without this, `StreamingEngine`'s fuser — the
|
||||
/// one behind `fuse_scored_calibrated`, which feeds provenance and the
|
||||
/// witness — always fuses with every node at the origin, regardless of
|
||||
/// deployment configuration, because nothing ever called this on it. This
|
||||
/// is a separate `MultistaticFuser` instance from the one
|
||||
/// `wifi-densepose-sensing-server`'s `AppState` owns (fixed for that
|
||||
/// instance in #1860); each fuser instance needs positions set on it
|
||||
/// independently. Safe to call before or after [`Self::set_multistatic_config`] —
|
||||
/// order does not matter, since that method now preserves whatever
|
||||
/// positions are already set across its rebuild.
|
||||
pub fn set_node_positions(&mut self, positions: std::collections::HashMap<u8, [f32; 3]>) {
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
@@ -1359,4 +1380,64 @@ mod tests {
|
||||
"an extra evidence ref must not collide with a model_version that absorbs it"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Issue #1870: StreamingEngine's own fuser never received positions ----
|
||||
|
||||
/// The governed path (`fuse_scored_calibrated`, feeding provenance and the
|
||||
/// witness) must carry each node's configured position -- not the origin
|
||||
/// for every node, which is what happened before `set_node_positions`
|
||||
/// existed on `StreamingEngine` and nothing ever configured this fuser
|
||||
/// instance (distinct from the one `wifi-densepose-sensing-server`'s
|
||||
/// `AppState` owns, fixed separately in #1860).
|
||||
#[test]
|
||||
fn governed_fuse_carries_each_nodes_configured_position() {
|
||||
let (mut e, _room) = engine();
|
||||
let mut positions = std::collections::HashMap::new();
|
||||
positions.insert(0u8, [1.0, 2.0, 3.0]);
|
||||
positions.insert(1u8, [4.0, 5.0, 6.0]);
|
||||
e.set_node_positions(positions.clone());
|
||||
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
let (fused, _score) = e
|
||||
.fuser
|
||||
.fuse_scored_calibrated(&frames, &[None, None], StreamingEngine::DEFAULT_COHERENCE_ACCEPT)
|
||||
.expect("cohort fuses");
|
||||
|
||||
assert_eq!(fused.node_positions.len(), frames.len());
|
||||
for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) {
|
||||
let expected = positions
|
||||
.get(&frame.node_id)
|
||||
.copied()
|
||||
.expect("every configured node has a position in this test");
|
||||
assert_eq!(
|
||||
*position, expected,
|
||||
"node {} received the wrong position on the governed path",
|
||||
frame.node_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `set_multistatic_config` used to rebuild the fuser from scratch,
|
||||
/// silently discarding any positions configured before it. A caller that
|
||||
/// sets positions once at startup and derives/applies a TDM-schedule
|
||||
/// config later (a real, documented ordering per #1049/#1057) must not
|
||||
/// lose them.
|
||||
#[test]
|
||||
fn positions_survive_a_later_set_multistatic_config() {
|
||||
let (mut e, _room) = engine();
|
||||
let mut positions = std::collections::HashMap::new();
|
||||
positions.insert(0u8, [7.0, 8.0, 9.0]);
|
||||
e.set_node_positions(positions.clone());
|
||||
|
||||
e.set_multistatic_config(MultistaticConfig {
|
||||
guard_interval_us: 30_000,
|
||||
..MultistaticConfig::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
e.fuser.node_position(0),
|
||||
Some([7.0, 8.0, 9.0]),
|
||||
"set_multistatic_config must not discard previously-configured positions"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Benchmarks for ADR-323 Realtek RTL8721Dx (AmebaDplus) CSI encode/decode
|
||||
//! throughput, at the real hardware's own reported dimensions (52
|
||||
//! subcarriers, 16-bit tone precision — see `example/wifi/wifi_csi/README.md`
|
||||
//! in the vendor SDK).
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use wifi_densepose_hardware::realtek_csi::simulator::{RealtekCsiSimulator, SimulatorConfig};
|
||||
use wifi_densepose_hardware::realtek_csi::CsiFrame;
|
||||
|
||||
fn bench_to_bytes_8bit(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let frame = sim.next_frame();
|
||||
c.bench_function("realtek_csi_to_bytes_52sc_8bit", |b| {
|
||||
b.iter(|| black_box(frame.to_bytes().unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_from_bytes_8bit(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let wire = sim.next_frame().to_bytes().unwrap();
|
||||
c.bench_function("realtek_csi_from_bytes_52sc_8bit", |b| {
|
||||
b.iter(|| black_box(CsiFrame::from_bytes(black_box(&wire)).unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_to_bytes_16bit(c: &mut Criterion) {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let frame = sim.next_frame();
|
||||
c.bench_function("realtek_csi_to_bytes_52sc_16bit", |b| {
|
||||
b.iter(|| black_box(frame.to_bytes().unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_from_bytes_16bit(c: &mut Criterion) {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let wire = sim.next_frame().to_bytes().unwrap();
|
||||
c.bench_function("realtek_csi_from_bytes_52sc_16bit", |b| {
|
||||
b.iter(|| black_box(CsiFrame::from_bytes(black_box(&wire)).unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_round_trip_full_datagram(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
c.bench_function("realtek_csi_round_trip_52sc_8bit", |b| {
|
||||
b.iter(|| {
|
||||
let frame = sim.next_frame();
|
||||
let wire = black_box(frame.to_bytes().unwrap());
|
||||
black_box(CsiFrame::from_bytes(&wire).unwrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_to_bytes_8bit,
|
||||
bench_from_bytes_8bit,
|
||||
bench_to_bytes_16bit,
|
||||
bench_from_bytes_16bit,
|
||||
bench_round_trip_full_datagram,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Deterministic Realtek RTL8721Dx (AmebaDplus) 1x1 CSI simulator (ADR-323).
|
||||
|
||||
use clap::Parser;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
net::{SocketAddr, UdpSocket},
|
||||
path::PathBuf,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use wifi_densepose_hardware::realtek_csi::{
|
||||
simulator::{RealtekCsiSimulator, SimulatorConfig},
|
||||
CsiFrame, CsiMode,
|
||||
};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "realtek-csi-sim",
|
||||
about = "Emit synthetic ADR-323 Realtek RTL8721Dx CSI frames"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(long, default_value_t = 100)]
|
||||
frames: u32,
|
||||
#[arg(long, default_value = "0x5241433152544c31", value_parser=parse_u64)]
|
||||
seed: u64,
|
||||
#[arg(long, default_value_t = 6)]
|
||||
channel: u8,
|
||||
#[arg(long)]
|
||||
bandwidth_40mhz: bool,
|
||||
#[arg(long, default_value_t = 52)]
|
||||
subcarriers: u16,
|
||||
#[arg(long, default_value_t = 16)]
|
||||
tone_bits: u8,
|
||||
#[arg(long, default_value_t = 1)]
|
||||
decimation: u8,
|
||||
#[arg(long, default_value_t = 64)]
|
||||
interval_ms: u64,
|
||||
#[arg(long)]
|
||||
udp: Option<SocketAddr>,
|
||||
/// Replay: little-endian u32 length followed by one ADR-323 envelope.
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
realtime: bool,
|
||||
}
|
||||
|
||||
fn parse_u64(v: &str) -> Result<u64, String> {
|
||||
if let Some(h) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
|
||||
u64::from_str_radix(h, 16).map_err(|e| e.to_string())
|
||||
} else {
|
||||
v.parse()
|
||||
.map_err(|e: std::num::ParseIntError| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(
|
||||
frame: CsiFrame,
|
||||
socket: Option<&UdpSocket>,
|
||||
destination: Option<SocketAddr>,
|
||||
output: &mut Option<File>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let wire = frame.to_bytes()?;
|
||||
if let (Some(s), Some(d)) = (socket, destination) {
|
||||
if s.send_to(&wire, d)? != wire.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::WriteZero, "partial UDP datagram").into());
|
||||
}
|
||||
}
|
||||
if let Some(f) = output {
|
||||
f.write_all(&(wire.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&wire)?;
|
||||
}
|
||||
Ok(wire.len())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let a = Args::parse();
|
||||
if a.udp.is_none() && a.output.is_none() {
|
||||
return Err("select at least one sink with --udp or --output".into());
|
||||
}
|
||||
let cfg = SimulatorConfig {
|
||||
seed: a.seed,
|
||||
csi_mode: CsiMode::Passive,
|
||||
channel: a.channel,
|
||||
bandwidth: if a.bandwidth_40mhz { 1 } else { 0 },
|
||||
num_sub_carrier: a.subcarriers,
|
||||
num_bit_per_tone: a.tone_bits,
|
||||
decimation: a.decimation,
|
||||
frame_period_us: (a.interval_ms * 1000) as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg)?;
|
||||
let socket = a.udp.map(|_| UdpSocket::bind("0.0.0.0:0")).transpose()?;
|
||||
let mut output = a.output.as_ref().map(File::create).transpose()?;
|
||||
let mut bytes = 0usize;
|
||||
for _ in 0..a.frames {
|
||||
bytes += emit(sim.next_frame(), socket.as_ref(), a.udp, &mut output)?;
|
||||
if a.realtime {
|
||||
thread::sleep(Duration::from_millis(a.interval_ms));
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"emitted {} synthetic Realtek RTL8721Dx CSI frames ({} bytes, channel={}, subcarriers={}, tone_bits={}, seed={:#x})",
|
||||
a.frames, bytes, a.channel, a.subcarriers, a.tone_bits, a.seed
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -145,13 +145,16 @@ impl PpduType {
|
||||
/// bit 2 : STBC
|
||||
/// bit 3 : LDPC (reserved — not yet populated by firmware)
|
||||
/// bit 4 : 802.15.4 time-sync valid (C6 only)
|
||||
/// bit 5-7 : reserved
|
||||
/// bit 5 : ESP-IDF invalid first CSI word was zeroed by firmware
|
||||
/// bit 6-7 : reserved
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Adr018Flags {
|
||||
pub bw40: bool,
|
||||
pub stbc: bool,
|
||||
pub ldpc: bool,
|
||||
pub ieee802154_sync_valid: bool,
|
||||
#[serde(default)]
|
||||
pub first_word_sanitized: bool,
|
||||
}
|
||||
|
||||
impl Adr018Flags {
|
||||
@@ -161,6 +164,7 @@ impl Adr018Flags {
|
||||
stbc: (b & 0x04) != 0,
|
||||
ldpc: (b & 0x08) != 0,
|
||||
ieee802154_sync_valid: (b & 0x10) != 0,
|
||||
first_word_sanitized: (b & 0x20) != 0,
|
||||
}
|
||||
}
|
||||
pub fn to_byte(self) -> u8 {
|
||||
@@ -169,6 +173,7 @@ impl Adr018Flags {
|
||||
if self.stbc { b |= 0x04; }
|
||||
if self.ldpc { b |= 0x08; }
|
||||
if self.ieee802154_sync_valid { b |= 0x10; }
|
||||
if self.first_word_sanitized { b |= 0x20; }
|
||||
b
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,15 +413,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn adr110_flags_round_trip_all_bits() {
|
||||
// All known flag bits set: bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
|
||||
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x1D);
|
||||
// All known flag bits set: bw40 + STBC + LDPC + sync + sanitized prefix = 0x3D.
|
||||
let data = build_test_frame_with_he(6, 1, &[(0, 0); 56], 1, 0x3D);
|
||||
let (frame, _) = Esp32CsiParser::parse_frame(&data).unwrap();
|
||||
assert!(frame.metadata.adr018_flags.bw40);
|
||||
assert!(frame.metadata.adr018_flags.stbc);
|
||||
assert!(frame.metadata.adr018_flags.ldpc);
|
||||
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
|
||||
assert!(frame.metadata.adr018_flags.first_word_sanitized);
|
||||
// Round-trip the encoder
|
||||
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x1D);
|
||||
assert_eq!(frame.metadata.adr018_flags.to_byte(), 0x3D);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,6 +62,9 @@ pub mod qualcomm_csi;
|
||||
/// ADR-264 host-side framing for Realtek RTL8720F CFR and FMCW radar reports.
|
||||
/// This module has no dependency on the vendor SDK.
|
||||
pub mod rtl8720f;
|
||||
/// ADR-323 vendor-neutral Realtek RTL8721Dx (AmebaDplus) 1x1 CSI framing and
|
||||
/// simulator. This module has no dependency on the vendor SDK.
|
||||
pub mod realtek_csi;
|
||||
|
||||
pub use bridge::CsiData;
|
||||
pub use csi_frame::{
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
//! Vendor-neutral Realtek RTL8721Dx (AmebaDplus) 1x1 CSI transport and
|
||||
//! deterministic simulator. This is not the vendor SDK's raw
|
||||
//! `rtw_event_csi_report_info` ABI (no CRC, C flexible-array payload); see
|
||||
//! ADR-323 for the field-by-field mapping and rationale.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
pub const RAC1_MAGIC: u32 = 0x3143_4152; // "RAC1" little endian
|
||||
pub const RAC1_VERSION: u8 = 1;
|
||||
pub const RAC1_HEADER_LEN: usize = 49;
|
||||
pub const RAC1_CRC_LEN: usize = 4;
|
||||
pub const RAC1_MAX_FRAME_LEN: usize = 65_507;
|
||||
/// Upper bound on subcarriers a 1x1 20/40 MHz OFDM/HT/VHT/HE capture can
|
||||
/// plausibly report; guards `to_bytes`/`from_bytes` against runaway sizes.
|
||||
pub const RAC1_MAX_SUBCARRIERS: usize = 4_096;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum CsiMode {
|
||||
Passive = 0,
|
||||
Active = 1,
|
||||
BoardToBoard = 2,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for CsiMode {
|
||||
type Error = CsiParseError;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Passive),
|
||||
1 => Ok(Self::Active),
|
||||
2 => Ok(Self::BoardToBoard),
|
||||
_ => Err(CsiParseError::UnknownCsiMode(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum ProtocolMode {
|
||||
Ofdm = 0,
|
||||
Ht = 1,
|
||||
Vht = 2,
|
||||
He = 3,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for ProtocolMode {
|
||||
type Error = CsiParseError;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Ofdm),
|
||||
1 => Ok(Self::Ht),
|
||||
2 => Ok(Self::Vht),
|
||||
3 => Ok(Self::He),
|
||||
_ => Err(CsiParseError::UnknownProtocolMode(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CsiFlags(pub u8);
|
||||
|
||||
impl CsiFlags {
|
||||
pub const SYNTHETIC: u8 = 1 << 0;
|
||||
pub fn contains(self, flag: u8) -> bool {
|
||||
self.0 & flag != 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiFrame {
|
||||
/// Disambiguates multi-board illuminator/receiver deployments; 0 = unset.
|
||||
pub node_id: u8,
|
||||
pub csi_mode: CsiMode,
|
||||
pub sequence: u32,
|
||||
/// Hardware-clock microseconds; wraps roughly every 71 minutes. Consumers
|
||||
/// must reorder by `sequence`, not raw timestamp arithmetic, across a wrap.
|
||||
pub timestamp_us: u32,
|
||||
pub peer_mac: [u8; 6],
|
||||
pub trig_mac: [u8; 6],
|
||||
pub channel: u8,
|
||||
/// 0 = 20 MHz, 1 = 40 MHz.
|
||||
pub bandwidth: u8,
|
||||
pub rx_rate: u8,
|
||||
pub protocol_mode: ProtocolMode,
|
||||
pub num_sub_carrier: u16,
|
||||
/// 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q).
|
||||
pub num_bit_per_tone: u8,
|
||||
/// Tone group: 1/2/4/8 (every Nth tone reported).
|
||||
pub decimation: u8,
|
||||
/// Single RF chain (1x1 hardware).
|
||||
pub rssi_dbm: i8,
|
||||
pub rxsc: u8,
|
||||
pub csi_valid: bool,
|
||||
pub flags: CsiFlags,
|
||||
/// One (I, Q) pair per reported subcarrier, widened to i16 regardless of
|
||||
/// wire precision (an 8-bit wire value fits losslessly).
|
||||
pub payload: Vec<(i16, i16)>,
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
fn component_bytes(&self) -> Result<usize, CsiParseError> {
|
||||
match self.num_bit_per_tone {
|
||||
16 => Ok(1),
|
||||
32 => Ok(2),
|
||||
other => Err(CsiParseError::InvalidToneBits(other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, CsiParseError> {
|
||||
self.validate()?;
|
||||
let comp_bytes = self.component_bytes()?;
|
||||
let payload_len = self
|
||||
.payload
|
||||
.len()
|
||||
.checked_mul(comp_bytes * 2)
|
||||
.ok_or(CsiParseError::LengthOverflow)?;
|
||||
let frame_len = RAC1_HEADER_LEN
|
||||
.checked_add(payload_len)
|
||||
.and_then(|n| n.checked_add(RAC1_CRC_LEN))
|
||||
.ok_or(CsiParseError::LengthOverflow)?;
|
||||
if frame_len > RAC1_MAX_FRAME_LEN {
|
||||
return Err(CsiParseError::FrameTooLarge(frame_len));
|
||||
}
|
||||
let mut out = Vec::with_capacity(frame_len);
|
||||
out.extend_from_slice(&RAC1_MAGIC.to_le_bytes());
|
||||
out.push(RAC1_VERSION);
|
||||
out.extend_from_slice(&(RAC1_HEADER_LEN as u16).to_le_bytes());
|
||||
out.extend_from_slice(&(frame_len as u32).to_le_bytes());
|
||||
out.push(self.node_id);
|
||||
out.push(self.csi_mode as u8);
|
||||
out.extend_from_slice(&self.sequence.to_le_bytes());
|
||||
out.extend_from_slice(&self.timestamp_us.to_le_bytes());
|
||||
out.extend_from_slice(&self.peer_mac);
|
||||
out.extend_from_slice(&self.trig_mac);
|
||||
out.push(self.channel);
|
||||
out.push(self.bandwidth);
|
||||
out.push(self.rx_rate);
|
||||
out.push(self.protocol_mode as u8);
|
||||
out.extend_from_slice(&self.num_sub_carrier.to_le_bytes());
|
||||
out.push(self.num_bit_per_tone);
|
||||
out.push(self.decimation);
|
||||
out.push(self.rssi_dbm as u8);
|
||||
out.push(self.rxsc);
|
||||
out.push(self.csi_valid as u8);
|
||||
out.push(self.flags.0);
|
||||
out.extend_from_slice(&(payload_len as u32).to_le_bytes());
|
||||
debug_assert_eq!(out.len(), RAC1_HEADER_LEN);
|
||||
for (i, q) in &self.payload {
|
||||
if comp_bytes == 1 {
|
||||
out.push(*i as i8 as u8);
|
||||
out.push(*q as i8 as u8);
|
||||
} else {
|
||||
out.extend_from_slice(&i.to_le_bytes());
|
||||
out.extend_from_slice(&q.to_le_bytes());
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&crc32_ieee(&out).to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn from_bytes(input: &[u8]) -> Result<(Self, usize), CsiParseError> {
|
||||
if input.len() < RAC1_HEADER_LEN {
|
||||
return Err(CsiParseError::InsufficientData {
|
||||
needed: RAC1_HEADER_LEN,
|
||||
got: input.len(),
|
||||
});
|
||||
}
|
||||
let magic = u32_at(input, 0);
|
||||
if magic != RAC1_MAGIC {
|
||||
return Err(CsiParseError::InvalidMagic(magic));
|
||||
}
|
||||
if input[4] != RAC1_VERSION {
|
||||
return Err(CsiParseError::UnsupportedVersion(input[4]));
|
||||
}
|
||||
let header_len = u16_at(input, 5) as usize;
|
||||
if header_len != RAC1_HEADER_LEN {
|
||||
return Err(CsiParseError::InvalidHeaderLength(header_len));
|
||||
}
|
||||
let frame_len = u32_at(input, 7) as usize;
|
||||
if frame_len > RAC1_MAX_FRAME_LEN {
|
||||
return Err(CsiParseError::FrameTooLarge(frame_len));
|
||||
}
|
||||
if frame_len < header_len + RAC1_CRC_LEN {
|
||||
return Err(CsiParseError::InvalidFrameLength(frame_len));
|
||||
}
|
||||
if input.len() < frame_len {
|
||||
return Err(CsiParseError::InsufficientData {
|
||||
needed: frame_len,
|
||||
got: input.len(),
|
||||
});
|
||||
}
|
||||
let expected_crc = u32_at(input, frame_len - 4);
|
||||
let actual_crc = crc32_ieee(&input[..frame_len - 4]);
|
||||
if expected_crc != actual_crc {
|
||||
return Err(CsiParseError::CrcMismatch {
|
||||
expected: expected_crc,
|
||||
actual: actual_crc,
|
||||
});
|
||||
}
|
||||
|
||||
let node_id = input[11];
|
||||
let csi_mode = CsiMode::try_from(input[12])?;
|
||||
let sequence = u32_at(input, 13);
|
||||
let timestamp_us = u32_at(input, 17);
|
||||
let mut peer_mac = [0u8; 6];
|
||||
peer_mac.copy_from_slice(&input[21..27]);
|
||||
let mut trig_mac = [0u8; 6];
|
||||
trig_mac.copy_from_slice(&input[27..33]);
|
||||
let channel = input[33];
|
||||
let bandwidth = input[34];
|
||||
let rx_rate = input[35];
|
||||
let protocol_mode = ProtocolMode::try_from(input[36])?;
|
||||
let num_sub_carrier = u16_at(input, 37);
|
||||
let num_bit_per_tone = input[39];
|
||||
let decimation = input[40];
|
||||
let rssi_dbm = input[41] as i8;
|
||||
let rxsc = input[42];
|
||||
let csi_valid = input[43] != 0;
|
||||
let flags = CsiFlags(input[44]);
|
||||
let payload_len = u32_at(input, 45) as usize;
|
||||
|
||||
if header_len + payload_len + RAC1_CRC_LEN != frame_len {
|
||||
return Err(CsiParseError::PayloadLengthMismatch);
|
||||
}
|
||||
let comp_bytes = match num_bit_per_tone {
|
||||
16 => 1usize,
|
||||
32 => 2usize,
|
||||
other => return Err(CsiParseError::InvalidToneBits(other)),
|
||||
};
|
||||
let expected_payload_len = (num_sub_carrier as usize)
|
||||
.checked_mul(comp_bytes * 2)
|
||||
.ok_or(CsiParseError::LengthOverflow)?;
|
||||
if payload_len != expected_payload_len {
|
||||
return Err(CsiParseError::PayloadLengthMismatch);
|
||||
}
|
||||
let payload_bytes = &input[header_len..header_len + payload_len];
|
||||
let payload = if comp_bytes == 1 {
|
||||
payload_bytes
|
||||
.chunks_exact(2)
|
||||
.map(|b| (b[0] as i8 as i16, b[1] as i8 as i16))
|
||||
.collect()
|
||||
} else {
|
||||
payload_bytes
|
||||
.chunks_exact(4)
|
||||
.map(|b| {
|
||||
(
|
||||
i16::from_le_bytes([b[0], b[1]]),
|
||||
i16::from_le_bytes([b[2], b[3]]),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let frame = Self {
|
||||
node_id,
|
||||
csi_mode,
|
||||
sequence,
|
||||
timestamp_us,
|
||||
peer_mac,
|
||||
trig_mac,
|
||||
channel,
|
||||
bandwidth,
|
||||
rx_rate,
|
||||
protocol_mode,
|
||||
num_sub_carrier,
|
||||
num_bit_per_tone,
|
||||
decimation,
|
||||
rssi_dbm,
|
||||
rxsc,
|
||||
csi_valid,
|
||||
flags,
|
||||
payload,
|
||||
};
|
||||
frame.validate()?;
|
||||
Ok((frame, frame_len))
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), CsiParseError> {
|
||||
if !matches!(self.bandwidth, 0 | 1) {
|
||||
return Err(CsiParseError::InvalidBandwidth(self.bandwidth));
|
||||
}
|
||||
let comp_bytes = self.component_bytes()?;
|
||||
if self.num_sub_carrier == 0 || self.num_sub_carrier as usize > RAC1_MAX_SUBCARRIERS {
|
||||
return Err(CsiParseError::InvalidSubcarrierCount(self.num_sub_carrier));
|
||||
}
|
||||
if self.payload.len() != self.num_sub_carrier as usize {
|
||||
return Err(CsiParseError::PayloadLengthMismatch);
|
||||
}
|
||||
if comp_bytes == 1 {
|
||||
let in_range = |v: i16| (i8::MIN as i16..=i8::MAX as i16).contains(&v);
|
||||
if self.payload.iter().any(|(i, q)| !in_range(*i) || !in_range(*q)) {
|
||||
return Err(CsiParseError::ToneValueOutOfRange);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum CsiParseError {
|
||||
#[error("insufficient data: needed {needed}, got {got}")]
|
||||
InsufficientData { needed: usize, got: usize },
|
||||
#[error("invalid magic {0:#010x}")]
|
||||
InvalidMagic(u32),
|
||||
#[error("unsupported version {0}")]
|
||||
UnsupportedVersion(u8),
|
||||
#[error("unknown CSI mode {0}")]
|
||||
UnknownCsiMode(u8),
|
||||
#[error("unknown protocol mode {0}")]
|
||||
UnknownProtocolMode(u8),
|
||||
#[error("invalid header length {0}")]
|
||||
InvalidHeaderLength(usize),
|
||||
#[error("invalid frame length {0}")]
|
||||
InvalidFrameLength(usize),
|
||||
#[error("frame too large: {0}")]
|
||||
FrameTooLarge(usize),
|
||||
#[error("length arithmetic overflow")]
|
||||
LengthOverflow,
|
||||
#[error("payload length mismatch")]
|
||||
PayloadLengthMismatch,
|
||||
#[error("invalid num_bit_per_tone {0} (must be 16 or 32)")]
|
||||
InvalidToneBits(u8),
|
||||
#[error("invalid subcarrier count {0}")]
|
||||
InvalidSubcarrierCount(u16),
|
||||
#[error("invalid bandwidth code {0} (must be 0 or 1)")]
|
||||
InvalidBandwidth(u8),
|
||||
#[error("tone I/Q value out of range for 8-bit precision")]
|
||||
ToneValueOutOfRange,
|
||||
#[error("CRC mismatch: expected {expected:#010x}, actual {actual:#010x}")]
|
||||
CrcMismatch { expected: u32, actual: u32 },
|
||||
}
|
||||
|
||||
pub mod simulator {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimulatorConfig {
|
||||
pub seed: u64,
|
||||
pub node_id: u8,
|
||||
pub csi_mode: CsiMode,
|
||||
pub peer_mac: [u8; 6],
|
||||
pub trig_mac: [u8; 6],
|
||||
pub channel: u8,
|
||||
pub bandwidth: u8,
|
||||
pub rx_rate: u8,
|
||||
pub protocol_mode: ProtocolMode,
|
||||
pub num_sub_carrier: u16,
|
||||
pub num_bit_per_tone: u8,
|
||||
pub decimation: u8,
|
||||
pub rssi_dbm: i8,
|
||||
pub frame_period_us: u32,
|
||||
}
|
||||
|
||||
impl Default for SimulatorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
seed: 0x5241_4331_5254_4c31, // "RAC1RTL1"-ish
|
||||
node_id: 1,
|
||||
csi_mode: CsiMode::Passive,
|
||||
peer_mac: [0xa4, 0x39, 0xb3, 0xa4, 0xbe, 0x2d],
|
||||
trig_mac: [0x00, 0xe0, 0x4c, 0x00, 0x0a, 0xa3],
|
||||
channel: 6,
|
||||
bandwidth: 0,
|
||||
rx_rate: 12, // RTW_RATE_6M
|
||||
protocol_mode: ProtocolMode::Ofdm,
|
||||
num_sub_carrier: 52,
|
||||
num_bit_per_tone: 16,
|
||||
decimation: 1,
|
||||
rssi_dbm: -45,
|
||||
frame_period_us: 64_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RealtekCsiSimulator {
|
||||
config: SimulatorConfig,
|
||||
rng: u64,
|
||||
sequence: u32,
|
||||
timestamp_us: u32,
|
||||
motion_phase: f32,
|
||||
}
|
||||
|
||||
impl RealtekCsiSimulator {
|
||||
pub fn new(config: SimulatorConfig) -> Result<Self, CsiParseError> {
|
||||
let s = Self {
|
||||
rng: config.seed,
|
||||
config,
|
||||
sequence: 0,
|
||||
timestamp_us: 0,
|
||||
motion_phase: 0.0,
|
||||
};
|
||||
s.csi_frame().validate()?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub fn next_frame(&mut self) -> CsiFrame {
|
||||
let frame = self.csi_frame();
|
||||
self.sequence = self.sequence.wrapping_add(1);
|
||||
self.timestamp_us = self.timestamp_us.wrapping_add(self.config.frame_period_us);
|
||||
self.motion_phase += 0.041;
|
||||
frame
|
||||
}
|
||||
|
||||
fn csi_frame(&self) -> CsiFrame {
|
||||
let mut rng = self.rng ^ self.sequence as u64;
|
||||
let comp_max = if self.config.num_bit_per_tone == 16 {
|
||||
110.0
|
||||
} else {
|
||||
1800.0
|
||||
};
|
||||
let payload = (0..self.config.num_sub_carrier as usize)
|
||||
.map(|tone| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 7;
|
||||
rng ^= rng << 17;
|
||||
let noise = ((rng >> 48) as i16 % 8) as f32;
|
||||
let phase = tone as f32 * 0.11 + self.motion_phase;
|
||||
(
|
||||
((phase.cos() * comp_max) + noise) as i16,
|
||||
((phase.sin() * comp_max) - noise) as i16,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
CsiFrame {
|
||||
node_id: self.config.node_id,
|
||||
csi_mode: self.config.csi_mode,
|
||||
sequence: self.sequence,
|
||||
timestamp_us: self.timestamp_us,
|
||||
peer_mac: self.config.peer_mac,
|
||||
trig_mac: self.config.trig_mac,
|
||||
channel: self.config.channel,
|
||||
bandwidth: self.config.bandwidth,
|
||||
rx_rate: self.config.rx_rate,
|
||||
protocol_mode: self.config.protocol_mode,
|
||||
num_sub_carrier: self.config.num_sub_carrier,
|
||||
num_bit_per_tone: self.config.num_bit_per_tone,
|
||||
decimation: self.config.decimation,
|
||||
rssi_dbm: self.config.rssi_dbm,
|
||||
rxsc: 0,
|
||||
csi_valid: true,
|
||||
flags: CsiFlags(CsiFlags::SYNTHETIC),
|
||||
payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], o: usize) -> u16 {
|
||||
u16::from_le_bytes([b[o], b[o + 1]])
|
||||
}
|
||||
fn u32_at(b: &[u8], o: usize) -> u32 {
|
||||
u32::from_le_bytes(b[o..o + 4].try_into().unwrap())
|
||||
}
|
||||
fn crc32_ieee(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for &byte in data {
|
||||
crc ^= byte as u32;
|
||||
for _ in 0..8 {
|
||||
crc = (crc >> 1) ^ ((0u32.wrapping_sub(crc & 1)) & 0xedb8_8320);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use simulator::*;
|
||||
|
||||
#[test]
|
||||
fn simulator_round_trip_is_deterministic() {
|
||||
let cfg = SimulatorConfig::default();
|
||||
let mut a = RealtekCsiSimulator::new(cfg.clone()).unwrap();
|
||||
let mut b = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let wa = a.next_frame().to_bytes().unwrap();
|
||||
assert_eq!(wa, b.next_frame().to_bytes().unwrap());
|
||||
let (decoded, n) = CsiFrame::from_bytes(&wa).unwrap();
|
||||
assert_eq!(n, wa.len());
|
||||
assert!(decoded.flags.contains(CsiFlags::SYNTHETIC));
|
||||
assert_eq!(decoded.payload.len(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_bit_precision_round_trip() {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let frame = sim.next_frame();
|
||||
let wire = frame.to_bytes().unwrap();
|
||||
let (decoded, n) = CsiFrame::from_bytes(&wire).unwrap();
|
||||
assert_eq!(n, wire.len());
|
||||
assert_eq!(decoded, frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_corruption_is_rejected() {
|
||||
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let mut w = s.next_frame().to_bytes().unwrap();
|
||||
let last = w.len() - 1;
|
||||
w[last] ^= 1;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_bytes(&w),
|
||||
Err(CsiParseError::CrcMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_is_rejected() {
|
||||
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let w = s.next_frame().to_bytes().unwrap();
|
||||
assert!(matches!(
|
||||
CsiFrame::from_bytes(&w[..w.len() - 1]),
|
||||
Err(CsiParseError::InsufficientData { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_subcarriers_is_rejected() {
|
||||
let cfg = SimulatorConfig {
|
||||
num_sub_carrier: 0,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
RealtekCsiSimulator::new(cfg),
|
||||
Err(CsiParseError::InvalidSubcarrierCount(0))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_tone_bits_is_rejected() {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 24,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
RealtekCsiSimulator::new(cfg),
|
||||
Err(CsiParseError::InvalidToneBits(24))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_never_panics_on_prefixes() {
|
||||
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let w = s.next_frame().to_bytes().unwrap();
|
||||
for end in 0..w.len() {
|
||||
let _ = CsiFrame::from_bytes(&w[..end]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_mismatch_is_rejected() {
|
||||
let mut s = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let mut w = s.next_frame().to_bytes().unwrap();
|
||||
w[0] ^= 0xff;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_bytes(&w),
|
||||
Err(CsiParseError::InvalidMagic(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ mdns-sd = "0.11"
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
|
||||
# Logging
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -43,6 +43,8 @@ per ADR-022 Phase 3.
|
||||
- **WebSocket broadcast** -- Real-time sensing updates pushed to all connected clients at
|
||||
`ws://localhost:8765/ws/sensing`.
|
||||
- **Static file serving** -- Hosts the sensing UI on port 8080 with CORS headers.
|
||||
- **Private startup baseline** -- Restores a recent, installation-bound empty-room field model
|
||||
without storing raw CSI or authorizing numeric vital signs (ADR-355).
|
||||
|
||||
## Modules
|
||||
|
||||
@@ -74,6 +76,23 @@ cargo run -p wifi-densepose-sensing-server -- \
|
||||
--static-dir ./ui
|
||||
```
|
||||
|
||||
### Empty-room startup baseline
|
||||
|
||||
Provide a stable installation ID and an application-owned private state directory:
|
||||
|
||||
```bash
|
||||
cargo run -p wifi-densepose-sensing-server -- \
|
||||
--installation-id installation-01 \
|
||||
--data-dir /path/to/private/application-state
|
||||
```
|
||||
|
||||
Complete the normal empty-room calibration, then call
|
||||
`POST /api/v1/calibration/bootstrap/promote`. The server measures a separate 12-sample holdout
|
||||
before storing an aggregate field-model snapshot. On a later start with the same installation ID,
|
||||
the snapshot is restored with `bootstrap_only` authority. It can reduce startup background false
|
||||
positives, but cannot authorize calibrated evidence or numeric heart and breathing rates. Use
|
||||
`POST /api/v1/calibration/reset` with administrator scope to remove it.
|
||||
|
||||
### Using as a library
|
||||
|
||||
```rust
|
||||
|
||||
@@ -139,6 +139,39 @@ pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_
|
||||
]
|
||||
}
|
||||
|
||||
/// Preserve the fifth feature's historical meaning for models trained before
|
||||
/// the server began measuring temporal frequency from a frame window.
|
||||
///
|
||||
/// Version 1 models learned a proxy equal to the strongest subcarrier index
|
||||
/// multiplied by 0.05. Version 2 and later models consume the measured
|
||||
/// temporal frequency in hertz. Keeping this translation at the model boundary
|
||||
/// prevents a server upgrade from silently changing an existing model's input
|
||||
/// distribution.
|
||||
pub fn compatible_dominant_frequency(
|
||||
model_version: u32,
|
||||
temporal_frequency_hz: f64,
|
||||
amplitudes: &[f64],
|
||||
) -> f64 {
|
||||
if model_version >= 2 {
|
||||
return if temporal_frequency_hz.is_finite() {
|
||||
temporal_frequency_hz.max(0.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
amplitudes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, amplitude)| amplitude.is_finite())
|
||||
.max_by(|(_, left), (_, right)| {
|
||||
left.partial_cmp(right)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(index, _)| index as f64 * 0.05)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Compute statistical features from raw subcarrier amplitudes.
|
||||
fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
|
||||
if amps.is_empty() {
|
||||
@@ -642,3 +675,21 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
|
||||
pub fn model_path() -> PathBuf {
|
||||
PathBuf::from("data/adaptive_model.json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compatibility_tests {
|
||||
use super::compatible_dominant_frequency;
|
||||
|
||||
#[test]
|
||||
fn version_one_keeps_legacy_subcarrier_proxy() {
|
||||
let amplitudes = [1.0, 2.0, 8.0, 3.0];
|
||||
assert_eq!(compatible_dominant_frequency(1, 1.25, &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);
|
||||
}
|
||||
}
|
||||
@@ -491,6 +491,10 @@ const READ_SAFE_MUTATIONS: &[&str] = &[
|
||||
// Capture and calibration: create data, never destroy it.
|
||||
"/api/v1/calibration/start",
|
||||
"/api/v1/calibration/stop",
|
||||
// Promotion consumes only server measured held out evidence and stores a
|
||||
// bounded privacy reduced prior. Cancel discards only an unfinished run.
|
||||
"/api/v1/calibration/bootstrap/promote",
|
||||
"/api/v1/calibration/cancel",
|
||||
"/api/v1/pose/calibrate",
|
||||
"/api/v1/recording/start",
|
||||
"/api/v1/recording/stop",
|
||||
@@ -1970,6 +1974,7 @@ mod scope_gate_polarity_tests {
|
||||
(Method::DELETE, "/api/v1/models/m1"),
|
||||
(Method::DELETE, "/api/v1/recording/r1"),
|
||||
(Method::POST, "/api/v1/config/ground-truth"),
|
||||
(Method::POST, "/api/v1/calibration/reset"),
|
||||
] {
|
||||
assert_eq!(
|
||||
required_scope_for(&m, p),
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user