mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-19 05:10:13 -05:00
Compare commits
40
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 |
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,14 +94,11 @@ jobs:
|
||||
idf.py set-target ${{ matrix.target }}
|
||||
idf.py build
|
||||
|
||||
- name: Build and run host-side ADR-110 unit tests
|
||||
- name: Build and run host-side firmware unit tests
|
||||
if: matrix.variant == 'c6-4mb'
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
make test_adr110
|
||||
./test_adr110
|
||||
make test_c6_antenna
|
||||
./test_c6_antenna
|
||||
make host_tests
|
||||
|
||||
- name: Verify binary size budget
|
||||
working-directory: firmware/esp32-csi-node
|
||||
@@ -205,3 +204,35 @@ jobs:
|
||||
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({
|
||||
|
||||
@@ -10,6 +10,35 @@ func main() {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// One connected-link RSSI sample for the Rust adapter. Keep the legacy
|
||||
// no-argument 10 Hz stream below unchanged for Python callers.
|
||||
if CommandLine.arguments.contains("--scan-once") {
|
||||
guard interface.powerOn(),
|
||||
let channel = interface.wlanChannel(),
|
||||
interface.rssiValue() < 0 else {
|
||||
fputs("WiFi is not connected\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let sample: [String: Any] = [
|
||||
"ssid": interface.ssid() ?? "",
|
||||
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
|
||||
"channel": channel.channelNumber,
|
||||
"rssi": interface.rssiValue(),
|
||||
"noise": interface.noiseMeasurement(),
|
||||
"timestamp": Date().timeIntervalSince1970,
|
||||
"tx_rate": interface.transmitRate()
|
||||
]
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: sample, options: [.sortedKeys])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write(Data([0x0a]))
|
||||
} catch {
|
||||
fputs("Could not encode WiFi sample: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Flush stdout automatically to prevent buffering issues with Python subprocess
|
||||
setbuf(stdout, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# ADR-323: RTL8721Dx (Realtek AmebaDplus) CSI Wire Protocol
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-09-15
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: realtek, ameba, amebadplus, rtl8721dx, csi, protocol, rust, udp
|
||||
|
||||
## Context
|
||||
|
||||
Unlike the MediaTek/Qualcomm CSI paths (ADR-266..269), which are simulator-first
|
||||
because no supported public vendor interface exports complex CSI, Realtek's
|
||||
official Ameba RTOS SDK (`Ameba-AIoT/ameba-rtos`, Apache-2.0) documents and
|
||||
exposes a real, callback-driven CSI API for the RTL8721Dx ("AmebaDplus")
|
||||
family: `wifi_csi_config()` plus the `RTW_EVENT_CSI_DONE` event delivering a
|
||||
`struct rtw_event_csi_report_info` (per-tone signed I/Q, 8-bit or 16-bit
|
||||
precision, selectable tone decimation, passive or active/triggered capture).
|
||||
This has been confirmed directly against SDK source
|
||||
(`component/wifi/api/wifi_api_event.h`) and against the official
|
||||
`example/wifi/wifi_csi` example, and validated on real PKM8721DAF-C13-F10
|
||||
hardware (build+flash+boot confirmed on branch `feat/rtl8721dx-csi-bringup`).
|
||||
|
||||
The RTL8721Dx is a 1x1 WiFi 4 part — single TX/RX chain, not MIMO — so the
|
||||
MediaTek/Qualcomm wire format (Tx/Rx dimensioned matrices, chipset chain
|
||||
counts) does not fit. The vendor SDK's raw struct is also not a safe host
|
||||
wire format as-is: it has no CRC, a C flexible-array-member payload, and
|
||||
fields sized for firmware convenience (e.g. a wrapping `u32` microsecond
|
||||
timestamp) rather than network robustness.
|
||||
|
||||
Per the existing repo convention (ESP32/ADR-018, MediaTek/ADR-266-267,
|
||||
Qualcomm/ADR-268-269, RTL8720F radar/ADR-263-264), each vendor CSI/radar
|
||||
source gets its own self-delimiting envelope and its own Rust module pair
|
||||
(`wifi-densepose-hardware` for framing+codec+simulator,
|
||||
`wifi-densepose-sensing-server` for the bounded JSON-safe snapshot), never a
|
||||
shared "generic vendor CSI" struct that would either lose vendor-specific
|
||||
fields or invite magic-space collisions. `SourceKind::Realtek` (value 2) is
|
||||
already assigned to the RTL8720F FMCW radar path (ADR-263/264) and must not
|
||||
be reused for CSI.
|
||||
|
||||
## Decision
|
||||
|
||||
Define `RAC1` ("Realtek Ameba CSI", version 1) as a little-endian,
|
||||
self-delimiting envelope, magic `0x3143_4152` (distinct from `MTC1`
|
||||
`0x3143544d`, `QCS1` 0x31535143, `RTR1` 0x31525452, and all ADR-018
|
||||
`0xC511000x` siblings):
|
||||
|
||||
Fixed 49-byte header (explicit field-by-field serialization, no `#[repr(C)]`
|
||||
padding, matching the MediaTek/Qualcomm modules' style):
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| magic | u32 LE | `0x3143_4152` |
|
||||
| version | u8 | 1 |
|
||||
| header_len | u16 LE | 49 |
|
||||
| frame_len | u32 LE | header + payload + 4-byte CRC |
|
||||
| node_id | u8 | disambiguates multi-board illuminator/receiver deployments; 0 = unset |
|
||||
| csi_mode | u8 | 0=passive, 1=active, 2=board-to-board (`CsiMode`) |
|
||||
| sequence | u32 LE | from firmware `csi_sequence` |
|
||||
| timestamp_us | u32 LE | from firmware `hw_assigned_timestamp` (hardware-clock microseconds, wraps ~71 min — consumers must not assume monotonic across a capture longer than that) |
|
||||
| peer_mac | [u8; 6] | firmware `mac_addr` |
|
||||
| trig_mac | [u8; 6] | firmware `trig_addr` |
|
||||
| channel | u8 | operating channel |
|
||||
| bandwidth | u8 | 0=20MHz, 1=40MHz |
|
||||
| rx_rate | u8 | firmware `rx_rate` |
|
||||
| protocol_mode | u8 | 0=OFDM,1=HT,2=VHT,3=HE (`ProtocolMode`) |
|
||||
| num_sub_carrier | u16 LE | tone count in payload |
|
||||
| num_bit_per_tone | u8 | 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q) |
|
||||
| decimation | u8 | tone group (1/2/4/8 — every Nth tone) |
|
||||
| rssi_dbm | i8 | single RF chain (1x1 hardware) |
|
||||
| rxsc | u8 | sub-20 MHz channel used |
|
||||
| csi_valid | u8 | 0/1 from firmware |
|
||||
| flags | u8 | bit 0 = SYNTHETIC (simulator provenance, never clearable downstream) |
|
||||
| payload_len | u32 LE | bytes, == `num_sub_carrier * (num_bit_per_tone / 8)` |
|
||||
|
||||
Payload: `num_sub_carrier` consecutive (I, Q) tone pairs, each
|
||||
`num_bit_per_tone / 16` bytes per component (1 byte/component when
|
||||
`num_bit_per_tone == 16`, 2 bytes/component little-endian signed when `== 32`)
|
||||
— i.e. exactly the byte layout the firmware already produces in
|
||||
`csi_data[]`, just framed. CRC-32/IEEE over header+payload, final 4
|
||||
little-endian bytes. One envelope per UDP datagram.
|
||||
|
||||
Parsers reject: unknown version, wrong `header_len`, `frame_len` exceeding
|
||||
the IPv4 UDP payload cap (65,507 bytes), `num_bit_per_tone` outside
|
||||
`{16, 32}`, `payload_len` inconsistent with `num_sub_carrier *
|
||||
(num_bit_per_tone/8)`, trailing bytes, and bad CRC — mirroring the
|
||||
MediaTek/Qualcomm parser contract exactly.
|
||||
|
||||
Host side: add `realtek_csi.rs` to both
|
||||
`wifi-densepose-hardware` (frame + codec + simulator, mirroring
|
||||
`mediatek_csi.rs`/`qualcomm_csi.rs`) and `wifi-densepose-sensing-server`
|
||||
(bounded `RealtekCsiSnapshot`, mirroring `QualcommCsiSnapshot`), wired into
|
||||
the same UDP listener loop and `/api/v1/csi/realtek/latest` route as the
|
||||
existing vendor sources. The initial Mac compatibility port keeps Realtek CSI
|
||||
as a separate bounded diagnostic stream. Fresh ESP32 frames remain the primary
|
||||
sensing source. This change does not add `SourceKind::RealtekCsi` to a signed
|
||||
ingest contract; that requires a separate reviewed identity-bound hardware
|
||||
proof. RAC1 CRC checks corruption, not origin.
|
||||
|
||||
Firmware side (`example/wifi/wifi_csi` derivative, built against the
|
||||
official Ameba RTOS SDK, tracked outside this repository per the existing
|
||||
`CLAUDE.local.md` convention — the vendor SDK itself is never vendored into
|
||||
this repo): join the board to the deployment's WiFi AP in STA mode
|
||||
(passive CSI against the real AP as peer, since only one physical board is
|
||||
available in this bring-up), read the AP's BSSID at runtime after
|
||||
association (never hardcode a MAC into firmware source), pack each
|
||||
`RTW_EVENT_CSI_DONE` report into one `RAC1` datagram, and `sendto()` it over
|
||||
UDP to the host sensing-server's existing CSI port.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- CSI is real, MEASURED hardware output from day one — no simulator-first
|
||||
bridge phase is needed the way MediaTek/Qualcomm required, because the
|
||||
vendor SDK genuinely exposes the capability and we have the board in hand.
|
||||
- One board is sufficient for a first useful signal (passive CSI against the
|
||||
home AP); active and board-to-board modes are additive later without a
|
||||
wire-format break (`csi_mode` already reserves the values).
|
||||
- Reuses the bounded UDP parser and status-route pattern without changing
|
||||
existing ESP32 sensing. Unsigned RAC1 diagnostics are not authorized as
|
||||
calibrated presence, person count, pose, or vital evidence.
|
||||
|
||||
### Negative
|
||||
|
||||
- 1x1 hardware means no spatial (multi-antenna) diversity from a single
|
||||
board; multi-board deployment is required for the differential/localization
|
||||
use cases described in the original RTL8721Dx capability analysis.
|
||||
- The 71-minute microsecond timestamp wraparound (inherited from the
|
||||
firmware's `u32` field) means long unattended captures need host-side
|
||||
sequence-based reordering, not raw timestamp arithmetic, across a wrap.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The RAC1 envelope intentionally does not carry the SDK's raw
|
||||
`csi_signature` (0xABCD) marker — our own `magic`/CRC supersede it as the
|
||||
framing integrity check.
|
||||
|
||||
## Hardware gates
|
||||
|
||||
- [x] Confirm SDK source exposes `RTW_EVENT_CSI_DONE` / `rtw_event_csi_report_info` (done — `component/wifi/api/wifi_api_event.h`).
|
||||
- [x] Build and flash a baseline image on real PKM8721DAF-C13-F10 hardware, confirm boot over serial (done — `feat/rtl8721dx-csi-bringup`).
|
||||
- [x] Flash the CSI-streaming firmware and confirm `RAC1` datagrams parse on the host from the real board (done, 2026-09-15 — see below).
|
||||
- [x] Record MEASURED throughput/latency/jitter from the real running firmware (done, 2026-09-15 — see below).
|
||||
- [ ] A human walk-test across the link (amplitude perturbation vs. static baseline) is deferred — requires the operator physically present; not claimed until performed.
|
||||
|
||||
### MEASURED: real RAC1 capture and sustained throughput (2026-09-15)
|
||||
|
||||
Passive CSI (`RTW_CSI_MODE_NORMAL`, `RTW_CSI_TRIG_BEACON | RTW_CSI_TRIG_DATA | RTW_CSI_TRIG_QOS_DATA`)
|
||||
against the deployment AP. A single `RAC1` datagram was first
|
||||
cross-checked field-by-field against the firmware's own serial log of the
|
||||
same report — every byte matched: magic `0x31434152`, RSSI byte exact match,
|
||||
channel/bandwidth/protocol/subcarrier-count exact match, 157-byte total frame
|
||||
(49-byte header + 104-byte payload + 4-byte CRC, exactly as specified above).
|
||||
|
||||
`RTW_CSI_MODE_RX_RESP` (estimate CSI from ACKs to the STA's own transmissions)
|
||||
never fired `RTW_EVENT_CSI_DONE` across two full test runs with confirmed
|
||||
real traffic (not a hang) — this mode does not work for this hardware/AP
|
||||
combination and `RTW_CSI_MODE_NORMAL` with beacon+data triggers is the
|
||||
working configuration.
|
||||
|
||||
**An earlier note here claimed `NORMAL` mode fires only once per CSI enable
|
||||
— that was wrong**, caused by a debug log line gated to print only every
|
||||
50th frame (`frame_count % 50 == 1`), which was misread as "only one report
|
||||
total." A follow-up capture with a UDP listener running concurrently with a
|
||||
board reset measured **277 real RAC1 datagrams in 40 seconds (43,489 bytes)**,
|
||||
with `csi_sequence` climbing continuously and monotonically in the firmware's
|
||||
own serial log across six separate 50-frame log checkpoints in one 45s
|
||||
session — CSI reporting is continuous, roughly 7-12 fps, not sparse.
|
||||
|
||||
### MEASURED: throughput optimization pass (2026-09-15)
|
||||
|
||||
Formalized as a repeatable tool: `scripts/benchmark-rtl8721dx-csi.py` (listens
|
||||
on the sensing-server's CSI UDP port, reports fps/throughput/RSSI/inter-frame
|
||||
interval/sequence-gap stats from real received `RAC1` datagrams).
|
||||
|
||||
**Baseline** (`trig_period=200` [64ms], `trig_frame_ctrl=0`,
|
||||
`CSI_REPORT_BUF_NUM=1`), 30s capture:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| RAC1 fps | 14.67 |
|
||||
| Throughput | 2,302.7 bytes/s |
|
||||
| Mean inter-frame interval | 68.3 ms |
|
||||
| RSSI | mean -34.4 dBm |
|
||||
|
||||
`trig_period` (units of 320µs) was hypothesized as the rate-limiting
|
||||
throttle — the observed 68.3ms mean interval tracks the 64ms configured
|
||||
period almost exactly. Confirmed by tuning:
|
||||
|
||||
1. `trig_period`: 200 → 15 (the SDK doc's stated minimum recommended value,
|
||||
~4.8ms)
|
||||
2. `trig_frame_ctrl`: 0 → `RTW_CSI_TRIG_ACK` (second trigger source: every
|
||||
link-layer ACK the AP sends for the heartbeat's own outgoing traffic, on
|
||||
top of the existing beacon trigger)
|
||||
3. `CSI_REPORT_BUF_NUM`: 1 → 4 (a "lack of csi buf!" drop was observed even
|
||||
at the slower baseline rate; more headroom needed at a higher rate)
|
||||
|
||||
**Tuned**, 30s capture:
|
||||
|
||||
| Metric | Value | vs. baseline |
|
||||
|---|---|---|
|
||||
| RAC1 fps | 66.40 | **4.5x** |
|
||||
| Throughput | 10,424.8 bytes/s | **4.5x** |
|
||||
| Mean inter-frame interval | 14.9 ms | 4.6x lower |
|
||||
| Sequence gaps | 78 / ~2036 (~3.8%) | new (0 observed at the slower baseline rate) |
|
||||
| RSSI | mean -31.1 dBm | (different session/position, not a controlled variable here) |
|
||||
|
||||
The `trig_period` hypothesis is confirmed as the primary lever. The ~3.8%
|
||||
frame-loss rate at 66 fps is a reasonable place to stop for now — it is
|
||||
UDP-over-WiFi loss, not a firmware defect, and is small enough not to block
|
||||
downstream sensing use; revisit only if a use case needs stronger delivery
|
||||
guarantees (sequence-gap-triggered re-request, TCP fallback, etc.).
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-266](ADR-266-mediatek-filogic-csi-platform.md) / [ADR-267](ADR-267-mediatek-mimo-csi-wire-protocol.md) — sibling vendor CSI ADR pair (simulator-first precedent)
|
||||
- [ADR-268](ADR-268-qualcomm-atheros-csi-platform.md) / [ADR-269](ADR-269-qualcomm-csi-wire-protocol.md) — sibling vendor CSI ADR pair
|
||||
- [ADR-263](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md) / [ADR-264](ADR-264-rtl8720f-radar-wire-protocol.md) — existing Realtek (RTL8720F radar) path; `SourceKind::Realtek` owner
|
||||
- `CLAUDE.local.md` — RTL8721Dx toolchain setup, board/COM port, first bring-up log
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-361: Point the whole fleet at one transmitter, to remove selection ambiguity
|
||||
|
||||
**Status:** Proposed — built, deployed fleet-wide, A-B-A measured. **Hypothesis CONFIRMED, adoption NOT justified.** The pairing *fraction* rises sharply; paired observations per second fall.
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** ADR-358 (falsified), ADR-359 (mechanism confirmed, not adopted), ADR-360 (falsified).
|
||||
|
||||
## The hypothesis, recorded long before it could be tested
|
||||
|
||||
From `docs/BACKLOG.md`, "Next: the actual pairing constraint":
|
||||
|
||||
> Two nodes can therefore fire in the same aligned window and still pick frames
|
||||
> from *different* transmitters, which can never pair under the
|
||||
> `(tx_mac, rx_seq)` key. Cheapest test and likely fix: **point the whole fleet
|
||||
> at one strong transmitter with the existing ADR-060 MAC filter.**
|
||||
|
||||
It had never been run, and the reason was mechanical rather than scientific:
|
||||
**`filter_mac` was writable only by `provision.py` over USB.** It is a 6-byte
|
||||
blob, absent from the `cfg_key_t` scalar table the config API validates
|
||||
against, so the test would have required nine physical visits. Exposing it over
|
||||
the network (see the firmware change accompanying this ADR) reduced the whole
|
||||
experiment to two config pushes.
|
||||
|
||||
## What was measured
|
||||
|
||||
The associated AP was chosen on measurement, not assumption. Five transmitters
|
||||
reach all nine receivers; only one carries a usable rate:
|
||||
|
||||
```
|
||||
transmitter n_rx total_fps mean_rssi
|
||||
8c:30:66:86:a4:21 9 109.81 -66.6 <- the AP, 26x the next
|
||||
96:30:66:86:a4:21 9 4.24 -65.9
|
||||
08:36:c9:71:ff:52 9 0.18 -75.9
|
||||
64:16:66:cb:17:07 9 0.08 -81.7
|
||||
10:a5:62:24:bb:30 9 0.06 -78.2
|
||||
```
|
||||
|
||||
A-B-A, ten 30 s windows per arm. `/api/v1/fusion` counters are cumulative since
|
||||
sink start, so they are **differenced over each window** and never divided as
|
||||
lifetime totals — the trap already recorded for `early_drop`/`csi_fps_samples`.
|
||||
The filter applied live on all nine nodes with **no restart**, so no reboot
|
||||
confounds any arm.
|
||||
|
||||
```
|
||||
arm n paired_fraction tx/s paired/s
|
||||
A1 control 10 0.5668 +/- 0.0107 73.0 41.4
|
||||
B filtered 10 0.7287 +/- 0.0352 33.9 24.7
|
||||
A2 control 10 0.5836 +/- 0.0120 81.5 47.6
|
||||
```
|
||||
|
||||
**Pairing fraction +0.1535 against the pooled control (+26.7% relative),
|
||||
Welch t = 13.26, df = 10.5, p < 0.0001.** Reversible: A2 returned to control,
|
||||
+0.0168 against A1.
|
||||
|
||||
Per-node confirmation that the filter did what it claims, taken on node 3 while
|
||||
eight nodes were still unfiltered:
|
||||
|
||||
```
|
||||
node 3 (filtered) AP fps 13.56 non-AP fps 0.28 non-AP share 2.0%
|
||||
node 8 (control) AP fps 12.18 non-AP fps 5.66 non-AP share 31.7%
|
||||
```
|
||||
|
||||
The AP rate is untouched; peer traffic falls to residue.
|
||||
|
||||
## The hypothesis is confirmed. The fix is not.
|
||||
|
||||
Transmitter ambiguity **was** suppressing pairing, and removing it lifts the
|
||||
fraction by a quarter. That settles a question that had been open since
|
||||
2026-09-07 and had resisted three other experiments.
|
||||
|
||||
But the backlog called this the "likely fix", and that half does not survive.
|
||||
It predicted yield would fall to ~13 fps and that "the frames that remain are
|
||||
pairable". Both are true, and neither is sufficient:
|
||||
|
||||
- **tx/s fell 56%** (77.3 -> 33.9)
|
||||
- **paired observations per second fell 44%** (44.4 -> 24.7)
|
||||
|
||||
A higher fraction of a much smaller pie. The quantity downstream sensing
|
||||
actually consumes is paired observations per second, and that went **down**.
|
||||
Fraction was the wrong figure of merit, and choosing it is what made this look
|
||||
like a fix rather than a diagnosis.
|
||||
|
||||
## Decision
|
||||
|
||||
**Do not adopt. Keep the branch unmerged.** Production stays unfiltered,
|
||||
`gate_mode=2`, `gate_seq_period=8`.
|
||||
|
||||
`filter_mac` **is** kept in the config API. It costs nothing when unset, it is
|
||||
now the cheapest available probe for "is this node's pairing transmitter-
|
||||
limited?", and the ADR-060 filter had been effectively unreachable for the
|
||||
fleet's whole life.
|
||||
|
||||
## What this opens
|
||||
|
||||
A **partial** filter is the obvious next question and is NOT what was tested
|
||||
here. Restricting to a handful of widely-heard transmitters rather than exactly
|
||||
one would trade less yield for some of the gain, and there is no reason to think
|
||||
the optimum is at either extreme. The five 9-receiver transmitters above are the
|
||||
natural candidate set, though four of them carry almost no rate.
|
||||
|
||||
That is a new experiment with a different independent variable, not a variant
|
||||
of this one. It should be sized on **paired observations per second**, which is
|
||||
the lesson this ADR paid for.
|
||||
|
||||
## Honest scope
|
||||
|
||||
Software-only. One house, one AP, nine nodes, ~15 minutes per arm, at 05:00
|
||||
local with the house quiet. The effect is far larger than the within-arm
|
||||
scatter and it reversed cleanly, but it has not been repeated across times of
|
||||
day, and pairing is known to move with the transmitter population (see
|
||||
"Transmitter population instability", 2026-09-02).
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR-362: Reading the on-node log is on-demand, never streamed
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** the on-node log engine (persistent 128 B-record ring + coredump-to-flash)
|
||||
|
||||
## Context
|
||||
|
||||
The firmware now keeps a persistent log on the node's `storage` volume and can
|
||||
write a coredump to flash. Nothing reads either one. The log is a new concept
|
||||
in this tree, so there is no existing consumer to extend and the access
|
||||
pattern is genuinely open.
|
||||
|
||||
Two shapes were considered:
|
||||
|
||||
1. **Ingest** — the server polls every node on a schedule, drains new records,
|
||||
and keeps its own copy that the UI reads.
|
||||
2. **On-demand** — the server fetches a node's log only when a human asks for
|
||||
it, and keeps nothing.
|
||||
|
||||
## Decision
|
||||
|
||||
**On-demand only. No streaming, no periodic polling, no server-side copy, no
|
||||
alerting.**
|
||||
|
||||
The log is pulled after a board hangs or crashes, or while troubleshooting a
|
||||
specific node. That is the whole access pattern.
|
||||
|
||||
## Why
|
||||
|
||||
- **The persistence already lives on the node.** The ring holds ~208 days at
|
||||
128 B per 300 s, and surviving a power cycle is the entire reason the module
|
||||
exists — a power cycle is how a wedged node gets recovered. A second copy on
|
||||
the server duplicates a durability property that is already satisfied.
|
||||
- **Airtime is this fleet's binding constraint, and it is the thing being
|
||||
diagnosed.** Two nodes uplinking at 1 Mbps were measured consuming 91% of
|
||||
fleet airtime. A periodic log drain across nine nodes would spend exactly the
|
||||
resource whose exhaustion is the most common thing you would open the log to
|
||||
investigate. An instrument must not perturb what it measures.
|
||||
- **Coredumps are 64 KB.** They are only ever worth moving when something has
|
||||
actually crashed.
|
||||
- **The use is diagnostic, not observational.** Nobody watches nine logs. You
|
||||
open one node's history when that node misbehaves.
|
||||
|
||||
## What this implies gets built
|
||||
|
||||
- A server route that proxies to the node on request — `GET
|
||||
/api/v1/nodes/{id}/log`, and the same for `/coredump` — authenticating with
|
||||
the OTA PSK the firmware endpoints already sit behind.
|
||||
- A page under the existing Tools menu to view one node's records.
|
||||
- **No** scheduler, **no** new storage, **no** retention policy on the server.
|
||||
|
||||
## Open question, deliberately not settled here
|
||||
|
||||
Whether the server decodes the 128 B records into JSON, or proxies raw bytes
|
||||
and lets the decoder at the edge (browser, or `node_log_read.py`) interpret
|
||||
them.
|
||||
|
||||
Decoding server-side means the record layout is defined in two places — C and
|
||||
Rust — and can drift silently. The pilot already paid for a version of this:
|
||||
a field whose sentinel the host had to learn to render as `n/a`, where a
|
||||
plausible wrong value would have been worse than an obvious gap. That argues
|
||||
for the raw proxy and a single definition of the format.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Real-time monitoring, log streaming, fleet-wide log aggregation, and alerting
|
||||
on log contents are all explicitly out of scope. If a future need genuinely
|
||||
requires one of them, it should revisit this ADR rather than grow into it.
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR 363: Native macOS USB node onboarding
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and physically qualified with one attached ESP32 S3 and one attached
|
||||
ESP32 C6 through an unsigned Mac Catalyst development build. Signed
|
||||
distribution remains a release gate.
|
||||
|
||||
## Context
|
||||
|
||||
RuView operators currently provision ESP32 nodes with command line tooling.
|
||||
The macOS app can list server accepted node identities, but it cannot establish
|
||||
that an attached serial device is RuView firmware, assign a collision free node
|
||||
identity, preserve its WiFi configuration, or prove that it joined the sensing
|
||||
server. A UI that merely stores a friendly name would create false success.
|
||||
|
||||
Generic shell access from the app would also expand authority beyond the task.
|
||||
Serial logs can contain operational data, and WiFi credentials must not enter
|
||||
application diagnostics or provisioning receipts.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Firmware 0.8.12 provides a bounded line protocol over the physical USB serial
|
||||
channel. A discovery request carries a random 128 bit nonce. The device
|
||||
response binds that nonce to chip family, firmware version, current node
|
||||
identity, private server target, configuration state, and a pseudonymous
|
||||
64 bit device digest derived from the base MAC under a RuView domain.
|
||||
|
||||
2. A configuration request is accepted only for the most recently issued
|
||||
nonce within 60 seconds. The nonce is single use. Node identity is limited
|
||||
to 1 through 255, the target is limited to RFC 1918 IPv4, and the target port
|
||||
is limited to 1 through 65535.
|
||||
|
||||
3. Existing WiFi configuration can be preserved. New credentials use strict
|
||||
base64 framing, bounded lengths, and control character rejection. Firmware
|
||||
commits NVS before acknowledging and rebooting. Credentials are never
|
||||
returned, logged, or included in the receipt.
|
||||
|
||||
4. The macOS client uses a native serial bridge. It allowlists exact character
|
||||
device prefixes, configures the port directly, and does not invoke a shell,
|
||||
Python, or an external process. It rechecks the device digest immediately
|
||||
before configuration to reduce port substitution risk.
|
||||
|
||||
5. The app suggests the lowest unused server node identity and the Mac private
|
||||
LAN address. Success is shown only after the configured node appears in the
|
||||
server inventory as active with evidence no older than three seconds.
|
||||
|
||||
6. Old firmware is reported as requiring an update. The app must not claim it
|
||||
provisioned a device based only on generic RuView serial logs.
|
||||
|
||||
7. Automatic firmware flashing is outside this decision. Flashing requires an
|
||||
exact chip and port check, a private backup, a matching image, and captured
|
||||
boot evidence. Release publication remains a separate maintainer action.
|
||||
|
||||
8. The device digest uses the PSA Crypto SHA 256 interface on ESP IDF 5.4 and
|
||||
6.0. This keeps the stable domain separated identity while avoiding the
|
||||
legacy Mbed TLS hash API removed by ESP IDF 6.
|
||||
|
||||
9. ESP32 C6 mmWave UART defaults use GPIO 4 and GPIO 5. Startup rejects any
|
||||
configured mmWave pin pair that overlaps the active console RX or TX pins.
|
||||
This preserves the onboarding input path after a failed mmWave probe.
|
||||
|
||||
## Security and privacy consequences
|
||||
|
||||
The app gains Mac App Sandbox USB and serial device access, but not general file
|
||||
or shell authority. The protocol does not authenticate a device with a hardware
|
||||
root of trust; the digest is stable identification, not a cryptographic device
|
||||
certificate. Physical USB possession is therefore the trust boundary for this
|
||||
version. An authenticated fleet identity remains ADR 305 scope.
|
||||
|
||||
The largest failure mode is a successful NVS write followed by failure to join
|
||||
the LAN. The user receives an explicit bounded verification failure instead of
|
||||
a success screen, while the board retains its committed settings and can be
|
||||
rescanned over USB.
|
||||
|
||||
## Acceptance test
|
||||
|
||||
Connect an ESP32 with firmware 0.8.12 to a Mac that is already receiving RuView
|
||||
nodes. Use only the macOS Add Sensor workflow to preserve WiFi, assign the
|
||||
lowest unused identity, and set the Mac private address. Pass when the UI
|
||||
reports the expected chip and firmware, the reassigned node remains fresh for
|
||||
five minutes, the stale collided identity expires, app restart preserves the
|
||||
profile, no server health, send, watchdog, panic, or reboot evidence appears,
|
||||
and no credential appears in app logs or the provisioning receipt.
|
||||
|
||||
The 2026-08-31 physical run passed on an ESP32 S3. The measured witness is in
|
||||
`docs/validation/2026-08-31-macos-usb-node-onboarding.md`.
|
||||
|
||||
The 2026-09-11 physical run passed on an ESP32 C6 over
|
||||
`cu.usbserial-3120`. The Mac app flashed the image, received the nonce bound
|
||||
hello receipt, preserved node identity 3, committed the server route, and
|
||||
verified fresh server evidence at minus 34 dBm. The run also exposed and fixed
|
||||
the GPIO 17 console RX conflict described above.
|
||||
@@ -115,6 +115,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) |
|
||||
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) |
|
||||
| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) |
|
||||
| [ADR-363](ADR-363-native-macos-usb-node-onboarding.md) | Native macOS USB node onboarding with nonce bound discovery and fresh server verification | Accepted and physically qualified on one ESP32 S3; C6 and signed distribution pending |
|
||||
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
|
||||
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
|
||||
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
|
||||
|
||||
@@ -81,6 +81,42 @@ v1, with its flat loss, was barely better than random on this same test.
|
||||
params, trained with InfoNCE (temperature 0.1, in-batch + temporal-far negatives), AdamW, 60 epochs.
|
||||
Temporal positives within 2 s; negatives >30 s apart. Time-disjoint 80/20 split.
|
||||
|
||||
**EWC (catastrophic-forgetting protection):** measured forgetting rate on the 4 protected
|
||||
tasks is **32.97%** — real and non-trivial, disclosed here rather than only in the raw
|
||||
`training-metrics.json`. Fisher information is computed and applied (protection strength
|
||||
2000), but this is not "solved forgetting" — a user fine-tuning per-room LoRA adapters
|
||||
should expect some drift on earlier tasks, not none.
|
||||
|
||||
## 🔧 v2.0.1 — fixed the published training-metrics.json itself (2026-09-09)
|
||||
|
||||
Separately from the v1→v2 accuracy retraction above, an unrelated bug was found and fixed
|
||||
in the **export code** (`scripts/train-ruvllm.js`) that produces `config.json` and
|
||||
`training-metrics.json` for this file set (`model.safetensors`, `model-q2/q4/q8.bin`,
|
||||
`presence-head.json`, `node-1/2.json`) — separate from the `csi-embed-v2*` files, which
|
||||
come from a different script.
|
||||
|
||||
The exported `lossHistory` and `contrastive.durationMs` were being read from a disconnected
|
||||
baseline pass (`ContrastiveTrainer.train()`, which computes loss but never updates the
|
||||
encoder) instead of from the loop that actually performs gradient updates on the encoder
|
||||
weights. Concretely: `lossHistory` published a flat, non-decreasing curve around `0.1352`
|
||||
for all 20 epochs, and `durationMs` reported ~12.4s — both measuring the wrong computation.
|
||||
|
||||
**This did not affect `finalLoss`/`improvement`/the 82.3% held-out temporal-triplet
|
||||
accuracy above** — those were already computed from the real training loop and are
|
||||
unchanged by this fix. It only affected the exported diagnostic curve and duration, which
|
||||
anyone auditing convergence from the published JSON would have been misled by. Re-run
|
||||
against the same real capture (`data/recordings/overnight-1775217646.csi.jsonl`) with the
|
||||
fix applied: `lossHistory` now shows a genuine decreasing curve (0.1037 -> 0.0654 across 20
|
||||
epochs) and `durationMs` reports the real ~40s gradient-loop time. `config.json`'s
|
||||
`training.steps` field (`12212300` — epochs × triplets, arithmetically consistent but read
|
||||
like optimizer-step count) is now reported as separate `epochs`/`tripletUpdatesPerEpoch`
|
||||
fields instead.
|
||||
|
||||
Repro: `node scripts/train-ruvllm.js --data data/recordings/overnight-1775217646.csi.jsonl
|
||||
--output <dir> --epochs 20` in [github.com/ruvnet/RuView](https://github.com/ruvnet/RuView)
|
||||
(the `aether-arena/staging/train_csi_embed.py` path cited above is for the separate
|
||||
`csi-embed-v2*` files and was not touched by this fix).
|
||||
|
||||
### v2 files & proof
|
||||
| File | Size | Use |
|
||||
|------|------|-----|
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
ARM_A1_control,30,1242,2162,0.5745,72.07
|
||||
ARM_A1_control,30,1199,2114,0.5672,70.47
|
||||
ARM_A1_control,30,1259,2223,0.5664,74.10
|
||||
ARM_A1_control,30,1221,2206,0.5535,73.53
|
||||
ARM_A1_control,30,1246,2182,0.5710,72.73
|
||||
ARM_A1_control,30,1295,2247,0.5763,74.90
|
||||
ARM_A1_control,30,1307,2242,0.5830,74.73
|
||||
ARM_A1_control,30,1232,2191,0.5623,73.03
|
||||
ARM_A1_control,30,1261,2225,0.5667,74.17
|
||||
ARM_A1_control,30,1152,2107,0.5467,70.23
|
||||
|
@@ -0,0 +1,10 @@
|
||||
ARM_A2_control,30,1352,2326,0.5813,77.53
|
||||
ARM_A2_control,30,1454,2429,0.5986,80.97
|
||||
ARM_A2_control,30,1474,2479,0.5946,82.63
|
||||
ARM_A2_control,30,1412,2456,0.5749,81.87
|
||||
ARM_A2_control,30,1387,2443,0.5677,81.43
|
||||
ARM_A2_control,30,1389,2384,0.5826,79.47
|
||||
ARM_A2_control,30,1399,2482,0.5637,82.73
|
||||
ARM_A2_control,30,1410,2375,0.5937,79.17
|
||||
ARM_A2_control,30,1509,2537,0.5948,84.57
|
||||
ARM_A2_control,30,1483,2541,0.5836,84.70
|
||||
|
@@ -0,0 +1,10 @@
|
||||
ARM_B_filtered,30,734,1062,0.6911,35.40
|
||||
ARM_B_filtered,30,700,982,0.7128,32.73
|
||||
ARM_B_filtered,30,734,1002,0.7325,33.40
|
||||
ARM_B_filtered,30,706,927,0.7616,30.90
|
||||
ARM_B_filtered,30,730,955,0.7644,31.83
|
||||
ARM_B_filtered,30,712,915,0.7781,30.50
|
||||
ARM_B_filtered,30,734,971,0.7559,32.37
|
||||
ARM_B_filtered,30,742,1103,0.6727,36.77
|
||||
ARM_B_filtered,30,812,1139,0.7129,37.97
|
||||
ARM_B_filtered,30,785,1114,0.7047,37.13
|
||||
|
@@ -0,0 +1,60 @@
|
||||
# Native macOS USB onboarding firmware witness
|
||||
|
||||
## Evidence class
|
||||
|
||||
MEASURED on 2026-08-31 with one physical ESP32 S3. This is not an ESP32 C6 or
|
||||
release distribution qualification.
|
||||
|
||||
## Hardware and firmware
|
||||
|
||||
The board was identified by the flashing tool as ESP32 S3 QFN56 revision 0.2
|
||||
with 2 MB embedded PSRAM and 8 MB flash. Firmware 0.8.9 was built for ESP32 S3
|
||||
with the displayless DevKitC defaults. The private configuration region was
|
||||
backed up before flashing. The flash operation omitted NVS, preserving the
|
||||
existing WiFi configuration.
|
||||
|
||||
No base MAC, WiFi credential, device digest, or raw CSI is retained in this
|
||||
witness.
|
||||
|
||||
## Results
|
||||
|
||||
1. The initial native USB scan failed because the ESP-IDF secondary USB
|
||||
Serial/JTAG console mirrors output but does not forward input to STDIN.
|
||||
2. Firmware was corrected to read the bounded onboarding protocol from the
|
||||
secondary VFS while retaining STDIN for UART console boards.
|
||||
3. A direct nonce challenge then returned ESP32 S3, firmware 0.8.9, configured
|
||||
state, the current node identity, and the private server route.
|
||||
4. The Mac app changed the collided identity from 1 to unused identity 5 while
|
||||
preserving WiFi. It showed success only after receiving fresh server
|
||||
evidence at minus 56 dBm.
|
||||
5. A five minute monitor collected 299 samples with zero node 5 missing events,
|
||||
zero node 5 stale events, zero server health failures, minus 57 to minus 50
|
||||
dBm RSSI, and 216 ms maximum packet age.
|
||||
6. Firmware uptime exceeded 376 seconds. Captured logs advanced from CSI
|
||||
callback 10,600 through 11,000, emitted a node 5 synchronization packet, and
|
||||
showed ESP NOW transmit failures equal to zero without reboot, watchdog, or
|
||||
panic evidence.
|
||||
7. The final channel isolation hardening image was flashed and challenged
|
||||
again. A following 60 sample check had zero missing, stale, or health failure
|
||||
events, a maximum packet age of 100 ms, and minus 57 to minus 50 dBm RSSI.
|
||||
|
||||
## Software gates
|
||||
|
||||
The nonce and configuration parser host test passed, the complete ESP32 S3
|
||||
firmware image built under ESP-IDF 5.4, and the application image occupied
|
||||
941,056 bytes with 55 percent of the smallest application partition free.
|
||||
|
||||
## Remaining gates
|
||||
|
||||
New WiFi credential entry, signed Mac distribution, and cross platform serial
|
||||
bridges remain unmeasured.
|
||||
|
||||
## ESP32 C6 follow up
|
||||
|
||||
On 2026-09-11, an ESP32 C6 on `cu.usbserial-3120` completed the Mac app flash,
|
||||
hello, configure, and verify sequence with firmware 0.8.12. The app preserved
|
||||
the existing node identity 3 and verified fresh sensing server evidence at
|
||||
minus 34 dBm. The board returned `RUVIEW_HELLO_OK_V1` within the bounded serial
|
||||
exchange after the C6 mmWave defaults moved from the UART0 RX conflict on GPIO
|
||||
17 to GPIO 4 and GPIO 5. This closes only the physical C6 onboarding gate. It
|
||||
does not qualify new WiFi credential entry or signed distribution.
|
||||
@@ -19,6 +19,25 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr
|
||||
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
|
||||
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
|
||||
|
||||
## Firmware 0.8.12: guided Mac onboarding
|
||||
|
||||
Firmware 0.8.12 adds the protocol used by the RuView macOS Add
|
||||
Sensor workflow. A directly attached S3 or C6 can identify itself through a
|
||||
nonce bound USB serial receipt, preserve its current WiFi settings, accept an
|
||||
unused node ID and private LAN server address, then reboot. The Mac app does
|
||||
not report success until the sensing server observes that node as fresh.
|
||||
|
||||
This is not a firmware flashing API. A board on 0.8.8 or earlier must receive a
|
||||
matching chip build through the documented backup and flash process once. The
|
||||
USB protocol rejects public target addresses and invalid identifiers. New WiFi
|
||||
credentials are bounded, committed only in NVS, and never included in the
|
||||
receipt or diagnostic output. See
|
||||
[ADR 363](../../docs/adr/ADR-363-native-macos-usb-node-onboarding.md).
|
||||
|
||||
On ESP32 C6, the default optional mmWave UART uses GPIO 4 and GPIO 5. Firmware
|
||||
refuses any mmWave configuration that overlaps the active console pins. This
|
||||
prevents a sensor probe from taking over UART0 RX and disabling Mac onboarding.
|
||||
|
||||
## Firmware 0.8.8 in plain language
|
||||
|
||||
Release 0.8.8 makes the sensing stream more internally consistent and easier
|
||||
@@ -343,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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
set(SRCS
|
||||
"main.c" "csi_collector.c"
|
||||
"main.c" "csi_collector.c" "node_log.c"
|
||||
"thermal.c" "stream_sender.c" "nvs_config.c"
|
||||
"serial_onboarding.c" "serial_onboarding_protocol.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
@@ -32,6 +33,9 @@ set(REQUIRES
|
||||
esp_http_client
|
||||
esp_app_format
|
||||
esp_timer
|
||||
fatfs # node_log.c: the storage FAT volume
|
||||
wear_levelling # node_log.c: wl_mount behind that volume
|
||||
espcoredump # coredump-to-flash readback
|
||||
esp_pm
|
||||
esp_driver_uart
|
||||
esp_driver_gpio
|
||||
@@ -40,6 +44,8 @@ set(REQUIRES
|
||||
driver
|
||||
lwip
|
||||
mbedtls
|
||||
efuse
|
||||
led_strip
|
||||
)
|
||||
|
||||
# ADR-110: C6-only components — pulled in when building for esp32c6.
|
||||
|
||||
@@ -750,6 +750,20 @@ uint16_t csi_collector_get_send_fail_count(void)
|
||||
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
|
||||
}
|
||||
|
||||
/* The gate here is fixed at compile time (mesh-aligned bucketing, falling
|
||||
* back to elapsed time when unsynced), so there is no mode or period to
|
||||
* report. Return the explicit sentinel rather than a number that reads as
|
||||
* a real setting. */
|
||||
uint8_t csi_collector_get_gate_mode(void)
|
||||
{
|
||||
return CSI_GATE_NOT_CONFIGURABLE;
|
||||
}
|
||||
|
||||
uint8_t csi_collector_get_gate_seq_period(void)
|
||||
{
|
||||
return CSI_GATE_NOT_CONFIGURABLE;
|
||||
}
|
||||
|
||||
/* ---- ADR-029: Channel hopping ---- */
|
||||
|
||||
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
|
||||
|
||||
@@ -139,4 +139,22 @@ uint16_t csi_collector_get_pkt_yield_per_sec(void);
|
||||
*/
|
||||
uint16_t csi_collector_get_send_fail_count(void);
|
||||
|
||||
/** Reported by the gate accessors when this build has no selectable gate. */
|
||||
#define CSI_GATE_NOT_CONFIGURABLE 0xFF
|
||||
|
||||
/**
|
||||
* Report which frame-selection gate is in force, for the on-node log.
|
||||
*
|
||||
* This build has exactly one gate -- mesh-aligned bucketing with an
|
||||
* elapsed-time fallback -- fixed at compile time, so there is no mode or
|
||||
* period to report and both accessors return CSI_GATE_NOT_CONFIGURABLE.
|
||||
*
|
||||
* They exist so the log record keeps a field of fixed width and meaning
|
||||
* whether or not a build can select a gate at runtime, and so an explicit
|
||||
* "not applicable" is stored rather than a plausible number that would
|
||||
* later be reasoned from.
|
||||
*/
|
||||
uint8_t csi_collector_get_gate_mode(void);
|
||||
uint8_t csi_collector_get_gate_seq_period(void);
|
||||
|
||||
#endif /* CSI_COLLECTOR_H */
|
||||
@@ -21,9 +21,11 @@
|
||||
#include "led_strip.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "node_log.h"
|
||||
#include "thermal.h"
|
||||
#include "stream_sender.h"
|
||||
#include "nvs_config.h"
|
||||
#include "serial_onboarding.h"
|
||||
#include "edge_processing.h"
|
||||
#include "ota_update.h"
|
||||
#include "power_mgmt.h"
|
||||
@@ -116,6 +118,10 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
|
||||
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
|
||||
/* The 7.5 h outage was root-caused to the MAX_RETRY latch only because
|
||||
* someone was watching the console at the time. Persist it. */
|
||||
node_log_note_disconnect((uint8_t)disc->reason, (int8_t)disc->rssi);
|
||||
node_log_event(NODE_LOG_EV_WIFI_DISCONNECT, disc->reason, disc->rssi);
|
||||
s_retry_num++;
|
||||
/* Release the boot wait once, so a node that comes up while the AP is
|
||||
* down still starts CSI capture and the mesh instead of blocking in
|
||||
@@ -336,6 +342,14 @@ void app_main(void)
|
||||
* ownership of GPIO3 and GPIO14 because the default implementation is a
|
||||
* no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */
|
||||
ESP_ERROR_CHECK(c6_xiao_antenna_apply());
|
||||
/* The native Mac onboarding bridge must remain available even when WiFi
|
||||
* credentials or the aggregator address are wrong. Start it before any
|
||||
* blocking network initialization. The protocol is physical-USB-only,
|
||||
* nonce-bound, bounded, and never prints credentials. */
|
||||
esp_err_t onboarding_ret = serial_onboarding_start(&g_nvs_config);
|
||||
if (onboarding_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "USB onboarding unavailable: %s", esp_err_to_name(onboarding_ret));
|
||||
}
|
||||
|
||||
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
|
||||
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 — see #962). On S3 we drive 48 (the
|
||||
@@ -451,6 +465,24 @@ void app_main(void)
|
||||
} else {
|
||||
ESP_LOGI(TAG, "reset reason: %s (%d)", why, (int)rr);
|
||||
}
|
||||
|
||||
/* And persist it. The console line above is exactly what a power cycle
|
||||
* destroys, which is the whole reason this fleet has no post-mortem for
|
||||
* a remote fault. node_log_init failing is non-fatal by design -- the
|
||||
* node comes up either way, it just has no memory. */
|
||||
if (node_log_init() == ESP_OK) {
|
||||
uint32_t prev_uptime_s = 0;
|
||||
nvs_handle_t nh;
|
||||
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
|
||||
nvs_get_u32(nh, "last_up_s", &prev_uptime_s);
|
||||
nvs_set_u32(nh, "last_up_s", 0);
|
||||
nvs_commit(nh);
|
||||
nvs_close(nh);
|
||||
}
|
||||
node_log_boot((uint32_t)rr, prev_uptime_s);
|
||||
ESP_LOGI(TAG, "on-node log active: boot_id=%u, %u records retained",
|
||||
(unsigned)node_log_boot_id(), (unsigned)node_log_count());
|
||||
}
|
||||
}
|
||||
|
||||
/* Bring thermal monitoring up before the radio is loaded, so the first
|
||||
@@ -655,5 +687,24 @@ void app_main(void)
|
||||
#ifdef CONFIG_UPLINK_WATCHDOG
|
||||
uplink_watchdog_tick();
|
||||
#endif
|
||||
/* Offered every 10 s; node_log enforces its own floor, so the cadence
|
||||
* lives in one place rather than being implied by this loop's delay.
|
||||
* Also refresh the uptime NVS cell, which is what lets the NEXT boot
|
||||
* record say how long the previous session actually survived -- the
|
||||
* difference between "it rebooted" and "it wedged after 225 s". */
|
||||
node_log_periodic();
|
||||
{
|
||||
static uint32_t s_last_persist_s;
|
||||
uint32_t up_s = (uint32_t)(esp_timer_get_time() / 1000000);
|
||||
if (up_s - s_last_persist_s >= 60) {
|
||||
s_last_persist_s = up_s;
|
||||
nvs_handle_t nh;
|
||||
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
|
||||
nvs_set_u32(nh, "last_up_s", up_s);
|
||||
nvs_commit(nh);
|
||||
nvs_close(nh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,9 +503,35 @@ esp_err_t mmwave_sensor_init(int uart_tx_pin, int uart_rx_pin)
|
||||
|
||||
return ESP_OK;
|
||||
|
||||
#else
|
||||
/* Default probe pins must never collide with the UART0 console. On the
|
||||
* ESP32-C6 the console is TX=GPIO16 / RX=GPIO17, so the legacy 17/18 pair
|
||||
* routed UART1 TX onto the console RX line and silently killed serial
|
||||
* input (USB onboarding, monitor commands) even when no sensor was found,
|
||||
* because uart_driver_delete() does not undo the GPIO matrix routing. */
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
if (uart_tx_pin < 0) uart_tx_pin = 4;
|
||||
if (uart_rx_pin < 0) uart_rx_pin = 5;
|
||||
#else
|
||||
if (uart_tx_pin < 0) uart_tx_pin = 17;
|
||||
if (uart_rx_pin < 0) uart_rx_pin = 18;
|
||||
#endif
|
||||
#if defined(CONFIG_ESP_CONSOLE_UART_CUSTOM)
|
||||
const int console_tx = CONFIG_ESP_CONSOLE_UART_TX_GPIO;
|
||||
const int console_rx = CONFIG_ESP_CONSOLE_UART_RX_GPIO;
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
const int console_tx = 16, console_rx = 17; /* U0TXD / U0RXD IOMUX pins */
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
|
||||
const int console_tx = 43, console_rx = 44;
|
||||
#else
|
||||
const int console_tx = 1, console_rx = 3;
|
||||
#endif
|
||||
if (uart_tx_pin == console_tx || uart_tx_pin == console_rx ||
|
||||
uart_rx_pin == console_tx || uart_rx_pin == console_rx) {
|
||||
ESP_LOGW(TAG, "mmWave probe pins (TX=%d, RX=%d) overlap the console UART (TX=%d, RX=%d); skipping probe",
|
||||
uart_tx_pin, uart_rx_pin, console_tx, console_rx);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
/* Install UART driver at MR60 baud (will be changed during probe). */
|
||||
uart_config_t uart_config = {
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -55,10 +55,11 @@ FUZZ_JOBS ?= 1
|
||||
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
|
||||
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
|
||||
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
|
||||
test_c6_antenna run_c6_antenna
|
||||
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding \
|
||||
run_delivery_contract
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal test_csi_sanitize test_c6_antenna
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
|
||||
# --- XIAO ESP32-C6 RF switch selection ---
|
||||
# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven
|
||||
@@ -122,9 +123,22 @@ test_thermal: test_thermal_hysteresis.c $(MAIN_DIR)/thermal.h
|
||||
run_thermal: test_thermal
|
||||
./test_thermal
|
||||
|
||||
# --- Native Mac USB onboarding protocol ---
|
||||
# Tests the production parser and secret validation without ESP-IDF. This pins
|
||||
# nonce shape, IPv4 and node bounds, base64 handling, and control-byte denial.
|
||||
test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.h
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic -I$(MAIN_DIR) -o $@ \
|
||||
test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onboarding_protocol.c
|
||||
|
||||
run_serial_onboarding: test_serial_onboarding
|
||||
./test_serial_onboarding
|
||||
|
||||
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
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna)"
|
||||
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.
|
||||
@@ -161,5 +175,5 @@ run_all: run_serialize run_edge run_nvs
|
||||
|
||||
clean:
|
||||
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal test_csi_sanitize test_c6_antenna
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.8.10
|
||||
0.8.12
|
||||
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RTL8721Dx (Realtek AmebaDplus) live CSI throughput/latency benchmark.
|
||||
|
||||
Listens on the wifi-densepose-sensing-server's CSI UDP port for real RAC1
|
||||
frames (ADR-323, docs/adr/ADR-323-rtl8721dx-ameba-csi-wire-protocol.md) sent
|
||||
by the ruview_csi_node firmware (branch feat/rtl8721dx-csi-bringup) running
|
||||
on real RTL8721Dx hardware, and reports MEASURED — not simulated — throughput
|
||||
and frame-rate statistics.
|
||||
|
||||
This does not itself trigger a board reset; run it while the firmware is
|
||||
already connected and streaming (see CLAUDE.local.md for the reset+capture
|
||||
pattern used to bring a fresh boot up during firmware iteration).
|
||||
|
||||
Usage:
|
||||
python scripts/benchmark-rtl8721dx-csi.py --duration 40
|
||||
python scripts/benchmark-rtl8721dx-csi.py --duration 40 --port 5005 --output results.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from pathlib import Path
|
||||
|
||||
RAC1_MAGIC = bytes.fromhex("52414331") # "RAC1" little-endian, see ADR-323
|
||||
RHB1_MAGIC = bytes.fromhex("52484231") # heartbeat marker (not a CSI frame)
|
||||
RAC1_HEADER_LEN = 49
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rac1Frame:
|
||||
recv_time: float
|
||||
length: int
|
||||
sequence: int
|
||||
timestamp_us: int
|
||||
channel: int
|
||||
bandwidth: int
|
||||
num_sub_carrier: int
|
||||
rssi_dbm: int
|
||||
csi_valid: int
|
||||
|
||||
|
||||
def parse_rac1_header(data: bytes, recv_time: float) -> Rac1Frame | None:
|
||||
if len(data) < RAC1_HEADER_LEN or data[:4] != RAC1_MAGIC:
|
||||
return None
|
||||
sequence = struct.unpack_from("<I", data, 13)[0]
|
||||
timestamp_us = struct.unpack_from("<I", data, 17)[0]
|
||||
channel = data[33]
|
||||
bandwidth = data[34]
|
||||
num_sub_carrier = struct.unpack_from("<H", data, 37)[0]
|
||||
rssi_dbm = struct.unpack_from("<b", data, 41)[0]
|
||||
csi_valid = data[43]
|
||||
return Rac1Frame(
|
||||
recv_time=recv_time,
|
||||
length=len(data),
|
||||
sequence=sequence,
|
||||
timestamp_us=timestamp_us,
|
||||
channel=channel,
|
||||
bandwidth=bandwidth,
|
||||
num_sub_carrier=num_sub_carrier,
|
||||
rssi_dbm=rssi_dbm,
|
||||
csi_valid=csi_valid,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
duration_s: float
|
||||
total_datagrams: int
|
||||
rac1_frames: int
|
||||
heartbeat_packets: int
|
||||
other_packets: int
|
||||
rac1_bytes: int
|
||||
rac1_fps: float
|
||||
rac1_bytes_per_sec: float
|
||||
sequence_gaps: int
|
||||
first_sequence: int | None
|
||||
last_sequence: int | None
|
||||
rssi_min_dbm: int | None
|
||||
rssi_max_dbm: int | None
|
||||
rssi_mean_dbm: float | None
|
||||
inter_frame_interval_ms_mean: float | None
|
||||
inter_frame_interval_ms_p95: float | None
|
||||
channels_seen: list[int] = field(default_factory=list)
|
||||
evidence: str = "MEASURED"
|
||||
|
||||
|
||||
def run_benchmark(port: int, duration: float, bind_ip: str = "0.0.0.0") -> BenchmarkResult:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind((bind_ip, port))
|
||||
sock.settimeout(0.5)
|
||||
|
||||
frames: list[Rac1Frame] = []
|
||||
total = 0
|
||||
heartbeats = 0
|
||||
other = 0
|
||||
|
||||
deadline = time.time() + duration
|
||||
print(f"Listening on {bind_ip}:{port} for {duration}s ...", file=sys.stderr)
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data, _addr = sock.recvfrom(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
recv_time = time.time()
|
||||
total += 1
|
||||
if data[:4] == RHB1_MAGIC:
|
||||
heartbeats += 1
|
||||
continue
|
||||
frame = parse_rac1_header(data, recv_time)
|
||||
if frame is not None:
|
||||
frames.append(frame)
|
||||
else:
|
||||
other += 1
|
||||
sock.close()
|
||||
|
||||
rac1_bytes = sum(f.length for f in frames)
|
||||
seqs = [f.sequence for f in frames]
|
||||
rssis = [f.rssi_dbm for f in frames]
|
||||
channels = sorted({f.channel for f in frames})
|
||||
|
||||
gaps = 0
|
||||
for a, b in zip(seqs, seqs[1:]):
|
||||
if b != a + 1:
|
||||
gaps += 1
|
||||
|
||||
intervals_ms = [
|
||||
(b.recv_time - a.recv_time) * 1000.0 for a, b in zip(frames, frames[1:])
|
||||
]
|
||||
intervals_ms.sort()
|
||||
|
||||
def percentile(values: list[float], p: float) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
idx = min(len(values) - 1, int(len(values) * p))
|
||||
return values[idx]
|
||||
|
||||
return BenchmarkResult(
|
||||
duration_s=duration,
|
||||
total_datagrams=total,
|
||||
rac1_frames=len(frames),
|
||||
heartbeat_packets=heartbeats,
|
||||
other_packets=other,
|
||||
rac1_bytes=rac1_bytes,
|
||||
rac1_fps=len(frames) / duration if duration > 0 else 0.0,
|
||||
rac1_bytes_per_sec=rac1_bytes / duration if duration > 0 else 0.0,
|
||||
sequence_gaps=gaps,
|
||||
first_sequence=seqs[0] if seqs else None,
|
||||
last_sequence=seqs[-1] if seqs else None,
|
||||
rssi_min_dbm=min(rssis) if rssis else None,
|
||||
rssi_max_dbm=max(rssis) if rssis else None,
|
||||
rssi_mean_dbm=(sum(rssis) / len(rssis)) if rssis else None,
|
||||
inter_frame_interval_ms_mean=(
|
||||
sum(intervals_ms) / len(intervals_ms) if intervals_ms else None
|
||||
),
|
||||
inter_frame_interval_ms_p95=percentile(intervals_ms, 0.95),
|
||||
channels_seen=channels,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--port", type=int, default=5005, help="UDP port to listen on (default: 5005, matches wifi-densepose-sensing-server)")
|
||||
parser.add_argument("--bind-ip", type=str, default="0.0.0.0")
|
||||
parser.add_argument("--duration", type=float, default=40.0, help="capture window in seconds")
|
||||
parser.add_argument("--output", type=Path, default=None, help="write results as JSON to this path")
|
||||
args = parser.parse_args()
|
||||
|
||||
result = run_benchmark(args.port, args.duration, args.bind_ip)
|
||||
|
||||
print(f"\n=== RTL8721Dx RAC1 CSI benchmark ({result.evidence}) ===")
|
||||
print(f"duration: {result.duration_s:.1f}s")
|
||||
print(f"total UDP datagrams: {result.total_datagrams}")
|
||||
print(f"RAC1 CSI frames: {result.rac1_frames}")
|
||||
print(f"heartbeat packets: {result.heartbeat_packets}")
|
||||
print(f"other/unrecognized: {result.other_packets}")
|
||||
print(f"RAC1 throughput: {result.rac1_fps:.2f} fps, {result.rac1_bytes_per_sec:.1f} bytes/s")
|
||||
print(f"sequence gaps: {result.sequence_gaps} (dropped/reordered frames)")
|
||||
if result.first_sequence is not None:
|
||||
print(f"sequence range: {result.first_sequence} .. {result.last_sequence}")
|
||||
if result.rssi_mean_dbm is not None:
|
||||
print(f"RSSI: min={result.rssi_min_dbm} max={result.rssi_max_dbm} mean={result.rssi_mean_dbm:.1f} dBm")
|
||||
if result.inter_frame_interval_ms_mean is not None:
|
||||
print(f"inter-frame interval: mean={result.inter_frame_interval_ms_mean:.1f}ms p95={result.inter_frame_interval_ms_p95:.1f}ms")
|
||||
print(f"channels seen: {result.channels_seen}")
|
||||
|
||||
if args.output:
|
||||
args.output.write_text(json.dumps(asdict(result), indent=2))
|
||||
print(f"\nwrote {args.output}", file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+27
-4
@@ -1166,11 +1166,21 @@ async function main() {
|
||||
// We iterate over triplets, compute gradients via computeGradient(), and apply
|
||||
// them to update the encoder's w2 layer (the embedding projection layer).
|
||||
console.log(' Applying gradient updates to encoder weights...');
|
||||
// Same bug class as lossHistory: contrastiveResult.durationMs times the
|
||||
// no-op contrastiveTrainer.train() baseline pass above, not this loop --
|
||||
// the actual gradient-update work. Time it here instead.
|
||||
const contrastiveLoopStart = Date.now();
|
||||
|
||||
const contrastiveLr = CONFIG.learningRate;
|
||||
const contrastiveEpochs = CONFIG.epochs;
|
||||
let initialContrastiveLoss = 0;
|
||||
let finalContrastiveLoss = 0;
|
||||
// Real per-epoch loss from the loop that actually updates encoder.w2 below —
|
||||
// NOT contrastiveResult.history, which comes from contrastiveTrainer.train()
|
||||
// above and never touches the encoder (see its own comment). Exporting that
|
||||
// instead of this produced a flat, meaningless lossHistory in the published
|
||||
// model's training-metrics.json.
|
||||
const realContrastiveLossHistory = [];
|
||||
|
||||
// Compute initial loss
|
||||
for (const triplet of triplets) {
|
||||
@@ -1230,7 +1240,9 @@ async function main() {
|
||||
if (CONFIG.verbose) console.log(` Epoch ${epoch + 1}/${contrastiveEpochs}: loss=${epochLoss.toFixed(6)}`);
|
||||
}
|
||||
finalContrastiveLoss = epochLoss;
|
||||
realContrastiveLossHistory.push({ epoch: epoch + 1, loss: epochLoss });
|
||||
}
|
||||
const realContrastiveDurationMs = Date.now() - contrastiveLoopStart;
|
||||
|
||||
// Re-encode all features with updated encoder
|
||||
console.log(' Re-encoding features with updated encoder...');
|
||||
@@ -1494,10 +1506,15 @@ async function main() {
|
||||
const exportModel = {
|
||||
metadata: {
|
||||
name: 'wifi-densepose-csi-embedding',
|
||||
version: '1.0.0',
|
||||
version: '2.0.1',
|
||||
architecture: 'csi-encoder-8-64-128',
|
||||
training: {
|
||||
steps: contrastiveResult.history.length * contrastiveTrainer.getTripletCount(),
|
||||
// Real epoch count and real per-epoch triplet-update count, reported
|
||||
// separately rather than pre-multiplied into one "steps" figure --
|
||||
// that reads as optimizer steps, but every triplet with loss>0 gets
|
||||
// an immediate direct weight update here, not a batched gradient step.
|
||||
epochs: realContrastiveLossHistory.length,
|
||||
tripletUpdatesPerEpoch: triplets.length,
|
||||
loss: contrastiveResult.finalLoss,
|
||||
learningRate: CONFIG.learningRate,
|
||||
},
|
||||
@@ -1609,8 +1626,14 @@ async function main() {
|
||||
initialLoss: contrastiveResult.initialLoss,
|
||||
finalLoss: contrastiveResult.finalLoss,
|
||||
improvement: contrastiveResult.improvement,
|
||||
durationMs: contrastiveResult.durationMs,
|
||||
lossHistory: contrastiveResult.history,
|
||||
// Real wall-clock of the actual gradient loop -- contrastiveResult.durationMs
|
||||
// timed the no-op baseline pass instead, same bug as lossHistory above.
|
||||
durationMs: realContrastiveDurationMs,
|
||||
// The real per-epoch loss from the loop that actually updates the
|
||||
// encoder (see realContrastiveLossHistory above) -- contrastiveResult.history
|
||||
// comes from a separate, non-weight-updating baseline pass and was flat/
|
||||
// meaningless here.
|
||||
lossHistory: realContrastiveLossHistory,
|
||||
},
|
||||
taskHeads: taskTrainingData.length > 0 ? {
|
||||
samples: labeledCount,
|
||||
|
||||
Generated
+4
-4
@@ -9109,9 +9109,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
@@ -9173,9 +9173,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
||||
@@ -234,9 +234,30 @@ impl StreamingEngine {
|
||||
/// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed
|
||||
/// trust cycles even after widening the guard elsewhere.
|
||||
///
|
||||
/// Rebuilds the fuser, so call before any frames are processed.
|
||||
/// Rebuilds the fuser, so call before any frames are processed. Preserves
|
||||
/// any node positions configured via [`Self::set_node_positions`] across
|
||||
/// the rebuild (issue #1870) — a caller that configures positions once at
|
||||
/// startup and calls this later (e.g. after deriving a TDM schedule) must
|
||||
/// not have that configuration silently discarded.
|
||||
pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) {
|
||||
let positions = self.fuser.node_positions_by_id().clone();
|
||||
self.fuser = MultistaticFuser::with_config(cfg);
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Configure node positions for the governed multistatic fuser, keyed by
|
||||
/// node id (issue #1870). Without this, `StreamingEngine`'s fuser — the
|
||||
/// one behind `fuse_scored_calibrated`, which feeds provenance and the
|
||||
/// witness — always fuses with every node at the origin, regardless of
|
||||
/// deployment configuration, because nothing ever called this on it. This
|
||||
/// is a separate `MultistaticFuser` instance from the one
|
||||
/// `wifi-densepose-sensing-server`'s `AppState` owns (fixed for that
|
||||
/// instance in #1860); each fuser instance needs positions set on it
|
||||
/// independently. Safe to call before or after [`Self::set_multistatic_config`] —
|
||||
/// order does not matter, since that method now preserves whatever
|
||||
/// positions are already set across its rebuild.
|
||||
pub fn set_node_positions(&mut self, positions: std::collections::HashMap<u8, [f32; 3]>) {
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
@@ -1359,4 +1380,64 @@ mod tests {
|
||||
"an extra evidence ref must not collide with a model_version that absorbs it"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Issue #1870: StreamingEngine's own fuser never received positions ----
|
||||
|
||||
/// The governed path (`fuse_scored_calibrated`, feeding provenance and the
|
||||
/// witness) must carry each node's configured position -- not the origin
|
||||
/// for every node, which is what happened before `set_node_positions`
|
||||
/// existed on `StreamingEngine` and nothing ever configured this fuser
|
||||
/// instance (distinct from the one `wifi-densepose-sensing-server`'s
|
||||
/// `AppState` owns, fixed separately in #1860).
|
||||
#[test]
|
||||
fn governed_fuse_carries_each_nodes_configured_position() {
|
||||
let (mut e, _room) = engine();
|
||||
let mut positions = std::collections::HashMap::new();
|
||||
positions.insert(0u8, [1.0, 2.0, 3.0]);
|
||||
positions.insert(1u8, [4.0, 5.0, 6.0]);
|
||||
e.set_node_positions(positions.clone());
|
||||
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
let (fused, _score) = e
|
||||
.fuser
|
||||
.fuse_scored_calibrated(&frames, &[None, None], StreamingEngine::DEFAULT_COHERENCE_ACCEPT)
|
||||
.expect("cohort fuses");
|
||||
|
||||
assert_eq!(fused.node_positions.len(), frames.len());
|
||||
for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) {
|
||||
let expected = positions
|
||||
.get(&frame.node_id)
|
||||
.copied()
|
||||
.expect("every configured node has a position in this test");
|
||||
assert_eq!(
|
||||
*position, expected,
|
||||
"node {} received the wrong position on the governed path",
|
||||
frame.node_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `set_multistatic_config` used to rebuild the fuser from scratch,
|
||||
/// silently discarding any positions configured before it. A caller that
|
||||
/// sets positions once at startup and derives/applies a TDM-schedule
|
||||
/// config later (a real, documented ordering per #1049/#1057) must not
|
||||
/// lose them.
|
||||
#[test]
|
||||
fn positions_survive_a_later_set_multistatic_config() {
|
||||
let (mut e, _room) = engine();
|
||||
let mut positions = std::collections::HashMap::new();
|
||||
positions.insert(0u8, [7.0, 8.0, 9.0]);
|
||||
e.set_node_positions(positions.clone());
|
||||
|
||||
e.set_multistatic_config(MultistaticConfig {
|
||||
guard_interval_us: 30_000,
|
||||
..MultistaticConfig::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
e.fuser.node_position(0),
|
||||
Some([7.0, 8.0, 9.0]),
|
||||
"set_multistatic_config must not discard previously-configured positions"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Benchmarks for ADR-323 Realtek RTL8721Dx (AmebaDplus) CSI encode/decode
|
||||
//! throughput, at the real hardware's own reported dimensions (52
|
||||
//! subcarriers, 16-bit tone precision — see `example/wifi/wifi_csi/README.md`
|
||||
//! in the vendor SDK).
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use wifi_densepose_hardware::realtek_csi::simulator::{RealtekCsiSimulator, SimulatorConfig};
|
||||
use wifi_densepose_hardware::realtek_csi::CsiFrame;
|
||||
|
||||
fn bench_to_bytes_8bit(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let frame = sim.next_frame();
|
||||
c.bench_function("realtek_csi_to_bytes_52sc_8bit", |b| {
|
||||
b.iter(|| black_box(frame.to_bytes().unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_from_bytes_8bit(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let wire = sim.next_frame().to_bytes().unwrap();
|
||||
c.bench_function("realtek_csi_from_bytes_52sc_8bit", |b| {
|
||||
b.iter(|| black_box(CsiFrame::from_bytes(black_box(&wire)).unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_to_bytes_16bit(c: &mut Criterion) {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let frame = sim.next_frame();
|
||||
c.bench_function("realtek_csi_to_bytes_52sc_16bit", |b| {
|
||||
b.iter(|| black_box(frame.to_bytes().unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_from_bytes_16bit(c: &mut Criterion) {
|
||||
let cfg = SimulatorConfig {
|
||||
num_bit_per_tone: 32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg).unwrap();
|
||||
let wire = sim.next_frame().to_bytes().unwrap();
|
||||
c.bench_function("realtek_csi_from_bytes_52sc_16bit", |b| {
|
||||
b.iter(|| black_box(CsiFrame::from_bytes(black_box(&wire)).unwrap()));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_round_trip_full_datagram(c: &mut Criterion) {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
c.bench_function("realtek_csi_round_trip_52sc_8bit", |b| {
|
||||
b.iter(|| {
|
||||
let frame = sim.next_frame();
|
||||
let wire = black_box(frame.to_bytes().unwrap());
|
||||
black_box(CsiFrame::from_bytes(&wire).unwrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_to_bytes_8bit,
|
||||
bench_from_bytes_8bit,
|
||||
bench_to_bytes_16bit,
|
||||
bench_from_bytes_16bit,
|
||||
bench_round_trip_full_datagram,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Deterministic Realtek RTL8721Dx (AmebaDplus) 1x1 CSI simulator (ADR-323).
|
||||
|
||||
use clap::Parser;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{self, Write},
|
||||
net::{SocketAddr, UdpSocket},
|
||||
path::PathBuf,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use wifi_densepose_hardware::realtek_csi::{
|
||||
simulator::{RealtekCsiSimulator, SimulatorConfig},
|
||||
CsiFrame, CsiMode,
|
||||
};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "realtek-csi-sim",
|
||||
about = "Emit synthetic ADR-323 Realtek RTL8721Dx CSI frames"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(long, default_value_t = 100)]
|
||||
frames: u32,
|
||||
#[arg(long, default_value = "0x5241433152544c31", value_parser=parse_u64)]
|
||||
seed: u64,
|
||||
#[arg(long, default_value_t = 6)]
|
||||
channel: u8,
|
||||
#[arg(long)]
|
||||
bandwidth_40mhz: bool,
|
||||
#[arg(long, default_value_t = 52)]
|
||||
subcarriers: u16,
|
||||
#[arg(long, default_value_t = 16)]
|
||||
tone_bits: u8,
|
||||
#[arg(long, default_value_t = 1)]
|
||||
decimation: u8,
|
||||
#[arg(long, default_value_t = 64)]
|
||||
interval_ms: u64,
|
||||
#[arg(long)]
|
||||
udp: Option<SocketAddr>,
|
||||
/// Replay: little-endian u32 length followed by one ADR-323 envelope.
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
realtime: bool,
|
||||
}
|
||||
|
||||
fn parse_u64(v: &str) -> Result<u64, String> {
|
||||
if let Some(h) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) {
|
||||
u64::from_str_radix(h, 16).map_err(|e| e.to_string())
|
||||
} else {
|
||||
v.parse()
|
||||
.map_err(|e: std::num::ParseIntError| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(
|
||||
frame: CsiFrame,
|
||||
socket: Option<&UdpSocket>,
|
||||
destination: Option<SocketAddr>,
|
||||
output: &mut Option<File>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let wire = frame.to_bytes()?;
|
||||
if let (Some(s), Some(d)) = (socket, destination) {
|
||||
if s.send_to(&wire, d)? != wire.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::WriteZero, "partial UDP datagram").into());
|
||||
}
|
||||
}
|
||||
if let Some(f) = output {
|
||||
f.write_all(&(wire.len() as u32).to_le_bytes())?;
|
||||
f.write_all(&wire)?;
|
||||
}
|
||||
Ok(wire.len())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let a = Args::parse();
|
||||
if a.udp.is_none() && a.output.is_none() {
|
||||
return Err("select at least one sink with --udp or --output".into());
|
||||
}
|
||||
let cfg = SimulatorConfig {
|
||||
seed: a.seed,
|
||||
csi_mode: CsiMode::Passive,
|
||||
channel: a.channel,
|
||||
bandwidth: if a.bandwidth_40mhz { 1 } else { 0 },
|
||||
num_sub_carrier: a.subcarriers,
|
||||
num_bit_per_tone: a.tone_bits,
|
||||
decimation: a.decimation,
|
||||
frame_period_us: (a.interval_ms * 1000) as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut sim = RealtekCsiSimulator::new(cfg)?;
|
||||
let socket = a.udp.map(|_| UdpSocket::bind("0.0.0.0:0")).transpose()?;
|
||||
let mut output = a.output.as_ref().map(File::create).transpose()?;
|
||||
let mut bytes = 0usize;
|
||||
for _ in 0..a.frames {
|
||||
bytes += emit(sim.next_frame(), socket.as_ref(), a.udp, &mut output)?;
|
||||
if a.realtime {
|
||||
thread::sleep(Duration::from_millis(a.interval_ms));
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"emitted {} synthetic Realtek RTL8721Dx CSI frames ({} bytes, channel={}, subcarriers={}, tone_bits={}, seed={:#x})",
|
||||
a.frames, bytes, a.channel, a.subcarriers, a.tone_bits, a.seed
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ use wifi_densepose_signal::ruvsense::field_model::{
|
||||
|
||||
use super::score_to_person_count;
|
||||
|
||||
/// Versioned schema for the immutable receipt emitted after field-model calibration.
|
||||
pub const CALIBRATION_MODEL_RECEIPT_SCHEMA: &str =
|
||||
"ruview.calibration.field-model-receipt.v1";
|
||||
|
||||
/// Versioned schema for per-frame calibrated occupancy evidence bound to a
|
||||
/// model receipt, published on the sensing stream.
|
||||
pub const CALIBRATED_PRESENCE_EVIDENCE_SCHEMA: &str =
|
||||
"ruview.calibration.calibrated-presence-evidence.v2";
|
||||
|
||||
/// Length-only canonicalizer for calibration frames (issue #1170 pattern,
|
||||
/// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the
|
||||
/// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,124 @@
|
||||
//! Bounded summaries for ADR-323 Realtek RTL8721Dx (AmebaDplus) 1x1 CSI frames.
|
||||
|
||||
use serde::Serialize;
|
||||
use wifi_densepose_hardware::realtek_csi::{CsiFlags, CsiFrame};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub(crate) struct RealtekCsiSnapshot {
|
||||
pub event_type: &'static str,
|
||||
pub source: &'static str,
|
||||
pub csi_mode: &'static str,
|
||||
pub node_id: u8,
|
||||
pub sequence: u32,
|
||||
pub timestamp_us: u32,
|
||||
pub peer_mac: String,
|
||||
pub trig_mac: String,
|
||||
pub channel: u8,
|
||||
pub bandwidth_mhz: u16,
|
||||
pub rx_rate: u8,
|
||||
pub protocol_mode: String,
|
||||
pub num_sub_carrier: u16,
|
||||
pub num_bit_per_tone: u8,
|
||||
pub decimation: u8,
|
||||
pub rssi_dbm: i8,
|
||||
pub rxsc: u8,
|
||||
pub csi_valid: bool,
|
||||
pub synthetic: bool,
|
||||
pub mean_amplitude: Option<f32>,
|
||||
pub peak_amplitude: Option<f32>,
|
||||
}
|
||||
|
||||
impl RealtekCsiSnapshot {
|
||||
pub(crate) fn from_frame(frame: &CsiFrame) -> Self {
|
||||
let synthetic = frame.flags.contains(CsiFlags::SYNTHETIC);
|
||||
let (mean_amplitude, peak_amplitude) = amplitude_summary(frame);
|
||||
Self {
|
||||
event_type: "realtek_csi",
|
||||
source: if synthetic {
|
||||
"realtek_csi:simulated"
|
||||
} else {
|
||||
"realtek_csi"
|
||||
},
|
||||
csi_mode: match frame.csi_mode {
|
||||
wifi_densepose_hardware::realtek_csi::CsiMode::Passive => "passive",
|
||||
wifi_densepose_hardware::realtek_csi::CsiMode::Active => "active",
|
||||
wifi_densepose_hardware::realtek_csi::CsiMode::BoardToBoard => "board_to_board",
|
||||
},
|
||||
node_id: frame.node_id,
|
||||
sequence: frame.sequence,
|
||||
timestamp_us: frame.timestamp_us,
|
||||
peer_mac: mac_string(&frame.peer_mac),
|
||||
trig_mac: mac_string(&frame.trig_mac),
|
||||
channel: frame.channel,
|
||||
bandwidth_mhz: if frame.bandwidth == 1 { 40 } else { 20 },
|
||||
rx_rate: frame.rx_rate,
|
||||
protocol_mode: format!("{:?}", frame.protocol_mode).to_ascii_lowercase(),
|
||||
num_sub_carrier: frame.num_sub_carrier,
|
||||
num_bit_per_tone: frame.num_bit_per_tone,
|
||||
decimation: frame.decimation,
|
||||
rssi_dbm: frame.rssi_dbm,
|
||||
rxsc: frame.rxsc,
|
||||
csi_valid: frame.csi_valid,
|
||||
synthetic,
|
||||
mean_amplitude,
|
||||
peak_amplitude,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mac_string(mac: &[u8; 6]) -> String {
|
||||
mac.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
fn amplitude_summary(frame: &CsiFrame) -> (Option<f32>, Option<f32>) {
|
||||
if frame.payload.is_empty() {
|
||||
return (None, None);
|
||||
}
|
||||
let amplitudes: Vec<f32> = frame
|
||||
.payload
|
||||
.iter()
|
||||
.map(|(i, q)| (*i as f32).hypot(*q as f32))
|
||||
.collect();
|
||||
let mean = amplitudes.iter().sum::<f32>() / amplitudes.len() as f32;
|
||||
let peak = amplitudes.into_iter().max_by(f32::total_cmp);
|
||||
(Some(mean), peak)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wifi_densepose_hardware::realtek_csi::simulator::{RealtekCsiSimulator, SimulatorConfig};
|
||||
|
||||
#[test]
|
||||
fn simulator_summary_preserves_dimensions_and_provenance() {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
|
||||
assert_eq!(snapshot.source, "realtek_csi:simulated");
|
||||
assert_eq!(snapshot.num_sub_carrier, 52);
|
||||
assert_eq!(snapshot.csi_mode, "passive");
|
||||
assert!(snapshot.mean_amplitude.unwrap() > 0.0);
|
||||
assert!(snapshot.peak_amplitude.unwrap() >= snapshot.mean_amplitude.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_string_is_distinct_from_realtek_radar() {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
|
||||
// Must never collapse to the bare "realtek"/"realtek:simulated" source
|
||||
// strings already owned by the RTL8720F radar path (realtek_radar.rs) —
|
||||
// effective_source()'s prefix matching in main.rs depends on this.
|
||||
assert_ne!(snapshot.source, "realtek");
|
||||
assert_ne!(snapshot.source, "realtek:simulated");
|
||||
assert!(snapshot.source.starts_with("realtek_csi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mac_addresses_are_formatted_colon_separated() {
|
||||
let mut sim = RealtekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
let snapshot = RealtekCsiSnapshot::from_frame(&sim.next_frame());
|
||||
assert_eq!(snapshot.peer_mac, "a4:39:b3:a4:be:2d");
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@
|
||||
//!
|
||||
//! macOS only. Gated behind `#[cfg(target_os = "macos")]` at the module level.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||
use crate::error::WifiScanError;
|
||||
@@ -64,11 +64,14 @@ impl MacosCoreWlanScanner {
|
||||
|
||||
/// Run the Swift helper and parse the output synchronously.
|
||||
///
|
||||
/// Returns one [`BssidObservation`] per BSSID seen in the scan.
|
||||
/// Returns one [`BssidObservation`] for the connected link.
|
||||
/// Helpers that fail to exit within five seconds are killed and reaped.
|
||||
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
let output = Command::new(&self.helper_path)
|
||||
let mut child = Command::new(&self.helper_path)
|
||||
.arg("--scan-once")
|
||||
.output()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
WifiScanError::ProcessError(format!(
|
||||
"failed to run mac_wifi helper ({}): {e}",
|
||||
@@ -76,6 +79,29 @@ impl MacosCoreWlanScanner {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Older helpers ignore --scan-once and stream forever. Bound the
|
||||
// wait so an outdated installation cannot hang capture or auto-detect.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
status => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(WifiScanError::ProcessError(match status {
|
||||
Err(e) => format!("failed to wait for mac_wifi: {e}"),
|
||||
_ => "mac_wifi --scan-once timed out; rebuild the Swift helper".into(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = child.wait_with_output().map_err(|e| {
|
||||
WifiScanError::ProcessError(format!("failed to read mac_wifi output: {e}"))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WifiScanError::ScanFailed {
|
||||
@@ -160,7 +186,8 @@ fn parse_json_line(line: &str, timestamp: Instant) -> Option<BssidObservation> {
|
||||
/// Resolve a BSSID string to a [`BssidId`].
|
||||
///
|
||||
/// If the MAC is all-zeros (macOS redaction), generate a synthetic
|
||||
/// locally-administered MAC from `SHA-256(ssid:channel)`.
|
||||
/// locally-administered MAC from the SSID and channel. Abstain when the
|
||||
/// real BSSID is unavailable and the SSID is blank.
|
||||
fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
|
||||
// Try parsing the real BSSID first.
|
||||
if let Ok(id) = BssidId::parse(bssid_str) {
|
||||
@@ -170,7 +197,13 @@ fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate synthetic BSSID: SHA-256(ssid:channel), take first 6 bytes,
|
||||
// Without either identity, unrelated networks on the same channel would
|
||||
// collapse to one synthetic BSSID. Do not emit an observation in that case.
|
||||
if ssid.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Generate synthetic BSSID from SSID and channel, take first 6 bytes,
|
||||
// set locally-administered + unicast bits (byte 0: bit 1 set, bit 0 clear).
|
||||
Some(synthetic_bssid(ssid, channel))
|
||||
}
|
||||
@@ -276,6 +309,32 @@ mod tests {
|
||||
{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn helper_process_errors_are_reported() {
|
||||
assert!(matches!(
|
||||
MacosCoreWlanScanner::with_path("/usr/bin/false").scan_sync(),
|
||||
Err(WifiScanError::ScanFailed { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
MacosCoreWlanScanner::with_path("/dev/null/mac_wifi").scan_sync(),
|
||||
Err(WifiScanError::ProcessError(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_helper_is_timed_out() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = std::env::temp_dir().join(format!("mac-wifi-timeout-{}", std::process::id()));
|
||||
// exec preserves the helper PID, so killing it cannot orphan sleep.
|
||||
std::fs::write(&path, "#!/bin/sh\nexec /bin/sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let start = Instant::now();
|
||||
let result = MacosCoreWlanScanner::with_path(path.to_string_lossy()).scan_sync();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(matches!(result, Err(WifiScanError::ProcessError(ref e)) if e.contains("timed out")));
|
||||
assert!(start.elapsed() < Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_output() {
|
||||
let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap();
|
||||
@@ -304,6 +363,29 @@ mod tests {
|
||||
assert_eq!(obs[2].bssid.0[0] & 0x01, 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_bssid_without_ssid_abstains() {
|
||||
for bssid in ["00:00:00:00:00:00", "", "invalid"] {
|
||||
for ssid in ["", " "] {
|
||||
let output = format!(
|
||||
r#"{{"ssid":"{ssid}","bssid":"{bssid}","rssi":-65,"noise":-88,"channel":36}}"#
|
||||
);
|
||||
assert!(parse_macos_scan_output(&output).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_bssid_without_ssid_is_preserved() {
|
||||
let output = r#"{"ssid":"","bssid":"aa:bb:cc:dd:ee:ff","rssi":-65,"noise":-88,"channel":36}"#;
|
||||
let obs = parse_macos_scan_output(output).unwrap();
|
||||
assert_eq!(obs.len(), 1);
|
||||
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
|
||||
assert!(obs[0].ssid.is_empty());
|
||||
assert_eq!(obs[0].rssi_dbm, -65.0);
|
||||
assert_eq!(obs[0].channel, 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_bssid_is_deterministic() {
|
||||
let a = synthetic_bssid("TestNet", 36);
|
||||
|
||||
Reference in new issue
Block a user