Compare commits

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

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

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

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

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

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

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

Measured on three ESP32-C6 nodes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: abstain when macOS WiFi identity is unavailable

---------

Co-authored-by: gyrwb6vjbj-ctrl <253160963+gyrwb6vjbj-ctrl@users.noreply.github.com>
2026-09-10 00:15:02 +00:00
37 changed files with 3099 additions and 101 deletions

No files matched your search

+37 -6
View File
@@ -53,7 +53,9 @@ jobs:
sdkconfig: sdkconfig.defaults
partition_table_name: partitions_display.csv
size_warn_kb: 1100
size_limit_kb: 1152
# Current 8 MB image is 1163 KiB after USB onboarding. The OTA app
# partition is 2048 KiB; retain a 1200 KiB hard gate and warning.
size_limit_kb: 1200
artifact_app: esp32-csi-node.bin
artifact_pt: partition-table.bin
- variant: 4mb
@@ -92,14 +94,11 @@ jobs:
idf.py set-target ${{ matrix.target }}
idf.py build
- name: Build and run host-side ADR-110 unit tests
- name: Build and run host-side firmware unit tests
if: matrix.variant == 'c6-4mb'
working-directory: firmware/esp32-csi-node/test
run: |
make test_adr110
./test_adr110
make test_c6_antenna
./test_c6_antenna
make host_tests
- name: Verify binary size budget
working-directory: firmware/esp32-csi-node
@@ -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/*
+29
View File
@@ -10,6 +10,35 @@ func main() {
exit(1)
}
// One connected-link RSSI sample for the Rust adapter. Keep the legacy
// no-argument 10 Hz stream below unchanged for Python callers.
if CommandLine.arguments.contains("--scan-once") {
guard interface.powerOn(),
let channel = interface.wlanChannel(),
interface.rssiValue() < 0 else {
fputs("WiFi is not connected\n", stderr)
exit(1)
}
let sample: [String: Any] = [
"ssid": interface.ssid() ?? "",
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
"channel": channel.channelNumber,
"rssi": interface.rssiValue(),
"noise": interface.noiseMeasurement(),
"timestamp": Date().timeIntervalSince1970,
"tx_rate": interface.transmitRate()
]
do {
let data = try JSONSerialization.data(withJSONObject: sample, options: [.sortedKeys])
FileHandle.standardOutput.write(data)
FileHandle.standardOutput.write(Data([0x0a]))
} catch {
fputs("Could not encode WiFi sample: \(error)\n", stderr)
exit(1)
}
return
}
// Flush stdout automatically to prevent buffering issues with Python subprocess
setbuf(stdout, nil)
@@ -0,0 +1,218 @@
# ADR-323: RTL8721Dx (Realtek AmebaDplus) CSI Wire Protocol
- **Status**: accepted
- **Date**: 2026-09-15
- **Deciders**: RuView maintainers
- **Tags**: realtek, ameba, amebadplus, rtl8721dx, csi, protocol, rust, udp
## Context
Unlike the MediaTek/Qualcomm CSI paths (ADR-266..269), which are simulator-first
because no supported public vendor interface exports complex CSI, Realtek's
official Ameba RTOS SDK (`Ameba-AIoT/ameba-rtos`, Apache-2.0) documents and
exposes a real, callback-driven CSI API for the RTL8721Dx ("AmebaDplus")
family: `wifi_csi_config()` plus the `RTW_EVENT_CSI_DONE` event delivering a
`struct rtw_event_csi_report_info` (per-tone signed I/Q, 8-bit or 16-bit
precision, selectable tone decimation, passive or active/triggered capture).
This has been confirmed directly against SDK source
(`component/wifi/api/wifi_api_event.h`) and against the official
`example/wifi/wifi_csi` example, and validated on real PKM8721DAF-C13-F10
hardware (build+flash+boot confirmed on branch `feat/rtl8721dx-csi-bringup`).
The RTL8721Dx is a 1x1 WiFi 4 part — single TX/RX chain, not MIMO — so the
MediaTek/Qualcomm wire format (Tx/Rx dimensioned matrices, chipset chain
counts) does not fit. The vendor SDK's raw struct is also not a safe host
wire format as-is: it has no CRC, a C flexible-array-member payload, and
fields sized for firmware convenience (e.g. a wrapping `u32` microsecond
timestamp) rather than network robustness.
Per the existing repo convention (ESP32/ADR-018, MediaTek/ADR-266-267,
Qualcomm/ADR-268-269, RTL8720F radar/ADR-263-264), each vendor CSI/radar
source gets its own self-delimiting envelope and its own Rust module pair
(`wifi-densepose-hardware` for framing+codec+simulator,
`wifi-densepose-sensing-server` for the bounded JSON-safe snapshot), never a
shared "generic vendor CSI" struct that would either lose vendor-specific
fields or invite magic-space collisions. `SourceKind::Realtek` (value 2) is
already assigned to the RTL8720F FMCW radar path (ADR-263/264) and must not
be reused for CSI.
## Decision
Define `RAC1` ("Realtek Ameba CSI", version 1) as a little-endian,
self-delimiting envelope, magic `0x3143_4152` (distinct from `MTC1`
`0x3143544d`, `QCS1` 0x31535143, `RTR1` 0x31525452, and all ADR-018
`0xC511000x` siblings):
Fixed 49-byte header (explicit field-by-field serialization, no `#[repr(C)]`
padding, matching the MediaTek/Qualcomm modules' style):
| Field | Type | Notes |
|---|---|---|
| magic | u32 LE | `0x3143_4152` |
| version | u8 | 1 |
| header_len | u16 LE | 49 |
| frame_len | u32 LE | header + payload + 4-byte CRC |
| node_id | u8 | disambiguates multi-board illuminator/receiver deployments; 0 = unset |
| csi_mode | u8 | 0=passive, 1=active, 2=board-to-board (`CsiMode`) |
| sequence | u32 LE | from firmware `csi_sequence` |
| timestamp_us | u32 LE | from firmware `hw_assigned_timestamp` (hardware-clock microseconds, wraps ~71 min — consumers must not assume monotonic across a capture longer than that) |
| peer_mac | [u8; 6] | firmware `mac_addr` |
| trig_mac | [u8; 6] | firmware `trig_addr` |
| channel | u8 | operating channel |
| bandwidth | u8 | 0=20MHz, 1=40MHz |
| rx_rate | u8 | firmware `rx_rate` |
| protocol_mode | u8 | 0=OFDM,1=HT,2=VHT,3=HE (`ProtocolMode`) |
| num_sub_carrier | u16 LE | tone count in payload |
| num_bit_per_tone | u8 | 16 (8-bit I + 8-bit Q) or 32 (16-bit I + 16-bit Q) |
| decimation | u8 | tone group (1/2/4/8 — every Nth tone) |
| rssi_dbm | i8 | single RF chain (1x1 hardware) |
| rxsc | u8 | sub-20 MHz channel used |
| csi_valid | u8 | 0/1 from firmware |
| flags | u8 | bit 0 = SYNTHETIC (simulator provenance, never clearable downstream) |
| payload_len | u32 LE | bytes, == `num_sub_carrier * (num_bit_per_tone / 8)` |
Payload: `num_sub_carrier` consecutive (I, Q) tone pairs, each
`num_bit_per_tone / 16` bytes per component (1 byte/component when
`num_bit_per_tone == 16`, 2 bytes/component little-endian signed when `== 32`)
— i.e. exactly the byte layout the firmware already produces in
`csi_data[]`, just framed. CRC-32/IEEE over header+payload, final 4
little-endian bytes. One envelope per UDP datagram.
Parsers reject: unknown version, wrong `header_len`, `frame_len` exceeding
the IPv4 UDP payload cap (65,507 bytes), `num_bit_per_tone` outside
`{16, 32}`, `payload_len` inconsistent with `num_sub_carrier *
(num_bit_per_tone/8)`, trailing bytes, and bad CRC — mirroring the
MediaTek/Qualcomm parser contract exactly.
Host side: add `realtek_csi.rs` to both
`wifi-densepose-hardware` (frame + codec + simulator, mirroring
`mediatek_csi.rs`/`qualcomm_csi.rs`) and `wifi-densepose-sensing-server`
(bounded `RealtekCsiSnapshot`, mirroring `QualcommCsiSnapshot`), wired into
the same UDP listener loop and `/api/v1/csi/realtek/latest` route as the
existing vendor sources. The initial Mac compatibility port keeps Realtek CSI
as a separate bounded diagnostic stream. Fresh ESP32 frames remain the primary
sensing source. This change does not add `SourceKind::RealtekCsi` to a signed
ingest contract; that requires a separate reviewed identity-bound hardware
proof. RAC1 CRC checks corruption, not origin.
Firmware side (`example/wifi/wifi_csi` derivative, built against the
official Ameba RTOS SDK, tracked outside this repository per the existing
`CLAUDE.local.md` convention — the vendor SDK itself is never vendored into
this repo): join the board to the deployment's WiFi AP in STA mode
(passive CSI against the real AP as peer, since only one physical board is
available in this bring-up), read the AP's BSSID at runtime after
association (never hardcode a MAC into firmware source), pack each
`RTW_EVENT_CSI_DONE` report into one `RAC1` datagram, and `sendto()` it over
UDP to the host sensing-server's existing CSI port.
## Consequences
### Positive
- CSI is real, MEASURED hardware output from day one — no simulator-first
bridge phase is needed the way MediaTek/Qualcomm required, because the
vendor SDK genuinely exposes the capability and we have the board in hand.
- One board is sufficient for a first useful signal (passive CSI against the
home AP); active and board-to-board modes are additive later without a
wire-format break (`csi_mode` already reserves the values).
- Reuses the bounded UDP parser and status-route pattern without changing
existing ESP32 sensing. Unsigned RAC1 diagnostics are not authorized as
calibrated presence, person count, pose, or vital evidence.
### Negative
- 1x1 hardware means no spatial (multi-antenna) diversity from a single
board; multi-board deployment is required for the differential/localization
use cases described in the original RTL8721Dx capability analysis.
- The 71-minute microsecond timestamp wraparound (inherited from the
firmware's `u32` field) means long unattended captures need host-side
sequence-based reordering, not raw timestamp arithmetic, across a wrap.
### Neutral
- The RAC1 envelope intentionally does not carry the SDK's raw
`csi_signature` (0xABCD) marker — our own `magic`/CRC supersede it as the
framing integrity check.
## Hardware gates
- [x] Confirm SDK source exposes `RTW_EVENT_CSI_DONE` / `rtw_event_csi_report_info` (done — `component/wifi/api/wifi_api_event.h`).
- [x] Build and flash a baseline image on real PKM8721DAF-C13-F10 hardware, confirm boot over serial (done — `feat/rtl8721dx-csi-bringup`).
- [x] Flash the CSI-streaming firmware and confirm `RAC1` datagrams parse on the host from the real board (done, 2026-09-15 — see below).
- [x] Record MEASURED throughput/latency/jitter from the real running firmware (done, 2026-09-15 — see below).
- [ ] A human walk-test across the link (amplitude perturbation vs. static baseline) is deferred — requires the operator physically present; not claimed until performed.
### MEASURED: real RAC1 capture and sustained throughput (2026-09-15)
Passive CSI (`RTW_CSI_MODE_NORMAL`, `RTW_CSI_TRIG_BEACON | RTW_CSI_TRIG_DATA | RTW_CSI_TRIG_QOS_DATA`)
against the deployment AP. A single `RAC1` datagram was first
cross-checked field-by-field against the firmware's own serial log of the
same report — every byte matched: magic `0x31434152`, RSSI byte exact match,
channel/bandwidth/protocol/subcarrier-count exact match, 157-byte total frame
(49-byte header + 104-byte payload + 4-byte CRC, exactly as specified above).
`RTW_CSI_MODE_RX_RESP` (estimate CSI from ACKs to the STA's own transmissions)
never fired `RTW_EVENT_CSI_DONE` across two full test runs with confirmed
real traffic (not a hang) — this mode does not work for this hardware/AP
combination and `RTW_CSI_MODE_NORMAL` with beacon+data triggers is the
working configuration.
**An earlier note here claimed `NORMAL` mode fires only once per CSI enable
— that was wrong**, caused by a debug log line gated to print only every
50th frame (`frame_count % 50 == 1`), which was misread as "only one report
total." A follow-up capture with a UDP listener running concurrently with a
board reset measured **277 real RAC1 datagrams in 40 seconds (43,489 bytes)**,
with `csi_sequence` climbing continuously and monotonically in the firmware's
own serial log across six separate 50-frame log checkpoints in one 45s
session — CSI reporting is continuous, roughly 7-12 fps, not sparse.
### MEASURED: throughput optimization pass (2026-09-15)
Formalized as a repeatable tool: `scripts/benchmark-rtl8721dx-csi.py` (listens
on the sensing-server's CSI UDP port, reports fps/throughput/RSSI/inter-frame
interval/sequence-gap stats from real received `RAC1` datagrams).
**Baseline** (`trig_period=200` [64ms], `trig_frame_ctrl=0`,
`CSI_REPORT_BUF_NUM=1`), 30s capture:
| Metric | Value |
|---|---|
| RAC1 fps | 14.67 |
| Throughput | 2,302.7 bytes/s |
| Mean inter-frame interval | 68.3 ms |
| RSSI | mean -34.4 dBm |
`trig_period` (units of 320µs) was hypothesized as the rate-limiting
throttle — the observed 68.3ms mean interval tracks the 64ms configured
period almost exactly. Confirmed by tuning:
1. `trig_period`: 200 → 15 (the SDK doc's stated minimum recommended value,
~4.8ms)
2. `trig_frame_ctrl`: 0 → `RTW_CSI_TRIG_ACK` (second trigger source: every
link-layer ACK the AP sends for the heartbeat's own outgoing traffic, on
top of the existing beacon trigger)
3. `CSI_REPORT_BUF_NUM`: 1 → 4 (a "lack of csi buf!" drop was observed even
at the slower baseline rate; more headroom needed at a higher rate)
**Tuned**, 30s capture:
| Metric | Value | vs. baseline |
|---|---|---|
| RAC1 fps | 66.40 | **4.5x** |
| Throughput | 10,424.8 bytes/s | **4.5x** |
| Mean inter-frame interval | 14.9 ms | 4.6x lower |
| Sequence gaps | 78 / ~2036 (~3.8%) | new (0 observed at the slower baseline rate) |
| RSSI | mean -31.1 dBm | (different session/position, not a controlled variable here) |
The `trig_period` hypothesis is confirmed as the primary lever. The ~3.8%
frame-loss rate at 66 fps is a reasonable place to stop for now — it is
UDP-over-WiFi loss, not a firmware defect, and is small enough not to block
downstream sensing use; revisit only if a use case needs stronger delivery
guarantees (sequence-gap-triggered re-request, TCP fallback, etc.).
## Links
- [ADR-266](ADR-266-mediatek-filogic-csi-platform.md) / [ADR-267](ADR-267-mediatek-mimo-csi-wire-protocol.md) — sibling vendor CSI ADR pair (simulator-first precedent)
- [ADR-268](ADR-268-qualcomm-atheros-csi-platform.md) / [ADR-269](ADR-269-qualcomm-csi-wire-protocol.md) — sibling vendor CSI ADR pair
- [ADR-263](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md) / [ADR-264](ADR-264-rtl8720f-radar-wire-protocol.md) — existing Realtek (RTL8720F radar) path; `SourceKind::Realtek` owner
- `CLAUDE.local.md` — RTL8721Dx toolchain setup, board/COM port, first bring-up log
@@ -0,0 +1,93 @@
# ADR 363: Native macOS USB node onboarding
## Status
Accepted and physically qualified with one attached ESP32 S3 and one attached
ESP32 C6 through an unsigned Mac Catalyst development build. Signed
distribution remains a release gate.
## Context
RuView operators currently provision ESP32 nodes with command line tooling.
The macOS app can list server accepted node identities, but it cannot establish
that an attached serial device is RuView firmware, assign a collision free node
identity, preserve its WiFi configuration, or prove that it joined the sensing
server. A UI that merely stores a friendly name would create false success.
Generic shell access from the app would also expand authority beyond the task.
Serial logs can contain operational data, and WiFi credentials must not enter
application diagnostics or provisioning receipts.
## Decision
1. Firmware 0.8.12 provides a bounded line protocol over the physical USB serial
channel. A discovery request carries a random 128 bit nonce. The device
response binds that nonce to chip family, firmware version, current node
identity, private server target, configuration state, and a pseudonymous
64 bit device digest derived from the base MAC under a RuView domain.
2. A configuration request is accepted only for the most recently issued
nonce within 60 seconds. The nonce is single use. Node identity is limited
to 1 through 255, the target is limited to RFC 1918 IPv4, and the target port
is limited to 1 through 65535.
3. Existing WiFi configuration can be preserved. New credentials use strict
base64 framing, bounded lengths, and control character rejection. Firmware
commits NVS before acknowledging and rebooting. Credentials are never
returned, logged, or included in the receipt.
4. The macOS client uses a native serial bridge. It allowlists exact character
device prefixes, configures the port directly, and does not invoke a shell,
Python, or an external process. It rechecks the device digest immediately
before configuration to reduce port substitution risk.
5. The app suggests the lowest unused server node identity and the Mac private
LAN address. Success is shown only after the configured node appears in the
server inventory as active with evidence no older than three seconds.
6. Old firmware is reported as requiring an update. The app must not claim it
provisioned a device based only on generic RuView serial logs.
7. Automatic firmware flashing is outside this decision. Flashing requires an
exact chip and port check, a private backup, a matching image, and captured
boot evidence. Release publication remains a separate maintainer action.
8. The device digest uses the PSA Crypto SHA 256 interface on ESP IDF 5.4 and
6.0. This keeps the stable domain separated identity while avoiding the
legacy Mbed TLS hash API removed by ESP IDF 6.
9. ESP32 C6 mmWave UART defaults use GPIO 4 and GPIO 5. Startup rejects any
configured mmWave pin pair that overlaps the active console RX or TX pins.
This preserves the onboarding input path after a failed mmWave probe.
## Security and privacy consequences
The app gains Mac App Sandbox USB and serial device access, but not general file
or shell authority. The protocol does not authenticate a device with a hardware
root of trust; the digest is stable identification, not a cryptographic device
certificate. Physical USB possession is therefore the trust boundary for this
version. An authenticated fleet identity remains ADR 305 scope.
The largest failure mode is a successful NVS write followed by failure to join
the LAN. The user receives an explicit bounded verification failure instead of
a success screen, while the board retains its committed settings and can be
rescanned over USB.
## Acceptance test
Connect an ESP32 with firmware 0.8.12 to a Mac that is already receiving RuView
nodes. Use only the macOS Add Sensor workflow to preserve WiFi, assign the
lowest unused identity, and set the Mac private address. Pass when the UI
reports the expected chip and firmware, the reassigned node remains fresh for
five minutes, the stale collided identity expires, app restart preserves the
profile, no server health, send, watchdog, panic, or reboot evidence appears,
and no credential appears in app logs or the provisioning receipt.
The 2026-08-31 physical run passed on an ESP32 S3. The measured witness is in
`docs/validation/2026-08-31-macos-usb-node-onboarding.md`.
The 2026-09-11 physical run passed on an ESP32 C6 over
`cu.usbserial-3120`. The Mac app flashed the image, received the nonce bound
hello receipt, preserved node identity 3, committed the server route, and
verified fresh server evidence at minus 34 dBm. The run also exposed and fixed
the GPIO 17 console RX conflict described above.
+1
View File
@@ -115,6 +115,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) |
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) |
| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) |
| [ADR-363](ADR-363-native-macos-usb-node-onboarding.md) | Native macOS USB node onboarding with nonce bound discovery and fresh server verification | Accepted and physically qualified on one ESP32 S3; C6 and signed distribution pending |
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
@@ -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.
+27
View File
@@ -19,6 +19,25 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
## Firmware 0.8.12: guided Mac onboarding
Firmware 0.8.12 adds the protocol used by the RuView macOS Add
Sensor workflow. A directly attached S3 or C6 can identify itself through a
nonce bound USB serial receipt, preserve its current WiFi settings, accept an
unused node ID and private LAN server address, then reboot. The Mac app does
not report success until the sensing server observes that node as fresh.
This is not a firmware flashing API. A board on 0.8.8 or earlier must receive a
matching chip build through the documented backup and flash process once. The
USB protocol rejects public target addresses and invalid identifiers. New WiFi
credentials are bounded, committed only in NVS, and never included in the
receipt or diagnostic output. See
[ADR 363](../../docs/adr/ADR-363-native-macos-usb-node-onboarding.md).
On ESP32 C6, the default optional mmWave UART uses GPIO 4 and GPIO 5. Firmware
refuses any mmWave configuration that overlaps the active console pins. This
prevents a sensor probe from taking over UART0 RX and disabling Mac onboarding.
## Firmware 0.8.8 in plain language
Release 0.8.8 makes the sensing stream more internally consistent and easier
@@ -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" "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"
@@ -43,6 +44,8 @@ set(REQUIRES
driver
lwip
mbedtls
efuse
led_strip
)
# ADR-110: C6-only components — pulled in when building for esp32c6.
+9
View File
@@ -25,6 +25,7 @@
#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"
@@ -341,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
@@ -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 = {
+42
View File
@@ -19,6 +19,14 @@
#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
@@ -380,3 +388,37 @@ esp_err_t node_log_clear(void)
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 */
+1 -1
View File
@@ -109,7 +109,7 @@ void nvs_config_load(nvs_config_t *cfg)
len = sizeof(buf);
if (nvs_get_str(handle, "ssid", buf, &len) == ESP_OK && len > 1) {
strlcpy(cfg->wifi_ssid, buf, sizeof(cfg->wifi_ssid));
ESP_LOGI(TAG, "NVS override: ssid=%s", cfg->wifi_ssid);
ESP_LOGI(TAG, "NVS override: ssid=<configured>");
}
/* WiFi password */
@@ -0,0 +1,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
@@ -6,3 +6,4 @@ phy_init, data, phy, 0x11000, 0x1000,
ota_0, app, ota_0, 0x20000, 0x200000,
ota_1, app, ota_1, 0x220000, 0x200000,
spiffs, data, spiffs, 0x420000, 0x1E0000,
storage, data, fat, 0x600000, 0x200000,
1 # ESP32-S3 CSI Node — 8MB flash partition table (ADR-045)
6 ota_0, app, ota_0, 0x20000, 0x200000,
7 ota_1, app, ota_1, 0x220000, 0x200000,
8 spiffs, data, spiffs, 0x420000, 0x1E0000,
9 storage, data, fat, 0x600000, 0x200000,
@@ -0,0 +1,4 @@
12e9fe5bd0533c3eb8ebb1cc36f775668cc4dd39f334ea65e26529b87211dc4c bootloader.bin
baff3940ede0ce1658f2f5c3ba458cc9007753eb1519231d67b1ffa724382824 esp32-csi-node.bin
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f ota_data_initial.bin
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 partition-table.bin
@@ -0,0 +1 @@
�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������Line truncated
@@ -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
+19 -5
View File
@@ -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
View File
@@ -1 +1 @@
0.8.10
0.8.12
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
RTL8721Dx (Realtek AmebaDplus) live CSI throughput/latency benchmark.
Listens on the wifi-densepose-sensing-server's CSI UDP port for real RAC1
frames (ADR-323, docs/adr/ADR-323-rtl8721dx-ameba-csi-wire-protocol.md) sent
by the ruview_csi_node firmware (branch feat/rtl8721dx-csi-bringup) running
on real RTL8721Dx hardware, and reports MEASURED — not simulated — throughput
and frame-rate statistics.
This does not itself trigger a board reset; run it while the firmware is
already connected and streaming (see CLAUDE.local.md for the reset+capture
pattern used to bring a fresh boot up during firmware iteration).
Usage:
python scripts/benchmark-rtl8721dx-csi.py --duration 40
python scripts/benchmark-rtl8721dx-csi.py --duration 40 --port 5005 --output results.json
"""
from __future__ import annotations
import argparse
import json
import socket
import struct
import sys
import time
from dataclasses import dataclass, asdict, field
from pathlib import Path
RAC1_MAGIC = bytes.fromhex("52414331") # "RAC1" little-endian, see ADR-323
RHB1_MAGIC = bytes.fromhex("52484231") # heartbeat marker (not a CSI frame)
RAC1_HEADER_LEN = 49
@dataclass
class Rac1Frame:
recv_time: float
length: int
sequence: int
timestamp_us: int
channel: int
bandwidth: int
num_sub_carrier: int
rssi_dbm: int
csi_valid: int
def parse_rac1_header(data: bytes, recv_time: float) -> Rac1Frame | None:
if len(data) < RAC1_HEADER_LEN or data[:4] != RAC1_MAGIC:
return None
sequence = struct.unpack_from("<I", data, 13)[0]
timestamp_us = struct.unpack_from("<I", data, 17)[0]
channel = data[33]
bandwidth = data[34]
num_sub_carrier = struct.unpack_from("<H", data, 37)[0]
rssi_dbm = struct.unpack_from("<b", data, 41)[0]
csi_valid = data[43]
return Rac1Frame(
recv_time=recv_time,
length=len(data),
sequence=sequence,
timestamp_us=timestamp_us,
channel=channel,
bandwidth=bandwidth,
num_sub_carrier=num_sub_carrier,
rssi_dbm=rssi_dbm,
csi_valid=csi_valid,
)
@dataclass
class BenchmarkResult:
duration_s: float
total_datagrams: int
rac1_frames: int
heartbeat_packets: int
other_packets: int
rac1_bytes: int
rac1_fps: float
rac1_bytes_per_sec: float
sequence_gaps: int
first_sequence: int | None
last_sequence: int | None
rssi_min_dbm: int | None
rssi_max_dbm: int | None
rssi_mean_dbm: float | None
inter_frame_interval_ms_mean: float | None
inter_frame_interval_ms_p95: float | None
channels_seen: list[int] = field(default_factory=list)
evidence: str = "MEASURED"
def run_benchmark(port: int, duration: float, bind_ip: str = "0.0.0.0") -> BenchmarkResult:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind((bind_ip, port))
sock.settimeout(0.5)
frames: list[Rac1Frame] = []
total = 0
heartbeats = 0
other = 0
deadline = time.time() + duration
print(f"Listening on {bind_ip}:{port} for {duration}s ...", file=sys.stderr)
while time.time() < deadline:
try:
data, _addr = sock.recvfrom(4096)
except socket.timeout:
continue
recv_time = time.time()
total += 1
if data[:4] == RHB1_MAGIC:
heartbeats += 1
continue
frame = parse_rac1_header(data, recv_time)
if frame is not None:
frames.append(frame)
else:
other += 1
sock.close()
rac1_bytes = sum(f.length for f in frames)
seqs = [f.sequence for f in frames]
rssis = [f.rssi_dbm for f in frames]
channels = sorted({f.channel for f in frames})
gaps = 0
for a, b in zip(seqs, seqs[1:]):
if b != a + 1:
gaps += 1
intervals_ms = [
(b.recv_time - a.recv_time) * 1000.0 for a, b in zip(frames, frames[1:])
]
intervals_ms.sort()
def percentile(values: list[float], p: float) -> float | None:
if not values:
return None
idx = min(len(values) - 1, int(len(values) * p))
return values[idx]
return BenchmarkResult(
duration_s=duration,
total_datagrams=total,
rac1_frames=len(frames),
heartbeat_packets=heartbeats,
other_packets=other,
rac1_bytes=rac1_bytes,
rac1_fps=len(frames) / duration if duration > 0 else 0.0,
rac1_bytes_per_sec=rac1_bytes / duration if duration > 0 else 0.0,
sequence_gaps=gaps,
first_sequence=seqs[0] if seqs else None,
last_sequence=seqs[-1] if seqs else None,
rssi_min_dbm=min(rssis) if rssis else None,
rssi_max_dbm=max(rssis) if rssis else None,
rssi_mean_dbm=(sum(rssis) / len(rssis)) if rssis else None,
inter_frame_interval_ms_mean=(
sum(intervals_ms) / len(intervals_ms) if intervals_ms else None
),
inter_frame_interval_ms_p95=percentile(intervals_ms, 0.95),
channels_seen=channels,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", type=int, default=5005, help="UDP port to listen on (default: 5005, matches wifi-densepose-sensing-server)")
parser.add_argument("--bind-ip", type=str, default="0.0.0.0")
parser.add_argument("--duration", type=float, default=40.0, help="capture window in seconds")
parser.add_argument("--output", type=Path, default=None, help="write results as JSON to this path")
args = parser.parse_args()
result = run_benchmark(args.port, args.duration, args.bind_ip)
print(f"\n=== RTL8721Dx RAC1 CSI benchmark ({result.evidence}) ===")
print(f"duration: {result.duration_s:.1f}s")
print(f"total UDP datagrams: {result.total_datagrams}")
print(f"RAC1 CSI frames: {result.rac1_frames}")
print(f"heartbeat packets: {result.heartbeat_packets}")
print(f"other/unrecognized: {result.other_packets}")
print(f"RAC1 throughput: {result.rac1_fps:.2f} fps, {result.rac1_bytes_per_sec:.1f} bytes/s")
print(f"sequence gaps: {result.sequence_gaps} (dropped/reordered frames)")
if result.first_sequence is not None:
print(f"sequence range: {result.first_sequence} .. {result.last_sequence}")
if result.rssi_mean_dbm is not None:
print(f"RSSI: min={result.rssi_min_dbm} max={result.rssi_max_dbm} mean={result.rssi_mean_dbm:.1f} dBm")
if result.inter_frame_interval_ms_mean is not None:
print(f"inter-frame interval: mean={result.inter_frame_interval_ms_mean:.1f}ms p95={result.inter_frame_interval_ms_p95:.1f}ms")
print(f"channels seen: {result.channels_seen}")
if args.output:
args.output.write_text(json.dumps(asdict(result), indent=2))
print(f"\nwrote {args.output}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
Generated
+4 -4
View File
@@ -9109,9 +9109,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.37"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"log",
"once_cell",
@@ -9173,9 +9173,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
@@ -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);