Compare commits

...

15 Commits

Author SHA1 Message Date
rUv aa7cb449bc fix(ci): bypass hardened entrypoint for asset inspection (#1364) 2026-07-19 02:50:47 -04:00
rUv 47dbdb29c0 fix(ci): restore workflow and LAN smoke tests (#1362) 2026-07-19 01:11:45 -04:00
rUv 6ee1b55896 feat: implement ADR-270 vendor provider beta (#1360) 2026-07-19 00:09:50 -04:00
rUv 76c80c33d7 feat(hardware): add Qualcomm CSI simulator and vendor roadmap (#1359) 2026-07-18 23:03:45 -04:00
rUv 232b1c79f6 feat(hardware): add MediaTek Filogic CSI simulator (#1358) 2026-07-18 22:00:09 -04:00
rUv 8a5af5dad4 feat: add RTL8720F Realtek radar beta support (#1356)
* docs(adr): plan RTL8720F radar SDK integration

* feat(hardware): add Rust RTL8720F radar simulator

* feat(ruview): ingest RTL8720F radar frames
2026-07-18 19:48:40 -04:00
rUv c7b3f0bcc5 Merge pull request #1355 from ruvnet/agent/open-issues-release
fix: open issue sweep and ESP32 v0.8.4 release
2026-07-18 18:17:38 -04:00
ruv 8c0bdaef92 fix open issue release blockers 2026-07-18 18:01:23 -04:00
rjperry36 1692b16f6e fix(sensing-server): canonicalize calibration frames to 56-tone grid (real HT40 fix)
Follow-up to the calibration deadlock fix. With the status gate unstuck,
maybe_feed_calibration reached feed_calibration, but a real ESP32 HT40 node
streams 128-wide amplitude frames while the single-link FieldModel is the
canonical 56-tone grid. LinkStats::update returned DimensionMismatch,
feed_calibration bubbled it, and maybe_feed_calibration swallowed it at debug
level — so frame_count stayed pinned at 0 on live hardware (presence/vitals,
which read the global history, were unaffected).

Resample each frame onto the model's canonical 56-tone grid via
HardwareNormalizer::resample_to_canonical before feeding — the same length-only
canonicalization the multistatic fusion path uses (#1170).

Pinned by maybe_feed_calibration_resamples_wide_frames_and_accumulates
(128-wide -> Collecting + count 1). sensing-server bin: 173 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-18 17:27:30 -04:00
rjperry36 4bab48e15f fix(sensing-server): unstick empty-room field-model calibration deadlock
POST /api/v1/calibration/start created the FieldModel in Uncalibrated, but
field_bridge::maybe_feed_calibration only fed frames while already Collecting
— and the only thing that sets Collecting is feed_calibration on its first
fed frame. The two gates deadlocked: no first frame was ever fed, so
calibration_frame_count stayed 0 and status never left Uncalibrated. Observed
live on a streaming ESP32 node as {"status":"Uncalibrated","frame_count":0}
that never advanced.

- field_bridge::maybe_feed_calibration: feed while Uncalibrated | Collecting
  so the first frame flips the model to Collecting and the count advances.
- calibration_stop: return structured {success:false, frame_count,
  frames_needed} instead of an opaque 500 when finalized with too few frames.
- FieldModel::min_calibration_frames() accessor for the guard above.
- Regression test: maybe_feed_calibration_advances_uncalibrated_to_collecting.

Presence/motion/vitals were unaffected (separate auto rolling baseline).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-18 17:27:08 -04:00
Matías J. Apablaza 6f0114c488 fix(firmware): accept WPA/WPA2-mixed routers; warn about compact-board thermal risk
Lower the STA auth threshold from WPA2_PSK to WPA_PSK so routers running
WPA/WPA2-mixed compatibility mode aren't rejected with
WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD (reason=211), and log the
disconnect reason/rssi instead of retrying blind. Also document the thermal
risk of running this firmware's continuous-radio, tier-2 DSP pipeline on
coin-sized clone boards (ESP32-S3-Zero, SuperMini) with minimal PCB copper
and budget regulators, after a field report of three such boards failing to
power on following a normal session.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-18 17:26:17 -04:00
Sushant c8e990c36f Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-18 17:26:17 -04:00
Sushant Guri 7925aa94ab fix(api): resolve event loop blocking in health and metrics endpoints 2026-07-18 17:26:17 -04:00
Matt Van Horn c2cf180afe docs: align firmware ESP-IDF version references to v5.4 (fixes #1177 b 2026-07-18 17:26:16 -04:00
Matt Van Horn 8c232d0894 fix: rename HeartRateExtractor.extract() weights param to phases 2026-07-18 17:26:16 -04:00
54 changed files with 6850 additions and 55 deletions
+6 -5
View File
@@ -88,8 +88,6 @@ jobs:
# ADR-262 P1: `wifi-densepose-rufield` path-deps the `vendor/rufield`
# submodule. Without a recursive checkout the workspace build fails to
# resolve those path deps in CI even though it passes locally.
with:
submodules: recursive
# `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`,
# `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the
@@ -146,7 +144,10 @@ jobs:
env:
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
run: cargo test -p wifi-densepose-worldmodel --no-default-features
run: >-
cargo test
--manifest-path crates/worldgraph/wifi-densepose-worldmodel/Cargo.toml
--no-default-features
# ADR-134 CIR tests are behind the `cir` feature so the bench dependency
# (Criterion) only pulls when actually exercised. Run them as a separate
@@ -249,7 +250,7 @@ jobs:
continue-on-error: true
uses: codecov/codecov-action@v6
with:
file: ./coverage.xml
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
@@ -500,4 +501,4 @@ jobs:
**Docker Image:**
`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}`
draft: false
prerelease: false
prerelease: false
+12 -4
View File
@@ -52,14 +52,16 @@ jobs:
target: esp32s3
sdkconfig: sdkconfig.defaults
partition_table_name: partitions_display.csv
size_limit_kb: 1100
size_warn_kb: 1100
size_limit_kb: 1152
artifact_app: esp32-csi-node.bin
artifact_pt: partition-table.bin
- variant: 4mb
target: esp32s3
sdkconfig: sdkconfig.defaults.4mb
partition_table_name: partitions_4mb.csv
size_limit_kb: 1100
size_warn_kb: 1100
size_limit_kb: 1152
artifact_app: esp32-csi-node-4mb.bin
artifact_pt: partition-table-4mb.bin
# ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core)
@@ -67,7 +69,8 @@ jobs:
target: esp32c6
sdkconfig: sdkconfig.defaults
partition_table_name: partitions_4mb.csv
size_limit_kb: 1100
size_warn_kb: 1100
size_limit_kb: 1152
artifact_app: esp32-csi-node-c6.bin
artifact_pt: partition-table-c6.bin
@@ -96,18 +99,23 @@ jobs:
make test_adr110
./test_adr110
- name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate)
- name: Verify binary size budget
working-directory: firmware/esp32-csi-node
run: |
BIN=build/esp32-csi-node.bin
SIZE=$(stat -c%s "$BIN")
MAX=$((${{ matrix.size_limit_kb }} * 1024))
WARN=$((${{ matrix.size_warn_kb }} * 1024))
echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
echo "Warning at: $WARN bytes (${{ matrix.size_warn_kb }} KB)"
echo "Size limit: $MAX bytes (${{ matrix.size_limit_kb }} KB)"
if [ "$SIZE" -gt "$MAX" ]; then
echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)"
exit 1
fi
if [ "$SIZE" -gt "$WARN" ]; then
echo "::warning::Firmware binary exceeds the ${{ matrix.size_warn_kb }} KB soft budget ($SIZE > $WARN); hard limit is ${{ matrix.size_limit_kb }} KB"
fi
echo "Binary size OK: $SIZE <= $MAX"
- name: Verify flash image integrity
+7 -4
View File
@@ -115,7 +115,7 @@ jobs:
# RUN guard catches missing ones at build time, this re-checks the
# pushed artifact post-hoc as belt-and-braces).
# 2. /health is up.
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
# 3. /api/v1/info returns 200 with the explicit trusted-LAN opt-in.
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
# Bearer header, 200 with the correct one (the #443 auth middleware).
# ---------------------------------------------------------------------
@@ -124,11 +124,14 @@ jobs:
set -euo pipefail
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
docker pull "$IMAGE"
docker run --rm "$IMAGE" sh -c \
docker run --rm --entrypoint sh "$IMAGE" -c \
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
docker run --rm --entrypoint sh "$IMAGE" -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
docker run -d --name sm -p 3000:3000 \
-e CSI_SOURCE=simulated \
-e RUVIEW_ALLOW_UNAUTHENTICATED=1 \
"$IMAGE"
# Wait up to 30 s for /health.
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
+3
View File
@@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies.
### Fixed
- **FastAPI health/metrics endpoints event-loop starvation.** Calling `psutil.cpu_percent(interval=1)` blocked the single-threaded async event loop for 1.0 second on every health check or metrics collection tick, stalling all incoming requests and WebSocket operations. Fixed by changing `cpu_percent` to use non-blocking `interval=None` and offloading all blocking OS metrics gathering to background thread pools via `asyncio.to_thread`. Verified event loop responsiveness via concurrency regression tests.
- **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option<MultistaticConfig>` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed).
- **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it.
- **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 4045 pps).
- **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn``ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `<jobsDir>/<id>.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20.
- **Empty-room field-model calibration collected nothing on real HT40 nodes — raw 128-wide frames rejected by the 56-tone model (follow-up to the deadlock fix below).** Once the status-gate deadlock was fixed, `maybe_feed_calibration` reached `feed_calibration`, but a real ESP32 HT40 node streams 128-wide amplitude vectors while the single-link `FieldModel` is the canonical 56-tone grid — so `LinkStats::update` returned `DimensionMismatch`, `feed_calibration` bubbled the error, and `maybe_feed_calibration` swallowed it at `debug` level. Net effect: `frame_count` stayed pinned at 0 on live hardware even though presence/motion/vitals (which read the global history) worked fine. Fixed by resampling each frame onto the model's canonical 56-tone grid via `HardwareNormalizer::resample_to_canonical` before feeding — the same length-only canonicalization the multistatic fusion path uses (#1170). Pinned by `field_bridge::maybe_feed_calibration_resamples_wide_frames_and_accumulates` (128-wide frame → Collecting + count 1; fails on old code). Verified live on the ESP32-S3 deployment.
- **Empty-room field-model calibration could never start — `/api/v1/calibration/*` was a dead endpoint (frame count pinned at 0).** `POST /calibration/start` creates the `FieldModel` in `Uncalibrated`, but the per-frame server feed `field_bridge::maybe_feed_calibration` only fed observations while the model was **already** `Collecting` — and the *only* thing that sets `Collecting` is `feed_calibration` itself (on its first fed frame). The two gates deadlocked: nothing ever fed the first frame, so `calibration_frame_count` stayed 0, `status` never left `Uncalibrated`, and the SVD room eigenstructure (eigenvalue-based person counting / localization) could never calibrate — observed live as `{"status":"Uncalibrated","frame_count":0}` never advancing on a streaming ESP32 node. Fixed the guard to feed while `Uncalibrated | Collecting` so the first frame flips the model to `Collecting` and the count advances. Also made `calibration_stop` return a structured `{success:false, frame_count, frames_needed}` (with a new `FieldModel::min_calibration_frames()` accessor) instead of an opaque 500 when finalized before enough empty-room frames accumulate. Pinned by `field_bridge::maybe_feed_calibration_advances_uncalibrated_to_collecting` (asserts `Uncalibrated → Collecting` + count 0 → 1 → 2; fails on old code). Presence/motion/vitals were unaffected — they use the separate auto rolling baseline, not the field model.
- **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed.
- **`csi_fps_ema` reported the CSI frame rate 40840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`.
- **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4).
+2
View File
@@ -93,6 +93,8 @@ All 5 ruvector crates integrated in workspace:
**Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline.
**⚠️ Compact boards (SuperMini, ESP32-S3-Zero, other coin-sized clones) run hot:** the firmware keeps the WiFi radio on continuously (`WIFI_PS_NONE`) and runs a full DSP pipeline (`edge_tier=2`), which is sustained high current draw. Full-size dev boards handle this fine; coin-sized clones with minimal PCB copper and budget regulators can run uncomfortably hot and, per at least one field report, have failed to power on again after a hot session. Give them airflow and check by touch during the first few minutes. See `firmware/esp32-csi-node/README.md` for details.
### Build & Test Commands (this repo)
```bash
# Rust — full workspace tests (1,031+ tests, ~2 min)
+2
View File
@@ -132,6 +132,8 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie
> | **ESP32 Mesh** | 3-6× ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
> | **ESP32-C6 research node** ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), [witness](docs/WITNESS-LOG-110.md), [reviewer guide](docs/ADR-110-REVIEW-GUIDE.md), [firmware v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)) | ESP32-C6-DevKit ($610) | ~$10 | Yes (Wi-Fi 6 capable) | Same CSI pipeline as S3 with the dual-target firmware. **Firmware-side ADR-110 substrate now closed** (v0.7.0): ESP-NOW cross-board mesh quantified at **99.56 % match / 104 µs smoothed offset stdev / 3.95× EMA suppression** over a 5-min two-board soak (witness §A0.10), 32-byte UDP sync packet with operator-tunable cadence (§A0.12), ADR-018 byte 19 bit 4 wire-fix sourced from the working ESP-NOW path (§A0.13). Wire format ready for HE-LTF PPDU tagging in ADR-018 bytes 18-19 (firmware encoder + Rust + Python decoders verified end-to-end across 23 unit tests). LP-core motion-gate RISC-V program and Wi-Fi 6 soft-AP with TWT Responder both ship as opt-in code paths (default off). **Hardware-gated for measurement**: HE-LTF live subcarrier capture needs an 11ax AP (IDF v5.4 doesn't expose AP-side HE config — §A0.6); ~5 µA LP-core hibernation needs an INA meter to capture; 802.15.4 raw RX is broken in IDF v5.4 (workaround: ESP-NOW transport, shipped + measured). See witness log for the empirical / claimed split. |
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
> | **Qualcomm CSI beta** ([ADR-268](docs/adr/ADR-268-qualcomm-atheros-csi-platform.md)) | QCA9300 now; QCN9074/QCN9274 experimental | ~$30-200 | Simulator now; hardware adapter gated | Rust `QCS1` codec, deterministic replay, UDP/API integration; modern ath11k/ath12k profiles do not claim public CSI export |
> | **Vendor provider beta** ([ADR-270](docs/adr/ADR-270-vendor-rf-sensing-integration-program.md)) | Origin, Plume, Mist, NETGEAR, Electric Imp, RF Solutions, Luma, Nest, Linksys, Wifigarden | Varies | Capability-dependent | Bounded Rust adapters and deterministic fixtures; telemetry/network-only/unsupported states cannot masquerade as CSI |
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) |
>
> No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py`
+6 -4
View File
@@ -2,6 +2,7 @@
Health check API endpoints
"""
import asyncio
import logging
import psutil
from typing import Dict, Any, Optional
@@ -168,7 +169,7 @@ async def health_check(request: Request):
overall_status = "degraded"
# Get system metrics
system_metrics = get_system_metrics()
system_metrics = await asyncio.to_thread(get_system_metrics)
uptime_seconds = (datetime.now() - _APP_START_TIME).total_seconds()
@@ -263,11 +264,12 @@ async def get_health_metrics(
):
"""Get detailed system metrics."""
try:
metrics = get_system_metrics()
metrics = await asyncio.to_thread(get_system_metrics)
# Add additional metrics if authenticated
if current_user:
metrics.update(get_detailed_metrics())
detailed = await asyncio.to_thread(get_detailed_metrics)
metrics.update(detailed)
return {
"timestamp": datetime.utcnow().isoformat(),
@@ -300,7 +302,7 @@ def get_system_metrics() -> Dict[str, Any]:
"""Get basic system metrics."""
try:
# CPU metrics
cpu_percent = psutil.cpu_percent(interval=1)
cpu_percent = psutil.cpu_percent(interval=None)
cpu_count = psutil.cpu_count()
# Memory metrics
+13 -10
View File
@@ -180,21 +180,24 @@ class MetricsService:
async def _collect_system_metrics(self):
"""Collect system-level metrics."""
try:
# CPU usage
cpu_percent = psutil.cpu_percent(interval=1)
# Query OS metrics in a background thread to prevent blocking the event loop
def gather_metrics():
return (
psutil.cpu_percent(interval=None),
psutil.virtual_memory().percent,
psutil.disk_usage('/'),
psutil.net_io_counters()
)
cpu_percent, mem_percent, disk, network = await asyncio.to_thread(gather_metrics)
# Record metrics on the main loop
self._metrics["system_cpu_usage"].add_point(cpu_percent)
self._metrics["system_memory_usage"].add_point(mem_percent)
# Memory usage
memory = psutil.virtual_memory()
self._metrics["system_memory_usage"].add_point(memory.percent)
# Disk usage
disk = psutil.disk_usage('/')
disk_percent = (disk.used / disk.total) * 100
self._metrics["system_disk_usage"].add_point(disk_percent)
# Network I/O
network = psutil.net_io_counters()
self._metrics["system_network_bytes_sent"].add_point(network.bytes_sent)
self._metrics["system_network_bytes_recv"].add_point(network.bytes_recv)
@@ -0,0 +1,59 @@
import asyncio
import time
import os
import sys
import pytest
# Add project root and archive/v1 to sys.path so we can import src modules
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../")))
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from archive.v1.src.api.routers.health import get_system_metrics
async def ticker():
"""Asynchronous background ticker to measure event loop latency/freezes."""
ticks = []
for _ in range(15):
ticks.append(time.time())
await asyncio.sleep(0.1)
return ticks
async def run_test():
print("Starting concurrency verification test...")
# Start the ticker background task
ticker_task = asyncio.create_task(ticker())
# Let ticker run for a few ticks
await asyncio.sleep(0.3)
print("Calling get_system_metrics offloaded to background thread...")
start_time = time.time()
# Query system metrics using to_thread (simulating FastAPI request)
metrics = await asyncio.to_thread(get_system_metrics)
duration = time.time() - start_time
print(f"get_system_metrics took: {duration:.4f}s")
# Wait for the ticker to complete
ticks = await ticker_task
# Calculate gaps between consecutive ticks to check for event loop freezes
gaps = [ticks[i+1] - ticks[i] for i in range(len(ticks)-1)]
max_gap = max(gaps)
print(f"All tick gaps: {[round(g, 3) for g in gaps]}")
print(f"Max event loop freeze: {max_gap:.4f}s")
# In pre-fix code, psutil.cpu_percent(interval=1) blocks for 1.0s,
# causing a gap of >1.0s. With our fix, it should be close to 0.1s.
return max_gap, duration
@pytest.mark.asyncio
async def test_get_system_metrics_does_not_starve_event_loop():
max_gap, duration = await run_test()
# ticker sleeps 0.1s; allow slack for CI, but we should not see ~1s gaps
assert max_gap < 0.6
assert duration < 0.6
@@ -83,7 +83,7 @@ This ADR covers Phase 1 (TV box as aggregator) and Phase 2 (custom WiFi firmware
|---------|--------|-------------|--------------|--------|
| Broadcom BCM43455 | brcmfmac | **Proven** (Nexmon CSI) | Yes | Low — patches exist |
| Realtek RTL8822CS | rtw88 | **Moderate** — driver is open-source, CSI hooks need adding | Yes (patched) | Medium |
| MediaTek MT7661 | mt76 | **Unknown** — MediaTek has released CSI tools for some chips | Yes | Medium-High |
| MediaTek MT7661 | mt76 | **Unverified** — no supported public CSI capture API was found in upstream `mt76` or public MediaTek SDK material | Yes | Research only |
2. **CSI extraction architecture** (Linux kernel driver modification):
@@ -0,0 +1,171 @@
# ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform
- **Status**: proposed
- **Date**: 2026-07-18
- **Deciders**: ruv
- **Tags**: realtek, rtl8720f, ameba, fmcw, radar, cfr, csi, hardware
- **Relates to**: ADR-018, ADR-063, ADR-064, ADR-095, ADR-097, ADR-260, ADR-262
## Context
Realtek's `RTL8720F-2.4G-Radar-Advantages_EN.pptx` describes an RTL8720F mode that shares the
2.4 GHz radio between Wi-Fi, Bluetooth, and an active FMCW radar. It offers two data products that
are useful to RuView:
1. **CFR (Channel Frequency Report)**, described by Realtek as the same concept as Wi-Fi CSI.
2. **Near and far Range-FFT reports**, preserving near-field content while extending observation to
approximately 56 m.
The proposed radio uses one transmit and one receive antenna, 20/40/70 MHz sweeps, configurable
8/16/32/64 microsecond chirp symbols, a maximum 2.56 ms FMCW packet, and a configurable frame
interval above 15 ms. The deck recommends 40 MHz outside Japan and 20 MHz in Japan. It also
describes EDCCA/CTS channel access, Wi-Fi/BT/radar time division, interference reporting, and
priority arbitration in the driver.
This is not a drop-in replacement for ESP32 CSI:
- it is **active monostatic FMCW**, while the ESP32 path observes Wi-Fi packet CSI;
- one Tx/one Rx has no angle-of-arrival or native multi-target separation;
- the stated 40 MHz range resolution is about 3.15 m, despite a finer 0.59 m Range-FFT report step;
- the presentation is a capability description, not an SDK contract. It contains no header names,
function signatures, callback ABI, binary layouts, toolchain version, licensing terms, or public
RTL8720F board package.
Realtek's public Ameba RTOS repository is the base. Release v1.2.1 includes the CSI API and fixes a
CSI application-buffer semaphore issue, but does not expose the radar application surface. Open
upstream PR #1336 (2026-07-18 snapshot) adds RTL8720F project artifacts, `AT+RAD`, `AT+RADDBG`, and
the public configuration call `wifi_radar_config(struct rtw_radar_action_parm *)`. Its public
parameter struct confirms mode, channel, 70/40/20 MHz bandwidth selector, trigger period, and
enable/config actions. Report reception still crosses non-public/placeholder HAL symbols such as
`wifi_hal_radar_recv_data(frame_num, frame_type, data)`, so the report layout and buffer lifetime
remain vendor-gated. Therefore the integration stays split at that boundary.
## Decision
RuView will support RTL8720F radar as an **optional, capability-negotiated source**, without
replacing the ESP32 firmware or treating radar CFR as byte-compatible with ADR-018 CSI.
The integration has three layers:
1. **Realtek device firmware**: a small application built in the vendor-supported Ameba SDK calls
the radar API, owns coexistence configuration, and emits versioned reports. This code lives under
`firmware/rtl8720f-radar/` only after the redistributable SDK/API is available.
2. **Transport-neutral wire contract**: CFR and Range-FFT reports are framed independently from the
vendor ABI and sent over UDP, USB CDC, or UART. ADR-264 defines this boundary.
3. **Rust host adapter**: `wifi-densepose-hardware` parses reports from bytes and converts CFR into
the existing CSI-domain representation, while Range-FFT remains a radar modality and feeds the
RuField/RuView cross-modality bridge from ADR-260/262.
The two report types remain semantically distinct:
| RTL8720F output | RuView representation | Permitted use |
|---|---|---|
| CFR | `CsiFrame` through a Realtek calibration adapter | CSI feature extraction after validation |
| Range-FFT near/far | `RadarFrame` / RuField `mmwave_radar`-class event with a 2.4 GHz descriptor | range, motion, presence, fusion |
| Vendor AI presence probability | derived observation with model/version provenance | advisory input, never ground truth |
| Interference report | quality/provenance metadata | reject, down-weight, or mark contaminated frames |
The modality registry should eventually distinguish `fmcw_radar_2_4ghz` from `mmwave_radar`; until
that RuField schema revision is accepted, the adapter must attach `carrier_hz = 2.4e9` and must not
claim millimetre-wave provenance.
## Delivery phases and gates
### P0 — Vendor enablement
Obtain the PR #1336-or-newer RTL8720F SDK package, radar API headers/libraries, a supported evaluation
board, flashing/debug instructions, report definitions, and written redistribution terms.
**Gate:** compile and run Realtek's unmodified radar example and capture CFR plus near/far
Range-FFT output. Until this passes, device firmware is `VENDOR_BLOCKED`, not implemented.
### P1 — Host-first contract
Implement ADR-264 types, parsers, fixtures, fuzz tests, and replay support without linking vendor
code. Use the Rust `Rtl8720fSimulator` as the only pre-hardware live source. It emits deterministic
CFR, near/far Range-FFT, interference, and capabilities frames through the same ADR-264 encoder and
parser used by hardware. Every simulated frame sets `RadarFlags::SYNTHETIC`; simulation results are
never reported as device measurements.
**Gate:** malformed inputs never panic; encode/decode round trips; unknown versions and report
types fail closed.
### P2 — RTL8720F firmware adapter
Wrap only the minimum vendor API surface: initialization, profile configuration, start/stop,
callback acquisition, interference status, and report serialization. Keep vendor types out of the
wire protocol.
**Gate:** 30-minute simultaneous Wi-Fi telemetry and radar capture with no watchdog reset, bounded
loss, monotonic sequence numbers, and explicit coexistence/interference statistics.
### P3 — Calibration and signal validation
Calibrate CFR phase/amplitude, Range-FFT bin spacing, static leakage, and clock drift. Compare
reported range against measured targets at multiple distances and bandwidths.
**Gate:** publish measured error distributions. Do not infer accuracy from report-bin spacing and
do not advertise multi-person pose or vital signs from the vendor deck.
### P4 — Fusion and productization
Feed calibrated CFR through the CSI path and Range-FFT through RuField, retaining source, mode,
bandwidth, calibration, firmware, and interference provenance.
**Gate:** ablation shows whether the radar stream improves a named RuView metric over ESP32 CSI
alone. If it does not, ship it only as an independent presence/range sensor.
## Consequences
### Positive
- One low-cost radio can provide active radar and CSI-like CFR while retaining Wi-Fi connectivity.
- Range-FFT adds an independent physical measurement for presence/range fusion.
- The vendor SDK is isolated from the Rust sensing core and from the stable on-wire contract.
- Capability negotiation permits future Realtek parts without another application-level fork.
### Negative
- The first implementation is blocked on access to the actual RTL8720F radar SDK/API and hardware.
- Active 2.4 GHz transmission changes coexistence, privacy, power, and regional compliance concerns.
- 1T1R and limited sweep bandwidth cannot provide the spatial resolution of multi-antenna mmWave.
- A second embedded toolchain and firmware release process must be maintained.
### Neutral
- ESP32 remains the default CSI node.
- Existing consumers receive normalized frames and do not link against Realtek code.
- Vendor AI output is optional metadata; RuView retains responsibility for its own validation.
## Rejected alternatives
1. **Map Range-FFT directly to `CsiFrame`.** Rejected because range bins and channel-frequency
samples have different axes and physical meaning.
2. **Link the Realtek SDK into the Rust server.** Rejected because it couples host builds to a
proprietary embedded ABI and toolchain.
3. **Wait to define any interface until hardware arrives.** Rejected because the host protocol,
parser safety, replay, and provenance can be developed and reviewed independently.
4. **Replace ESP32 nodes.** Rejected because the modes are complementary and availability differs.
## Open vendor questions
- Exact RTL8720F part/board identifier and production availability.
- SDK repository/tag, compiler, RTOS, binary blobs, license, and redistribution permissions.
- Radar initialization/configuration/callback API signatures and threading/ISR constraints.
- CFR and near/far Range-FFT element type, complex ordering, scaling, endianness, and timestamps.
- Whether CFR is calibrated complex data and whether phase remains coherent across frames.
- Maximum report rates, buffer ownership, DMA/cache constraints, and Wi-Fi throughput impact.
- Region/channel enforcement and whether 70 MHz operation is allowed by the supplied firmware.
- Secure boot, signed OTA, unique device identity, and firmware attestation support.
## Sources
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 3 and 1019,
supplied 2026-07-18. This is product material, not measured RuView validation.
- [Ameba-AIoT/ameba-rtos releases](https://github.com/Ameba-AIoT/ameba-rtos/releases), reviewed
2026-07-18; v1.2.1 is the current QC release and includes a CSI buffer-semaphore fix.
- [Ameba-AIoT/ameba-rtos PR #1336](https://github.com/Ameba-AIoT/ameba-rtos/pull/1336), reviewed
2026-07-18; exposes RTL8720F build assets, `wifi_radar_config`, and radar AT commands while report
internals remain in binary/private layers.
- ADR-063 (mmWave sensor fusion), ADR-095/097 (source normalization), and ADR-260/262 (RuField
multimodal event model and live bridge).
@@ -0,0 +1,148 @@
# ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports
- **Status**: proposed
- **Date**: 2026-07-18
- **Deciders**: ruv
- **Tags**: realtek, rtl8720f, protocol, cfr, range-fft, udp, serial
- **Depends on**: ADR-263
- **Relates to**: ADR-018, ADR-095, ADR-097, ADR-099, ADR-260
## Context
ADR-263 adopts RTL8720F radar behind an anti-corruption boundary. The Realtek presentation names
CFR, near Range-FFT, far Range-FFT, and interference reports, but does not specify their binary ABI.
RuView needs a stable, testable contract that can be implemented before the vendor SDK arrives and
that will not expose vendor structs, pointer layouts, padding, or callback lifetime rules over the
network.
ADR-018 already defines ESP32 CSI framing. Reusing its magic or pretending that Realtek radar is an
ESP32 packet would make source detection ambiguous and erase radar-specific calibration metadata.
## Decision
Define a new little-endian `RtlRadarFrameV1` envelope with its own magic and explicit payload type.
This is a RuView protocol, not a claim about Realtek's native memory layout.
### Envelope
All integer fields are little-endian. Floating-point payloads use IEEE-754 binary32. No C struct is
sent by `memcpy`; firmware serializes each field explicitly.
| Offset | Size | Field | Meaning |
|---:|---:|---|---|
| 0 | 4 | magic | ASCII `RTR1` (`0x31525452`) |
| 4 | 1 | version | `1` |
| 5 | 1 | report_type | 1 CFR, 2 range-near, 3 range-far, 4 interference, 5 capabilities |
| 6 | 2 | header_len | complete header size, initially 56 |
| 8 | 4 | frame_len | header + payload + CRC |
| 12 | 4 | sequence | wraps modulo 2^32 |
| 16 | 8 | timestamp_us | monotonic device time at acquisition |
| 24 | 8 | device_id | stable pseudonymous identifier, not a MAC address |
| 32 | 4 | center_freq_khz | RF centre frequency |
| 36 | 2 | bandwidth_mhz | 20, 40, or 70 |
| 38 | 2 | flags | calibration/interference/saturation/time-sync flags |
| 40 | 2 | element_count | complex samples or range bins |
| 42 | 1 | element_format | 0 bytes/TLV, 1 complex-i16, 2 complex-f32, 3 power-u16, 4 power-f32 |
| 43 | 1 | antenna_count | expected to be 1 for the deck's 1T1R configuration |
| 44 | 4 | scale | quantized-to-physical multiplier; `1.0` for float payloads |
| 48 | 4 | bin_spacing | Hz for CFR, metres for Range-FFT |
| 52 | 4 | calibration_id | device calibration revision/hash prefix |
| 56 | variable | payload | determined by type, count, and format |
| final-4 | 4 | crc32 | IEEE CRC-32 over header and payload |
If vendor evidence shows that 56 bytes is too costly, a later protocol version may introduce a
compact header. V1 favors auditable provenance over premature byte savings.
### Payload semantics
- **CFR** contains ordered complex channel-frequency samples. The adapter must know the frequency
origin/order and must not fabricate missing phase. Uncalibrated frames carry the uncalibrated flag
and cannot enter phase-sensitive processing.
- **Range-near/range-far** contains ordered range bins. Near and far are separate report types so
filtering and leakage behavior are never hidden from consumers.
- **Interference** contains a versioned TLV set for channel-busy, detected-during-chirp, estimated
interference power, and packet jitter. Unknown TLVs are skipped by length.
- **Capabilities** is emitted at boot and on request. It declares supported report types, bandwidths,
chirp lengths, maximum elements/report, maximum frame rate, firmware version, and SDK identifier.
### Transport
The identical envelope is supported over:
- UDP datagrams for normal RuView ingestion;
- USB CDC or UART with COBS framing and a zero-byte delimiter;
- file replay as a length-prefixed sequence of envelopes.
One envelope must fit one UDP datagram. Fragmentation is not part of V1; firmware rejects a profile
whose maximum report exceeds the configured MTU and reports the required size through capabilities.
### Parser and trust rules
The host parser:
1. validates magic, version, lengths, enum values, element count/format multiplication, and CRC
before allocating or decoding the payload;
2. caps frames at 64 KiB and elements at a configured hardware maximum;
3. rejects non-finite float metadata/payload values;
4. tracks sequence gaps and timestamp regressions per device;
5. preserves unknown flags but never interprets them as trusted;
6. attaches transport source, firmware/SDK version, calibration ID, and interference state to
provenance;
7. labels fixture/generated frames as synthetic.
No vendor-provided presence probability bypasses RuView privacy, provenance, or quality gates.
## Consequences
### Positive
- Firmware, transport, parser, replay, and fusion can evolve independently.
- Fuzzing and golden fixtures require no Realtek SDK or board.
- CFR and Range-FFT retain correct axes and calibration provenance.
- A boot-time capabilities frame makes SDK/API drift observable.
### Negative
- Serialization adds CPU and bandwidth overhead compared with dumping a vendor buffer.
- V1 fields may need revision after the actual API and report limits are disclosed.
- UDP provides integrity/error detection, not authenticity or confidentiality.
### Neutral
- Authentication can be layered with ADR-032 device identity or a signed RuField receipt without
changing report semantics.
- ESP32 ADR-018 framing remains unchanged.
## Implementation plan
1. Add `rtl8720f` types/parser module to `wifi-densepose-hardware` behind no vendor dependency.
2. Add golden CFR, near/far Range-FFT, interference, and capabilities fixtures.
3. Add property/fuzz tests for length arithmetic, enum handling, CRC, and float validation.
4. Add a replay CLI that prints normalized metadata without running inference.
5. Once SDK access exists, implement the embedded serializer and verify captured frames against the
host golden decoder.
6. Revise this proposed ADR with measured element counts, rates, and API names before acceptance.
Host-side steps 13 are implemented in `wifi-densepose-hardware::rtl8720f`: typed report and
element enums, semantic type/format validation, bounded length arithmetic, CRC verification,
finite-float checks, encode/decode round trips, corruption/truncation tests, and deterministic
arbitrary-input panic checks. Cross-language vectors remain blocked on the vendor SDK callback ABI.
Bit 15 of `flags` is reserved by RuView as `SYNTHETIC`; the Rust simulator always sets it and real
firmware must never set it. The simulator is deterministic by seed and exercises the production
encoder/parser rather than a parallel mock representation.
## Acceptance criteria
- Rust encode/decode round-trip for every report type.
- Cross-language golden vector produced by the RTL8720F firmware.
- Zero parser panics over the fuzz corpus and arbitrary byte input.
- Detection of single-bit corruption, truncation, count overflow, timestamp regression, and gaps.
- Captured CFR frequency order and Range-FFT bin spacing verified against vendor documentation and a
measured target.
## Sources
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 1119, supplied
2026-07-18.
- ADR-018 (ESP32 framing), ADR-095/097 (hardware normalization), ADR-260 (multimodal event model),
and ADR-263 (platform decision).
@@ -0,0 +1,75 @@
# ADR-266: MediaTek Filogic CSI Platform
- **Status**: accepted
- **Date**: 2026-07-18
- **Deciders**: RuView maintainers
- **Tags**: mediatek, filogic, mt76, csi, openwrt, rust
## Context
RuView needs a high-antenna-count, router-class Wi-Fi sensing path beyond ESP32.
MediaTek Filogic platforms are attractive because the upstream BSD-3-Clause
`mt76` driver supports MT7915/MT792x/MT7996 families and OpenWrt supports
MT7981/MT7986/MT7988 systems. The OpenWrt One (MT7981B + MT7976C) additionally
publishes schematics, platform datasheets, register documentation, serial, and
JTAG access. The BPI-R3 (MT7986 + MT7975N/P) offers dual-band 4x4 radios.
The current upstream `mt76` tree has testmode, debugfs, RX descriptors, and MCU
event plumbing, but no supported public interface for exporting per-packet
complex channel estimates. Public MediaTek SDK material likewise does not expose
an equivalent to Espressif's CSI callback. PHY computation of channel estimates
does not imply that firmware transfers those estimates to host memory.
Existing RuView documents that describe MT7661 CSI-over-UDP or released
MediaTek CSI tools are unverified architectural hypotheses, not supported
hardware claims.
## Decision
1. Use the OpenWrt One as the primary future hardware/upstreaming target and the
BPI-R3 as the secondary 4x4 validation target.
2. Build a Rust-first simulator and host transport before hardware arrives.
3. Keep the transport independent of private firmware structures. A future
`mt76` adapter must translate a documented kernel/firmware report into it.
4. Prefer Generic Netlink for capability/control messages and relayfs or a
bounded character-device stream if sustained CSI volume exceeds Netlink's
practical throughput.
5. Do not redistribute vendor firmware, private headers, or SDK components.
6. Label simulator frames end-to-end and never present them as physical capture.
7. Do not claim MediaTek hardware CSI support until complex CSI from a physical
device passes calibration, sequence, timestamp, and repeatability tests.
## Consequences
### Positive
- Development and integration testing can start without fabricating a vendor ABI.
- OpenWrt One provides a repairable, upstream-friendly hardware target.
- The same RuView ingestion path can accept simulator, replay, and future driver data.
- Rust bounds checking isolates untrusted kernel/network input from inference code.
### Negative
- The simulator cannot prove firmware export availability or sensing accuracy.
- A firmware change or MediaTek cooperation may be required before physical CSI exists.
- Router-class builds and driver iteration are slower than MCU firmware development.
### Neutral
- NeuroPilot may later accelerate inference but is unrelated to CSI capture.
- Wi-Fi 7/MLO support remains a later phase after a single-link contract is stable.
## Hardware gates
- Identify a firmware/host report containing complex channel estimates.
- Document dimensions, quantization, chain ordering, subcarrier indexing, lifetime,
timestamps, sequence behavior, calibration, maximum size, and report rate.
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
## Links
- [ADR-123: BFLD capture path](ADR-123-bfld-capture-path-nexmon-and-esp32.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- [upstream mt76](https://github.com/openwrt/mt76)
- [OpenWrt One](https://openwrt.org/toh/openwrt/one)
- [MediaTek OpenWrt feed](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/)
@@ -0,0 +1,61 @@
# ADR-267: MediaTek MIMO CSI Wire Protocol
- **Status**: accepted
- **Date**: 2026-07-18
- **Deciders**: RuView maintainers
- **Tags**: mediatek, csi, protocol, rust, udp, replay
## Context
The MediaTek simulator, captured regression fixtures, and a future `mt76` agent
need one safe host-side representation. Copying an undocumented firmware layout
would couple RuView to a private ABI and make malformed kernel/network data risky.
MIMO CSI also requires explicit Tx/Rx/subcarrier dimensions and per-Rx-chain RSSI.
## Decision
Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- 72-byte fixed header with magic, version, report kind, total length, sequence,
monotonic timestamp, device ID, chipset profile, frequency, bandwidth, flags,
Tx/Rx dimensions, numeric format, PPDU type, subcarrier count, noise floor,
scale, subcarrier spacing, calibration ID, and payload length.
- CSI payload begins with one signed RSSI byte per Rx chain, followed by
`tx_count * rx_count * subcarrier_count` complex values in Tx-major,
Rx-major, subcarrier-major order.
- Supported numeric formats are complex signed i16 and complex finite f32.
- Capability reports use bounded opaque TLVs until a public driver contract exists.
- CRC-32/IEEE covers header and payload; the final four bytes carry the checksum.
- One envelope maps to one UDP datagram, capped at the IPv4 UDP payload maximum
of 65,507 bytes. Replay files prefix each envelope with a little-endian `u32`.
- Parsers reject unknown versions/types/formats, invalid dimensions/bandwidth,
multiplication overflow, inconsistent payload lengths, non-finite floats,
bad CRC, trailing datagram bytes, and frames above the cap.
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
## Consequences
### Positive
- Deterministic simulator and future hardware use identical parsing and APIs.
- Explicit dimensions prevent ambiguous antenna or subcarrier interpretation.
- CRC, finite-value checks, and hard caps make network/replay ingestion robust.
- The format supports MT7981, MT7986, and MT7996 profiles without claiming their
undocumented firmware layouts.
### Negative
- A translation/copy step is required from a future kernel report.
- Maximum-size Wi-Fi 7 matrices may need segmentation in a later protocol version.
### Neutral
- Version 1 models one link per report; MLO correlation is a future extension.
- Capability TLVs are intentionally conservative until hardware metadata is known.
## Links
- [ADR-266: MediaTek Filogic CSI platform](ADR-266-mediatek-filogic-csi-platform.md)
- [ADR-018: ESP32 binary CSI framing](ADR-018-esp32-csi-frame-protocol.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
@@ -0,0 +1,41 @@
# ADR-268: Qualcomm Atheros CSI Platform Strategy
- **Status**: accepted
- **Date**: 2026-07-18
- **Tags**: qualcomm, atheros, csi, ath9k, ath11k, ath12k, simulator
## Context
RuView needs a Qualcomm path that is useful before vendor hardware access while
remaining honest about firmware boundaries. QCA9300 has demonstrated CSI tooling
through ath9k/PicoScenes-class systems. QCN9074 and QCN9274 have upstream Linux
connectivity drivers, but upstream ath11k/ath12k support does not by itself prove
that raw per-packet complex CSI is exported by public firmware.
## Decision
1. Use QCA9300 as the first physical baseline: 802.11n, up to 3x3 MIMO and
20/40 MHz. Accept translated captures from established research tooling.
2. Model QCN9074 (Wi-Fi 6/6E, 4x4, up to 160 MHz) and QCN9274 (Wi-Fi 7, 4x4,
up to 160 MHz in protocol v1) as explicitly experimental simulator profiles.
3. Keep firmware/kernel formats behind a Rust adapter. RuView ingests only the
validated QCS1 application envelope defined by ADR-269.
4. Never label simulated frames as hardware. Physical support requires captured
fixtures, firmware provenance, antenna ordering, scaling and repeatability tests.
5. Prefer an upstream-reviewed Generic Netlink or relay-style export if modern
Qualcomm firmware exposes CFR/CSI; do not depend on undisclosed structs.
## Consequences
- Development, APIs and downstream sensing can be tested immediately.
- QCA9300 offers the shortest path to real Qualcomm data.
- Modern profiles may remain simulator-only until firmware cooperation exists.
- A translation copy is accepted in exchange for a stable, fuzzable boundary.
## Links
- [ADR-269: QCS1 wire protocol](ADR-269-qualcomm-csi-wire-protocol.md)
- [Linux ath11k supported devices](https://wireless.docs.kernel.org/en/latest/en/users/drivers/ath11k.html)
- [PicoScenes supported hardware](https://ps.zpj.io/manual/hardware.html)
- [ADR-270: vendor integration portfolio and acceptance gates](ADR-270-vendor-rf-sensing-integration-program.md)
@@ -0,0 +1,40 @@
# ADR-269: Qualcomm CSI Wire Protocol
- **Status**: accepted
- **Date**: 2026-07-18
- **Tags**: qualcomm, csi, protocol, rust, udp, replay
## Decision
Define `QCS1` version 1 as a vendor-boundary envelope, not a Qualcomm firmware ABI.
It uses a 72-byte little-endian header plus payload and CRC-32/IEEE. The header
records report kind, total length, sequence, monotonic timestamp, device ID,
chipset profile, center frequency, bandwidth, flags, Tx/Rx counts, numeric format,
PPDU type, subcarrier count, noise floor, scale, subcarrier spacing, calibration
ID and payload length.
CSI payloads contain one signed RSSI byte per receive chain followed by
`tx * rx * subcarriers` complex i16 or finite f32 values in Tx-major, Rx-major,
subcarrier-major order. Capability reports carry bounded opaque bytes. One QCS1
frame maps to one UDP datagram; replay files prefix each frame with a little-endian
u32 length.
Parsers fail closed on unknown enums, bad CRC, truncation, trailing datagram data,
non-finite values, inconsistent dimensions, chipset chain/bandwidth violations,
payload mismatches, arithmetic overflow and the IPv4 UDP payload ceiling. A
synthetic flag provides end-to-end simulator provenance.
Version 1 profiles are QCA9300, QCN9074 and QCN9274. QCA9300 is capped at three
chains and 40 MHz; modern profiles are capped at four chains and 160 MHz.
## Consequences
- Simulator, replay and future hardware adapters share one validated Rust API.
- No private firmware layout is represented or redistributed.
- 320 MHz/EHT matrices require segmentation or a later protocol revision.
## Links
- [ADR-268: Qualcomm platform strategy](ADR-268-qualcomm-atheros-csi-platform.md)
- [ADR-267: MediaTek MTC1 protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
@@ -0,0 +1,122 @@
# ADR-270: Vendor RF Sensing Integration Program
- **Status**: accepted
- **Date**: 2026-07-18
- **Deciders**: RuView maintainers
- **Tags**: vendors, csi, telemetry, simulator, rust, hardware-validation
## Context
RuView is evaluating Qualcomm, RF Solutions, Origin AI, Plume, Linksys,
Electric Imp, Mist/Juniper, Luma, Google Nest, NETGEAR and Wifigarden. These
names do not represent equivalent integration surfaces: some expose raw CSI,
some expose derived sensing events or network telemetry, and some expose no
supported developer interface. A repeated implementation process must not turn
brand compatibility, Linux connectivity or synthetic fixtures into a false CSI
claim.
## Decision
Adopt a Rust-first provider portfolio with explicit capability negotiation:
- `ComplexCsi`: calibrated per-packet complex channel matrices.
- `DerivedSensing`: vendor-produced motion, occupancy or location events.
- `RfTelemetry`: RSSI, radio, client and topology observations.
- `NetworkOnly`: useful as excitation/AP infrastructure but not a sensor.
- `Unsupported`: no stable, lawful or supportable integration surface.
Every provider follows the same gated loop:
1. Verify an authoritative API/SDK, exact model/chipset and licensing boundary.
2. Write provider and wire/contract ADRs before coupling core code to a vendor.
3. Implement bounded Rust types, explicit capabilities and synthetic provenance.
4. Test deterministic replay, corruption, loss, reconnect, backpressure, schema
evolution and secrets handling.
5. Promote to hardware support only after lawful physical capture on an exact
model/firmware, calibration and repeatability tests, and fixture publication
rights. Simulator success never satisfies this gate.
6. Publish code/release and an upstream or vendor collaboration announcement
that states the measured-versus-simulated boundary.
### Portfolio decisions
| Provider | Classification | Decision |
|---|---|---|
| Qualcomm QCA9300 | `ComplexCsi` candidate | Implement first physical baseline via established ath9k research tooling; QCS1 adapter ships simulator-first. |
| Qualcomm QCN9074/QCN9274 | experimental `ComplexCsi` | Simulator and protocol now; require confirmed ath11k/ath12k firmware export before hardware claim. |
| Origin AI | commercial `DerivedSensing`, possible CSI | Pursue NDA sandbox/API and raw-data rights; isolate proprietary engine behind provider trait/service boundary. |
| Plume/OpenSync | `RfTelemetry`; Plume Sense is gated `DerivedSensing` | Build optional OVSDB/control-plane adapter; negotiate Sense separately and do not infer raw CSI. |
| Mist/Juniper | `RfTelemetry` + location | Conditional read-only REST/webhook adapter for occupancy, RSSI and coordinates; no CSI claim. |
| NETGEAR | partner-gated `RfTelemetry` | Insight adapter only after API access; exact legacy OpenWrt models remain community experiments. |
| Luma | discontinued OpenWrt salvage target | Generic OpenWrt telemetry/pcap fixture only when already owned; no procurement or Luma CSI source. |
| Google Nest Wifi | `NetworkOnly` | Use as traffic/AP infrastructure; Device Access does not expose router CSI or radio telemetry. |
| Linksys | `Unsupported` for sensing | Linksys Aware reached end of support in 2024; record capability probe only, if needed. |
| Electric Imp | scalar IoT/RSSI telemetry | Optional agent/impCentral bridge for existing fleets; reject as CSI acquisition hardware. |
| RF Solutions | non-Wi-Fi RF/IoT telemetry | Exclude from sensing backend; optional RIoT environmental fusion is a separate future concern. |
| Wifigarden | commercial OEM, capability unknown | Hold implementation pending chipset, schema, offline, calibration and data-rights disclosure. |
### Provider boundary
Core code consumes a vendor-neutral `RfSource`-style contract whose capability
set prevents RSSI, location or derived occupancy from being represented as CSI.
Cloud adapters use bounded async queues, regional endpoints, secret-provider
credentials and explicit data provenance. Proprietary device SDKs live behind a
feature-gated FFI or sidecar boundary and are never redistributed without rights.
## Consequences
### Positive
- The integration loop can be repeated without duplicating unsafe parsers.
- Product integrations remain useful even when only telemetry is available.
- Public releases make hardware confidence and simulator confidence distinct.
### Negative
- Several named vendors cannot produce a legitimate CSI implementation today.
- Commercial providers require contracts, subscriptions, test vectors or NDAs.
- Exact hardware revisions and firmware provenance increase validation effort.
### Neutral
- A no-go or telemetry-only ADR is a completed research outcome, not a failed port.
- Vendor status and APIs must be rechecked before each implementation begins.
## Implementation Status
The ADR-270 provider contract is implemented in Rust. Each portfolio entry has
a descriptor, bounded decoder or explicit fail-closed access state, deterministic
contract fixtures where lawful, registry coverage, and API exposure:
- Origin AI: contract-configured derived-sensing decoder and request plan.
- Plume/OpenSync: read-only OVSDB request plan and RF telemetry decoder.
- Mist/Juniper: regional request configuration, paginated RF/location decoder.
- NETGEAR Insight: regional partner request configuration and telemetry decoder.
- Electric Imp and RF Solutions: bounded scalar telemetry bridges.
- Luma: explicitly experimental generic OpenWrt telemetry bridge.
- Google Nest: network-only contract events; never represented as CSI.
- Linksys: `Unsupported` decoder because Linksys Aware is end-of-support.
- Wifigarden: `ContractRequired` decoder pending a disclosed SDK/schema.
`vendor-rf-sim` generates deterministic, provenance-labelled events for the
eight providers with a defined event contract and refuses to fabricate Linksys
or Wifigarden events. The sensing server exposes provider descriptors and latest
events under `/api/v1/rf/vendors` and accepts validated canonical simulator
events over its existing UDP port. Physical/vendor-cloud validation remains
separate from implementation completeness and is reflected by
`hardware_validated: false` until performed.
## Evidence and Links
- [ADR-268: Qualcomm strategy](ADR-268-qualcomm-atheros-csi-platform.md)
- [OpenSync developer sandbox](https://www.opensync.io/developer)
- [Origin AI Wi-Fi sensing architecture](https://www.originwirelessai.com/wifi-sensing/)
- [Juniper Mist webhook hierarchy](https://www.juniper.net/documentation/us/en/software/mist/automation-integration/topics/topic-map/webhook-hierarchy.html)
- [Linksys product end-of-life](https://www.linksys.com/pages/linksys-product-end-of-life)
- [Google Nest Device Access supported devices](https://developers.google.com/nest/device-access/supported-devices)
- [OpenWrt Luma WRTQ-329ACN](https://openwrt.org/toh/hwdata/luma/luma_wrtq-329acn)
- [NETGEAR Insight compatible devices](https://kb.netgear.com/000048452/What-devices-can-I-discover-monitor-and-manage-with-Insight)
- [Electric Imp imp005 hardware guide](https://developer.electricimp.com/hardware/imp/imp005_hardware_guide)
- [RF Solutions company portfolio](https://www.rfsolutions.co.uk/about-us-i1/)
- [Wifigarden service terms](https://policies.wifigarden.com/en-us/terms-of-service)
+5
View File
@@ -1,5 +1,10 @@
# Architecture Decision Records
Latest proposed decisions:
- [ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports](ADR-264-rtl8720f-radar-wire-protocol.md)
- [ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md)
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
## Why ADRs?
+3 -3
View File
@@ -425,7 +425,7 @@ pub enum WifiChipset {
BroadcomBcm43455,
/// Realtek RTL8822CS via modified rtw88 driver.
RealtekRtl8822cs,
/// MediaTek MT7661 via mt76 driver modification.
/// Proposed MediaTek MT7661 research target; no public CSI export is verified.
MediatekMt7661,
}
@@ -455,7 +455,7 @@ pub struct Esp32CompatFrame {
```
**Domain Services:**
- `CsiExtractionService` — Reads raw CSI from patched driver via Netlink socket (BCM43455), procfs (RTL8822CS), or UDP (MT7661)
- `CsiExtractionService` — Reads raw CSI from a validated chipset adapter. Nexmon/BCM43455 is the established Linux example; RTL8822CS and MT7661 remain unverified research targets and must not be advertised as working capture paths.
- `SubcarrierResamplerService` — Resamples chipset-specific subcarrier counts to match ESP32 format (e.g., 256 → 128 via decimation or interpolation)
- `ProtocolTranslatorService` — Converts `ChipsetCsiFrame` to `Esp32CompatFrame` with ADR-018 binary encoding
- `CalibrationService` — Compensates for chipset-specific phase offsets, antenna spacing, and gain differences relative to ESP32 CSI
@@ -625,7 +625,7 @@ pub struct EspNodeConnection {
### ESP32 Protocol ACL (CSI Bridge)
The WiFi CSI Bridge translates chipset-specific CSI formats (Nexmon, rtw88, mt76) into the ESP32 binary protocol (ADR-018). The sensing server never knows whether frames came from a real ESP32 or a TV box WiFi chipset. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
The WiFi CSI Bridge translates validated chipset-specific CSI formats into a versioned RuView envelope. Nexmon is the established Linux example; rtw88 and mt76 require a verified complex-CSI export before implementation. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
### Armbian Platform ACL
+45
View File
@@ -0,0 +1,45 @@
# RuView v0.9.0-realtek-beta.1
This prerelease introduces the Rust-first RTL8720F 2.4 GHz radar transport and
RuView ingestion path. It is intentionally simulator-validated until Realtek
hardware and the vendor SDK callback ABI arrive.
## Included
- ADR-263 records the upstream Ameba integration and licensing boundary.
- ADR-264 defines a versioned, bounded, CRC-protected radar envelope.
- `rtl8720f-sim` emits deterministic CFR, near-range, far-range, interference,
and capability reports to UDP or replay files.
- The sensing server validates RTL8720F datagrams, publishes bounded summaries
over `/ws/sensing`, and exposes the latest report at
`/api/v1/radar/latest`.
- Synthetic provenance is retained end to end as `realtek:simulated`; simulator
data is never presented as hardware data.
## Compatibility
The adapter tracks the radar control surface proposed by Ameba RTOS pull
request #1336 (`wifi_radar_config`, `AT+RAD`, and `AT+RADDBG`). The stable Ameba
RTOS v1.2.1 release does not yet expose the complete radar receive callback ABI,
so no vendor-private headers or binary libraries are copied into this release.
## Validation status
- Rust codec round trips, corruption rejection, size bounds, and deterministic
simulator tests pass.
- RuView server ingestion, REST reporting, and source provenance were exercised
end to end over loopback UDP.
- Windows release binaries are built from this branch and accompanied by
SHA-256 checksums.
## Known limitations
- No physical RTL8720F board has been flashed or measured.
- The vendor report callback and exact report layouts remain an SDK/hardware
validation gate; the adapter boundary may change when those arrive.
- This beta exposes transport and aggregate radar observability. Radar-to-pose,
vital-sign inference, RF calibration, and accuracy claims are not enabled.
- 2.4 GHz radar reports are not mislabeled as mmWave or Wi-Fi CSI events.
Do not deploy this prerelease for safety-critical, medical, or occupancy billing
uses. It is an integration beta for SDK and hardware bring-up.
+32
View File
@@ -0,0 +1,32 @@
# RuView v0.9.1-mediatek-beta.1
This simulator-first beta adds a Rust MediaTek Filogic MIMO CSI transport and
RuView ingestion path while preserving the boundary between demonstrated host
integration and unavailable physical CSI export.
## Included
- ADR-266 selects OpenWrt One (MT7981/MT7976) as the primary future hardware
target and BPI-R3 (MT7986/MT7975) as the secondary 4x4 target.
- ADR-267 defines the bounded, versioned, CRC-protected `MTC1` wire protocol.
- `mediatek-csi-sim` provides deterministic MT7981, MT7986, and MT7996 profiles,
complex MIMO CSI, per-chain RSSI, UDP streaming, and replay output.
- RuView validates MediaTek datagrams, publishes bounded WebSocket summaries,
and exposes `/api/v1/csi/mediatek/latest`.
- `mediatek:simulated` provenance is retained end to end.
## Validation
- Codec round trips, deterministic output, corruption/truncation rejection,
dimension limits, finite-value enforcement, and prefix parsing are tested.
- All hardware and sensing-server regression tests pass.
- All three profiles were streamed over loopback UDP and verified through the
RuView REST API.
## Hardware boundary
Upstream `mt76` and public MediaTek SDK material do not currently expose a
supported raw complex CSI API. This release does not redistribute private SDK
material, invent a firmware ABI, or claim physical MediaTek capture. Hardware
support requires a documented firmware/driver channel-estimate export followed
by calibration and repeatability validation.
+22
View File
@@ -0,0 +1,22 @@
# RuView v0.9.2-qualcomm-beta.1
This simulator-first beta adds a Rust Qualcomm Atheros CSI boundary without
claiming modern Qualcomm firmware exports that have not been physically verified.
## Included
- ADR-268 selects QCA9300 as the first physical baseline and treats QCN9074 and
QCN9274 as experimental modern profiles.
- ADR-269 defines the bounded, versioned, CRC-protected `QCS1` protocol.
- `qualcomm-csi-sim` emits deterministic MIMO CSI over UDP or replay files.
- The sensing server validates QCS1 datagrams, broadcasts bounded summaries and
exposes `/api/v1/csi/qualcomm/latest`.
- `qualcomm:simulated` provenance is retained end to end.
## Validation boundary
Codec, corruption, truncation, finite-value, dimensions, chipset bandwidth,
determinism and prefix parsing are automated. Loopback UDP/API validation covers
all profiles. Physical QCA9300 comparison and modern firmware export validation
remain hardware gates and will be published with firmware and calibration details.
@@ -0,0 +1,21 @@
# RuView v0.9.3 Vendor Providers Beta 1
This beta implements ADR-270 as a capability-safe Rust provider program across
all ten researched vendors.
## Included
- Shared `VendorRfProvider` contract with bounded event validation.
- Origin AI, Plume/OpenSync, Mist/Juniper, NETGEAR Insight, Electric Imp,
RF Solutions, Luma/OpenWrt and Google Nest contract adapters.
- Explicit fail-closed Linksys (`Unsupported`) and Wifigarden
(`ContractRequired`) providers.
- Deterministic `vendor-rf-sim` JSONL/UDP fixtures for defined contracts.
- Provider registry, descriptors, latest-event REST endpoints and WebSocket
summaries through the sensing server.
## Boundary
This release implements and validates software contracts. It does not claim
vendor-cloud credentials, commercial SDK rights, physical hardware validation,
or complex CSI support for telemetry-only providers.
+17
View File
@@ -1861,6 +1861,23 @@ node scripts/eval-wiflow.js \
--data data/paired/*.jsonl
```
> **Model format boundary:** `train-wiflow-supervised.js` produces the
> JavaScript WiFlow model `wiflow-v1.json`. There is currently no supported
> command that converts that JSON model into the sensing server's binary RVF
> container, and renaming the file to `.rvf` does not convert it. Use the JSON
> model with the JavaScript evaluation/inference tools. To train a model that
> the Rust sensing server can load, use its native training path, which writes
> RVF directly:
>
> ```bash
> cargo run -p wifi-densepose-sensing-server --release -- \
> --train --dataset data/mmfi --dataset-type mmfi \
> --epochs 100 --save-rvf models/room-model.rvf
> ```
>
> The camera+CSI paired JSONL workflow and the native RVF trainer are separate
> pipelines today. A JSON-to-RVF exporter is future work.
**Evaluation protocol matters.** Use `eval-wiflow.js` (torso-normalized
PCK@20, the metric comparable to published WiFi-pose results) on a temporal
hold-out, and sanity-check that predictions actually vary across frames
+60
View File
@@ -0,0 +1,60 @@
# ADR-270 Vendor RF Providers
RuView exposes a capability-safe Rust provider layer for vendor sensing and RF
telemetry. It never converts RSSI, occupancy, location or network inventory into
complex CSI.
## API
- `GET /api/v1/rf/vendors` — all provider descriptors and access states.
- `GET /api/v1/rf/vendors/latest` — latest validated event per vendor.
- `GET /api/v1/rf/vendors/:vendor/latest` — latest event for one stable vendor ID.
- `POST /api/v1/rf/vendors/:vendor/events` — ingest the vendor's documented
sidecar/webhook payload through its strict provider decoder. This `/api/v1/*`
route uses the server's bearer-token policy when configured.
Stable IDs are `origin_ai`, `plume`, `mist`, `netgear`, `electric_imp`,
`rf_solutions`, `linksys`, `luma`, `google_nest`, and `wifigarden`.
## Deterministic simulator
```bash
cd v2
cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \
--vendor plume --frames 100 --output plume.jsonl
# Stream canonical synthetic events to the sensing server UDP port.
cargo run -p wifi-densepose-hardware --bin vendor-rf-sim -- \
--vendor mist --frames 100 --udp 127.0.0.1:5005 --realtime
```
Supported simulator names are `origin-ai`, `plume`, `mist`, `netgear`,
`electric-imp`, `rf-solutions`, `luma`, and `google-nest`. Linksys is refused
because its sensing service is discontinued. Wifigarden is refused until a
contracted event schema exists.
Every synthetic event includes `synthetic: true`, a deterministic sequence and
timestamp, and a source ending in `-sim-01`.
Canonical UDP JSON is accepted only when `synthetic: true`. Live vendor payloads
must use the HTTP ingestion route so provider-specific schemas, metric allowlists,
access states and bounds cannot be bypassed.
## Live/provider payloads
Provider decoders are strict, bounded and reject unknown schema fields. Origin
paths and credentials are supplied by the commercial contract. Plume uses a
read-only allow-listed OVSDB request plan. Mist and NETGEAR configurations use
regional HTTPS endpoints with redacted tokens. Electric Imp, RF Solutions and
Luma accept only allow-listed scalar metrics. Google Nest remains network-only.
Credentials are never embedded in fixtures or descriptors. Linksys returns
`Unsupported`; Wifigarden returns `ContractRequired`. These are usable,
test-covered provider outcomes—not simulated integrations.
## Hardware honesty
All descriptors remain `hardware_validated: false` until exact hardware/cloud
versions, lawful access, repeatable captures, calibration where applicable, and
fixture publication rights have been verified. Passing the simulator and API
tests validates RuView software only.
+1 -1
View File
@@ -1,5 +1,5 @@
# ESP32 CSI Node Firmware (ADR-018)
# Requires ESP-IDF v5.2+
# Requires ESP-IDF v5.4+
cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS "")
+2
View File
@@ -113,6 +113,8 @@ curl http://<ESP32_IP>:8032/wasm/list
> **Tip:** A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps.
> **⚠️ Thermal warning — compact boards (ESP32-S3-Zero, SuperMini, other coin-sized clones):** This firmware runs the WiFi radio with modem sleep disabled (`WIFI_PS_NONE`, required for continuous CSI capture) plus a full edge-processing DSP pipeline on Core 1 (`edge_tier=2`) plus, on ADR-183 builds, a continuous 40 Hz onboard LED driver. That's sustained high current draw with no duty-cycling. Full-size dev boards (DevKitC-1, XIAO) have more copper pour and thermal mass around the regulator and tolerate this fine. Coin-sized clones with minimal PCB area and budget regulators may run hot to the touch during normal operation, and in at least one field report, boards that ran hot during a session failed to power on afterward (regulator damage suspected — see issue tracker). Give these boards airflow, don't stack or enclose them, and check them by touch during the first several minutes of a new deployment. If a board is uncomfortably hot (not just warm), power it down and let it cool before continuing.
---
## Firmware Architecture
+6 -1
View File
@@ -67,6 +67,8 @@ static void event_handler(void *arg, esp_event_base_t event_base,
if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
if (s_retry_num < MAX_RETRY) {
esp_wifi_connect();
s_retry_num++;
@@ -102,7 +104,10 @@ static void wifi_init_sta(void)
wifi_config_t wifi_config = {
.sta = {
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
/* WPA_PSK (not WPA2_PSK) so routers running WPA/WPA2-mixed
* compatibility mode aren't rejected with
* WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD (#1050). */
.threshold.authmode = WIFI_AUTH_WPA_PSK,
},
};
+1 -1
View File
@@ -1 +1 @@
0.7.0
0.8.4
@@ -18,6 +18,8 @@ Bring a RuView sensing node online: build firmware → flash → provision WiFi
**Not supported:** original ESP32, ESP32-C3 (single-core).
**⚠️ Ask about board form factor before flashing.** If the user's board is a coin-sized clone (ESP32-S3-Zero, SuperMini, or similar — not a full DevKitC/XIAO-style board with a real USB connector and visible regulator), warn them before they walk away from it: this firmware runs the WiFi radio continuously (`WIFI_PS_NONE`) plus a full DSP pipeline (`edge_tier=2`), which is sustained high current draw that full-size dev boards handle fine but tiny clones with minimal copper/budget regulators may not. At least one field report: boards ran hot during a normal session and failed to power on again afterward (regulator damage suspected). Tell them to give the board airflow (don't stack/enclose it) and check it by touch during the first several minutes of any new deployment.
## 1. Build firmware (Windows — Python subprocess, NOT bash directly)
ESP-IDF v5.4 does not support MSYS2/Git Bash. Use the Espressif Python venv as a subprocess with `MSYSTEM*` env vars stripped. The proven command lives in `CLAUDE.local.md` — reproduce it:
+4 -4
View File
@@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
hr = HeartRateExtractor.esp32_default()
rng = Random(43)
for i in range(1500):
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, weights=weights)
residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, phases=phases)
def _one_frame():
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, weights=weights)
residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, phases=phases)
benchmark(_one_frame)
+27 -5
View File
@@ -242,7 +242,22 @@ impl PyBreathingExtractor {
// ─── HeartRateExtractor ──────────────────────────────────────────────
/// Extracts heart rate (40120 BPM) from per-subcarrier amplitude
/// residuals via 0.82.0 Hz bandpass + autocorrelation peak detection.
/// residuals and per-subcarrier unwrapped phases (radians) via
/// 0.82.0 Hz bandpass + autocorrelation peak detection.
///
/// Python:
/// ```python
/// from wifi_densepose import HeartRateExtractor
///
/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window
///
/// # Feed residuals and matching unwrapped phases from your preprocessor.
/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate
/// # extraction because the Rust core requires phase data for each subcarrier.
/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …])
/// if est is not None:
/// print(est.value_bpm, est.confidence)
/// ```
#[pyclass(name = "HeartRateExtractor")]
pub struct PyHeartRateExtractor {
inner: HeartRateExtractor,
@@ -265,10 +280,17 @@ impl PyHeartRateExtractor {
Self { inner: HeartRateExtractor::esp32_default() }
}
/// Extract heart rate from per-subcarrier residuals. GIL released
/// during DSP.
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights));
/// Extract heart rate from per-subcarrier residuals and matching
/// per-subcarrier unwrapped phases (radians). Empty phases are invalid
/// and return `None` because the Rust extractor requires phase data.
/// GIL released during DSP.
fn extract(
&mut self,
py: Python<'_>,
residuals: Vec<f64>,
phases: Vec<f64>,
) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &phases));
est.map(PyVitalEstimate::from_rust)
}
+35 -1
View File
@@ -185,10 +185,44 @@ def test_heart_rate_explicit_ctor() -> None:
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
hr = HeartRateExtractor.esp32_default()
out = hr.extract(residuals=[0.0] * 56, weights=[])
out = hr.extract(residuals=[0.0] * 56, phases=[0.0] * 56)
assert out is None
def test_heart_rate_extract_rejects_old_weights_keyword() -> None:
hr = HeartRateExtractor.esp32_default()
with pytest.raises(TypeError):
hr.extract(residuals=[0.0] * 56, weights=[0.0] * 56)
def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None:
"""Drive the extractor with a synthetic 1.2 Hz sine (72 BPM) plus
same-length phase data. This proves the binding feeds Rust's required
`phases` slice instead of an empty vector that would keep returning None."""
hr = HeartRateExtractor.esp32_default()
sample_rate = 100.0
target_freq = 1.2 # 72 BPM
n_samples = int(60 * sample_rate)
phases = [i * 0.01 for i in range(56)]
produced_estimate = False
rng = Random(43)
for i in range(n_samples):
t = i / sample_rate
base = math.sin(2.0 * math.pi * target_freq * t)
residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)]
est = hr.extract(residuals=residuals, phases=phases)
if est is not None:
produced_estimate = True
assert math.isfinite(est.value_bpm)
assert 0.0 <= est.confidence <= 1.0
break
assert produced_estimate, (
"HeartRateExtractor never produced an estimate after 60s of synthetic data"
)
# ─── Build feature flag ──────────────────────────────────────────────
@@ -13,6 +13,36 @@ hardware sources. All parsing operates on byte buffers with no C FFI or hardware
compile time, making the crate fully portable and deterministic -- the same bytes in always produce
the same parsed output.
## RTL8720F radar simulator (ADR-263/264)
Until Realtek hardware and the radar report SDK arrive, the Rust-only simulator exercises the same
versioned CFR/Range-FFT wire codec used by the future device adapter. Every frame is marked
`SYNTHETIC`.
```powershell
cargo run -p wifi-densepose-hardware --bin rtl8720f-sim -- `
--frames 100 --seed 0x8720f123456789ab `
--output rtl8720f-synthetic.rtr
```
Add `--udp 127.0.0.1:5005 --realtime` to stream one ADR-264 frame per UDP datagram. Replay files
contain a little-endian `u32` frame length followed by the encoded frame.
## MediaTek Filogic CSI simulator (ADR-266/267)
The Rust-only simulator models bounded MIMO CSI for MT7981/MT7976,
MT7986/MT7975, and MT7988/MT7996 profiles without claiming an undocumented
MediaTek firmware ABI. Every frame is marked `SYNTHETIC`.
```powershell
cargo run -p wifi-densepose-hardware --bin mediatek-csi-sim -- `
--profile mt7981 --frames 100 --output mediatek-synthetic.mtc
```
Add `--udp 127.0.0.1:5005 --realtime` to stream one CRC-protected ADR-267
frame per UDP datagram. Physical support remains gated on a documented `mt76`
or MediaTek firmware channel-estimate export.
## Features
- **ESP32 binary parser** -- Parses ADR-018 binary CSI frames streamed over UDP from ESP32 and
@@ -0,0 +1,133 @@
//! Deterministic MediaTek Filogic MIMO CSI simulator (ADR-266/267).
use clap::{Parser, ValueEnum};
use std::{
fs::File,
io::{self, Write},
net::{SocketAddr, UdpSocket},
path::PathBuf,
thread,
time::Duration,
};
use wifi_densepose_hardware::mediatek_csi::{
simulator::{MediatekCsiSimulator, SimulatorConfig},
ChipsetProfile, CsiFrame,
};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Profile {
Mt7981,
Mt7986,
Mt7996,
}
impl Profile {
fn chipset(self) -> ChipsetProfile {
match self {
Self::Mt7981 => ChipsetProfile::Mt7981Mt7976,
Self::Mt7986 => ChipsetProfile::Mt7986Mt7975,
Self::Mt7996 => ChipsetProfile::Mt7988Mt7996,
}
}
fn default_chains(self) -> u8 {
match self {
Self::Mt7981 => 3,
Self::Mt7986 | Self::Mt7996 => 4,
}
}
}
#[derive(Debug, Parser)]
#[command(
name = "mediatek-csi-sim",
about = "Emit synthetic ADR-267 MediaTek Filogic MIMO CSI frames"
)]
struct Args {
#[arg(long, value_enum, default_value_t=Profile::Mt7981)]
profile: Profile,
#[arg(long, default_value_t = 100)]
frames: u32,
#[arg(long, default_value="0x4d544b4353490001", value_parser=parse_u64)]
seed: u64,
#[arg(long, default_value_t = 80)]
bandwidth: u16,
#[arg(long, default_value_t = 2)]
tx: u8,
#[arg(long)]
rx: Option<u8>,
#[arg(long, default_value_t = 256)]
subcarriers: u16,
#[arg(long, default_value_t = 20)]
interval_ms: u64,
#[arg(long)]
udp: Option<SocketAddr>,
/// Replay: little-endian u32 length followed by one ADR-267 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,
chipset: a.profile.chipset(),
bandwidth_mhz: a.bandwidth,
tx_count: a.tx,
rx_count: a.rx.unwrap_or_else(|| a.profile.default_chains()),
subcarriers: a.subcarriers,
frame_period_us: a.interval_ms * 1000,
..Default::default()
};
let mut sim = MediatekCsiSimulator::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 = emit(
sim.capabilities_frame(),
socket.as_ref(),
a.udp,
&mut output,
)?;
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 MediaTek CSI frames ({} bytes, profile={}, seed={:#x})",
a.frames + 1,
bytes,
a.profile.chipset().name(),
a.seed
);
Ok(())
}
@@ -0,0 +1,147 @@
//! Deterministic Qualcomm Atheros MIMO CSI simulator (ADR-268/269).
use clap::{Parser, ValueEnum};
use std::{
fs::File,
io::{self, Write},
net::{SocketAddr, UdpSocket},
path::PathBuf,
thread,
time::Duration,
};
use wifi_densepose_hardware::qualcomm_csi::{
simulator::{QualcommCsiSimulator, SimulatorConfig},
ChipsetProfile, CsiFrame,
};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Profile {
Qca9300,
Qcn9074,
Qcn9274,
}
impl Profile {
fn chipset(self) -> ChipsetProfile {
match self {
Self::Qca9300 => ChipsetProfile::Qca9300,
Self::Qcn9074 => ChipsetProfile::Qcn9074,
Self::Qcn9274 => ChipsetProfile::Qcn9274,
}
}
fn default_chains(self) -> u8 {
match self {
Self::Qca9300 => 3,
Self::Qcn9074 | Self::Qcn9274 => 4,
}
}
fn default_bandwidth(self) -> u16 {
match self {
Self::Qca9300 => 40,
Self::Qcn9074 | Self::Qcn9274 => 80,
}
}
fn default_subcarriers(self) -> u16 {
match self {
Self::Qca9300 => 114,
Self::Qcn9074 | Self::Qcn9274 => 256,
}
}
}
#[derive(Debug, Parser)]
#[command(
name = "qualcomm-csi-sim",
about = "Emit synthetic ADR-269 Qualcomm Atheros MIMO CSI frames"
)]
struct Args {
#[arg(long, value_enum, default_value_t=Profile::Qca9300)]
profile: Profile,
#[arg(long, default_value_t = 100)]
frames: u32,
#[arg(long, default_value="0x5143414353490001", value_parser=parse_u64)]
seed: u64,
#[arg(long)]
bandwidth: Option<u16>,
#[arg(long, default_value_t = 2)]
tx: u8,
#[arg(long)]
rx: Option<u8>,
#[arg(long)]
subcarriers: Option<u16>,
#[arg(long, default_value_t = 20)]
interval_ms: u64,
#[arg(long)]
udp: Option<SocketAddr>,
/// Replay: little-endian u32 length followed by one ADR-269 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,
chipset: a.profile.chipset(),
bandwidth_mhz: a.bandwidth.unwrap_or_else(|| a.profile.default_bandwidth()),
tx_count: a.tx,
rx_count: a.rx.unwrap_or_else(|| a.profile.default_chains()),
subcarriers: a
.subcarriers
.unwrap_or_else(|| a.profile.default_subcarriers()),
frame_period_us: a.interval_ms * 1000,
..Default::default()
};
let mut sim = QualcommCsiSimulator::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 = emit(
sim.capabilities_frame(),
socket.as_ref(),
a.udp,
&mut output,
)?;
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 Qualcomm CSI frames ({} bytes, profile={}, seed={:#x})",
a.frames + 1,
bytes,
a.profile.chipset().name(),
a.seed
);
Ok(())
}
@@ -0,0 +1,118 @@
//! Rust-only RTL8720F radar simulator for pre-hardware integration.
use std::{
fs::File,
io::{self, Write},
net::{SocketAddr, UdpSocket},
path::PathBuf,
thread,
time::Duration,
};
use clap::Parser;
use wifi_densepose_hardware::rtl8720f::{
simulator::{Rtl8720fSimulator, SimulatorConfig},
RadarFrame, ReportType,
};
#[derive(Debug, Parser)]
#[command(
name = "rtl8720f-sim",
about = "Emit synthetic ADR-264 RTL8720F radar frames"
)]
struct Args {
#[arg(long, default_value_t = 100)]
frames: u32,
#[arg(long, default_value = "0x8720f123456789ab", value_parser = parse_u64)]
seed: u64,
#[arg(long, default_value_t = 40)]
bandwidth: u16,
#[arg(long, default_value_t = 15)]
interval_ms: u64,
/// UDP destination; each frame is one datagram.
#[arg(long)]
udp: Option<SocketAddr>,
/// Replay file; LE u32 length followed by ADR-264 bytes.
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
realtime: bool,
}
fn parse_u64(value: &str) -> Result<u64, String> {
if let Some(hex) = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
{
u64::from_str_radix(hex, 16).map_err(|error| error.to_string())
} else {
value.parse::<u64>().map_err(|error| error.to_string())
}
}
fn emit(
frame: RadarFrame,
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(socket), Some(destination)) = (socket, destination) {
let sent = socket.send_to(&wire, destination)?;
if sent != wire.len() {
return Err(io::Error::new(io::ErrorKind::WriteZero, "partial UDP datagram").into());
}
}
if let Some(file) = output {
file.write_all(&(wire.len() as u32).to_le_bytes())?;
file.write_all(&wire)?;
}
Ok(wire.len())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
if args.udp.is_none() && args.output.is_none() {
return Err("select at least one sink with --udp or --output".into());
}
let config = SimulatorConfig {
seed: args.seed,
bandwidth_mhz: args.bandwidth,
frame_period_us: args.interval_ms * 1_000,
..SimulatorConfig::default()
};
let mut simulator = Rtl8720fSimulator::new(config)?;
let socket = args.udp.map(|_| UdpSocket::bind("0.0.0.0:0")).transpose()?;
let mut output = args.output.as_ref().map(File::create).transpose()?;
let mut bytes_emitted = emit(
simulator.capabilities_frame(),
socket.as_ref(),
args.udp,
&mut output,
)?;
for index in 0..args.frames {
let report_type = match index % 16 {
15 => ReportType::Interference,
value if value % 4 == 1 => ReportType::RangeNear,
value if value % 4 == 3 => ReportType::RangeFar,
_ => ReportType::Cfr,
};
bytes_emitted += emit(
simulator.next_frame(report_type),
socket.as_ref(),
args.udp,
&mut output,
)?;
if args.realtime {
thread::sleep(Duration::from_millis(args.interval_ms));
}
}
eprintln!(
"emitted {} synthetic RTL8720F frames ({} bytes, seed={:#x})",
args.frames + 1,
bytes_emitted,
args.seed
);
Ok(())
}
@@ -0,0 +1,232 @@
//! Deterministic ADR-270 vendor event simulator.
use clap::{Parser, ValueEnum};
use std::{
collections::BTreeMap,
fs::File,
io::{self, Write},
net::{SocketAddr, UdpSocket},
path::PathBuf,
thread,
time::Duration,
};
use wifi_densepose_hardware::vendor_rf::{
ProviderAvailability, ProviderDescriptor, RfCapability, VendorId, VendorRfEvent,
};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Vendor {
OriginAi,
Plume,
Mist,
Netgear,
ElectricImp,
RfSolutions,
Luma,
GoogleNest,
Linksys,
Wifigarden,
}
impl Vendor {
fn id(self) -> VendorId {
match self {
Self::OriginAi => VendorId::OriginAi,
Self::Plume => VendorId::Plume,
Self::Mist => VendorId::Mist,
Self::Netgear => VendorId::Netgear,
Self::ElectricImp => VendorId::ElectricImp,
Self::RfSolutions => VendorId::RfSolutions,
Self::Luma => VendorId::Luma,
Self::GoogleNest => VendorId::GoogleNest,
Self::Linksys => VendorId::Linksys,
Self::Wifigarden => VendorId::Wifigarden,
}
}
fn descriptor(self) -> ProviderDescriptor {
let (capabilities, availability, reason) = match self {
Self::OriginAi => (
vec![RfCapability::DerivedSensing],
ProviderAvailability::ContractRequired,
"Origin partner API/SDK contract required",
),
Self::Plume => (
vec![RfCapability::RfTelemetry],
ProviderAvailability::CredentialsRequired,
"OpenSync telemetry; Plume Sense is separately gated",
),
Self::Mist => (
vec![RfCapability::RfTelemetry],
ProviderAvailability::CredentialsRequired,
"Mist REST/webhook telemetry",
),
Self::Netgear => (
vec![RfCapability::RfTelemetry],
ProviderAvailability::CredentialsRequired,
"Insight partner API telemetry",
),
Self::ElectricImp => (
vec![RfCapability::RfTelemetry],
ProviderAvailability::CredentialsRequired,
"impCentral scalar telemetry only",
),
Self::RfSolutions => (
vec![RfCapability::RfTelemetry],
ProviderAvailability::CredentialsRequired,
"RIoT environmental telemetry only",
),
Self::Luma => (
vec![RfCapability::RfTelemetry],
ProviderAvailability::Experimental,
"discontinued OpenWrt salvage fixture",
),
Self::GoogleNest => (
vec![RfCapability::NetworkOnly],
ProviderAvailability::Experimental,
"network infrastructure contract fixture only",
),
Self::Linksys => (
vec![RfCapability::Unsupported],
ProviderAvailability::Unsupported,
"Linksys Aware reached end of support",
),
Self::Wifigarden => (
vec![RfCapability::Unsupported],
ProviderAvailability::ContractRequired,
"technical SDK disclosure required",
),
};
ProviderDescriptor {
vendor: self.id(),
capabilities,
availability,
hardware_validated: false,
reason: reason.into(),
}
}
}
#[derive(Debug, Parser)]
#[command(
name = "vendor-rf-sim",
about = "Emit deterministic ADR-270 vendor RF events"
)]
struct Args {
#[arg(long, value_enum)]
vendor: Vendor,
#[arg(long, default_value_t = 100)]
frames: u64,
#[arg(long, default_value_t = 0x5255_5645_4e44_4f52)]
seed: u64,
#[arg(long, default_value_t = 100)]
interval_ms: u64,
#[arg(long)]
udp: Option<SocketAddr>,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
realtime: bool,
}
fn next_random(state: &mut u64) -> f64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
(*state >> 11) as f64 / ((1u64 << 53) as f64)
}
fn event(vendor: Vendor, sequence: u64, timestamp_us: u64, state: &mut u64) -> VendorRfEvent {
let capability = vendor.descriptor().capabilities[0];
let wave = (sequence as f64 * 0.17).sin();
let noise = next_random(state) - 0.5;
let metrics = match capability {
RfCapability::DerivedSensing => BTreeMap::from([
(
"motion_score".into(),
(0.5 + 0.35 * wave + 0.05 * noise).clamp(0.0, 1.0),
),
("occupancy_count".into(), if wave > 0.0 { 2.0 } else { 1.0 }),
("confidence".into(), 0.92),
]),
RfCapability::RfTelemetry => BTreeMap::from([
("rssi_dbm".into(), -52.0 + 5.0 * wave + noise),
("client_count".into(), if wave > 0.0 { 4.0 } else { 3.0 }),
(
"channel_utilization".into(),
(0.31 + 0.08 * wave).clamp(0.0, 1.0),
),
]),
RfCapability::NetworkOnly => {
BTreeMap::from([("reachable".into(), 1.0), ("device_count".into(), 5.0)])
}
_ => BTreeMap::new(),
};
VendorRfEvent {
vendor: vendor.id(),
capability,
sequence,
timestamp_us,
source_id: format!("{}-sim-01", vendor.id().as_str()),
synthetic: true,
metrics,
label: Some("deterministic_contract_fixture".into()),
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
if args.udp.is_none() && args.output.is_none() {
return Err("select at least one sink with --udp or --output".into());
}
let descriptor = args.vendor.descriptor();
descriptor.validate()?;
if matches!(
descriptor.availability,
ProviderAvailability::Unsupported | ProviderAvailability::ContractRequired
) && descriptor.capabilities.contains(&RfCapability::Unsupported)
{
return Err(format!(
"{} has no simulatable event contract: {}",
descriptor.vendor.as_str(),
descriptor.reason
)
.into());
}
let socket = args.udp.map(|_| UdpSocket::bind("0.0.0.0:0")).transpose()?;
let mut output = args.output.as_ref().map(File::create).transpose()?;
let mut rng = args.seed;
let mut bytes = 0usize;
for sequence in 0..args.frames {
let value = event(
args.vendor,
sequence,
sequence * args.interval_ms * 1_000,
&mut rng,
);
value.validate(&descriptor)?;
let wire = serde_json::to_vec(&value)?;
if let (Some(socket), Some(destination)) = (&socket, args.udp) {
if socket.send_to(&wire, destination)? != wire.len() {
return Err(
io::Error::new(io::ErrorKind::WriteZero, "partial UDP datagram").into(),
);
}
}
if let Some(file) = &mut output {
file.write_all(&wire)?;
file.write_all(b"\n")?;
}
bytes += wire.len();
if args.realtime {
thread::sleep(Duration::from_millis(args.interval_ms));
}
}
eprintln!(
"emitted {} synthetic {} events ({} bytes, seed={:#x})",
args.frames,
args.vendor.id().as_str(),
bytes,
args.seed
);
Ok(())
}
+36 -3
View File
@@ -47,12 +47,21 @@ mod esp32_parser;
// standardized report path until an OTA binding exists.
pub mod ieee80211bf;
pub mod sync_packet;
/// ADR-270 capability-safe vendor RF provider contract.
pub mod vendor_rf;
// ADR-081: Rust mirror of the firmware radio abstraction layer (L1) and
// mesh sensing plane (L3). Lets host tests, simulators, and future
// coordinator-node Rust code drive the controller stack without
// touching any downstream signal/ruvector/train/mat crate.
pub mod radio_ops;
/// ADR-267 vendor-neutral MediaTek Filogic MIMO CSI framing and simulator.
pub mod mediatek_csi;
/// ADR-269 vendor-neutral Qualcomm Atheros CSI framing and simulator.
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;
pub use bridge::CsiData;
pub use csi_frame::{
@@ -64,12 +73,36 @@ pub use esp32_parser::{
RUVIEW_FEATURE_MAGIC, RUVIEW_FEATURE_STATE_MAGIC, RUVIEW_FUSED_VITALS_MAGIC,
RUVIEW_TEMPORAL_MAGIC, RUVIEW_VITALS_MAGIC,
};
pub use sync_packet::{
SyncPacket, SyncPacketFlags, SYNC_PACKET_MAGIC, SYNC_PACKET_SIZE, SYNC_PACKET_PROTO_VER,
};
pub use radio_ops::{
crc32_ieee, decode_anomaly_alert, decode_mesh, decode_node_status, encode_health, AnomalyAlert,
AuthClass, CaptureProfile, MeshError, MeshHeader, MeshMsgType, MeshRole, MockRadio, NodeStatus,
RadioError, RadioHealth, RadioMode, RadioOps, MESH_HEADER_SIZE, MESH_MAGIC, MESH_MAX_PAYLOAD,
MESH_VERSION,
};
pub use mediatek_csi::{
ChipsetProfile as MediatekChipsetProfile, CsiFlags as MediatekCsiFlags,
CsiFrame as MediatekCsiFrame, CsiParseError as MediatekCsiParseError,
CsiPayload as MediatekCsiPayload, ElementFormat as MediatekElementFormat,
PpduType as MediatekPpduType, ReportKind as MediatekReportKind,
MEDIATEK_CSI_HEADER_LEN, MEDIATEK_CSI_MAGIC, MEDIATEK_CSI_VERSION,
};
pub use qualcomm_csi::{
ChipsetProfile as QualcommChipsetProfile, CsiFlags as QualcommCsiFlags,
CsiFrame as QualcommCsiFrame, CsiParseError as QualcommCsiParseError,
CsiPayload as QualcommCsiPayload, ElementFormat as QualcommElementFormat,
PpduType as QualcommPpduType, ReportKind as QualcommReportKind,
QUALCOMM_CSI_HEADER_LEN, QUALCOMM_CSI_MAGIC, QUALCOMM_CSI_VERSION,
};
pub use rtl8720f::{
ElementFormat as Rtl8720fElementFormat, RadarFlags as Rtl8720fRadarFlags,
RadarFrame as Rtl8720fRadarFrame, RadarParseError as Rtl8720fRadarParseError,
RadarPayload as Rtl8720fRadarPayload, ReportType as Rtl8720fReportType,
RTL8720F_RADAR_HEADER_LEN, RTL8720F_RADAR_MAGIC, RTL8720F_RADAR_VERSION,
};
pub use sync_packet::{
SyncPacket, SyncPacketFlags, SYNC_PACKET_MAGIC, SYNC_PACKET_PROTO_VER, SYNC_PACKET_SIZE,
};
pub use vendor_rf::{
ProviderAvailability, ProviderDescriptor, RfCapability, VendorEventError, VendorId,
VendorRfEvent, VendorRfProvider,
};
@@ -0,0 +1,680 @@
//! Vendor-neutral MediaTek Filogic MIMO CSI transport and deterministic simulator.
//! This is not a MediaTek firmware ABI; see ADR-266/267.
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const MEDIATEK_CSI_MAGIC: u32 = 0x3143_544d; // "MTC1" little endian
pub const MEDIATEK_CSI_VERSION: u8 = 1;
pub const MEDIATEK_CSI_HEADER_LEN: usize = 72;
pub const MEDIATEK_CSI_CRC_LEN: usize = 4;
pub const MEDIATEK_CSI_MAX_FRAME_LEN: usize = 65_507;
pub const MEDIATEK_CSI_MAX_ELEMENTS: usize = 16_384;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ReportKind {
Csi = 1,
Capabilities = 2,
}
impl TryFrom<u8> for ReportKind {
type Error = CsiParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Csi),
2 => Ok(Self::Capabilities),
_ => Err(CsiParseError::UnknownReportKind(value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u16)]
pub enum ChipsetProfile {
Mt7981Mt7976 = 1,
Mt7986Mt7975 = 2,
Mt7988Mt7996 = 3,
}
impl TryFrom<u16> for ChipsetProfile {
type Error = CsiParseError;
fn try_from(value: u16) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Mt7981Mt7976),
2 => Ok(Self::Mt7986Mt7975),
3 => Ok(Self::Mt7988Mt7996),
_ => Err(CsiParseError::UnknownChipset(value)),
}
}
}
impl ChipsetProfile {
pub fn name(self) -> &'static str {
match self {
Self::Mt7981Mt7976 => "mt7981-mt7976",
Self::Mt7986Mt7975 => "mt7986-mt7975",
Self::Mt7988Mt7996 => "mt7988-mt7996",
}
}
pub fn max_chains(self) -> u8 {
match self {
Self::Mt7981Mt7976 => 3,
Self::Mt7986Mt7975 | Self::Mt7988Mt7996 => 4,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ElementFormat {
ComplexI16 = 1,
ComplexF32 = 2,
Bytes = 3,
}
impl TryFrom<u8> for ElementFormat {
type Error = CsiParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::ComplexI16),
2 => Ok(Self::ComplexF32),
3 => Ok(Self::Bytes),
_ => Err(CsiParseError::UnknownElementFormat(value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum PpduType {
Ht = 1,
Vht = 2,
HeSu = 3,
HeMu = 4,
Eht = 5,
}
impl TryFrom<u8> for PpduType {
type Error = CsiParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Ht),
2 => Ok(Self::Vht),
3 => Ok(Self::HeSu),
4 => Ok(Self::HeMu),
5 => Ok(Self::Eht),
_ => Err(CsiParseError::UnknownPpduType(value)),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CsiFlags(pub u16);
impl CsiFlags {
pub const CALIBRATED: u16 = 1 << 0;
pub const SATURATED: u16 = 1 << 1;
pub const TIME_SYNCHRONIZED: u16 = 1 << 2;
pub const DROPPED_PREDECESSOR: u16 = 1 << 3;
pub const SYNTHETIC: u16 = 1 << 15;
pub fn contains(self, flag: u16) -> bool {
self.0 & flag != 0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CsiPayload {
ComplexI16 {
rssi_dbm: Vec<i8>,
values: Vec<[i16; 2]>,
},
ComplexF32 {
rssi_dbm: Vec<i8>,
values: Vec<[f32; 2]>,
},
Bytes(Vec<u8>),
}
impl CsiPayload {
pub fn len(&self) -> usize {
match self {
Self::ComplexI16 { values, .. } => values.len(),
Self::ComplexF32 { values, .. } => values.len(),
Self::Bytes(values) => values.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn rssi_dbm(&self) -> &[i8] {
match self {
Self::ComplexI16 { rssi_dbm, .. } | Self::ComplexF32 { rssi_dbm, .. } => rssi_dbm,
Self::Bytes(_) => &[],
}
}
fn format(&self) -> ElementFormat {
match self {
Self::ComplexI16 { .. } => ElementFormat::ComplexI16,
Self::ComplexF32 { .. } => ElementFormat::ComplexF32,
Self::Bytes(_) => ElementFormat::Bytes,
}
}
fn encoded_len(&self) -> usize {
match self {
Self::ComplexI16 { rssi_dbm, values } => rssi_dbm.len() + values.len() * 4,
Self::ComplexF32 { rssi_dbm, values } => rssi_dbm.len() + values.len() * 8,
Self::Bytes(values) => values.len(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CsiFrame {
pub report_kind: ReportKind,
pub sequence: u32,
pub timestamp_us: u64,
pub device_id: u64,
pub chipset: ChipsetProfile,
pub bandwidth_mhz: u16,
pub center_freq_khz: u32,
pub flags: CsiFlags,
pub tx_count: u8,
pub rx_count: u8,
pub ppdu_type: PpduType,
pub subcarrier_count: u16,
pub noise_floor_dbm: i8,
pub scale: f32,
pub subcarrier_spacing_hz: f32,
pub calibration_id: u32,
pub payload: CsiPayload,
}
impl CsiFrame {
pub fn to_bytes(&self) -> Result<Vec<u8>, CsiParseError> {
self.validate()?;
let payload_len = self.payload.encoded_len();
let frame_len = MEDIATEK_CSI_HEADER_LEN
.checked_add(payload_len)
.and_then(|n| n.checked_add(MEDIATEK_CSI_CRC_LEN))
.ok_or(CsiParseError::LengthOverflow)?;
if frame_len > MEDIATEK_CSI_MAX_FRAME_LEN {
return Err(CsiParseError::FrameTooLarge(frame_len));
}
let mut out = Vec::with_capacity(frame_len);
out.extend_from_slice(&MEDIATEK_CSI_MAGIC.to_le_bytes());
out.push(MEDIATEK_CSI_VERSION);
out.push(self.report_kind as u8);
out.extend_from_slice(&(MEDIATEK_CSI_HEADER_LEN as u16).to_le_bytes());
out.extend_from_slice(&(frame_len as u32).to_le_bytes());
out.extend_from_slice(&self.sequence.to_le_bytes());
out.extend_from_slice(&self.timestamp_us.to_le_bytes());
out.extend_from_slice(&self.device_id.to_le_bytes());
out.extend_from_slice(&(self.chipset as u16).to_le_bytes());
out.extend_from_slice(&self.bandwidth_mhz.to_le_bytes());
out.extend_from_slice(&self.center_freq_khz.to_le_bytes());
out.extend_from_slice(&self.flags.0.to_le_bytes());
out.push(self.tx_count);
out.push(self.rx_count);
out.push(self.payload.format() as u8);
out.push(self.ppdu_type as u8);
out.extend_from_slice(&self.subcarrier_count.to_le_bytes());
out.push(self.payload.rssi_dbm().len() as u8);
out.push(self.noise_floor_dbm as u8);
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&self.scale.to_le_bytes());
out.extend_from_slice(&self.subcarrier_spacing_hz.to_le_bytes());
out.extend_from_slice(&self.calibration_id.to_le_bytes());
out.extend_from_slice(&(payload_len as u32).to_le_bytes());
out.extend_from_slice(&0u32.to_le_bytes());
debug_assert_eq!(out.len(), MEDIATEK_CSI_HEADER_LEN);
match &self.payload {
CsiPayload::ComplexI16 { rssi_dbm, values } => {
out.extend(rssi_dbm.iter().map(|v| *v as u8));
for [i, q] in values {
out.extend_from_slice(&i.to_le_bytes());
out.extend_from_slice(&q.to_le_bytes());
}
}
CsiPayload::ComplexF32 { rssi_dbm, values } => {
out.extend(rssi_dbm.iter().map(|v| *v as u8));
for [i, q] in values {
out.extend_from_slice(&i.to_le_bytes());
out.extend_from_slice(&q.to_le_bytes());
}
}
CsiPayload::Bytes(values) => out.extend_from_slice(values),
}
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() < MEDIATEK_CSI_HEADER_LEN {
return Err(CsiParseError::InsufficientData {
needed: MEDIATEK_CSI_HEADER_LEN,
got: input.len(),
});
}
let magic = u32_at(input, 0);
if magic != MEDIATEK_CSI_MAGIC {
return Err(CsiParseError::InvalidMagic(magic));
}
if input[4] != MEDIATEK_CSI_VERSION {
return Err(CsiParseError::UnsupportedVersion(input[4]));
}
let report_kind = ReportKind::try_from(input[5])?;
let header_len = u16_at(input, 6) as usize;
if header_len != MEDIATEK_CSI_HEADER_LEN {
return Err(CsiParseError::InvalidHeaderLength(header_len));
}
let frame_len = u32_at(input, 8) as usize;
if frame_len > MEDIATEK_CSI_MAX_FRAME_LEN {
return Err(CsiParseError::FrameTooLarge(frame_len));
}
if frame_len < header_len + MEDIATEK_CSI_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 chipset = ChipsetProfile::try_from(u16_at(input, 32))?;
let format = ElementFormat::try_from(input[44])?;
let ppdu_type = PpduType::try_from(input[45])?;
let tx_count = input[42];
let rx_count = input[43];
let subcarrier_count = u16_at(input, 46);
let rssi_count = input[48] as usize;
let payload_len = u32_at(input, 64) as usize;
if header_len + payload_len + 4 != frame_len {
return Err(CsiParseError::PayloadLengthMismatch);
}
let payload_bytes = &input[header_len..header_len + payload_len];
let elements = (tx_count as usize)
.checked_mul(rx_count as usize)
.and_then(|n| n.checked_mul(subcarrier_count as usize))
.ok_or(CsiParseError::LengthOverflow)?;
let payload = match format {
ElementFormat::Bytes => CsiPayload::Bytes(payload_bytes.to_vec()),
ElementFormat::ComplexI16 => {
if rssi_count > payload_bytes.len()
|| payload_bytes.len() - rssi_count != elements * 4
{
return Err(CsiParseError::PayloadLengthMismatch);
}
let rssi_dbm = payload_bytes[..rssi_count]
.iter()
.map(|v| *v as i8)
.collect();
let values = payload_bytes[rssi_count..]
.chunks_exact(4)
.map(|b| {
[
i16::from_le_bytes([b[0], b[1]]),
i16::from_le_bytes([b[2], b[3]]),
]
})
.collect();
CsiPayload::ComplexI16 { rssi_dbm, values }
}
ElementFormat::ComplexF32 => {
if rssi_count > payload_bytes.len()
|| payload_bytes.len() - rssi_count != elements * 8
{
return Err(CsiParseError::PayloadLengthMismatch);
}
let rssi_dbm = payload_bytes[..rssi_count]
.iter()
.map(|v| *v as i8)
.collect();
let mut values = Vec::with_capacity(elements);
for b in payload_bytes[rssi_count..].chunks_exact(8) {
let i = f32::from_le_bytes(b[0..4].try_into().unwrap());
let q = f32::from_le_bytes(b[4..8].try_into().unwrap());
if !i.is_finite() || !q.is_finite() {
return Err(CsiParseError::NonFiniteValue);
}
values.push([i, q]);
}
CsiPayload::ComplexF32 { rssi_dbm, values }
}
};
let frame = Self {
report_kind,
sequence: u32_at(input, 12),
timestamp_us: u64_at(input, 16),
device_id: u64_at(input, 24),
chipset,
bandwidth_mhz: u16_at(input, 34),
center_freq_khz: u32_at(input, 36),
flags: CsiFlags(u16_at(input, 40)),
tx_count,
rx_count,
ppdu_type,
subcarrier_count,
noise_floor_dbm: input[49] as i8,
scale: f32_at(input, 52),
subcarrier_spacing_hz: f32_at(input, 56),
calibration_id: u32_at(input, 60),
payload,
};
frame.validate()?;
Ok((frame, frame_len))
}
fn validate(&self) -> Result<(), CsiParseError> {
if !matches!(self.bandwidth_mhz, 20 | 40 | 80 | 160) {
return Err(CsiParseError::InvalidBandwidth(self.bandwidth_mhz));
}
if self.tx_count == 0
|| self.rx_count == 0
|| self.tx_count > self.chipset.max_chains()
|| self.rx_count > self.chipset.max_chains()
{
return Err(CsiParseError::InvalidDimensions);
}
if !self.scale.is_finite()
|| self.scale <= 0.0
|| !self.subcarrier_spacing_hz.is_finite()
|| self.subcarrier_spacing_hz <= 0.0
{
return Err(CsiParseError::NonFiniteValue);
}
match (&self.report_kind, &self.payload) {
(ReportKind::Csi, CsiPayload::ComplexI16 { rssi_dbm, values }) => {
self.validate_csi(rssi_dbm, values.len())
}
(ReportKind::Csi, CsiPayload::ComplexF32 { rssi_dbm, values }) => {
if !values.iter().flatten().all(|v| v.is_finite()) {
return Err(CsiParseError::NonFiniteValue);
}
self.validate_csi(rssi_dbm, values.len())
}
(ReportKind::Capabilities, CsiPayload::Bytes(v)) if !v.is_empty() => Ok(()),
_ => Err(CsiParseError::PayloadTypeMismatch),
}
}
fn validate_csi(&self, rssi: &[i8], values: usize) -> Result<(), CsiParseError> {
let expected =
self.tx_count as usize * self.rx_count as usize * self.subcarrier_count as usize;
if expected == 0 || expected > MEDIATEK_CSI_MAX_ELEMENTS {
return Err(CsiParseError::InvalidDimensions);
}
if values != expected || rssi.len() != self.rx_count as usize {
return Err(CsiParseError::PayloadLengthMismatch);
}
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 report kind {0}")]
UnknownReportKind(u8),
#[error("unknown chipset profile {0}")]
UnknownChipset(u16),
#[error("unknown element format {0}")]
UnknownElementFormat(u8),
#[error("unknown PPDU type {0}")]
UnknownPpduType(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("payload type does not match report kind")]
PayloadTypeMismatch,
#[error("invalid MIMO dimensions")]
InvalidDimensions,
#[error("invalid bandwidth {0} MHz")]
InvalidBandwidth(u16),
#[error("non-finite or non-positive numeric metadata/value")]
NonFiniteValue,
#[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 device_id: u64,
pub chipset: ChipsetProfile,
pub bandwidth_mhz: u16,
pub center_freq_khz: u32,
pub tx_count: u8,
pub rx_count: u8,
pub subcarriers: u16,
pub frame_period_us: u64,
}
impl Default for SimulatorConfig {
fn default() -> Self {
Self {
seed: 0x4d54_4b43_5349_0001,
device_id: 0x4f57_5254_4d54_4b31,
chipset: ChipsetProfile::Mt7981Mt7976,
bandwidth_mhz: 80,
center_freq_khz: 5_210_000,
tx_count: 2,
rx_count: 3,
subcarriers: 256,
frame_period_us: 20_000,
}
}
}
pub struct MediatekCsiSimulator {
config: SimulatorConfig,
rng: u64,
sequence: u32,
timestamp_us: u64,
motion_phase: f32,
}
impl MediatekCsiSimulator {
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 capabilities_frame(&self) -> CsiFrame {
self.base(
ReportKind::Capabilities,
CsiPayload::Bytes(vec![
1,
1,
self.config.chipset.max_chains(),
2,
1,
0b0000_1111,
3,
2,
(self.config.subcarriers & 255) as u8,
(self.config.subcarriers >> 8) as u8,
]),
)
}
pub fn next_frame(&mut self) -> CsiFrame {
let frame = self.csi_frame().expect("validated simulator config");
self.sequence = self.sequence.wrapping_add(1);
self.timestamp_us = self.timestamp_us.wrapping_add(self.config.frame_period_us);
self.motion_phase += 0.037;
frame
}
fn csi_frame(&self) -> Result<CsiFrame, CsiParseError> {
let mut rng = self.rng ^ self.sequence as u64;
let count = self.config.tx_count as usize
* self.config.rx_count as usize
* self.config.subcarriers as usize;
let values = (0..count)
.map(|idx| {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
let noise = ((rng >> 48) as i16 % 24) as f32;
let sc = (idx % self.config.subcarriers as usize) as f32;
let chain = (idx / self.config.subcarriers as usize) as f32;
let phase = sc * 0.031 + chain * 0.23 + self.motion_phase;
[
((phase.cos() * 1800.0) + noise) as i16,
((phase.sin() * 1800.0) - noise) as i16,
]
})
.collect();
Ok(self.base(
ReportKind::Csi,
CsiPayload::ComplexI16 {
rssi_dbm: (0..self.config.rx_count)
.map(|i| -42 - i as i8 * 2)
.collect(),
values,
},
))
}
fn base(&self, kind: ReportKind, payload: CsiPayload) -> CsiFrame {
CsiFrame {
report_kind: kind,
sequence: self.sequence,
timestamp_us: self.timestamp_us,
device_id: self.config.device_id,
chipset: self.config.chipset,
bandwidth_mhz: self.config.bandwidth_mhz,
center_freq_khz: self.config.center_freq_khz,
flags: CsiFlags(CsiFlags::CALIBRATED | CsiFlags::SYNTHETIC),
tx_count: self.config.tx_count,
rx_count: self.config.rx_count,
ppdu_type: PpduType::HeSu,
subcarrier_count: self.config.subcarriers,
noise_floor_dbm: -95,
scale: 1.0 / 2048.0,
subcarrier_spacing_hz: 312_500.0,
calibration_id: 1,
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 u64_at(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
fn f32_at(b: &[u8], o: usize) -> f32 {
f32::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 = MediatekCsiSimulator::new(cfg.clone()).unwrap();
let mut b = MediatekCsiSimulator::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(), 2 * 3 * 256);
}
#[test]
fn capabilities_round_trip() {
let s = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let f = s.capabilities_frame();
let w = f.to_bytes().unwrap();
assert_eq!(CsiFrame::from_bytes(&w).unwrap().0, f);
}
#[test]
fn crc_corruption_is_rejected() {
let mut s = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let mut w = s.next_frame().to_bytes().unwrap();
w[80] ^= 1;
assert!(matches!(
CsiFrame::from_bytes(&w),
Err(CsiParseError::CrcMismatch { .. })
));
}
#[test]
fn truncation_is_rejected() {
let mut s = MediatekCsiSimulator::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 invalid_dimensions_are_rejected() {
let cfg = SimulatorConfig {
rx_count: 4,
chipset: ChipsetProfile::Mt7981Mt7976,
..Default::default()
};
assert!(matches!(
MediatekCsiSimulator::new(cfg),
Err(CsiParseError::InvalidDimensions)
));
}
#[test]
fn non_finite_float_is_rejected() {
let mut s = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let mut f = s.next_frame();
f.payload = CsiPayload::ComplexF32 {
rssi_dbm: vec![-40, -42, -44],
values: vec![[f32::NAN, 0.0]; 2 * 3 * 256],
};
assert_eq!(f.to_bytes().unwrap_err(), CsiParseError::NonFiniteValue);
}
#[test]
fn parser_never_panics_on_prefixes() {
let mut s = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let w = s.next_frame().to_bytes().unwrap();
for end in 0..w.len() {
let _ = CsiFrame::from_bytes(&w[..end]);
}
}
}
@@ -0,0 +1,700 @@
//! Vendor-neutral Qualcomm Atheros MIMO CSI transport and deterministic simulator.
//! This is not a Qualcomm firmware ABI; see ADR-268/269.
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const QUALCOMM_CSI_MAGIC: u32 = 0x3153_4351; // "QCS1" little endian
pub const QUALCOMM_CSI_VERSION: u8 = 1;
pub const QUALCOMM_CSI_HEADER_LEN: usize = 72;
pub const QUALCOMM_CSI_CRC_LEN: usize = 4;
pub const QUALCOMM_CSI_MAX_FRAME_LEN: usize = 65_507;
pub const QUALCOMM_CSI_MAX_ELEMENTS: usize = 16_384;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ReportKind {
Csi = 1,
Capabilities = 2,
}
impl TryFrom<u8> for ReportKind {
type Error = CsiParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Csi),
2 => Ok(Self::Capabilities),
_ => Err(CsiParseError::UnknownReportKind(value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u16)]
pub enum ChipsetProfile {
Qca9300 = 1,
Qcn9074 = 2,
Qcn9274 = 3,
}
impl TryFrom<u16> for ChipsetProfile {
type Error = CsiParseError;
fn try_from(value: u16) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Qca9300),
2 => Ok(Self::Qcn9074),
3 => Ok(Self::Qcn9274),
_ => Err(CsiParseError::UnknownChipset(value)),
}
}
}
impl ChipsetProfile {
pub fn name(self) -> &'static str {
match self {
Self::Qca9300 => "qca9300",
Self::Qcn9074 => "qcn9074",
Self::Qcn9274 => "qcn9274",
}
}
pub fn max_chains(self) -> u8 {
match self {
Self::Qca9300 => 3,
Self::Qcn9074 | Self::Qcn9274 => 4,
}
}
pub fn max_bandwidth_mhz(self) -> u16 {
match self {
Self::Qca9300 => 40,
Self::Qcn9074 | Self::Qcn9274 => 160,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ElementFormat {
ComplexI16 = 1,
ComplexF32 = 2,
Bytes = 3,
}
impl TryFrom<u8> for ElementFormat {
type Error = CsiParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::ComplexI16),
2 => Ok(Self::ComplexF32),
3 => Ok(Self::Bytes),
_ => Err(CsiParseError::UnknownElementFormat(value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum PpduType {
Ht = 1,
Vht = 2,
HeSu = 3,
HeMu = 4,
Eht = 5,
}
impl TryFrom<u8> for PpduType {
type Error = CsiParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Ht),
2 => Ok(Self::Vht),
3 => Ok(Self::HeSu),
4 => Ok(Self::HeMu),
5 => Ok(Self::Eht),
_ => Err(CsiParseError::UnknownPpduType(value)),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CsiFlags(pub u16);
impl CsiFlags {
pub const CALIBRATED: u16 = 1 << 0;
pub const SATURATED: u16 = 1 << 1;
pub const TIME_SYNCHRONIZED: u16 = 1 << 2;
pub const DROPPED_PREDECESSOR: u16 = 1 << 3;
pub const SYNTHETIC: u16 = 1 << 15;
pub fn contains(self, flag: u16) -> bool {
self.0 & flag != 0
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CsiPayload {
ComplexI16 {
rssi_dbm: Vec<i8>,
values: Vec<[i16; 2]>,
},
ComplexF32 {
rssi_dbm: Vec<i8>,
values: Vec<[f32; 2]>,
},
Bytes(Vec<u8>),
}
impl CsiPayload {
pub fn len(&self) -> usize {
match self {
Self::ComplexI16 { values, .. } => values.len(),
Self::ComplexF32 { values, .. } => values.len(),
Self::Bytes(values) => values.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn rssi_dbm(&self) -> &[i8] {
match self {
Self::ComplexI16 { rssi_dbm, .. } | Self::ComplexF32 { rssi_dbm, .. } => rssi_dbm,
Self::Bytes(_) => &[],
}
}
fn format(&self) -> ElementFormat {
match self {
Self::ComplexI16 { .. } => ElementFormat::ComplexI16,
Self::ComplexF32 { .. } => ElementFormat::ComplexF32,
Self::Bytes(_) => ElementFormat::Bytes,
}
}
fn encoded_len(&self) -> usize {
match self {
Self::ComplexI16 { rssi_dbm, values } => rssi_dbm.len() + values.len() * 4,
Self::ComplexF32 { rssi_dbm, values } => rssi_dbm.len() + values.len() * 8,
Self::Bytes(values) => values.len(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CsiFrame {
pub report_kind: ReportKind,
pub sequence: u32,
pub timestamp_us: u64,
pub device_id: u64,
pub chipset: ChipsetProfile,
pub bandwidth_mhz: u16,
pub center_freq_khz: u32,
pub flags: CsiFlags,
pub tx_count: u8,
pub rx_count: u8,
pub ppdu_type: PpduType,
pub subcarrier_count: u16,
pub noise_floor_dbm: i8,
pub scale: f32,
pub subcarrier_spacing_hz: f32,
pub calibration_id: u32,
pub payload: CsiPayload,
}
impl CsiFrame {
pub fn to_bytes(&self) -> Result<Vec<u8>, CsiParseError> {
self.validate()?;
let payload_len = self.payload.encoded_len();
let frame_len = QUALCOMM_CSI_HEADER_LEN
.checked_add(payload_len)
.and_then(|n| n.checked_add(QUALCOMM_CSI_CRC_LEN))
.ok_or(CsiParseError::LengthOverflow)?;
if frame_len > QUALCOMM_CSI_MAX_FRAME_LEN {
return Err(CsiParseError::FrameTooLarge(frame_len));
}
let mut out = Vec::with_capacity(frame_len);
out.extend_from_slice(&QUALCOMM_CSI_MAGIC.to_le_bytes());
out.push(QUALCOMM_CSI_VERSION);
out.push(self.report_kind as u8);
out.extend_from_slice(&(QUALCOMM_CSI_HEADER_LEN as u16).to_le_bytes());
out.extend_from_slice(&(frame_len as u32).to_le_bytes());
out.extend_from_slice(&self.sequence.to_le_bytes());
out.extend_from_slice(&self.timestamp_us.to_le_bytes());
out.extend_from_slice(&self.device_id.to_le_bytes());
out.extend_from_slice(&(self.chipset as u16).to_le_bytes());
out.extend_from_slice(&self.bandwidth_mhz.to_le_bytes());
out.extend_from_slice(&self.center_freq_khz.to_le_bytes());
out.extend_from_slice(&self.flags.0.to_le_bytes());
out.push(self.tx_count);
out.push(self.rx_count);
out.push(self.payload.format() as u8);
out.push(self.ppdu_type as u8);
out.extend_from_slice(&self.subcarrier_count.to_le_bytes());
out.push(self.payload.rssi_dbm().len() as u8);
out.push(self.noise_floor_dbm as u8);
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&self.scale.to_le_bytes());
out.extend_from_slice(&self.subcarrier_spacing_hz.to_le_bytes());
out.extend_from_slice(&self.calibration_id.to_le_bytes());
out.extend_from_slice(&(payload_len as u32).to_le_bytes());
out.extend_from_slice(&0u32.to_le_bytes());
debug_assert_eq!(out.len(), QUALCOMM_CSI_HEADER_LEN);
match &self.payload {
CsiPayload::ComplexI16 { rssi_dbm, values } => {
out.extend(rssi_dbm.iter().map(|v| *v as u8));
for [i, q] in values {
out.extend_from_slice(&i.to_le_bytes());
out.extend_from_slice(&q.to_le_bytes());
}
}
CsiPayload::ComplexF32 { rssi_dbm, values } => {
out.extend(rssi_dbm.iter().map(|v| *v as u8));
for [i, q] in values {
out.extend_from_slice(&i.to_le_bytes());
out.extend_from_slice(&q.to_le_bytes());
}
}
CsiPayload::Bytes(values) => out.extend_from_slice(values),
}
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() < QUALCOMM_CSI_HEADER_LEN {
return Err(CsiParseError::InsufficientData {
needed: QUALCOMM_CSI_HEADER_LEN,
got: input.len(),
});
}
let magic = u32_at(input, 0);
if magic != QUALCOMM_CSI_MAGIC {
return Err(CsiParseError::InvalidMagic(magic));
}
if input[4] != QUALCOMM_CSI_VERSION {
return Err(CsiParseError::UnsupportedVersion(input[4]));
}
let report_kind = ReportKind::try_from(input[5])?;
let header_len = u16_at(input, 6) as usize;
if header_len != QUALCOMM_CSI_HEADER_LEN {
return Err(CsiParseError::InvalidHeaderLength(header_len));
}
let frame_len = u32_at(input, 8) as usize;
if frame_len > QUALCOMM_CSI_MAX_FRAME_LEN {
return Err(CsiParseError::FrameTooLarge(frame_len));
}
if frame_len < header_len + QUALCOMM_CSI_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 chipset = ChipsetProfile::try_from(u16_at(input, 32))?;
let format = ElementFormat::try_from(input[44])?;
let ppdu_type = PpduType::try_from(input[45])?;
let tx_count = input[42];
let rx_count = input[43];
let subcarrier_count = u16_at(input, 46);
let rssi_count = input[48] as usize;
let payload_len = u32_at(input, 64) as usize;
if header_len + payload_len + 4 != frame_len {
return Err(CsiParseError::PayloadLengthMismatch);
}
let payload_bytes = &input[header_len..header_len + payload_len];
let elements = (tx_count as usize)
.checked_mul(rx_count as usize)
.and_then(|n| n.checked_mul(subcarrier_count as usize))
.ok_or(CsiParseError::LengthOverflow)?;
let payload = match format {
ElementFormat::Bytes => CsiPayload::Bytes(payload_bytes.to_vec()),
ElementFormat::ComplexI16 => {
if rssi_count > payload_bytes.len()
|| payload_bytes.len() - rssi_count != elements * 4
{
return Err(CsiParseError::PayloadLengthMismatch);
}
let rssi_dbm = payload_bytes[..rssi_count]
.iter()
.map(|v| *v as i8)
.collect();
let values = payload_bytes[rssi_count..]
.chunks_exact(4)
.map(|b| {
[
i16::from_le_bytes([b[0], b[1]]),
i16::from_le_bytes([b[2], b[3]]),
]
})
.collect();
CsiPayload::ComplexI16 { rssi_dbm, values }
}
ElementFormat::ComplexF32 => {
if rssi_count > payload_bytes.len()
|| payload_bytes.len() - rssi_count != elements * 8
{
return Err(CsiParseError::PayloadLengthMismatch);
}
let rssi_dbm = payload_bytes[..rssi_count]
.iter()
.map(|v| *v as i8)
.collect();
let mut values = Vec::with_capacity(elements);
for b in payload_bytes[rssi_count..].chunks_exact(8) {
let i = f32::from_le_bytes(b[0..4].try_into().unwrap());
let q = f32::from_le_bytes(b[4..8].try_into().unwrap());
if !i.is_finite() || !q.is_finite() {
return Err(CsiParseError::NonFiniteValue);
}
values.push([i, q]);
}
CsiPayload::ComplexF32 { rssi_dbm, values }
}
};
let frame = Self {
report_kind,
sequence: u32_at(input, 12),
timestamp_us: u64_at(input, 16),
device_id: u64_at(input, 24),
chipset,
bandwidth_mhz: u16_at(input, 34),
center_freq_khz: u32_at(input, 36),
flags: CsiFlags(u16_at(input, 40)),
tx_count,
rx_count,
ppdu_type,
subcarrier_count,
noise_floor_dbm: input[49] as i8,
scale: f32_at(input, 52),
subcarrier_spacing_hz: f32_at(input, 56),
calibration_id: u32_at(input, 60),
payload,
};
frame.validate()?;
Ok((frame, frame_len))
}
fn validate(&self) -> Result<(), CsiParseError> {
if !matches!(self.bandwidth_mhz, 20 | 40 | 80 | 160)
|| self.bandwidth_mhz > self.chipset.max_bandwidth_mhz()
{
return Err(CsiParseError::InvalidBandwidth(self.bandwidth_mhz));
}
if self.tx_count == 0
|| self.rx_count == 0
|| self.tx_count > self.chipset.max_chains()
|| self.rx_count > self.chipset.max_chains()
{
return Err(CsiParseError::InvalidDimensions);
}
if !self.scale.is_finite()
|| self.scale <= 0.0
|| !self.subcarrier_spacing_hz.is_finite()
|| self.subcarrier_spacing_hz <= 0.0
{
return Err(CsiParseError::NonFiniteValue);
}
match (&self.report_kind, &self.payload) {
(ReportKind::Csi, CsiPayload::ComplexI16 { rssi_dbm, values }) => {
self.validate_csi(rssi_dbm, values.len())
}
(ReportKind::Csi, CsiPayload::ComplexF32 { rssi_dbm, values }) => {
if !values.iter().flatten().all(|v| v.is_finite()) {
return Err(CsiParseError::NonFiniteValue);
}
self.validate_csi(rssi_dbm, values.len())
}
(ReportKind::Capabilities, CsiPayload::Bytes(v)) if !v.is_empty() => Ok(()),
_ => Err(CsiParseError::PayloadTypeMismatch),
}
}
fn validate_csi(&self, rssi: &[i8], values: usize) -> Result<(), CsiParseError> {
let expected =
self.tx_count as usize * self.rx_count as usize * self.subcarrier_count as usize;
if expected == 0 || expected > QUALCOMM_CSI_MAX_ELEMENTS {
return Err(CsiParseError::InvalidDimensions);
}
if values != expected || rssi.len() != self.rx_count as usize {
return Err(CsiParseError::PayloadLengthMismatch);
}
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 report kind {0}")]
UnknownReportKind(u8),
#[error("unknown chipset profile {0}")]
UnknownChipset(u16),
#[error("unknown element format {0}")]
UnknownElementFormat(u8),
#[error("unknown PPDU type {0}")]
UnknownPpduType(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("payload type does not match report kind")]
PayloadTypeMismatch,
#[error("invalid MIMO dimensions")]
InvalidDimensions,
#[error("invalid bandwidth {0} MHz")]
InvalidBandwidth(u16),
#[error("non-finite or non-positive numeric metadata/value")]
NonFiniteValue,
#[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 device_id: u64,
pub chipset: ChipsetProfile,
pub bandwidth_mhz: u16,
pub center_freq_khz: u32,
pub tx_count: u8,
pub rx_count: u8,
pub subcarriers: u16,
pub frame_period_us: u64,
}
impl Default for SimulatorConfig {
fn default() -> Self {
Self {
seed: 0x5143_4143_5349_0001,
device_id: 0x5255_5651_4341_3031,
chipset: ChipsetProfile::Qca9300,
bandwidth_mhz: 40,
center_freq_khz: 5_210_000,
tx_count: 2,
rx_count: 3,
subcarriers: 114,
frame_period_us: 20_000,
}
}
}
pub struct QualcommCsiSimulator {
config: SimulatorConfig,
rng: u64,
sequence: u32,
timestamp_us: u64,
motion_phase: f32,
}
impl QualcommCsiSimulator {
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 capabilities_frame(&self) -> CsiFrame {
self.base(
ReportKind::Capabilities,
CsiPayload::Bytes(vec![
1,
1,
self.config.chipset.max_chains(),
2,
1,
0b0000_1111,
3,
2,
(self.config.subcarriers & 255) as u8,
(self.config.subcarriers >> 8) as u8,
]),
)
}
pub fn next_frame(&mut self) -> CsiFrame {
let frame = self.csi_frame().expect("validated simulator config");
self.sequence = self.sequence.wrapping_add(1);
self.timestamp_us = self.timestamp_us.wrapping_add(self.config.frame_period_us);
self.motion_phase += 0.037;
frame
}
fn csi_frame(&self) -> Result<CsiFrame, CsiParseError> {
let mut rng = self.rng ^ self.sequence as u64;
let count = self.config.tx_count as usize
* self.config.rx_count as usize
* self.config.subcarriers as usize;
let values = (0..count)
.map(|idx| {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
let noise = ((rng >> 48) as i16 % 24) as f32;
let sc = (idx % self.config.subcarriers as usize) as f32;
let chain = (idx / self.config.subcarriers as usize) as f32;
let phase = sc * 0.031 + chain * 0.23 + self.motion_phase;
[
((phase.cos() * 1800.0) + noise) as i16,
((phase.sin() * 1800.0) - noise) as i16,
]
})
.collect();
Ok(self.base(
ReportKind::Csi,
CsiPayload::ComplexI16 {
rssi_dbm: (0..self.config.rx_count)
.map(|i| -42 - i as i8 * 2)
.collect(),
values,
},
))
}
fn base(&self, kind: ReportKind, payload: CsiPayload) -> CsiFrame {
CsiFrame {
report_kind: kind,
sequence: self.sequence,
timestamp_us: self.timestamp_us,
device_id: self.config.device_id,
chipset: self.config.chipset,
bandwidth_mhz: self.config.bandwidth_mhz,
center_freq_khz: self.config.center_freq_khz,
flags: CsiFlags(CsiFlags::CALIBRATED | CsiFlags::SYNTHETIC),
tx_count: self.config.tx_count,
rx_count: self.config.rx_count,
ppdu_type: PpduType::HeSu,
subcarrier_count: self.config.subcarriers,
noise_floor_dbm: -95,
scale: 1.0 / 2048.0,
subcarrier_spacing_hz: 312_500.0,
calibration_id: 1,
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 u64_at(b: &[u8], o: usize) -> u64 {
u64::from_le_bytes(b[o..o + 8].try_into().unwrap())
}
fn f32_at(b: &[u8], o: usize) -> f32 {
f32::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 = QualcommCsiSimulator::new(cfg.clone()).unwrap();
let mut b = QualcommCsiSimulator::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(), 2 * 3 * 114);
}
#[test]
fn capabilities_round_trip() {
let s = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap();
let f = s.capabilities_frame();
let w = f.to_bytes().unwrap();
assert_eq!(CsiFrame::from_bytes(&w).unwrap().0, f);
}
#[test]
fn crc_corruption_is_rejected() {
let mut s = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap();
let mut w = s.next_frame().to_bytes().unwrap();
w[80] ^= 1;
assert!(matches!(
CsiFrame::from_bytes(&w),
Err(CsiParseError::CrcMismatch { .. })
));
}
#[test]
fn truncation_is_rejected() {
let mut s = QualcommCsiSimulator::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 invalid_dimensions_are_rejected() {
let cfg = SimulatorConfig {
rx_count: 4,
chipset: ChipsetProfile::Qca9300,
..Default::default()
};
assert!(matches!(
QualcommCsiSimulator::new(cfg),
Err(CsiParseError::InvalidDimensions)
));
}
#[test]
fn non_finite_float_is_rejected() {
let mut s = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap();
let mut f = s.next_frame();
f.payload = CsiPayload::ComplexF32 {
rssi_dbm: vec![-40, -42, -44],
values: vec![[f32::NAN, 0.0]; 2 * 3 * 114],
};
assert_eq!(f.to_bytes().unwrap_err(), CsiParseError::NonFiniteValue);
}
#[test]
fn parser_never_panics_on_prefixes() {
let mut s = QualcommCsiSimulator::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 qca9300_rejects_wifi6_bandwidths() {
let cfg = SimulatorConfig {
bandwidth_mhz: 80,
..Default::default()
};
assert!(matches!(
QualcommCsiSimulator::new(cfg),
Err(CsiParseError::InvalidBandwidth(80))
));
}
}
@@ -0,0 +1,852 @@
//! ADR-264 transport-neutral framing for Realtek RTL8720F radar reports.
//!
//! This is a RuView-owned wire contract around the public Ameba API boundary,
//! not a representation of Realtek's private structs. Upstream PR #1336 exposes
//! `wifi_radar_config(struct rtw_radar_action_parm *)`; the report callback ABI
//! remains vendor-gated. Keeping this codec byte-oriented lets host development,
//! replay, and fuzzing proceed without linking the Ameba SDK.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::radio_ops::crc32_ieee;
pub const RTL8720F_RADAR_MAGIC: u32 = 0x3152_5452; // "RTR1" in little endian
pub const RTL8720F_RADAR_VERSION: u8 = 1;
pub const RTL8720F_RADAR_HEADER_LEN: usize = 56;
pub const RTL8720F_RADAR_CRC_LEN: usize = 4;
/// Largest payload that can be carried in one IPv4 UDP datagram.
pub const RTL8720F_RADAR_MAX_FRAME_LEN: usize = 65_507;
pub const RTL8720F_RADAR_MAX_ELEMENTS: usize = 16_384;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ReportType {
Cfr = 1,
RangeNear = 2,
RangeFar = 3,
Interference = 4,
Capabilities = 5,
}
impl TryFrom<u8> for ReportType {
type Error = RadarParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Cfr),
2 => Ok(Self::RangeNear),
3 => Ok(Self::RangeFar),
4 => Ok(Self::Interference),
5 => Ok(Self::Capabilities),
_ => Err(RadarParseError::UnknownReportType(value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[repr(u8)]
pub enum ElementFormat {
/// TLV/opaque byte payload used by capabilities and interference reports.
Bytes = 0,
ComplexI16 = 1,
ComplexF32 = 2,
PowerU16 = 3,
PowerF32 = 4,
}
impl ElementFormat {
fn bytes_per_element(self) -> usize {
match self {
Self::Bytes => 1,
Self::ComplexI16 | Self::PowerF32 => 4,
Self::ComplexF32 => 8,
Self::PowerU16 => 2,
}
}
}
impl TryFrom<u8> for ElementFormat {
type Error = RadarParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Bytes),
1 => Ok(Self::ComplexI16),
2 => Ok(Self::ComplexF32),
3 => Ok(Self::PowerU16),
4 => Ok(Self::PowerF32),
_ => Err(RadarParseError::UnknownElementFormat(value)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RadarFlags(pub u16);
impl RadarFlags {
pub const CALIBRATED: u16 = 1 << 0;
pub const INTERFERENCE_DETECTED: u16 = 1 << 1;
pub const SATURATED: u16 = 1 << 2;
pub const TIME_SYNCHRONIZED: u16 = 1 << 3;
/// Frame was produced by a simulator/replay generator, never real hardware.
pub const SYNTHETIC: u16 = 1 << 15;
pub fn contains(self, flag: u16) -> bool {
self.0 & flag != 0
}
}
/// Deterministic, Rust-only RTL8720F source used until hardware is available.
/// It emits the same [`RadarFrame`] objects and wire bytes as the vendor adapter.
pub mod simulator {
use super::*;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SimulatorConfig {
pub seed: u64,
pub device_id: u64,
pub center_freq_khz: u32,
pub bandwidth_mhz: u16,
pub frame_period_us: u64,
pub cfr_bins: u16,
pub range_bins: u16,
}
impl Default for SimulatorConfig {
fn default() -> Self {
Self {
seed: 0x8720_F123_4567_89AB,
device_id: 0x5254_4C38_3732_3046,
center_freq_khz: 2_442_000,
bandwidth_mhz: 40,
frame_period_us: 15_000,
cfr_bins: 128,
range_bins: 32,
}
}
}
#[derive(Debug, Clone)]
pub struct Rtl8720fSimulator {
config: SimulatorConfig,
rng: u64,
sequence: u32,
timestamp_us: u64,
target_distance_m: f32,
target_velocity_mps: f32,
}
impl Rtl8720fSimulator {
pub fn new(config: SimulatorConfig) -> Result<Self, RadarParseError> {
if !matches!(config.bandwidth_mhz, 20 | 40 | 70) {
return Err(RadarParseError::InvalidBandwidth(config.bandwidth_mhz));
}
if config.cfr_bins == 0 || config.range_bins == 0 {
return Err(RadarParseError::TooManyElements(0));
}
Ok(Self {
rng: config.seed,
config,
sequence: 0,
timestamp_us: 0,
target_distance_m: 2.0,
target_velocity_mps: 0.20,
})
}
pub fn config(&self) -> &SimulatorConfig {
&self.config
}
pub fn target_distance_m(&self) -> f32 {
self.target_distance_m
}
pub fn target_velocity_mps(&self) -> f32 {
self.target_velocity_mps
}
/// Emit the boot-time capabilities report as compact TLVs:
/// type 1 = bandwidth bitset, 2 = CFR bins, 3 = range bins,
/// 4 = minimum frame period in microseconds.
pub fn capabilities_frame(&self) -> RadarFrame {
let bandwidths = 0b0000_0111u8; // 20, 40, 70 MHz
let mut bytes = vec![1, 1, bandwidths, 2, 2];
bytes.extend_from_slice(&self.config.cfr_bins.to_le_bytes());
bytes.extend_from_slice(&[3, 2]);
bytes.extend_from_slice(&self.config.range_bins.to_le_bytes());
bytes.extend_from_slice(&[4, 4]);
bytes.extend_from_slice(&(self.config.frame_period_us as u32).to_le_bytes());
self.frame(
ReportType::Capabilities,
0,
0,
RadarPayload::Bytes(bytes),
0.0,
)
}
pub fn next_frame(&mut self, report_type: ReportType) -> RadarFrame {
let sequence = self.sequence;
let timestamp_us = self.timestamp_us;
self.sequence = self.sequence.wrapping_add(1);
self.timestamp_us = self.timestamp_us.wrapping_add(self.config.frame_period_us);
self.advance_target();
match report_type {
ReportType::Cfr => {
let values = (0..self.config.cfr_bins)
.map(|bin| {
let phase = bin as f32 * 0.17 + sequence as f32 * 0.05;
let noise_i = self.noise_i16(20);
let noise_q = self.noise_i16(20);
[
(phase.cos() * 1800.0) as i16 + noise_i,
(phase.sin() * 1800.0) as i16 + noise_q,
]
})
.collect();
self.frame(
report_type,
sequence,
timestamp_us,
RadarPayload::ComplexI16(values),
self.config.bandwidth_mhz as f32 * 1_000_000.0
/ self.config.cfr_bins as f32,
)
}
ReportType::RangeNear | ReportType::RangeFar => {
let bin_spacing = match self.config.bandwidth_mhz {
70 => 0.33,
40 => 0.59,
_ => 1.18,
};
let target_bin = (self.target_distance_m / bin_spacing).round() as usize;
let values = (0..self.config.range_bins as usize)
.map(|bin| {
let distance = bin.abs_diff(target_bin) as f32;
let peak = 1000.0 * (-0.5 * distance * distance).exp();
let leakage = if report_type == ReportType::RangeNear && bin < 2 {
250.0
} else {
0.0
};
(peak + leakage + self.noise_f32(12.0)).max(0.0)
})
.collect();
self.frame(
report_type,
sequence,
timestamp_us,
RadarPayload::PowerF32(values),
bin_spacing,
)
}
ReportType::Interference => {
// TLV: channel-busy %, detected-during-chirp, signed dBm.
let busy = (self.next_u32() % 35) as u8;
let detected = u8::from(busy > 25);
let dbm = (-90i8 + (self.next_u32() % 25) as i8) as u8;
let bytes = vec![1, 1, busy, 2, 1, detected, 3, 1, dbm];
let mut frame = self.frame(
report_type,
sequence,
timestamp_us,
RadarPayload::Bytes(bytes),
0.0,
);
if detected != 0 {
frame.flags.0 |= RadarFlags::INTERFERENCE_DETECTED;
}
frame
}
ReportType::Capabilities => self.capabilities_frame(),
}
}
pub fn next_wire(&mut self, report_type: ReportType) -> Result<Vec<u8>, RadarParseError> {
self.next_frame(report_type).to_bytes()
}
fn frame(
&self,
report_type: ReportType,
sequence: u32,
timestamp_us: u64,
payload: RadarPayload,
bin_spacing: f32,
) -> RadarFrame {
RadarFrame {
report_type,
sequence,
timestamp_us,
device_id: self.config.device_id,
center_freq_khz: self.config.center_freq_khz,
bandwidth_mhz: self.config.bandwidth_mhz,
flags: RadarFlags(RadarFlags::CALIBRATED | RadarFlags::SYNTHETIC),
antenna_count: 1,
scale: 1.0,
bin_spacing,
calibration_id: 0,
payload,
}
}
fn advance_target(&mut self) {
let dt = self.config.frame_period_us as f32 / 1_000_000.0;
self.target_distance_m += self.target_velocity_mps * dt;
if self.target_distance_m >= 5.5 || self.target_distance_m <= 0.8 {
self.target_velocity_mps = -self.target_velocity_mps;
self.target_distance_m = self.target_distance_m.clamp(0.8, 5.5);
}
}
fn next_u32(&mut self) -> u32 {
// PCG-style state transition with xorshift output; deterministic and dependency-free.
self.rng = self
.rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let word = (((self.rng >> 18) ^ self.rng) >> 27) as u32;
word.rotate_right((self.rng >> 59) as u32)
}
fn noise_i16(&mut self, amplitude: i16) -> i16 {
(self.next_u32() % (amplitude as u32 * 2 + 1)) as i16 - amplitude
}
fn noise_f32(&mut self, amplitude: f32) -> f32 {
let unit = self.next_u32() as f32 / u32::MAX as f32;
(unit * 2.0 - 1.0) * amplitude
}
}
impl Iterator for Rtl8720fSimulator {
type Item = RadarFrame;
fn next(&mut self) -> Option<Self::Item> {
let report_type = match self.sequence % 4 {
0 | 2 => ReportType::Cfr,
1 => ReportType::RangeNear,
_ => ReportType::RangeFar,
};
Some(self.next_frame(report_type))
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum RadarPayload {
Bytes(Vec<u8>),
ComplexI16(Vec<[i16; 2]>),
ComplexF32(Vec<[f32; 2]>),
PowerU16(Vec<u16>),
PowerF32(Vec<f32>),
}
impl RadarPayload {
pub fn format(&self) -> ElementFormat {
match self {
Self::Bytes(_) => ElementFormat::Bytes,
Self::ComplexI16(_) => ElementFormat::ComplexI16,
Self::ComplexF32(_) => ElementFormat::ComplexF32,
Self::PowerU16(_) => ElementFormat::PowerU16,
Self::PowerF32(_) => ElementFormat::PowerF32,
}
}
pub fn len(&self) -> usize {
match self {
Self::Bytes(v) => v.len(),
Self::ComplexI16(v) => v.len(),
Self::ComplexF32(v) => v.len(),
Self::PowerU16(v) => v.len(),
Self::PowerF32(v) => v.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn encoded_len(&self) -> usize {
self.len() * self.format().bytes_per_element()
}
fn validate_finite(&self) -> Result<(), RadarParseError> {
let valid = match self {
Self::ComplexF32(values) => values.iter().flatten().all(|v| v.is_finite()),
Self::PowerF32(values) => values.iter().all(|v| v.is_finite()),
_ => true,
};
if valid {
Ok(())
} else {
Err(RadarParseError::NonFiniteValue)
}
}
fn encode_into(&self, out: &mut Vec<u8>) {
match self {
Self::Bytes(values) => out.extend_from_slice(values),
Self::ComplexI16(values) => values.iter().for_each(|value| {
out.extend_from_slice(&value[0].to_le_bytes());
out.extend_from_slice(&value[1].to_le_bytes());
}),
Self::ComplexF32(values) => values.iter().for_each(|value| {
out.extend_from_slice(&value[0].to_le_bytes());
out.extend_from_slice(&value[1].to_le_bytes());
}),
Self::PowerU16(values) => values
.iter()
.for_each(|value| out.extend_from_slice(&value.to_le_bytes())),
Self::PowerF32(values) => values
.iter()
.for_each(|value| out.extend_from_slice(&value.to_le_bytes())),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RadarFrame {
pub report_type: ReportType,
pub sequence: u32,
pub timestamp_us: u64,
pub device_id: u64,
pub center_freq_khz: u32,
pub bandwidth_mhz: u16,
pub flags: RadarFlags,
pub antenna_count: u8,
pub scale: f32,
pub bin_spacing: f32,
pub calibration_id: u32,
pub payload: RadarPayload,
}
impl RadarFrame {
pub fn to_bytes(&self) -> Result<Vec<u8>, RadarParseError> {
self.validate()?;
let payload_len = self.payload.encoded_len();
let frame_len = RTL8720F_RADAR_HEADER_LEN
.checked_add(payload_len)
.and_then(|value| value.checked_add(RTL8720F_RADAR_CRC_LEN))
.ok_or(RadarParseError::LengthOverflow)?;
if frame_len > RTL8720F_RADAR_MAX_FRAME_LEN {
return Err(RadarParseError::FrameTooLarge(frame_len));
}
let mut out = Vec::with_capacity(frame_len);
out.extend_from_slice(&RTL8720F_RADAR_MAGIC.to_le_bytes());
out.push(RTL8720F_RADAR_VERSION);
out.push(self.report_type as u8);
out.extend_from_slice(&(RTL8720F_RADAR_HEADER_LEN as u16).to_le_bytes());
out.extend_from_slice(&(frame_len as u32).to_le_bytes());
out.extend_from_slice(&self.sequence.to_le_bytes());
out.extend_from_slice(&self.timestamp_us.to_le_bytes());
out.extend_from_slice(&self.device_id.to_le_bytes());
out.extend_from_slice(&self.center_freq_khz.to_le_bytes());
out.extend_from_slice(&self.bandwidth_mhz.to_le_bytes());
out.extend_from_slice(&self.flags.0.to_le_bytes());
out.extend_from_slice(&(self.payload.len() as u16).to_le_bytes());
out.push(self.payload.format() as u8);
out.push(self.antenna_count);
out.extend_from_slice(&self.scale.to_le_bytes());
out.extend_from_slice(&self.bin_spacing.to_le_bytes());
out.extend_from_slice(&self.calibration_id.to_le_bytes());
debug_assert_eq!(out.len(), RTL8720F_RADAR_HEADER_LEN);
self.payload.encode_into(&mut out);
let crc = crc32_ieee(&out);
out.extend_from_slice(&crc.to_le_bytes());
Ok(out)
}
pub fn from_bytes(input: &[u8]) -> Result<(Self, usize), RadarParseError> {
if input.len() < RTL8720F_RADAR_HEADER_LEN {
return Err(RadarParseError::InsufficientData {
needed: RTL8720F_RADAR_HEADER_LEN,
got: input.len(),
});
}
let magic = read_u32(input, 0);
if magic != RTL8720F_RADAR_MAGIC {
return Err(RadarParseError::InvalidMagic(magic));
}
if input[4] != RTL8720F_RADAR_VERSION {
return Err(RadarParseError::UnsupportedVersion(input[4]));
}
let report_type = ReportType::try_from(input[5])?;
let header_len = read_u16(input, 6) as usize;
if header_len < RTL8720F_RADAR_HEADER_LEN {
return Err(RadarParseError::InvalidHeaderLength(header_len));
}
let frame_len = read_u32(input, 8) as usize;
if frame_len > RTL8720F_RADAR_MAX_FRAME_LEN {
return Err(RadarParseError::FrameTooLarge(frame_len));
}
if frame_len < header_len + RTL8720F_RADAR_CRC_LEN {
return Err(RadarParseError::InvalidFrameLength(frame_len));
}
if input.len() < frame_len {
return Err(RadarParseError::InsufficientData {
needed: frame_len,
got: input.len(),
});
}
let element_count = read_u16(input, 40) as usize;
if element_count > RTL8720F_RADAR_MAX_ELEMENTS {
return Err(RadarParseError::TooManyElements(element_count));
}
let format = ElementFormat::try_from(input[42])?;
validate_type_format(report_type, format)?;
let payload_len = element_count
.checked_mul(format.bytes_per_element())
.ok_or(RadarParseError::LengthOverflow)?;
let expected_len = header_len
.checked_add(payload_len)
.and_then(|value| value.checked_add(RTL8720F_RADAR_CRC_LEN))
.ok_or(RadarParseError::LengthOverflow)?;
if expected_len != frame_len {
return Err(RadarParseError::PayloadLengthMismatch {
expected: expected_len,
got: frame_len,
});
}
let expected_crc = read_u32(input, frame_len - RTL8720F_RADAR_CRC_LEN);
let actual_crc = crc32_ieee(&input[..frame_len - RTL8720F_RADAR_CRC_LEN]);
if expected_crc != actual_crc {
return Err(RadarParseError::CrcMismatch {
expected: expected_crc,
actual: actual_crc,
});
}
let scale = read_f32(input, 44);
let bin_spacing = read_f32(input, 48);
if !scale.is_finite() || !bin_spacing.is_finite() {
return Err(RadarParseError::NonFiniteValue);
}
let payload = decode_payload(format, &input[header_len..header_len + payload_len])?;
let frame = Self {
report_type,
sequence: read_u32(input, 12),
timestamp_us: read_u64(input, 16),
device_id: read_u64(input, 24),
center_freq_khz: read_u32(input, 32),
bandwidth_mhz: read_u16(input, 36),
flags: RadarFlags(read_u16(input, 38)),
antenna_count: input[43],
scale,
bin_spacing,
calibration_id: read_u32(input, 52),
payload,
};
frame.validate()?;
Ok((frame, frame_len))
}
fn validate(&self) -> Result<(), RadarParseError> {
if !matches!(self.bandwidth_mhz, 20 | 40 | 70) {
return Err(RadarParseError::InvalidBandwidth(self.bandwidth_mhz));
}
if self.antenna_count == 0 || self.antenna_count > 8 {
return Err(RadarParseError::InvalidAntennaCount(self.antenna_count));
}
if self.payload.len() > RTL8720F_RADAR_MAX_ELEMENTS
|| self.payload.len() > u16::MAX as usize
{
return Err(RadarParseError::TooManyElements(self.payload.len()));
}
if !self.scale.is_finite() || !self.bin_spacing.is_finite() {
return Err(RadarParseError::NonFiniteValue);
}
validate_type_format(self.report_type, self.payload.format())?;
self.payload.validate_finite()
}
}
fn validate_type_format(
report_type: ReportType,
format: ElementFormat,
) -> Result<(), RadarParseError> {
let valid = match report_type {
ReportType::Cfr => matches!(
format,
ElementFormat::ComplexI16 | ElementFormat::ComplexF32
),
ReportType::RangeNear | ReportType::RangeFar => {
matches!(format, ElementFormat::PowerU16 | ElementFormat::PowerF32)
}
ReportType::Interference | ReportType::Capabilities => format == ElementFormat::Bytes,
};
if valid {
Ok(())
} else {
Err(RadarParseError::InvalidTypeFormat {
report_type,
format,
})
}
}
fn decode_payload(format: ElementFormat, bytes: &[u8]) -> Result<RadarPayload, RadarParseError> {
let payload = match format {
ElementFormat::Bytes => RadarPayload::Bytes(bytes.to_vec()),
ElementFormat::ComplexI16 => RadarPayload::ComplexI16(
bytes
.chunks_exact(4)
.map(|c| [read_i16(c, 0), read_i16(c, 2)])
.collect(),
),
ElementFormat::ComplexF32 => RadarPayload::ComplexF32(
bytes
.chunks_exact(8)
.map(|c| [read_f32(c, 0), read_f32(c, 4)])
.collect(),
),
ElementFormat::PowerU16 => {
RadarPayload::PowerU16(bytes.chunks_exact(2).map(|c| read_u16(c, 0)).collect())
}
ElementFormat::PowerF32 => {
RadarPayload::PowerF32(bytes.chunks_exact(4).map(|c| read_f32(c, 0)).collect())
}
};
payload.validate_finite()?;
Ok(payload)
}
fn read_u16(buf: &[u8], offset: usize) -> u16 {
u16::from_le_bytes([buf[offset], buf[offset + 1]])
}
fn read_i16(buf: &[u8], offset: usize) -> i16 {
i16::from_le_bytes([buf[offset], buf[offset + 1]])
}
fn read_u32(buf: &[u8], offset: usize) -> u32 {
u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
}
fn read_u64(buf: &[u8], offset: usize) -> u64 {
u64::from_le_bytes(buf[offset..offset + 8].try_into().unwrap())
}
fn read_f32(buf: &[u8], offset: usize) -> f32 {
f32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
}
#[derive(Debug, Error, PartialEq)]
pub enum RadarParseError {
#[error("insufficient data: need {needed} bytes, got {got}")]
InsufficientData { needed: usize, got: usize },
#[error("invalid RTL8720F radar magic {0:#010x}")]
InvalidMagic(u32),
#[error("unsupported RTL8720F radar protocol version {0}")]
UnsupportedVersion(u8),
#[error("unknown radar report type {0}")]
UnknownReportType(u8),
#[error("unknown radar element format {0}")]
UnknownElementFormat(u8),
#[error("invalid header length {0}")]
InvalidHeaderLength(usize),
#[error("invalid frame length {0}")]
InvalidFrameLength(usize),
#[error("frame is too large: {0} bytes")]
FrameTooLarge(usize),
#[error("element count exceeds limit: {0}")]
TooManyElements(usize),
#[error("length arithmetic overflow")]
LengthOverflow,
#[error("payload/frame length mismatch: expected {expected}, got {got}")]
PayloadLengthMismatch { expected: usize, got: usize },
#[error("CRC mismatch: encoded {expected:#010x}, computed {actual:#010x}")]
CrcMismatch { expected: u32, actual: u32 },
#[error("non-finite floating-point value")]
NonFiniteValue,
#[error("invalid bandwidth {0} MHz")]
InvalidBandwidth(u16),
#[error("invalid antenna count {0}")]
InvalidAntennaCount(u8),
#[error("report {report_type:?} cannot use element format {format:?}")]
InvalidTypeFormat {
report_type: ReportType,
format: ElementFormat,
},
}
#[cfg(test)]
mod tests {
use super::simulator::{Rtl8720fSimulator, SimulatorConfig};
use super::*;
fn cfr_frame() -> RadarFrame {
RadarFrame {
report_type: ReportType::Cfr,
sequence: 42,
timestamp_us: 123_456,
device_id: 0x1122_3344_5566_7788,
center_freq_khz: 2_442_000,
bandwidth_mhz: 40,
flags: RadarFlags(RadarFlags::CALIBRATED | RadarFlags::TIME_SYNCHRONIZED),
antenna_count: 1,
scale: 1.0 / 4096.0,
bin_spacing: 312_500.0,
calibration_id: 0xAABB_CCDD,
payload: RadarPayload::ComplexI16(vec![[12, -7], [2048, -2048], [0, 1]]),
}
}
#[test]
fn cfr_round_trip_and_stream_consumption() {
let frame = cfr_frame();
let mut wire = frame.to_bytes().unwrap();
let encoded_len = wire.len();
wire.extend_from_slice(&[9, 8, 7]);
let (decoded, consumed) = RadarFrame::from_bytes(&wire).unwrap();
assert_eq!(decoded, frame);
assert_eq!(consumed, encoded_len);
}
#[test]
fn every_report_family_round_trips() {
let payloads = [
(
ReportType::RangeNear,
RadarPayload::PowerU16(vec![1, 2, u16::MAX]),
),
(
ReportType::RangeFar,
RadarPayload::PowerF32(vec![0.0, 1.5, 9.25]),
),
(
ReportType::Interference,
RadarPayload::Bytes(vec![1, 2, 0x34, 0x12]),
),
(
ReportType::Capabilities,
RadarPayload::Bytes(vec![2, 1, 40]),
),
];
for (report_type, payload) in payloads {
let mut frame = cfr_frame();
frame.report_type = report_type;
frame.payload = payload;
let (decoded, _) = RadarFrame::from_bytes(&frame.to_bytes().unwrap()).unwrap();
assert_eq!(decoded, frame);
}
}
#[test]
fn single_bit_corruption_is_detected() {
let mut wire = cfr_frame().to_bytes().unwrap();
wire[RTL8720F_RADAR_HEADER_LEN + 1] ^= 0x01;
assert!(matches!(
RadarFrame::from_bytes(&wire),
Err(RadarParseError::CrcMismatch { .. })
));
}
#[test]
fn truncation_is_reported_without_panicking() {
let wire = cfr_frame().to_bytes().unwrap();
for end in 0..wire.len() {
assert!(RadarFrame::from_bytes(&wire[..end]).is_err());
}
}
#[test]
fn count_length_mismatch_fails_before_payload_decode() {
let mut wire = cfr_frame().to_bytes().unwrap();
wire[40..42].copy_from_slice(&100u16.to_le_bytes());
let crc_offset = wire.len() - RTL8720F_RADAR_CRC_LEN;
let crc = crc32_ieee(&wire[..crc_offset]);
wire[crc_offset..].copy_from_slice(&crc.to_le_bytes());
assert!(matches!(
RadarFrame::from_bytes(&wire),
Err(RadarParseError::PayloadLengthMismatch { .. })
));
}
#[test]
fn invalid_semantic_combinations_are_rejected() {
let mut frame = cfr_frame();
frame.payload = RadarPayload::PowerU16(vec![1]);
assert!(matches!(
frame.to_bytes(),
Err(RadarParseError::InvalidTypeFormat { .. })
));
frame.report_type = ReportType::RangeNear;
frame.bandwidth_mhz = 80;
assert_eq!(
frame.to_bytes().unwrap_err(),
RadarParseError::InvalidBandwidth(80)
);
}
#[test]
fn non_finite_values_are_rejected() {
let mut frame = cfr_frame();
frame.scale = f32::NAN;
assert_eq!(
frame.to_bytes().unwrap_err(),
RadarParseError::NonFiniteValue
);
frame.scale = 1.0;
frame.payload = RadarPayload::ComplexF32(vec![[f32::INFINITY, 0.0]]);
assert_eq!(
frame.to_bytes().unwrap_err(),
RadarParseError::NonFiniteValue
);
}
#[test]
fn arbitrary_short_inputs_never_panic() {
let mut state = 0x1234_5678u32;
for len in 0..256usize {
let mut bytes = vec![0u8; len];
for byte in &mut bytes {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*byte = (state >> 24) as u8;
}
let result = std::panic::catch_unwind(|| RadarFrame::from_bytes(&bytes));
assert!(result.is_ok(), "parser panicked for {len} bytes");
}
}
#[test]
fn simulator_is_deterministic_and_uses_real_wire_boundary() {
let mut a = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap();
let mut b = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap();
for _ in 0..12 {
let a_wire = a.next_wire(ReportType::Cfr).unwrap();
let b_wire = b.next_wire(ReportType::Cfr).unwrap();
assert_eq!(a_wire, b_wire);
let (decoded, consumed) = RadarFrame::from_bytes(&a_wire).unwrap();
assert_eq!(consumed, a_wire.len());
assert!(decoded.flags.contains(RadarFlags::SYNTHETIC));
}
}
#[test]
fn simulator_range_peak_tracks_ground_truth() {
let mut sim = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap();
let frame = sim.next_frame(ReportType::RangeFar);
let RadarPayload::PowerF32(power) = frame.payload else {
panic!("expected power bins")
};
let peak = power
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.unwrap()
.0;
let observed_m = peak as f32 * frame.bin_spacing;
assert!((observed_m - sim.target_distance_m()).abs() <= frame.bin_spacing);
}
#[test]
fn simulator_capabilities_are_explicitly_synthetic() {
let sim = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap();
let frame = sim.capabilities_frame();
assert_eq!(frame.report_type, ReportType::Capabilities);
assert!(frame.flags.contains(RadarFlags::SYNTHETIC));
let wire = frame.to_bytes().unwrap();
assert_eq!(RadarFrame::from_bytes(&wire).unwrap().0, frame);
}
}
@@ -0,0 +1,207 @@
//! ADR-270 vendor RF provider contract.
//!
//! This model prevents RSSI, cloud occupancy, or network inventory from being
//! represented as complex CSI. Vendor adapters may emit only declared capabilities.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use thiserror::Error;
pub const MAX_VENDOR_METRICS: usize = 64;
pub const MAX_VENDOR_KEY_LEN: usize = 64;
pub const MAX_VENDOR_TEXT_LEN: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VendorId {
OriginAi,
Plume,
Mist,
Netgear,
ElectricImp,
RfSolutions,
Linksys,
Luma,
GoogleNest,
Wifigarden,
}
impl VendorId {
pub const ALL: [Self; 10] = [
Self::OriginAi,
Self::Plume,
Self::Mist,
Self::Netgear,
Self::ElectricImp,
Self::RfSolutions,
Self::Linksys,
Self::Luma,
Self::GoogleNest,
Self::Wifigarden,
];
pub fn as_str(self) -> &'static str {
match self {
Self::OriginAi => "origin_ai",
Self::Plume => "plume",
Self::Mist => "mist",
Self::Netgear => "netgear",
Self::ElectricImp => "electric_imp",
Self::RfSolutions => "rf_solutions",
Self::Linksys => "linksys",
Self::Luma => "luma",
Self::GoogleNest => "google_nest",
Self::Wifigarden => "wifigarden",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RfCapability {
ComplexCsi,
DerivedSensing,
RfTelemetry,
NetworkOnly,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderAvailability {
Available,
CredentialsRequired,
ContractRequired,
Experimental,
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderDescriptor {
pub vendor: VendorId,
pub capabilities: Vec<RfCapability>,
pub availability: ProviderAvailability,
pub hardware_validated: bool,
pub reason: String,
}
impl ProviderDescriptor {
pub fn validate(&self) -> Result<(), VendorEventError> {
if self.capabilities.is_empty()
|| self.reason.is_empty()
|| self.reason.len() > MAX_VENDOR_TEXT_LEN
{
return Err(VendorEventError::InvalidDescriptor);
}
if self.hardware_validated && self.availability != ProviderAvailability::Available {
return Err(VendorEventError::InvalidDescriptor);
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VendorRfEvent {
pub vendor: VendorId,
pub capability: RfCapability,
pub sequence: u64,
pub timestamp_us: u64,
pub source_id: String,
pub synthetic: bool,
pub metrics: BTreeMap<String, f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl VendorRfEvent {
pub fn validate(&self, descriptor: &ProviderDescriptor) -> Result<(), VendorEventError> {
descriptor.validate()?;
if self.vendor != descriptor.vendor || !descriptor.capabilities.contains(&self.capability) {
return Err(VendorEventError::CapabilityMismatch);
}
if matches!(
self.capability,
RfCapability::ComplexCsi | RfCapability::Unsupported
) {
return Err(VendorEventError::InvalidEventCapability);
}
if self.source_id.is_empty()
|| self.source_id.len() > MAX_VENDOR_TEXT_LEN
|| self.metrics.is_empty()
|| self.metrics.len() > MAX_VENDOR_METRICS
|| self
.metrics
.iter()
.any(|(k, v)| k.is_empty() || k.len() > MAX_VENDOR_KEY_LEN || !v.is_finite())
|| self
.label
.as_ref()
.is_some_and(|v| v.len() > MAX_VENDOR_TEXT_LEN)
{
return Err(VendorEventError::InvalidPayload);
}
Ok(())
}
}
pub trait VendorRfProvider: Send + Sync {
fn descriptor(&self) -> ProviderDescriptor;
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError>;
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum VendorEventError {
#[error("invalid provider descriptor")]
InvalidDescriptor,
#[error("event capability does not match provider")]
CapabilityMismatch,
#[error("complex CSI and unsupported states cannot be represented as scalar vendor events")]
InvalidEventCapability,
#[error("invalid or unbounded vendor payload")]
InvalidPayload,
#[error("malformed provider payload: {0}")]
MalformedPayload(String),
#[error("provider credentials are required")]
CredentialsRequired,
#[error("commercial contract or SDK access is required")]
ContractRequired,
#[error("provider has no supported sensing interface")]
Unsupported,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_vendor_has_a_stable_identifier() {
let names: std::collections::BTreeSet<_> =
VendorId::ALL.iter().map(|v| v.as_str()).collect();
assert_eq!(names.len(), VendorId::ALL.len());
}
#[test]
fn scalar_contract_rejects_csi_masquerading() {
let descriptor = ProviderDescriptor {
vendor: VendorId::Plume,
capabilities: vec![RfCapability::RfTelemetry],
availability: ProviderAvailability::CredentialsRequired,
hardware_validated: false,
reason: "OpenSync telemetry".into(),
};
let event = VendorRfEvent {
vendor: VendorId::Plume,
capability: RfCapability::ComplexCsi,
sequence: 1,
timestamp_us: 1,
source_id: "pod-1".into(),
synthetic: true,
metrics: BTreeMap::from([("rssi_dbm".into(), -42.0)]),
label: None,
};
assert_eq!(
event.validate(&descriptor),
Err(VendorEventError::CapabilityMismatch)
);
}
}
@@ -7,12 +7,22 @@
//! score-based heuristic in `score_to_person_count`.
use std::collections::VecDeque;
use std::sync::LazyLock;
use wifi_densepose_signal::hardware_norm::HardwareNormalizer;
use wifi_densepose_signal::ruvsense::field_model::{
CalibrationStatus, FieldModel, FieldModelConfig,
};
use super::score_to_person_count;
/// 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
/// configured for the canonical 56-tone grid, and `feed_calibration` rejects
/// any other width with `DimensionMismatch`. Resampling here (default 56)
/// lets real HT40 nodes actually calibrate instead of silently feeding nothing.
static CALIB_NORMALIZER: LazyLock<HardwareNormalizer> = LazyLock::new(HardwareNormalizer::new);
/// Number of recent frames to feed into perturbation extraction.
const OCCUPANCY_WINDOW: usize = 50;
@@ -99,15 +109,27 @@ pub fn occupancy_or_fallback(
/// Feed the latest frame to the FieldModel during calibration collection.
///
/// Only acts when the model status is `Collecting`. Wraps the latest frame
/// as a single-link observation (n_links=1) and feeds it.
/// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame
/// flips a freshly-started (`Uncalibrated`) model to `Collecting` inside
/// `feed_calibration`; without accepting the `Uncalibrated` state here the two
/// gates deadlock and the frame count never leaves 0 (calibration/start yields
/// an `Uncalibrated` model that nothing would ever advance). Wraps the latest
/// frame as a single-link observation (n_links=1) and feeds it.
pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<Vec<f64>>) {
if field.status() != CalibrationStatus::Collecting {
if !matches!(
field.status(),
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting
) {
return;
}
if let Some(latest) = frame_history.back() {
// Single-link observation: [1][n_subcarriers]
let observations = vec![latest.clone()];
// Resample the raw amplitude vector onto the FieldModel's canonical
// 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames;
// feeding those raw made every `feed_calibration` fail DimensionMismatch
// (swallowed at debug level), pinning frame_count at 0 even after the
// status-gate deadlock was fixed. Single-link observation: [1][56].
let canonical = CALIB_NORMALIZER.resample_to_canonical(latest);
let observations = vec![canonical];
if let Err(e) = field.feed_calibration(&observations) {
tracing::debug!("FieldModel calibration feed: {e}");
}
@@ -180,4 +202,65 @@ mod tests {
assert_eq!(positions.len(), 1);
assert_eq!(positions[0], [3.0, 4.0, 5.0]);
}
/// Regression: a freshly-started (`Uncalibrated`) field model must begin
/// collecting once frames arrive. Before the fix, `maybe_feed_calibration`
/// only fed while already `Collecting`, but only `feed_calibration` sets
/// `Collecting` — so the first frame was never fed and the count stayed 0.
#[test]
fn maybe_feed_calibration_advances_uncalibrated_to_collecting() {
let mut field = FieldModel::new(single_link_config()).expect("field model");
assert_eq!(field.status(), CalibrationStatus::Uncalibrated);
assert_eq!(field.calibration_frame_count(), 0);
// n_subcarriers defaults to 56; one single-link frame of that width.
let frame = vec![0.5_f64; 56];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(frame);
maybe_feed_calibration(&mut field, &history);
assert_eq!(
field.status(),
CalibrationStatus::Collecting,
"first frame must flip Uncalibrated -> Collecting"
);
assert_eq!(
field.calibration_frame_count(),
1,
"frame count must advance past 0"
);
// Subsequent frames keep accumulating while Collecting.
maybe_feed_calibration(&mut field, &history);
assert_eq!(field.calibration_frame_count(), 2);
}
/// Regression (#1170 pattern): a real HT40 node streams 128-wide amplitude
/// frames, but the FieldModel is a 56-tone grid. Before canonicalization,
/// `feed_calibration` rejected every frame with DimensionMismatch (swallowed
/// at debug), so frame_count stayed 0 even with the deadlock fixed. The feed
/// must resample 128 → 56 and actually accumulate.
#[test]
fn maybe_feed_calibration_resamples_wide_frames_and_accumulates() {
let mut field = FieldModel::new(single_link_config()).expect("field model");
// 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw.
let wide = vec![0.5_f64; 128];
let mut history: VecDeque<Vec<f64>> = VecDeque::new();
history.push_back(wide);
maybe_feed_calibration(&mut field, &history);
assert_eq!(
field.status(),
CalibrationStatus::Collecting,
"128-wide frame must resample to 56 and be accepted"
);
assert_eq!(
field.calibration_frame_count(),
1,
"wide frame must accumulate, not be silently dropped"
);
}
}
@@ -34,3 +34,11 @@ pub mod sparse_inference;
#[allow(dead_code)]
pub mod trainer;
pub mod vital_signs;
/// ADR-270 Mist and NETGEAR telemetry providers.
pub mod vendor_mist_netgear;
/// ADR-270 Origin AI and Plume/OpenSync providers.
pub mod vendor_origin_plume;
/// ADR-270 scalar, network-only, and fail-closed vendor providers.
pub mod vendor_remaining;
/// ADR-270 provider registry and canonical event helpers.
pub mod vendor_rf;
@@ -17,6 +17,9 @@ mod field_bridge;
mod field_localize;
mod model_format;
mod multistatic_bridge;
mod mediatek_csi;
mod qualcomm_csi;
mod realtek_radar;
pub mod pose;
mod rvf_container;
mod rvf_pipeline;
@@ -30,13 +33,14 @@ use wifi_densepose_sensing_server::{
};
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
use std::collections::{HashMap, VecDeque};
use std::collections::{BTreeMap, HashMap, VecDeque};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::{
body::Bytes,
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, Query, State,
@@ -1028,6 +1032,20 @@ struct AppStateInner {
source: String,
/// Instant of the last ESP32 UDP frame received (for offline detection).
last_esp32_frame: Option<std::time::Instant>,
/// Latest validated RTL8720F summary; raw radar samples are not retained here.
latest_realtek_radar: Option<realtek_radar::RealtekRadarSnapshot>,
/// Instant of the last validated RTL8720F UDP frame.
last_realtek_frame: Option<std::time::Instant>,
/// Latest validated MediaTek CSI summary; raw matrices are not retained here.
latest_mediatek_csi: Option<mediatek_csi::MediatekCsiSnapshot>,
/// Instant of the last validated MediaTek CSI UDP frame.
last_mediatek_frame: Option<std::time::Instant>,
/// Latest validated Qualcomm CSI summary; raw matrices are not retained here.
latest_qualcomm_csi: Option<qualcomm_csi::QualcommCsiSnapshot>,
/// Instant of the last validated Qualcomm CSI UDP frame.
last_qualcomm_frame: Option<std::time::Instant>,
/// Latest bounded ADR-270 event per vendor. Complex CSI uses dedicated transports.
latest_vendor_rf: BTreeMap<String, wifi_densepose_sensing_server::vendor_rf::VendorEventSnapshot>,
tx: broadcast::Sender<String>,
// ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state +
// a parallel broadcast topic (`/ws/introspection`) running alongside
@@ -1199,6 +1217,27 @@ impl AppStateInner {
}
}
}
if self.source.starts_with("realtek") {
if let Some(last) = self.last_realtek_frame {
if last.elapsed() > ESP32_OFFLINE_TIMEOUT {
return format!("{}:offline", self.source);
}
}
}
if self.source.starts_with("mediatek") {
if let Some(last) = self.last_mediatek_frame {
if last.elapsed() > ESP32_OFFLINE_TIMEOUT {
return format!("{}:offline", self.source);
}
}
}
if self.source.starts_with("qualcomm") {
if let Some(last) = self.last_qualcomm_frame {
if last.elapsed() > ESP32_OFFLINE_TIMEOUT {
return format!("{}:offline", self.source);
}
}
}
self.source.clone()
}
}
@@ -3351,6 +3390,113 @@ async fn latest(State(state): State<SharedState>) -> Json<serde_json::Value> {
}
}
async fn latest_realtek_radar(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.latest_realtek_radar {
Some(snapshot) => Json(serde_json::to_value(snapshot).unwrap_or_default()),
None => Json(serde_json::json!({"status": "no Realtek radar data yet"})),
}
}
async fn latest_mediatek_csi(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.latest_mediatek_csi {
Some(snapshot) => Json(serde_json::to_value(snapshot).unwrap_or_default()),
None => Json(serde_json::json!({"status": "no MediaTek CSI data yet"})),
}
}
async fn latest_qualcomm_csi(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.latest_qualcomm_csi {
Some(snapshot) => Json(serde_json::to_value(snapshot).unwrap_or_default()),
None => Json(serde_json::json!({"status": "no Qualcomm CSI data yet"})),
}
}
async fn vendor_descriptors() -> Json<serde_json::Value> {
Json(
serde_json::to_value(wifi_densepose_sensing_server::vendor_rf::descriptors())
.unwrap_or_default(),
)
}
async fn latest_vendor_events(State(state): State<SharedState>) -> Json<serde_json::Value> {
let state = state.read().await;
Json(serde_json::to_value(&state.latest_vendor_rf).unwrap_or_default())
}
async fn latest_vendor_event(
State(state): State<SharedState>,
Path(vendor): Path<String>,
) -> impl IntoResponse {
let Some(vendor_id) = wifi_densepose_sensing_server::vendor_rf::vendor_from_str(&vendor) else {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "unknown vendor"})));
};
let state = state.read().await;
let canonical_vendor = vendor_id.as_str();
match state.latest_vendor_rf.get(canonical_vendor) {
Some(snapshot) => (
StatusCode::OK,
Json(serde_json::to_value(snapshot).unwrap_or_default()),
),
None => (
StatusCode::OK,
Json(serde_json::json!({
"status": "no vendor RF data yet",
"vendor": canonical_vendor
})),
),
}
}
async fn ingest_vendor_events(
State(state): State<SharedState>,
Path(vendor): Path<String>,
payload: Bytes,
) -> impl IntoResponse {
let Some(vendor_id) = wifi_densepose_sensing_server::vendor_rf::vendor_from_str(&vendor) else {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "unknown vendor"})));
};
let events = match wifi_densepose_sensing_server::vendor_rf::decode_provider(vendor_id, &payload) {
Ok(events) => events,
Err(error) => {
let status = match error {
wifi_densepose_hardware::vendor_rf::VendorEventError::Unsupported => StatusCode::NOT_IMPLEMENTED,
wifi_densepose_hardware::vendor_rf::VendorEventError::ContractRequired
| wifi_densepose_hardware::vendor_rf::VendorEventError::CredentialsRequired => StatusCode::FORBIDDEN,
_ => StatusCode::BAD_REQUEST,
};
return (status, Json(serde_json::json!({"error": error.to_string(), "vendor": vendor})));
}
};
let mut accepted = 0usize;
let mut state = state.write().await;
let canonical_vendor = vendor_id.as_str().to_string();
for event in events {
match wifi_densepose_sensing_server::vendor_rf::VendorEventSnapshot::from_event(event) {
Ok(snapshot) => {
let json = serde_json::to_string(&snapshot).ok();
state.source = snapshot.source.clone();
state
.latest_vendor_rf
.insert(canonical_vendor.clone(), snapshot);
if let Some(json) = json {
let _ = state.tx.send(json);
}
accepted += 1;
}
Err(error) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": error.to_string(), "vendor": canonical_vendor})),
)
}
}
}
(StatusCode::ACCEPTED, Json(serde_json::json!({"vendor": vendor, "accepted": accepted})))
}
/// Generate WiFi-derived pose keypoints from sensing data.
///
/// Keypoint positions are modulated by real signal features rather than a pure
@@ -4991,6 +5137,20 @@ async fn calibration_start(State(state): State<SharedState>) -> Json<serde_json:
async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::Value> {
let mut s = state.write().await;
if let Some(ref mut fm) = s.field_model {
// Guard: finalizing before enough empty-room frames have accumulated
// is a client-side sequencing error, not a server fault. Return a
// clear, structured message (with progress) instead of a 500 so the
// caller knows to keep the room empty and poll /calibration/status.
let have = fm.calibration_frame_count();
let need = fm.min_calibration_frames() as u64;
if have < need {
return Json(serde_json::json!({
"success": false,
"error": "Not enough calibration frames yet — keep the room empty and poll /calibration/status until frame_count reaches the target.",
"frame_count": have,
"frames_needed": need,
}));
}
let ts = chrono::Utc::now().timestamp_micros() as u64;
match fm.finalize_calibration(ts, 0) {
Ok(modes) => {
@@ -5030,6 +5190,18 @@ async fn calibration_status(State(state): State<SharedState>) -> Json<serde_json
}
}
/// Compatibility surface used by the bundled dashboard. Activity history is
/// not persisted by the Rust server yet, so return an honest empty collection
/// instead of advertising the endpoint and responding with 404.
async fn pose_activities() -> Json<serde_json::Value> {
Json(serde_json::json!({
"activities": [],
"total": 0,
"persisted": false,
"message": "Activity history is not persisted by the Rust sensing server.",
}))
}
/// Generate a simple timestamp string (epoch seconds) for recording IDs.
fn chrono_timestamp() -> u64 {
std::time::SystemTime::now()
@@ -5419,7 +5591,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
let addr = format!("0.0.0.0:{udp_port}");
let socket = match UdpSocket::bind(&addr).await {
Ok(s) => {
info!("UDP listening on {addr} for ESP32 CSI frames");
info!("UDP listening on {addr} for ESP32, MediaTek, Qualcomm CSI, and RTL8720F radar frames");
s
}
Err(e) => {
@@ -5428,10 +5600,90 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
}
};
let mut buf = [0u8; 2048];
let mut buf = vec![0u8; wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAX_FRAME_LEN];
loop {
match socket.recv_from(&mut buf).await {
Ok((len, src)) => {
if len > 0 && buf[0] == b'{' {
match serde_json::from_slice::<wifi_densepose_hardware::vendor_rf::VendorRfEvent>(&buf[..len])
.map_err(|error| error.to_string())
.and_then(|event| wifi_densepose_sensing_server::vendor_rf::VendorEventSnapshot::from_event(event).map_err(|error| error.to_string()))
{
Ok(snapshot) if snapshot.event.synthetic => {
debug!("Vendor RF event from {src}: vendor={} capability={:?} seq={}", snapshot.event.vendor.as_str(), snapshot.event.capability, snapshot.event.sequence);
let json = serde_json::to_string(&snapshot).ok();
let mut state = state.write().await;
state.source = snapshot.source.clone();
state.latest_vendor_rf.insert(snapshot.event.vendor.as_str().to_string(), snapshot);
if let Some(json) = json { let _ = state.tx.send(json); }
}
Ok(_) => warn!("Rejected non-synthetic canonical vendor event from {src}; live payloads must use the provider decoder HTTP route"),
Err(error) => warn!("Rejected ADR-270 vendor event from {src}: {error}"),
}
continue;
}
if len >= 4
&& u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice"))
== wifi_densepose_hardware::qualcomm_csi::QUALCOMM_CSI_MAGIC
{
match wifi_densepose_hardware::qualcomm_csi::CsiFrame::from_bytes(&buf[..len]) {
Ok((frame, consumed)) if consumed == len => {
let snapshot = qualcomm_csi::QualcommCsiSnapshot::from_frame(&frame);
debug!("Qualcomm CSI from {src}: profile={} seq={} dimensions={}x{}x{}", snapshot.chipset, snapshot.sequence, snapshot.tx_count, snapshot.rx_count, snapshot.subcarrier_count);
let json = serde_json::to_string(&snapshot).ok();
let mut s = state.write().await;
s.source = snapshot.source.to_string();
s.last_qualcomm_frame = Some(std::time::Instant::now());
s.latest_qualcomm_csi = Some(snapshot);
if let Some(json) = json { let _ = s.tx.send(json); }
}
Ok((_, consumed)) => warn!("Qualcomm CSI datagram from {src} has trailing bytes: consumed={consumed} received={len}"),
Err(error) => warn!("Rejected Qualcomm CSI datagram from {src}: {error}"),
}
continue;
}
if len >= 4
&& u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice"))
== wifi_densepose_hardware::mediatek_csi::MEDIATEK_CSI_MAGIC
{
match wifi_densepose_hardware::mediatek_csi::CsiFrame::from_bytes(&buf[..len]) {
Ok((frame, consumed)) if consumed == len => {
let snapshot = mediatek_csi::MediatekCsiSnapshot::from_frame(&frame);
debug!("MediaTek CSI from {src}: profile={} seq={} dimensions={}x{}x{}", snapshot.chipset, snapshot.sequence, snapshot.tx_count, snapshot.rx_count, snapshot.subcarrier_count);
let json = serde_json::to_string(&snapshot).ok();
let mut s = state.write().await;
s.source = snapshot.source.to_string();
s.last_mediatek_frame = Some(std::time::Instant::now());
s.latest_mediatek_csi = Some(snapshot);
if let Some(json) = json { let _ = s.tx.send(json); }
}
Ok((_, consumed)) => warn!("MediaTek CSI datagram from {src} has trailing bytes: consumed={consumed} received={len}"),
Err(error) => warn!("Rejected MediaTek CSI datagram from {src}: {error}"),
}
continue;
}
if len >= 4
&& u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice"))
== wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAGIC
{
match wifi_densepose_hardware::rtl8720f::RadarFrame::from_bytes(&buf[..len]) {
Ok((frame, consumed)) if consumed == len => {
let snapshot = realtek_radar::RealtekRadarSnapshot::from_frame(&frame);
debug!("RTL8720F radar from {src}: type={} seq={} elements={}", snapshot.report_type, snapshot.sequence, snapshot.element_count);
let json = serde_json::to_string(&snapshot).ok();
let mut s = state.write().await;
s.source = snapshot.source.to_string();
s.last_realtek_frame = Some(std::time::Instant::now());
s.latest_realtek_radar = Some(snapshot);
if let Some(json) = json {
let _ = s.tx.send(json);
}
}
Ok((_, consumed)) => warn!("RTL8720F radar datagram from {src} has trailing bytes: consumed={consumed} received={len}"),
Err(error) => warn!("Rejected RTL8720F radar datagram from {src}: {error}"),
}
continue;
}
// ADR-039: Try edge vitals packet first (magic 0xC511_0002).
if let Some(vitals) = parse_esp32_vitals(&buf[..len]) {
debug!(
@@ -7526,6 +7778,13 @@ async fn main() {
tick: 0,
source: source.into(),
last_esp32_frame: None,
latest_realtek_radar: None,
last_realtek_frame: None,
latest_mediatek_csi: None,
last_mediatek_frame: None,
latest_qualcomm_csi: None,
last_qualcomm_frame: None,
latest_vendor_rf: BTreeMap::new(),
tx,
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
intro_tx,
@@ -7742,6 +8001,13 @@ async fn main() {
.route("/api/v1/metrics", get(health_metrics))
// Sensing endpoints
.route("/api/v1/sensing/latest", get(latest))
.route("/api/v1/radar/latest", get(latest_realtek_radar))
.route("/api/v1/csi/mediatek/latest", get(latest_mediatek_csi))
.route("/api/v1/csi/qualcomm/latest", get(latest_qualcomm_csi))
.route("/api/v1/rf/vendors", get(vendor_descriptors))
.route("/api/v1/rf/vendors/latest", get(latest_vendor_events))
.route("/api/v1/rf/vendors/:vendor/latest", get(latest_vendor_event))
.route("/api/v1/rf/vendors/:vendor/events", post(ingest_vendor_events))
// Per-node health endpoint
.route("/api/v1/nodes", get(nodes_endpoint))
// ADR-110 iter 29 — per-node mesh sync state for HTTP clients.
@@ -7768,6 +8034,13 @@ async fn main() {
.route("/api/v1/pose/current", get(pose_current))
.route("/api/v1/pose/stats", get(pose_stats))
.route("/api/v1/pose/zones/summary", get(pose_zones_summary))
.route("/api/v1/pose/activities", get(pose_activities))
// Dashboard-compatible aliases for the field-model calibration API.
.route("/api/v1/pose/calibrate", post(calibration_start))
.route(
"/api/v1/pose/calibration/status",
get(calibration_status),
)
// Stream endpoints
.route("/api/v1/stream/status", get(stream_status))
.route("/api/v1/stream/pose", get(ws_pose_handler))
@@ -0,0 +1,127 @@
//! Bounded summaries for ADR-267 MediaTek MIMO CSI frames.
use serde::Serialize;
use wifi_densepose_hardware::mediatek_csi::{CsiFlags, CsiFrame, CsiPayload, ReportKind};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct MediatekCsiSnapshot {
pub event_type: &'static str,
pub source: &'static str,
pub report_kind: &'static str,
pub sequence: u32,
pub timestamp_us: u64,
pub device_id: String,
pub chipset: &'static str,
pub center_freq_khz: u32,
pub bandwidth_mhz: u16,
pub tx_count: u8,
pub rx_count: u8,
pub subcarrier_count: u16,
pub element_count: usize,
pub ppdu_type: String,
pub rssi_dbm: Vec<i8>,
pub noise_floor_dbm: i8,
pub calibrated: bool,
pub synthetic: bool,
pub saturated: bool,
pub time_synchronized: bool,
pub dropped_predecessor: bool,
pub calibration_id: u32,
pub subcarrier_spacing_hz: f32,
pub mean_amplitude: Option<f32>,
pub peak_amplitude: Option<f32>,
}
impl MediatekCsiSnapshot {
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: "mediatek_csi",
source: if synthetic {
"mediatek:simulated"
} else {
"mediatek"
},
report_kind: match frame.report_kind {
ReportKind::Csi => "csi",
ReportKind::Capabilities => "capabilities",
},
sequence: frame.sequence,
timestamp_us: frame.timestamp_us,
device_id: format!("{:016x}", frame.device_id),
chipset: frame.chipset.name(),
center_freq_khz: frame.center_freq_khz,
bandwidth_mhz: frame.bandwidth_mhz,
tx_count: frame.tx_count,
rx_count: frame.rx_count,
subcarrier_count: frame.subcarrier_count,
element_count: frame.payload.len(),
ppdu_type: format!("{:?}", frame.ppdu_type).to_ascii_lowercase(),
rssi_dbm: frame.payload.rssi_dbm().to_vec(),
noise_floor_dbm: frame.noise_floor_dbm,
calibrated: frame.flags.contains(CsiFlags::CALIBRATED),
synthetic,
saturated: frame.flags.contains(CsiFlags::SATURATED),
time_synchronized: frame.flags.contains(CsiFlags::TIME_SYNCHRONIZED),
dropped_predecessor: frame.flags.contains(CsiFlags::DROPPED_PREDECESSOR),
calibration_id: frame.calibration_id,
subcarrier_spacing_hz: frame.subcarrier_spacing_hz,
mean_amplitude,
peak_amplitude,
}
}
}
fn amplitude_summary(frame: &CsiFrame) -> (Option<f32>, Option<f32>) {
let amplitudes: Vec<f32> = match &frame.payload {
CsiPayload::ComplexI16 { values, .. } => values
.iter()
.map(|[i, q]| (*i as f32).hypot(*q as f32) * frame.scale)
.collect(),
CsiPayload::ComplexF32 { values, .. } => values
.iter()
.map(|[i, q]| i.hypot(*q) * frame.scale)
.collect(),
CsiPayload::Bytes(_) => return (None, None),
};
if amplitudes.is_empty() {
return (None, None);
}
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::mediatek_csi::simulator::{MediatekCsiSimulator, SimulatorConfig};
#[test]
fn simulator_summary_preserves_dimensions_and_provenance() {
let mut sim = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = MediatekCsiSnapshot::from_frame(&sim.next_frame());
assert_eq!(snapshot.source, "mediatek:simulated");
assert_eq!(
(
snapshot.tx_count,
snapshot.rx_count,
snapshot.subcarrier_count
),
(2, 3, 256)
);
assert_eq!(snapshot.element_count, 1536);
assert!(snapshot.mean_amplitude.unwrap() > 0.0);
assert!(snapshot.peak_amplitude.unwrap() >= snapshot.mean_amplitude.unwrap());
}
#[test]
fn capability_summary_does_not_invent_signal_statistics() {
let sim = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = MediatekCsiSnapshot::from_frame(&sim.capabilities_frame());
assert_eq!(snapshot.report_kind, "capabilities");
assert_eq!(snapshot.mean_amplitude, None);
assert!(snapshot.rssi_dbm.is_empty());
}
}
@@ -0,0 +1,127 @@
//! Bounded summaries for ADR-269 Qualcomm MIMO CSI frames.
use serde::Serialize;
use wifi_densepose_hardware::qualcomm_csi::{CsiFlags, CsiFrame, CsiPayload, ReportKind};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct QualcommCsiSnapshot {
pub event_type: &'static str,
pub source: &'static str,
pub report_kind: &'static str,
pub sequence: u32,
pub timestamp_us: u64,
pub device_id: String,
pub chipset: &'static str,
pub center_freq_khz: u32,
pub bandwidth_mhz: u16,
pub tx_count: u8,
pub rx_count: u8,
pub subcarrier_count: u16,
pub element_count: usize,
pub ppdu_type: String,
pub rssi_dbm: Vec<i8>,
pub noise_floor_dbm: i8,
pub calibrated: bool,
pub synthetic: bool,
pub saturated: bool,
pub time_synchronized: bool,
pub dropped_predecessor: bool,
pub calibration_id: u32,
pub subcarrier_spacing_hz: f32,
pub mean_amplitude: Option<f32>,
pub peak_amplitude: Option<f32>,
}
impl QualcommCsiSnapshot {
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: "qualcomm_csi",
source: if synthetic {
"qualcomm:simulated"
} else {
"qualcomm"
},
report_kind: match frame.report_kind {
ReportKind::Csi => "csi",
ReportKind::Capabilities => "capabilities",
},
sequence: frame.sequence,
timestamp_us: frame.timestamp_us,
device_id: format!("{:016x}", frame.device_id),
chipset: frame.chipset.name(),
center_freq_khz: frame.center_freq_khz,
bandwidth_mhz: frame.bandwidth_mhz,
tx_count: frame.tx_count,
rx_count: frame.rx_count,
subcarrier_count: frame.subcarrier_count,
element_count: frame.payload.len(),
ppdu_type: format!("{:?}", frame.ppdu_type).to_ascii_lowercase(),
rssi_dbm: frame.payload.rssi_dbm().to_vec(),
noise_floor_dbm: frame.noise_floor_dbm,
calibrated: frame.flags.contains(CsiFlags::CALIBRATED),
synthetic,
saturated: frame.flags.contains(CsiFlags::SATURATED),
time_synchronized: frame.flags.contains(CsiFlags::TIME_SYNCHRONIZED),
dropped_predecessor: frame.flags.contains(CsiFlags::DROPPED_PREDECESSOR),
calibration_id: frame.calibration_id,
subcarrier_spacing_hz: frame.subcarrier_spacing_hz,
mean_amplitude,
peak_amplitude,
}
}
}
fn amplitude_summary(frame: &CsiFrame) -> (Option<f32>, Option<f32>) {
let amplitudes: Vec<f32> = match &frame.payload {
CsiPayload::ComplexI16 { values, .. } => values
.iter()
.map(|[i, q]| (*i as f32).hypot(*q as f32) * frame.scale)
.collect(),
CsiPayload::ComplexF32 { values, .. } => values
.iter()
.map(|[i, q]| i.hypot(*q) * frame.scale)
.collect(),
CsiPayload::Bytes(_) => return (None, None),
};
if amplitudes.is_empty() {
return (None, None);
}
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::qualcomm_csi::simulator::{QualcommCsiSimulator, SimulatorConfig};
#[test]
fn simulator_summary_preserves_dimensions_and_provenance() {
let mut sim = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = QualcommCsiSnapshot::from_frame(&sim.next_frame());
assert_eq!(snapshot.source, "qualcomm:simulated");
assert_eq!(
(
snapshot.tx_count,
snapshot.rx_count,
snapshot.subcarrier_count
),
(2, 3, 114)
);
assert_eq!(snapshot.element_count, 684);
assert!(snapshot.mean_amplitude.unwrap() > 0.0);
assert!(snapshot.peak_amplitude.unwrap() >= snapshot.mean_amplitude.unwrap());
}
#[test]
fn capability_summary_does_not_invent_signal_statistics() {
let sim = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = QualcommCsiSnapshot::from_frame(&sim.capabilities_frame());
assert_eq!(snapshot.report_kind, "capabilities");
assert_eq!(snapshot.mean_amplitude, None);
assert!(snapshot.rssi_dbm.is_empty());
}
}
@@ -0,0 +1,137 @@
//! Bounded, privacy-conscious summaries of RTL8720F radar transport frames.
use serde::Serialize;
use wifi_densepose_hardware::rtl8720f::{RadarFlags, RadarFrame, RadarPayload, ReportType};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct RealtekRadarSnapshot {
pub event_type: &'static str,
pub source: &'static str,
pub report_type: &'static str,
pub sequence: u32,
pub timestamp_us: u64,
pub device_id: String,
pub center_freq_khz: u32,
pub bandwidth_mhz: u16,
pub antenna_count: u8,
pub element_count: usize,
pub calibrated: bool,
pub synthetic: bool,
pub interference_detected: bool,
pub saturated: bool,
pub time_synchronized: bool,
pub calibration_id: u32,
pub bin_spacing: f32,
pub peak_range_m: Option<f32>,
pub peak_power: Option<f32>,
pub mean_cfr_amplitude: Option<f32>,
}
impl RealtekRadarSnapshot {
pub(crate) fn from_frame(frame: &RadarFrame) -> Self {
let synthetic = frame.flags.contains(RadarFlags::SYNTHETIC);
let (peak_range_m, peak_power) = range_peak(frame);
Self {
event_type: "realtek_radar",
source: if synthetic {
"realtek:simulated"
} else {
"realtek"
},
report_type: report_type_name(frame.report_type),
sequence: frame.sequence,
timestamp_us: frame.timestamp_us,
device_id: format!("{:016x}", frame.device_id),
center_freq_khz: frame.center_freq_khz,
bandwidth_mhz: frame.bandwidth_mhz,
antenna_count: frame.antenna_count,
element_count: frame.payload.len(),
calibrated: frame.flags.contains(RadarFlags::CALIBRATED),
synthetic,
interference_detected: frame.flags.contains(RadarFlags::INTERFERENCE_DETECTED),
saturated: frame.flags.contains(RadarFlags::SATURATED),
time_synchronized: frame.flags.contains(RadarFlags::TIME_SYNCHRONIZED),
calibration_id: frame.calibration_id,
bin_spacing: frame.bin_spacing,
peak_range_m,
peak_power,
mean_cfr_amplitude: mean_cfr_amplitude(frame),
}
}
}
fn report_type_name(report_type: ReportType) -> &'static str {
match report_type {
ReportType::Cfr => "cfr",
ReportType::RangeNear => "range_near",
ReportType::RangeFar => "range_far",
ReportType::Interference => "interference",
ReportType::Capabilities => "capabilities",
}
}
fn range_peak(frame: &RadarFrame) -> (Option<f32>, Option<f32>) {
let max = match &frame.payload {
RadarPayload::PowerU16(values) => values
.iter()
.enumerate()
.max_by_key(|(_, value)| *value)
.map(|(index, value)| (index, *value as f32 * frame.scale)),
RadarPayload::PowerF32(values) => values
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.map(|(index, value)| (index, *value * frame.scale)),
_ => None,
};
max.map_or((None, None), |(index, power)| {
(Some(index as f32 * frame.bin_spacing), Some(power))
})
}
fn mean_cfr_amplitude(frame: &RadarFrame) -> Option<f32> {
let (sum, count) = match &frame.payload {
RadarPayload::ComplexI16(values) => (
values
.iter()
.map(|[i, q]| ((*i as f32).hypot(*q as f32)) * frame.scale)
.sum::<f32>(),
values.len(),
),
RadarPayload::ComplexF32(values) => (
values
.iter()
.map(|[i, q]| i.hypot(*q) * frame.scale)
.sum::<f32>(),
values.len(),
),
_ => return None,
};
(count != 0).then_some(sum / count as f32)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_hardware::rtl8720f::simulator::{Rtl8720fSimulator, SimulatorConfig};
#[test]
fn synthetic_range_summary_has_peak_and_provenance() {
let mut simulator = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot =
RealtekRadarSnapshot::from_frame(&simulator.next_frame(ReportType::RangeNear));
assert_eq!(snapshot.source, "realtek:simulated");
assert!(snapshot.synthetic);
assert!(snapshot.peak_range_m.is_some());
assert!(snapshot.peak_power.unwrap() > 0.0);
assert_eq!(snapshot.mean_cfr_amplitude, None);
}
#[test]
fn synthetic_cfr_summary_exposes_only_aggregate_amplitude() {
let mut simulator = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = RealtekRadarSnapshot::from_frame(&simulator.next_frame(ReportType::Cfr));
assert!(snapshot.mean_cfr_amplitude.unwrap() > 0.0);
assert_eq!(snapshot.peak_power, None);
}
}
@@ -0,0 +1,712 @@
//! ADR-270 adapters for Mist/Juniper and NETGEAR Insight.
//!
//! These cloud APIs expose client RF telemetry and location/network context.
//! They do not expose complex channel state information (CSI), and this module
//! deliberately cannot construct a `ComplexCsi` event.
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::fmt;
use wifi_densepose_hardware::vendor_rf::{
ProviderAvailability, ProviderDescriptor, RfCapability, VendorEventError, VendorId,
VendorRfEvent, VendorRfProvider,
};
pub const MAX_VENDOR_PAYLOAD_BYTES: usize = 1024 * 1024;
pub const MAX_EVENTS_PER_PAGE: usize = 1_000;
pub const MAX_CURSOR_BYTES: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MistRegion {
Global,
Europe,
AsiaPacific,
Australia,
}
impl MistRegion {
pub const fn base_url(self) -> &'static str {
match self {
Self::Global => "https://api.mist.com",
Self::Europe => "https://api.eu.mist.com",
Self::AsiaPacific => "https://api.ac2.mist.com",
Self::Australia => "https://api.gc1.mist.com",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetgearRegion {
NorthAmerica,
Europe,
Australia,
}
impl NetgearRegion {
pub const fn base_url(self) -> &'static str {
match self {
Self::NorthAmerica => "https://insight.netgear.com",
Self::Europe => "https://eu.insight.netgear.com",
Self::Australia => "https://au.insight.netgear.com",
}
}
}
/// An authentication value whose `Debug` output is always redacted.
#[derive(Clone, PartialEq, Eq)]
pub struct SecretToken(String);
impl SecretToken {
pub fn new(value: impl Into<String>) -> Result<Self, VendorEventError> {
let value = value.into();
if value.is_empty() || value.len() > 4_096 || value.chars().any(char::is_control) {
return Err(VendorEventError::InvalidPayload);
}
Ok(Self(value))
}
/// Intended only for constructing an HTTP authorization header.
pub fn expose_for_header(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("SecretToken([REDACTED])")
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct VendorRequestConfig {
pub base_url: &'static str,
pub path: String,
pub cursor: Option<String>,
pub token: SecretToken,
}
impl VendorRequestConfig {
pub fn validate(&self) -> Result<(), VendorEventError> {
if !self.base_url.starts_with("https://")
|| !self.path.starts_with('/')
|| self.path.contains("..")
|| self.path.chars().any(char::is_control)
|| self.cursor.as_deref().is_some_and(|cursor| {
cursor.is_empty()
|| cursor.len() > MAX_CURSOR_BYTES
|| cursor
.chars()
.any(|c| c.is_control() || c == '&' || c == '?' || c == '#')
})
{
return Err(VendorEventError::InvalidPayload);
}
Ok(())
}
}
impl fmt::Debug for VendorRequestConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VendorRequestConfig")
.field("base_url", &self.base_url)
.field("path", &self.path)
.field("cursor", &self.cursor.as_ref().map(|_| "[PRESENT]"))
.field("token", &"[REDACTED]")
.finish()
}
}
pub fn mist_request(
region: MistRegion,
site_id: &str,
cursor: Option<String>,
token: SecretToken,
) -> Result<VendorRequestConfig, VendorEventError> {
validate_identifier(site_id)?;
let request = VendorRequestConfig {
base_url: region.base_url(),
path: format!("/api/v1/sites/{site_id}/stats/clients"),
cursor,
token,
};
request.validate()?;
Ok(request)
}
pub fn netgear_request(
region: NetgearRegion,
location_id: &str,
cursor: Option<String>,
token: SecretToken,
) -> Result<VendorRequestConfig, VendorEventError> {
validate_identifier(location_id)?;
let request = VendorRequestConfig {
base_url: region.base_url(),
path: format!("/api/v1/locations/{location_id}/clients"),
cursor,
token,
};
request.validate()?;
Ok(request)
}
fn validate_identifier(value: &str) -> Result<(), VendorEventError> {
if value.is_empty()
|| value.len() > 128
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
{
return Err(VendorEventError::InvalidPayload);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct DecodedVendorPage {
pub events: Vec<VendorRfEvent>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct MistProvider;
impl MistProvider {
pub fn decode_page(&self, payload: &[u8]) -> Result<DecodedVendorPage, VendorEventError> {
decode_page(
payload,
VendorId::Mist,
mist_descriptor(),
parse_mist_record,
)
}
}
impl VendorRfProvider for MistProvider {
fn descriptor(&self) -> ProviderDescriptor {
mist_descriptor()
}
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
Ok(self.decode_page(payload)?.events)
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NetgearInsightProvider;
impl NetgearInsightProvider {
pub fn decode_page(&self, payload: &[u8]) -> Result<DecodedVendorPage, VendorEventError> {
decode_page(
payload,
VendorId::Netgear,
netgear_descriptor(),
parse_netgear_record,
)
}
}
impl VendorRfProvider for NetgearInsightProvider {
fn descriptor(&self) -> ProviderDescriptor {
netgear_descriptor()
}
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
Ok(self.decode_page(payload)?.events)
}
}
pub fn mist_descriptor() -> ProviderDescriptor {
ProviderDescriptor {
vendor: VendorId::Mist,
capabilities: vec![RfCapability::RfTelemetry, RfCapability::NetworkOnly],
availability: ProviderAvailability::CredentialsRequired,
hardware_validated: false,
reason: "Mist cloud client RF telemetry and location context; never CSI".into(),
}
}
pub fn netgear_descriptor() -> ProviderDescriptor {
ProviderDescriptor {
vendor: VendorId::Netgear,
capabilities: vec![RfCapability::RfTelemetry, RfCapability::NetworkOnly],
availability: ProviderAvailability::CredentialsRequired,
hardware_validated: false,
reason: "NETGEAR Insight client RF and network telemetry; never CSI".into(),
}
}
type RecordParser = fn(&Map<String, Value>, usize) -> Result<VendorRfEvent, VendorEventError>;
fn decode_page(
payload: &[u8],
vendor: VendorId,
descriptor: ProviderDescriptor,
parser: RecordParser,
) -> Result<DecodedVendorPage, VendorEventError> {
if payload.is_empty() || payload.len() > MAX_VENDOR_PAYLOAD_BYTES {
return Err(VendorEventError::InvalidPayload);
}
let root: Value = serde_json::from_slice(payload)
.map_err(|e| VendorEventError::MalformedPayload(e.to_string()))?;
let (records, next_cursor) = extract_records_and_cursor(&root)?;
if records.is_empty() || records.len() > MAX_EVENTS_PER_PAGE {
return Err(VendorEventError::InvalidPayload);
}
let mut events = Vec::with_capacity(records.len());
for (index, value) in records.iter().enumerate() {
let object = value.as_object().ok_or(VendorEventError::InvalidPayload)?;
let event = parser(object, index)?;
if event.vendor != vendor || event.capability != RfCapability::RfTelemetry {
return Err(VendorEventError::CapabilityMismatch);
}
event.validate(&descriptor)?;
events.push(event);
}
Ok(DecodedVendorPage {
events,
next_cursor,
})
}
fn extract_records_and_cursor(
root: &Value,
) -> Result<(&[Value], Option<String>), VendorEventError> {
if let Some(records) = root.as_array() {
return Ok((records, None));
}
let object = root.as_object().ok_or(VendorEventError::InvalidPayload)?;
let records = ["results", "data", "clients", "events", "items"]
.iter()
.find_map(|key| object.get(*key).and_then(Value::as_array))
.ok_or(VendorEventError::InvalidPayload)?;
let cursor_value = object
.get("next_cursor")
.or_else(|| object.get("nextPageToken"))
.or_else(|| object.get("next_page_token"))
.or_else(|| {
object
.get("pagination")
.and_then(Value::as_object)
.and_then(|p| p.get("next").or_else(|| p.get("cursor")))
});
let next_cursor = match cursor_value {
None | Some(Value::Null) => None,
Some(Value::String(value)) if valid_cursor(value) => Some(value.clone()),
Some(_) => return Err(VendorEventError::InvalidPayload),
};
Ok((records, next_cursor))
}
fn valid_cursor(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_CURSOR_BYTES
&& !value
.chars()
.any(|c| c.is_control() || c == '&' || c == '?' || c == '#')
}
#[derive(Debug, Deserialize)]
struct MistRecord {
#[serde(alias = "client_id", alias = "mac")]
id: String,
#[serde(default, alias = "last_seen", alias = "lastSeen")]
timestamp: Option<Value>,
#[serde(default, alias = "rssi_dbm")]
rssi: Option<f64>,
#[serde(default)]
snr: Option<f64>,
#[serde(default)]
channel: Option<f64>,
#[serde(default)]
x: Option<f64>,
#[serde(default)]
y: Option<f64>,
#[serde(default)]
site_id: Option<String>,
#[serde(default)]
ap_id: Option<String>,
#[serde(default, alias = "event_type", alias = "type")]
event: Option<String>,
}
fn parse_mist_record(
object: &Map<String, Value>,
index: usize,
) -> Result<VendorRfEvent, VendorEventError> {
let record: MistRecord = serde_json::from_value(Value::Object(object.clone()))
.map_err(|e| VendorEventError::MalformedPayload(e.to_string()))?;
validate_source(&record.id)?;
validate_optional_text(record.site_id.as_deref())?;
validate_optional_text(record.ap_id.as_deref())?;
validate_optional_text(record.event.as_deref())?;
let timestamp_us = parse_timestamp(record.timestamp.as_ref())?;
let mut metrics = BTreeMap::new();
push_metric(&mut metrics, "rssi_dbm", record.rssi, -150.0, 20.0)?;
push_metric(&mut metrics, "snr_db", record.snr, -50.0, 100.0)?;
push_metric(&mut metrics, "channel", record.channel, 1.0, 7_000.0)?;
push_metric(&mut metrics, "x_m", record.x, -1_000_000.0, 1_000_000.0)?;
push_metric(&mut metrics, "y_m", record.y, -1_000_000.0, 1_000_000.0)?;
if metrics.is_empty() {
return Err(VendorEventError::InvalidPayload);
}
Ok(VendorRfEvent {
vendor: VendorId::Mist,
capability: RfCapability::RfTelemetry,
sequence: stable_sequence(&record.id, timestamp_us, index),
timestamp_us,
source_id: record.id,
synthetic: false,
metrics,
label: context_label(&[record.site_id, record.ap_id, record.event])?,
})
}
#[derive(Debug, Deserialize)]
struct NetgearRecord {
#[serde(
alias = "clientId",
alias = "mac",
alias = "macAddress",
alias = "deviceId"
)]
id: String,
#[serde(default, alias = "lastSeen", alias = "observedAt")]
timestamp: Option<Value>,
#[serde(default, alias = "signalStrength", alias = "rssi_dbm")]
rssi: Option<f64>,
#[serde(default, alias = "signalToNoiseRatio")]
snr: Option<f64>,
#[serde(default)]
channel: Option<f64>,
#[serde(default, alias = "txRateMbps", alias = "tx_rate")]
tx_rate_mbps: Option<f64>,
#[serde(default, alias = "rxRateMbps", alias = "rx_rate")]
rx_rate_mbps: Option<f64>,
#[serde(default, alias = "locationId")]
location_id: Option<String>,
#[serde(default, alias = "accessPointId", alias = "apId")]
ap_id: Option<String>,
#[serde(default, alias = "ssidName")]
ssid: Option<String>,
}
fn parse_netgear_record(
object: &Map<String, Value>,
index: usize,
) -> Result<VendorRfEvent, VendorEventError> {
let record: NetgearRecord = serde_json::from_value(Value::Object(object.clone()))
.map_err(|e| VendorEventError::MalformedPayload(e.to_string()))?;
validate_source(&record.id)?;
validate_optional_text(record.location_id.as_deref())?;
validate_optional_text(record.ap_id.as_deref())?;
validate_optional_text(record.ssid.as_deref())?;
let timestamp_us = parse_timestamp(record.timestamp.as_ref())?;
let mut metrics = BTreeMap::new();
push_metric(&mut metrics, "rssi_dbm", record.rssi, -150.0, 20.0)?;
push_metric(&mut metrics, "snr_db", record.snr, -50.0, 100.0)?;
push_metric(&mut metrics, "channel", record.channel, 1.0, 7_000.0)?;
push_metric(
&mut metrics,
"tx_rate_mbps",
record.tx_rate_mbps,
0.0,
100_000.0,
)?;
push_metric(
&mut metrics,
"rx_rate_mbps",
record.rx_rate_mbps,
0.0,
100_000.0,
)?;
if metrics.is_empty() {
return Err(VendorEventError::InvalidPayload);
}
Ok(VendorRfEvent {
vendor: VendorId::Netgear,
capability: RfCapability::RfTelemetry,
sequence: stable_sequence(&record.id, timestamp_us, index),
timestamp_us,
source_id: record.id,
synthetic: false,
metrics,
label: context_label(&[record.location_id, record.ap_id, record.ssid])?,
})
}
fn parse_timestamp(value: Option<&Value>) -> Result<u64, VendorEventError> {
let value = value.ok_or(VendorEventError::InvalidPayload)?;
if let Some(text) = value.as_str() {
if let Ok(raw) = text.parse::<u64>() {
return normalize_integer_timestamp(raw);
}
if let Ok(raw) = text.parse::<f64>() {
return normalize_fractional_timestamp(raw);
}
let parsed = chrono::DateTime::parse_from_rfc3339(text)
.map_err(|_| VendorEventError::InvalidPayload)?;
return u64::try_from(parsed.timestamp_micros())
.map_err(|_| VendorEventError::InvalidPayload);
}
if let Some(raw) = value.as_u64() {
return normalize_integer_timestamp(raw);
}
normalize_fractional_timestamp(value.as_f64().ok_or(VendorEventError::InvalidPayload)?)
}
fn normalize_integer_timestamp(raw: u64) -> Result<u64, VendorEventError> {
if raw == 0 {
return Err(VendorEventError::InvalidPayload);
}
// Normalize seconds, milliseconds, or microseconds to microseconds.
if raw < 10_000_000_000 {
raw.checked_mul(1_000_000)
.ok_or(VendorEventError::InvalidPayload)
} else if raw < 10_000_000_000_000 {
raw.checked_mul(1_000)
.ok_or(VendorEventError::InvalidPayload)
} else if raw < 10_000_000_000_000_000 {
Ok(raw)
} else {
Err(VendorEventError::InvalidPayload)
}
}
fn normalize_fractional_timestamp(raw: f64) -> Result<u64, VendorEventError> {
if !raw.is_finite() || raw <= 0.0 {
return Err(VendorEventError::InvalidPayload);
}
let micros = if raw < 10_000_000_000.0 {
raw * 1_000_000.0
} else if raw < 10_000_000_000_000.0 {
raw * 1_000.0
} else if raw < 10_000_000_000_000_000.0 {
raw
} else {
return Err(VendorEventError::InvalidPayload);
};
if !micros.is_finite() || micros > u64::MAX as f64 {
return Err(VendorEventError::InvalidPayload);
}
Ok(micros.round() as u64)
}
fn push_metric(
metrics: &mut BTreeMap<String, f64>,
name: &str,
value: Option<f64>,
minimum: f64,
maximum: f64,
) -> Result<(), VendorEventError> {
if let Some(value) = value {
if !value.is_finite() || !(minimum..=maximum).contains(&value) {
return Err(VendorEventError::InvalidPayload);
}
metrics.insert(name.into(), value);
}
Ok(())
}
fn validate_source(value: &str) -> Result<(), VendorEventError> {
if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) {
return Err(VendorEventError::InvalidPayload);
}
Ok(())
}
fn validate_optional_text(value: Option<&str>) -> Result<(), VendorEventError> {
if value.is_some_and(|v| v.is_empty() || v.len() > 256 || v.chars().any(char::is_control)) {
return Err(VendorEventError::InvalidPayload);
}
Ok(())
}
fn context_label(parts: &[Option<String>]) -> Result<Option<String>, VendorEventError> {
let label = parts
.iter()
.filter_map(Option::as_deref)
.collect::<Vec<_>>()
.join("/");
if label.len() > 256 {
return Err(VendorEventError::InvalidPayload);
}
Ok((!label.is_empty()).then_some(label))
}
fn stable_sequence(source: &str, timestamp_us: u64, index: usize) -> u64 {
// FNV-1a gives a deterministic correlation key without process-random state.
let mut hash = 0xcbf29ce484222325_u64;
for byte in source
.bytes()
.chain(timestamp_us.to_le_bytes())
.chain((index as u64).to_le_bytes())
{
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
const MIST_FIXTURE: &[u8] = br#"{
"results": [
{"id":"aa:bb:cc:dd:ee:ff","timestamp":1710000000,"rssi":-47,"snr":31,"channel":44,"x":12.5,"y":8.25,"site_id":"site-1","ap_id":"ap-7"},
{"client_id":"station-2","last_seen":1710000000123,"rssi_dbm":-62,"event_type":"client-info"}
],
"next_cursor":"page-2"
}"#;
const NETGEAR_FIXTURE: &[u8] = br#"{
"data": [
{"clientId":"client-1","observedAt":"1710000000000000","signalStrength":-53,"signalToNoiseRatio":24,"channel":149,"txRateMbps":866.7,"rxRateMbps":721.2,"locationId":"office","accessPointId":"ap-2","ssidName":"lab"}
],
"pagination":{"next":"cursor-2"}
}"#;
#[test]
fn descriptors_are_honest_and_valid() {
for descriptor in [mist_descriptor(), netgear_descriptor()] {
descriptor.validate().unwrap();
assert!(!descriptor.hardware_validated);
assert_eq!(
descriptor.availability,
ProviderAvailability::CredentialsRequired
);
assert!(!descriptor.capabilities.contains(&RfCapability::ComplexCsi));
}
}
#[test]
fn mist_rest_and_webhook_fixture_is_deterministic() {
let provider = MistProvider;
let first = provider.decode_page(MIST_FIXTURE).unwrap();
let second = provider.decode_page(MIST_FIXTURE).unwrap();
assert_eq!(first, second);
assert_eq!(first.next_cursor.as_deref(), Some("page-2"));
assert_eq!(first.events.len(), 2);
assert_eq!(first.events[0].timestamp_us, 1_710_000_000_000_000);
assert_eq!(first.events[1].timestamp_us, 1_710_000_000_123_000);
assert_eq!(first.events[0].metrics["x_m"], 12.5);
assert!(!first.events[0].synthetic);
}
#[test]
fn netgear_page_fixture_normalizes_aliases() {
let page = NetgearInsightProvider.decode_page(NETGEAR_FIXTURE).unwrap();
assert_eq!(page.next_cursor.as_deref(), Some("cursor-2"));
assert_eq!(page.events.len(), 1);
let event = &page.events[0];
assert_eq!(event.vendor, VendorId::Netgear);
assert_eq!(event.capability, RfCapability::RfTelemetry);
assert_eq!(event.metrics["tx_rate_mbps"], 866.7);
assert_eq!(event.label.as_deref(), Some("office/ap-2/lab"));
}
#[test]
fn top_level_array_is_supported_without_pagination() {
let payload = br#"[{"mac":"a","timestamp":1710000000,"rssi":-40}]"#;
let page = MistProvider.decode_page(payload).unwrap();
assert_eq!(page.events.len(), 1);
assert_eq!(page.next_cursor, None);
}
#[test]
fn payload_and_page_bounds_are_enforced() {
assert_eq!(
MistProvider.decode(&vec![b' '; MAX_VENDOR_PAYLOAD_BYTES + 1]),
Err(VendorEventError::InvalidPayload)
);
let values = (0..=MAX_EVENTS_PER_PAGE)
.map(|_| serde_json::json!({"id":"a","timestamp":1710000000,"rssi":-40}))
.collect::<Vec<_>>();
let bytes = serde_json::to_vec(&values).unwrap();
assert_eq!(
MistProvider.decode(&bytes),
Err(VendorEventError::InvalidPayload)
);
}
#[test]
fn missing_identity_timestamp_or_metrics_fails_closed() {
for payload in [
br#"[{"timestamp":1710000000,"rssi":-40}]"#.as_slice(),
br#"[{"id":"a","rssi":-40}]"#.as_slice(),
br#"[{"id":"a","timestamp":1710000000}]"#.as_slice(),
] {
assert!(MistProvider.decode(payload).is_err());
}
}
#[test]
fn invalid_metric_cursor_and_record_schema_fail_closed() {
assert!(MistProvider
.decode(br#"[{"id":"a","timestamp":1710000000,"rssi":999}]"#)
.is_err());
assert!(NetgearInsightProvider.decode(br#"{"data":[42]}"#).is_err());
assert!(MistProvider
.decode_page(
br#"{"results":[{"id":"a","timestamp":1710000000,"rssi":-40}],"next_cursor":"x&admin=true"}"#
)
.is_err());
}
#[test]
fn request_configuration_is_regional_validated_and_redacted() {
let token = SecretToken::new("super-secret").unwrap();
let mist = mist_request(
MistRegion::Europe,
"site_1",
Some("page-2".into()),
token.clone(),
)
.unwrap();
assert_eq!(mist.base_url, "https://api.eu.mist.com");
assert_eq!(mist.path, "/api/v1/sites/site_1/stats/clients");
let netgear = netgear_request(NetgearRegion::Australia, "location-7", None, token).unwrap();
assert_eq!(netgear.base_url, "https://au.insight.netgear.com");
assert!(!format!("{netgear:?}").contains("super-secret"));
assert!(!format!("{:?}", netgear.token).contains("super-secret"));
assert!(mist_request(
MistRegion::Global,
"../other-site",
None,
SecretToken::new("token").unwrap()
)
.is_err());
}
#[test]
fn token_rejects_header_injection() {
assert!(SecretToken::new("token\r\nX-Injected: yes").is_err());
assert!(SecretToken::new("").is_err());
}
#[test]
fn timestamp_units_are_normalized_and_extremes_rejected() {
assert_eq!(
parse_timestamp(Some(&serde_json::json!(1_710_000_000))).unwrap(),
1_710_000_000_000_000
);
assert_eq!(
parse_timestamp(Some(&serde_json::json!(1_710_000_000_123_u64))).unwrap(),
1_710_000_000_123_000
);
assert!(parse_timestamp(Some(&serde_json::json!(0))).is_err());
assert!(parse_timestamp(Some(&serde_json::json!(-1))).is_err());
assert!(parse_timestamp(Some(&serde_json::json!(u64::MAX))).is_err());
assert_eq!(
parse_timestamp(Some(&serde_json::json!("2024-03-09T16:00:00Z"))).unwrap(),
1_710_000_000_000_000
);
assert_eq!(
parse_timestamp(Some(&serde_json::json!(1710000000.25))).unwrap(),
1_710_000_000_250_000
);
}
}
@@ -0,0 +1,598 @@
//! Capability-safe ADR-270 adapters for Origin AI and Plume/OpenSync.
//!
//! Origin publishes derived sensing results through contract-gated APIs and
//! webhooks. Public documentation does not define stable paths, so paths are
//! supplied by the contracted deployment configuration. OpenSync exports RF
//! and network telemetry; neither adapter promotes scalar data to complex CSI.
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use wifi_densepose_hardware::vendor_rf::{
ProviderAvailability, ProviderDescriptor, RfCapability, VendorEventError, VendorId,
VendorRfEvent, VendorRfProvider,
};
const MAX_PAYLOAD_BYTES: usize = 256 * 1024;
const MAX_EVENTS_PER_PAYLOAD: usize = 256;
const MAX_ENDPOINT_LEN: usize = 2048;
const MAX_ENV_NAME_LEN: usize = 128;
/// A request plan deliberately containing a credential *reference*, not a secret.
#[derive(Debug, Clone, PartialEq)]
pub struct VendorRequest {
pub method: &'static str,
pub endpoint: String,
pub headers: BTreeMap<String, String>,
pub credential_env: Option<String>,
pub body: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OriginAiConfig {
/// Contract-provided HTTPS sensing-server base URL.
pub base_url: String,
/// Contract-provided relative event API path. No public path is assumed.
pub event_path: String,
/// Name of the environment variable holding the partner bearer token.
pub token_env: String,
}
impl OriginAiConfig {
pub fn validate(&self) -> Result<(), VendorEventError> {
validate_https_base(&self.base_url)?;
validate_relative_path(&self.event_path)?;
validate_env_name(&self.token_env)
}
/// Constructs a GET plan. The caller resolves `credential_env` at execution time.
pub fn events_request(&self) -> Result<VendorRequest, VendorEventError> {
self.validate()?;
Ok(VendorRequest {
method: "GET",
endpoint: join_endpoint(&self.base_url, &self.event_path),
headers: BTreeMap::from([("accept".into(), "application/json".into())]),
credential_env: Some(self.token_env.clone()),
body: None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlumeOpenSyncConfig {
/// HTTPS northbound/sandbox URL supplied by the operator.
pub base_url: String,
/// Relative endpoint accepting OVSDB JSON-RPC for this deployment.
pub ovsdb_path: String,
/// Optional environment variable used by a protected northbound endpoint.
pub token_env: Option<String>,
}
impl PlumeOpenSyncConfig {
pub fn validate(&self) -> Result<(), VendorEventError> {
validate_https_base(&self.base_url)?;
validate_relative_path(&self.ovsdb_path)?;
if let Some(name) = &self.token_env {
validate_env_name(name)?;
}
Ok(())
}
/// Builds a read-only OVSDB `select` transaction for an allow-listed table.
pub fn select_request(&self, table: &str) -> Result<VendorRequest, VendorEventError> {
self.validate()?;
if !matches!(
table,
"Wifi_Radio_State" | "Wifi_VIF_State" | "Wifi_Associated_Clients"
) {
return Err(VendorEventError::InvalidPayload);
}
Ok(VendorRequest {
method: "POST",
endpoint: join_endpoint(&self.base_url, &self.ovsdb_path),
headers: BTreeMap::from([
("accept".into(), "application/json".into()),
("content-type".into(), "application/json".into()),
]),
credential_env: self.token_env.clone(),
body: Some(json!({
"jsonrpc": "2.0",
"id": "ruview-read-only",
"method": "transact",
"params": ["Open_vSwitch", {"op": "select", "table": table, "where": []}]
})),
})
}
}
#[derive(Debug, Clone, Default)]
pub struct OriginAiProvider;
impl OriginAiProvider {
pub fn synthetic_fixture(seed: u64, count: usize) -> Vec<VendorRfEvent> {
let count = count.min(MAX_EVENTS_PER_PAYLOAD);
(0..count)
.map(|index| {
let state = splitmix64(seed.wrapping_add(index as u64));
let motion = (state & 1) as f64;
let confidence = 0.70 + ((state >> 8) % 300) as f64 / 1000.0;
VendorRfEvent {
vendor: VendorId::OriginAi,
capability: RfCapability::DerivedSensing,
sequence: index as u64,
timestamp_us: 1_700_000_000_000_000 + index as u64 * 100_000,
source_id: format!("origin-sim-{:08x}", seed as u32),
synthetic: true,
metrics: BTreeMap::from([
("motion".into(), motion),
("confidence".into(), confidence),
]),
label: Some(if motion == 1.0 { "motion" } else { "clear" }.into()),
}
})
.collect()
}
}
impl VendorRfProvider for OriginAiProvider {
fn descriptor(&self) -> ProviderDescriptor {
ProviderDescriptor {
vendor: VendorId::OriginAi,
capabilities: vec![RfCapability::DerivedSensing],
availability: ProviderAvailability::ContractRequired,
hardware_validated: false,
reason: "Origin partner API/SDK access is contract-gated; adapter accepts derived sensing only"
.into(),
}
}
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
check_payload(payload)?;
let envelope: OriginEnvelope = decode_json(payload)?;
if envelope.events.is_empty() || envelope.events.len() > MAX_EVENTS_PER_PAYLOAD {
return Err(VendorEventError::InvalidPayload);
}
envelope
.events
.into_iter()
.map(|event| event.into_vendor_event(&self.descriptor()))
.collect()
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct OriginEnvelope {
events: Vec<OriginEvent>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct OriginEvent {
sequence: u64,
timestamp_us: u64,
source_id: String,
kind: OriginKind,
confidence: f64,
#[serde(default)]
value: Option<f64>,
#[serde(default)]
label: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum OriginKind {
Motion,
Occupancy,
Presence,
Fall,
BreathingRate,
}
impl OriginEvent {
fn into_vendor_event(
self,
descriptor: &ProviderDescriptor,
) -> Result<VendorRfEvent, VendorEventError> {
if self.timestamp_us == 0 || !(0.0..=1.0).contains(&self.confidence) {
return Err(VendorEventError::InvalidPayload);
}
let (metric, requires_value) = match self.kind {
OriginKind::Motion => ("motion", false),
OriginKind::Occupancy => ("occupancy", false),
OriginKind::Presence => ("presence", false),
OriginKind::Fall => ("fall", false),
OriginKind::BreathingRate => ("breathing_rate_bpm", true),
};
let value = self.value.ok_or(VendorEventError::InvalidPayload)?;
if !value.is_finite()
|| (requires_value && !(1.0..=120.0).contains(&value))
|| (!requires_value && value != 0.0 && value != 1.0)
{
return Err(VendorEventError::InvalidPayload);
}
let event = VendorRfEvent {
vendor: VendorId::OriginAi,
capability: RfCapability::DerivedSensing,
sequence: self.sequence,
timestamp_us: self.timestamp_us,
source_id: self.source_id,
synthetic: false,
metrics: BTreeMap::from([
(metric.into(), value),
("confidence".into(), self.confidence),
]),
label: self.label,
};
event.validate(descriptor)?;
Ok(event)
}
}
#[derive(Debug, Clone, Default)]
pub struct PlumeOpenSyncProvider;
impl PlumeOpenSyncProvider {
pub fn synthetic_fixture(seed: u64, count: usize) -> Vec<VendorRfEvent> {
let count = count.min(MAX_EVENTS_PER_PAYLOAD);
(0..count)
.map(|index| {
let state = splitmix64(seed.wrapping_add(index as u64));
VendorRfEvent {
vendor: VendorId::Plume,
capability: RfCapability::RfTelemetry,
sequence: index as u64,
timestamp_us: 1_700_000_000_000_000 + index as u64 * 250_000,
source_id: format!("opensync-sim-{:08x}", seed as u32),
synthetic: true,
metrics: BTreeMap::from([
("rssi_dbm".into(), -30.0 - (state % 55) as f64),
("channel".into(), 1.0 + ((state >> 8) % 165) as f64),
(
"noise_floor_dbm".into(),
-100.0 + ((state >> 16) % 12) as f64,
),
]),
label: None,
}
})
.collect()
}
}
impl VendorRfProvider for PlumeOpenSyncProvider {
fn descriptor(&self) -> ProviderDescriptor {
ProviderDescriptor {
vendor: VendorId::Plume,
capabilities: vec![RfCapability::RfTelemetry],
availability: ProviderAvailability::CredentialsRequired,
hardware_validated: false,
reason: "OpenSync radio/client telemetry only; Plume Sense is a separate gated service"
.into(),
}
}
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
check_payload(payload)?;
let envelope: OpenSyncEnvelope = decode_json(payload)?;
if envelope.observations.is_empty() || envelope.observations.len() > MAX_EVENTS_PER_PAYLOAD
{
return Err(VendorEventError::InvalidPayload);
}
envelope
.observations
.into_iter()
.map(|event| event.into_vendor_event(&self.descriptor()))
.collect()
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct OpenSyncEnvelope {
observations: Vec<OpenSyncObservation>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct OpenSyncObservation {
sequence: u64,
timestamp_us: u64,
source_id: String,
rssi_dbm: f64,
channel: u16,
#[serde(default)]
noise_floor_dbm: Option<f64>,
#[serde(default)]
tx_rate_mbps: Option<f64>,
#[serde(default)]
rx_rate_mbps: Option<f64>,
#[serde(default)]
clients: Option<u16>,
}
impl OpenSyncObservation {
fn into_vendor_event(
self,
descriptor: &ProviderDescriptor,
) -> Result<VendorRfEvent, VendorEventError> {
if self.timestamp_us == 0
|| !(-127.0..=0.0).contains(&self.rssi_dbm)
|| self.channel == 0
|| self.channel > 233
{
return Err(VendorEventError::InvalidPayload);
}
let mut metrics = BTreeMap::from([
("rssi_dbm".into(), self.rssi_dbm),
("channel".into(), self.channel as f64),
]);
insert_optional(
&mut metrics,
"noise_floor_dbm",
self.noise_floor_dbm,
-127.0,
0.0,
)?;
insert_optional(
&mut metrics,
"tx_rate_mbps",
self.tx_rate_mbps,
0.0,
100_000.0,
)?;
insert_optional(
&mut metrics,
"rx_rate_mbps",
self.rx_rate_mbps,
0.0,
100_000.0,
)?;
if let Some(clients) = self.clients {
metrics.insert("clients".into(), clients as f64);
}
let event = VendorRfEvent {
vendor: VendorId::Plume,
capability: RfCapability::RfTelemetry,
sequence: self.sequence,
timestamp_us: self.timestamp_us,
source_id: self.source_id,
synthetic: false,
metrics,
label: None,
};
event.validate(descriptor)?;
Ok(event)
}
}
fn insert_optional(
metrics: &mut BTreeMap<String, f64>,
key: &str,
value: Option<f64>,
minimum: f64,
maximum: f64,
) -> Result<(), VendorEventError> {
if let Some(value) = value {
if !value.is_finite() || !(minimum..=maximum).contains(&value) {
return Err(VendorEventError::InvalidPayload);
}
metrics.insert(key.into(), value);
}
Ok(())
}
fn check_payload(payload: &[u8]) -> Result<(), VendorEventError> {
if payload.is_empty() || payload.len() > MAX_PAYLOAD_BYTES {
Err(VendorEventError::InvalidPayload)
} else {
Ok(())
}
}
fn decode_json<T: for<'de> Deserialize<'de>>(payload: &[u8]) -> Result<T, VendorEventError> {
serde_json::from_slice(payload).map_err(|error| {
let message = error.to_string();
VendorEventError::MalformedPayload(message.chars().take(160).collect())
})
}
fn validate_https_base(value: &str) -> Result<(), VendorEventError> {
let authority = value
.strip_prefix("https://")
.and_then(|rest| rest.split('/').next())
.unwrap_or_default();
if value.len() > MAX_ENDPOINT_LEN
|| authority.is_empty()
|| authority.contains('@')
|| authority.chars().any(char::is_whitespace)
|| value.contains(['\r', '\n', '#', '?'])
{
return Err(VendorEventError::InvalidPayload);
}
Ok(())
}
fn validate_relative_path(value: &str) -> Result<(), VendorEventError> {
if value.len() > MAX_ENDPOINT_LEN
|| !value.starts_with('/')
|| value.starts_with("//")
|| value.contains(['\r', '\n', '#', '?'])
|| value.split('/').any(|segment| segment == "..")
{
return Err(VendorEventError::InvalidPayload);
}
Ok(())
}
fn validate_env_name(value: &str) -> Result<(), VendorEventError> {
if value.is_empty()
|| value.len() > MAX_ENV_NAME_LEN
|| !value
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
|| value.as_bytes()[0].is_ascii_digit()
{
return Err(VendorEventError::InvalidPayload);
}
Ok(())
}
fn join_endpoint(base: &str, path: &str) -> String {
format!("{}{}", base.trim_end_matches('/'), path)
}
fn splitmix64(mut value: u64) -> u64 {
value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
value ^ (value >> 31)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_hardware::vendor_rf::MAX_VENDOR_TEXT_LEN;
#[test]
fn descriptors_are_honest_about_capabilities_and_access() {
let origin = OriginAiProvider.descriptor();
assert_eq!(origin.capabilities, vec![RfCapability::DerivedSensing]);
assert_eq!(origin.availability, ProviderAvailability::ContractRequired);
let plume = PlumeOpenSyncProvider.descriptor();
assert_eq!(plume.capabilities, vec![RfCapability::RfTelemetry]);
assert_eq!(
plume.availability,
ProviderAvailability::CredentialsRequired
);
assert!(!origin.capabilities.contains(&RfCapability::ComplexCsi));
assert!(!plume.capabilities.contains(&RfCapability::ComplexCsi));
}
#[test]
fn origin_decodes_only_derived_sensing() {
let payload = br#"{"events":[{"sequence":7,"timestamp_us":42,"source_id":"zone-a","kind":"occupancy","confidence":0.91,"value":1,"label":"occupied"}]}"#;
let events = OriginAiProvider.decode(payload).unwrap();
assert_eq!(events[0].capability, RfCapability::DerivedSensing);
assert_eq!(events[0].metrics["occupancy"], 1.0);
assert!(!events[0].synthetic);
}
#[test]
fn origin_rejects_unknown_raw_csi_and_bad_values() {
let raw = br#"{"events":[{"sequence":1,"timestamp_us":2,"source_id":"z","kind":"motion","confidence":1,"value":1,"raw_csi":[[1,2]]}]}"#;
assert!(OriginAiProvider.decode(raw).is_err());
let bad_confidence = br#"{"events":[{"sequence":1,"timestamp_us":2,"source_id":"z","kind":"motion","confidence":1.1,"value":1}]}"#;
assert_eq!(
OriginAiProvider.decode(bad_confidence),
Err(VendorEventError::InvalidPayload)
);
}
#[test]
fn plume_decodes_telemetry_without_inventing_csi() {
let payload = br#"{"observations":[{"sequence":9,"timestamp_us":10,"source_id":"pod-a","rssi_dbm":-54,"channel":36,"noise_floor_dbm":-94,"tx_rate_mbps":1200,"clients":4}]}"#;
let events = PlumeOpenSyncProvider.decode(payload).unwrap();
assert_eq!(events[0].capability, RfCapability::RfTelemetry);
assert_eq!(events[0].metrics["rssi_dbm"], -54.0);
assert!(!events[0].metrics.contains_key("csi"));
}
#[test]
fn malformed_empty_oversized_and_unbounded_arrays_fail_closed() {
assert!(OriginAiProvider.decode(b"{").is_err());
assert_eq!(
OriginAiProvider.decode(b""),
Err(VendorEventError::InvalidPayload)
);
assert_eq!(
PlumeOpenSyncProvider.decode(&vec![b' '; MAX_PAYLOAD_BYTES + 1]),
Err(VendorEventError::InvalidPayload)
);
let event = r#"{"sequence":1,"timestamp_us":2,"source_id":"p","rssi_dbm":-50,"channel":1}"#;
let payload = format!("{{\"observations\":[{}]}}", vec![event; 257].join(","));
assert_eq!(
PlumeOpenSyncProvider.decode(payload.as_bytes()),
Err(VendorEventError::InvalidPayload)
);
}
#[test]
fn telemetry_ranges_are_enforced() {
let payload = br#"{"observations":[{"sequence":1,"timestamp_us":2,"source_id":"p","rssi_dbm":4,"channel":36}]}"#;
assert_eq!(
PlumeOpenSyncProvider.decode(payload),
Err(VendorEventError::InvalidPayload)
);
}
#[test]
fn fixtures_are_deterministic_bounded_and_marked_synthetic() {
let a = OriginAiProvider::synthetic_fixture(19, 4);
assert_eq!(a, OriginAiProvider::synthetic_fixture(19, 4));
assert!(a.iter().all(|event| event.synthetic));
let p = PlumeOpenSyncProvider::synthetic_fixture(23, usize::MAX);
assert_eq!(p.len(), MAX_EVENTS_PER_PAYLOAD);
assert!(p.iter().all(|event| event.synthetic));
assert_eq!(p, PlumeOpenSyncProvider::synthetic_fixture(23, usize::MAX));
}
#[test]
fn request_plans_reference_secrets_without_embedding_them() {
let origin = OriginAiConfig {
base_url: "https://partner.example".into(),
event_path: "/contract/v1/events".into(),
token_env: "ORIGIN_AI_TOKEN".into(),
}
.events_request()
.unwrap();
assert_eq!(
origin.endpoint,
"https://partner.example/contract/v1/events"
);
assert_eq!(origin.credential_env.as_deref(), Some("ORIGIN_AI_TOKEN"));
assert!(format!("{origin:?}").find("Bearer ").is_none());
let plume = PlumeOpenSyncConfig {
base_url: "https://sandbox.example".into(),
ovsdb_path: "/ovsdb".into(),
token_env: Some("OPENSYNC_TOKEN".into()),
}
.select_request("Wifi_Radio_State")
.unwrap();
assert_eq!(plume.body.as_ref().unwrap()["method"], "transact");
assert_eq!(plume.credential_env.as_deref(), Some("OPENSYNC_TOKEN"));
}
#[test]
fn request_validation_rejects_injection_and_write_tables() {
let config = PlumeOpenSyncConfig {
base_url: "https://sandbox.example".into(),
ovsdb_path: "/ovsdb".into(),
token_env: None,
};
assert_eq!(
config.select_request("AWLAN_Node"),
Err(VendorEventError::InvalidPayload)
);
let bad = OriginAiConfig {
base_url: "http://insecure.example".into(),
event_path: "/events\r\nx: y".into(),
token_env: "token".into(),
};
assert_eq!(bad.events_request(), Err(VendorEventError::InvalidPayload));
}
#[test]
fn labels_and_source_ids_obey_shared_contract_bounds() {
let label = "x".repeat(MAX_VENDOR_TEXT_LEN + 1);
let payload = format!(
"{{\"events\":[{{\"sequence\":1,\"timestamp_us\":2,\"source_id\":\"z\",\"kind\":\"motion\",\"confidence\":1,\"value\":1,\"label\":\"{label}\"}}]}}"
);
assert_eq!(
OriginAiProvider.decode(payload.as_bytes()),
Err(VendorEventError::InvalidPayload)
);
}
}
@@ -0,0 +1,481 @@
//! ADR-270 providers whose useful integration surface is scalar telemetry,
//! network-only metadata, or an explicit no-go decision.
//!
//! None of these providers emits complex CSI. The small JSON contract in this
//! module is intended for sidecars and deterministic replay fixtures; it is not
//! a claim that a vendor exposes this exact wire format.
use serde::Deserialize;
use std::collections::BTreeMap;
use wifi_densepose_hardware::vendor_rf::{
ProviderAvailability, ProviderDescriptor, RfCapability, VendorEventError, VendorId,
VendorRfEvent, VendorRfProvider,
};
/// Upper bound applied before JSON decoding, limiting parser allocation.
pub const MAX_REMAINING_VENDOR_PAYLOAD_BYTES: usize = 64 * 1024;
/// Upper bound on events accepted in one sidecar envelope.
pub const MAX_REMAINING_VENDOR_EVENTS: usize = 256;
const ELECTRIC_IMP_METRICS: &[MetricRule] = &[
MetricRule::new("battery_v", 0.0, 100.0, false),
MetricRule::new("humidity_percent", 0.0, 100.0, false),
MetricRule::new("rssi_dbm", -127.0, 0.0, false),
MetricRule::new("temperature_c", -100.0, 200.0, false),
MetricRule::new("voltage_v", 0.0, 1_000.0, false),
];
const RF_SOLUTIONS_METRICS: &[MetricRule] = &[
MetricRule::new("battery_v", 0.0, 100.0, false),
MetricRule::new("humidity_percent", 0.0, 100.0, false),
MetricRule::new("relay_state", 0.0, 1.0, true),
MetricRule::new("rssi_dbm", -127.0, 0.0, false),
MetricRule::new("temperature_c", -100.0, 200.0, false),
];
const LUMA_METRICS: &[MetricRule] = &[
MetricRule::new("client_count", 0.0, 1_000_000.0, true),
MetricRule::new("noise_dbm", -127.0, 0.0, false),
MetricRule::new("rssi_dbm", -127.0, 0.0, false),
MetricRule::new("rx_bytes", 0.0, 9_007_199_254_740_991.0, true),
MetricRule::new("tx_bytes", 0.0, 9_007_199_254_740_991.0, true),
];
const GOOGLE_NEST_METRICS: &[MetricRule] = &[
MetricRule::new("client_count", 0.0, 1_000_000.0, true),
MetricRule::new("probe_count", 0.0, 1_000_000_000.0, true),
MetricRule::new("rx_bytes", 0.0, 9_007_199_254_740_991.0, true),
MetricRule::new("tx_bytes", 0.0, 9_007_199_254_740_991.0, true),
];
#[derive(Debug, Clone, Copy)]
struct MetricRule {
name: &'static str,
minimum: f64,
maximum: f64,
integer: bool,
}
impl MetricRule {
const fn new(name: &'static str, minimum: f64, maximum: f64, integer: bool) -> Self {
Self {
name,
minimum,
maximum,
integer,
}
}
fn accepts(self, value: f64) -> bool {
value.is_finite()
&& (self.minimum..=self.maximum).contains(&value)
&& (!self.integer || value.fract() == 0.0)
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ScalarEnvelope {
events: Vec<ScalarEvent>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ScalarEvent {
sequence: u64,
timestamp_us: u64,
source_id: String,
synthetic: bool,
metrics: BTreeMap<String, f64>,
#[serde(default)]
label: Option<String>,
}
fn descriptor(
vendor: VendorId,
capability: RfCapability,
availability: ProviderAvailability,
reason: &str,
) -> ProviderDescriptor {
ProviderDescriptor {
vendor,
capabilities: vec![capability],
availability,
hardware_validated: false,
reason: reason.to_owned(),
}
}
fn decode_bounded_scalar_events(
payload: &[u8],
provider: &ProviderDescriptor,
capability: RfCapability,
allowed_metrics: &[MetricRule],
) -> Result<Vec<VendorRfEvent>, VendorEventError> {
provider.validate()?;
if !provider.capabilities.contains(&capability)
|| matches!(
capability,
RfCapability::ComplexCsi | RfCapability::Unsupported
)
{
return Err(VendorEventError::CapabilityMismatch);
}
if payload.is_empty() || payload.len() > MAX_REMAINING_VENDOR_PAYLOAD_BYTES {
return Err(VendorEventError::InvalidPayload);
}
let envelope: ScalarEnvelope = serde_json::from_slice(payload).map_err(|error| {
VendorEventError::MalformedPayload(error.to_string().chars().take(160).collect())
})?;
if envelope.events.is_empty() || envelope.events.len() > MAX_REMAINING_VENDOR_EVENTS {
return Err(VendorEventError::InvalidPayload);
}
envelope
.events
.into_iter()
.map(|event| {
// An allowlist keeps arbitrary scalar fields from silently changing
// the meaning of a provider contract (and rejects CSI-shaped data).
if event.timestamp_us == 0
|| event.source_id.chars().any(char::is_control)
|| event
.label
.as_deref()
.is_some_and(|label| label.chars().any(char::is_control))
{
return Err(VendorEventError::InvalidPayload);
}
for (key, value) in &event.metrics {
let rule = allowed_metrics
.iter()
.find(|rule| rule.name == key)
.ok_or(VendorEventError::CapabilityMismatch)?;
if !rule.accepts(*value) {
return Err(VendorEventError::InvalidPayload);
}
}
let event = VendorRfEvent {
vendor: provider.vendor,
capability,
sequence: event.sequence,
timestamp_us: event.timestamp_us,
source_id: event.source_id,
synthetic: event.synthetic,
metrics: event.metrics,
label: event.label,
};
event.validate(provider)?;
Ok(event)
})
.collect()
}
/// Electric Imp agent/impCentral scalar telemetry bridge for existing fleets.
#[derive(Debug, Clone, Copy, Default)]
pub struct ElectricImpProvider;
impl VendorRfProvider for ElectricImpProvider {
fn descriptor(&self) -> ProviderDescriptor {
descriptor(
VendorId::ElectricImp,
RfCapability::RfTelemetry,
ProviderAvailability::CredentialsRequired,
"Optional authenticated agent/impCentral scalar telemetry bridge; never CSI",
)
}
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
let descriptor = self.descriptor();
decode_bounded_scalar_events(
payload,
&descriptor,
RfCapability::RfTelemetry,
ELECTRIC_IMP_METRICS,
)
}
}
/// RF Solutions environmental/RIoT scalar telemetry boundary.
#[derive(Debug, Clone, Copy, Default)]
pub struct RfSolutionsProvider;
impl VendorRfProvider for RfSolutionsProvider {
fn descriptor(&self) -> ProviderDescriptor {
descriptor(
VendorId::RfSolutions,
RfCapability::RfTelemetry,
ProviderAvailability::Experimental,
"Optional non-Wi-Fi environmental telemetry fusion; excluded as a CSI source",
)
}
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
let descriptor = self.descriptor();
decode_bounded_scalar_events(
payload,
&descriptor,
RfCapability::RfTelemetry,
RF_SOLUTIONS_METRICS,
)
}
}
/// Generic OpenWrt telemetry fixture for already-owned discontinued Luma units.
#[derive(Debug, Clone, Copy, Default)]
pub struct LumaOpenWrtProvider;
impl VendorRfProvider for LumaOpenWrtProvider {
fn descriptor(&self) -> ProviderDescriptor {
descriptor(
VendorId::Luma,
RfCapability::RfTelemetry,
ProviderAvailability::Experimental,
"Generic OpenWrt scalar telemetry fixture for already-owned Luma hardware; no Luma CSI claim",
)
}
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
let descriptor = self.descriptor();
decode_bounded_scalar_events(
payload,
&descriptor,
RfCapability::RfTelemetry,
LUMA_METRICS,
)
}
}
/// Google Nest Wifi may participate as network infrastructure, not a sensor.
#[derive(Debug, Clone, Copy, Default)]
pub struct GoogleNestProvider;
impl VendorRfProvider for GoogleNestProvider {
fn descriptor(&self) -> ProviderDescriptor {
descriptor(
VendorId::GoogleNest,
RfCapability::NetworkOnly,
ProviderAvailability::Experimental,
"Network-only replay events; Device Access exposes no router CSI or RF telemetry",
)
}
fn decode(&self, payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
let descriptor = self.descriptor();
decode_bounded_scalar_events(
payload,
&descriptor,
RfCapability::NetworkOnly,
GOOGLE_NEST_METRICS,
)
}
}
/// Linksys Aware reached end of support; there is no supported sensing API.
#[derive(Debug, Clone, Copy, Default)]
pub struct LinksysProvider;
impl VendorRfProvider for LinksysProvider {
fn descriptor(&self) -> ProviderDescriptor {
descriptor(
VendorId::Linksys,
RfCapability::Unsupported,
ProviderAvailability::Unsupported,
"Linksys Aware reached end of support in 2024; no supported sensing interface",
)
}
fn decode(&self, _payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
Err(VendorEventError::Unsupported)
}
}
/// Wifigarden remains gated until its commercial SDK contract is disclosed.
#[derive(Debug, Clone, Copy, Default)]
pub struct WifigardenProvider;
impl VendorRfProvider for WifigardenProvider {
fn descriptor(&self) -> ProviderDescriptor {
descriptor(
VendorId::Wifigarden,
RfCapability::Unsupported,
ProviderAvailability::ContractRequired,
"Commercial SDK, chipset, schema, calibration and data-rights disclosure required",
)
}
fn decode(&self, _payload: &[u8]) -> Result<Vec<VendorRfEvent>, VendorEventError> {
Err(VendorEventError::ContractRequired)
}
}
/// Deterministic synthetic Electric Imp sidecar contract fixture.
pub const ELECTRIC_IMP_CONTRACT_FIXTURE: &[u8] = br#"{"events":[{"sequence":7,"timestamp_us":1700000000000000,"source_id":"imp005-fixture","synthetic":true,"metrics":{"rssi_dbm":-48.0,"temperature_c":21.5},"label":"lab"}]}"#;
/// Deterministic synthetic RF Solutions sidecar contract fixture.
pub const RF_SOLUTIONS_CONTRACT_FIXTURE: &[u8] = br#"{"events":[{"sequence":8,"timestamp_us":1700000000000100,"source_id":"riot-fixture","synthetic":true,"metrics":{"battery_v":3.1,"humidity_percent":44.0},"label":"lab"}]}"#;
/// Deterministic synthetic generic OpenWrt/Luma contract fixture.
pub const LUMA_OPENWRT_CONTRACT_FIXTURE: &[u8] = br#"{"events":[{"sequence":9,"timestamp_us":1700000000000200,"source_id":"luma-openwrt-fixture","synthetic":true,"metrics":{"client_count":3.0,"noise_dbm":-91.0,"rx_bytes":1024.0},"label":"openwrt"}]}"#;
/// Deterministic synthetic Google Nest network-only contract fixture.
pub const GOOGLE_NEST_CONTRACT_FIXTURE: &[u8] = br#"{"events":[{"sequence":10,"timestamp_us":1700000000000300,"source_id":"nest-fixture","synthetic":true,"metrics":{"client_count":4.0,"probe_count":2.0},"label":"network_activity"}]}"#;
#[cfg(test)]
mod tests {
use super::*;
fn assert_valid_fixture<P: VendorRfProvider>(
provider: P,
fixture: &[u8],
vendor: VendorId,
capability: RfCapability,
) {
let descriptor = provider.descriptor();
descriptor.validate().expect("descriptor must be valid");
let first = provider
.decode(fixture)
.expect("fixture must decode deterministically");
let second = provider.decode(fixture).expect("fixture must replay");
assert_eq!(first, second);
assert_eq!(first.len(), 1);
assert_eq!(first[0].vendor, vendor);
assert_eq!(first[0].capability, capability);
assert!(first[0].synthetic);
first[0]
.validate(&descriptor)
.expect("fixture event must satisfy provider contract");
}
#[test]
fn scalar_and_network_fixtures_are_deterministic_and_honest() {
assert_valid_fixture(
ElectricImpProvider,
ELECTRIC_IMP_CONTRACT_FIXTURE,
VendorId::ElectricImp,
RfCapability::RfTelemetry,
);
assert_valid_fixture(
RfSolutionsProvider,
RF_SOLUTIONS_CONTRACT_FIXTURE,
VendorId::RfSolutions,
RfCapability::RfTelemetry,
);
assert_valid_fixture(
LumaOpenWrtProvider,
LUMA_OPENWRT_CONTRACT_FIXTURE,
VendorId::Luma,
RfCapability::RfTelemetry,
);
assert_valid_fixture(
GoogleNestProvider,
GOOGLE_NEST_CONTRACT_FIXTURE,
VendorId::GoogleNest,
RfCapability::NetworkOnly,
);
}
#[test]
fn unavailable_providers_fail_before_interpreting_payloads() {
let linksys = LinksysProvider;
assert_eq!(
linksys.descriptor().availability,
ProviderAvailability::Unsupported
);
assert_eq!(
linksys.decode(b"not json"),
Err(VendorEventError::Unsupported)
);
let wifigarden = WifigardenProvider;
assert_eq!(
wifigarden.descriptor().availability,
ProviderAvailability::ContractRequired
);
assert_eq!(
wifigarden.decode(ELECTRIC_IMP_CONTRACT_FIXTURE),
Err(VendorEventError::ContractRequired)
);
}
#[test]
fn rejects_empty_oversized_and_excess_event_payloads() {
let provider = ElectricImpProvider;
assert_eq!(provider.decode(b""), Err(VendorEventError::InvalidPayload));
assert_eq!(
provider.decode(&vec![b' '; MAX_REMAINING_VENDOR_PAYLOAD_BYTES + 1]),
Err(VendorEventError::InvalidPayload)
);
let events = (0..=MAX_REMAINING_VENDOR_EVENTS)
.map(|sequence| {
format!(
r#"{{"sequence":{sequence},"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{{"rssi_dbm":-40.0}}}}"#
)
})
.collect::<Vec<_>>()
.join(",");
let payload = format!(r#"{{"events":[{events}]}}"#);
assert_eq!(
provider.decode(payload.as_bytes()),
Err(VendorEventError::InvalidPayload)
);
}
#[test]
fn rejects_csi_or_cross_provider_metric_masquerading() {
let fake_csi = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{"csi_real":1.0}}]}"#;
assert_eq!(
ElectricImpProvider.decode(fake_csi),
Err(VendorEventError::CapabilityMismatch)
);
let rf_metric_in_network_event = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{"rssi_dbm":-40.0}}]}"#;
assert_eq!(
GoogleNestProvider.decode(rf_metric_in_network_event),
Err(VendorEventError::CapabilityMismatch)
);
}
#[test]
fn rejects_invalid_values_bounds_and_schema_extensions() {
let non_finite = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{"rssi_dbm":1e999}}]}"#;
assert!(matches!(
ElectricImpProvider.decode(non_finite),
Err(VendorEventError::MalformedPayload(_)) | Err(VendorEventError::InvalidPayload)
));
let empty_metrics = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{}}]}"#;
assert_eq!(
ElectricImpProvider.decode(empty_metrics),
Err(VendorEventError::InvalidPayload)
);
let unknown_field = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","synthetic":true,"metrics":{"rssi_dbm":-40.0},"csi":[]}]}"#;
assert!(matches!(
ElectricImpProvider.decode(unknown_field),
Err(VendorEventError::MalformedPayload(_))
));
let missing_provenance = br#"{"events":[{"sequence":1,"timestamp_us":1,"source_id":"x","metrics":{"rssi_dbm":-40.0}}]}"#;
assert!(matches!(
ElectricImpProvider.decode(missing_provenance),
Err(VendorEventError::MalformedPayload(_))
));
}
#[test]
fn no_remaining_provider_claims_complex_csi_or_hardware_validation() {
let descriptors = [
ElectricImpProvider.descriptor(),
RfSolutionsProvider.descriptor(),
LumaOpenWrtProvider.descriptor(),
GoogleNestProvider.descriptor(),
LinksysProvider.descriptor(),
WifigardenProvider.descriptor(),
];
for descriptor in descriptors {
descriptor.validate().expect("descriptor must be valid");
assert!(!descriptor.hardware_validated);
assert!(!descriptor.capabilities.contains(&RfCapability::ComplexCsi));
}
}
}
@@ -0,0 +1,104 @@
//! ADR-270 provider registry and canonical event helpers.
use serde::Serialize;
use wifi_densepose_hardware::vendor_rf::{
ProviderDescriptor, VendorEventError, VendorId, VendorRfEvent, VendorRfProvider,
};
use crate::vendor_mist_netgear::{MistProvider, NetgearInsightProvider};
use crate::vendor_origin_plume::{OriginAiProvider, PlumeOpenSyncProvider};
use crate::vendor_remaining::{
ElectricImpProvider, GoogleNestProvider, LinksysProvider, LumaOpenWrtProvider,
RfSolutionsProvider, WifigardenProvider,
};
pub fn descriptor_for(vendor: VendorId) -> ProviderDescriptor {
match vendor {
VendorId::OriginAi => OriginAiProvider.descriptor(),
VendorId::Plume => PlumeOpenSyncProvider.descriptor(),
VendorId::Mist => MistProvider.descriptor(),
VendorId::Netgear => NetgearInsightProvider.descriptor(),
VendorId::ElectricImp => ElectricImpProvider.descriptor(),
VendorId::RfSolutions => RfSolutionsProvider.descriptor(),
VendorId::Linksys => LinksysProvider.descriptor(),
VendorId::Luma => LumaOpenWrtProvider.descriptor(),
VendorId::GoogleNest => GoogleNestProvider.descriptor(),
VendorId::Wifigarden => WifigardenProvider.descriptor(),
}
}
pub fn descriptors() -> Vec<ProviderDescriptor> {
VendorId::ALL.into_iter().map(descriptor_for).collect()
}
pub fn vendor_from_str(value: &str) -> Option<VendorId> {
VendorId::ALL
.into_iter()
.find(|vendor| vendor.as_str() == value)
}
pub fn decode_provider(
vendor: VendorId,
payload: &[u8],
) -> Result<Vec<VendorRfEvent>, VendorEventError> {
match vendor {
VendorId::OriginAi => OriginAiProvider.decode(payload),
VendorId::Plume => PlumeOpenSyncProvider.decode(payload),
VendorId::Mist => MistProvider.decode(payload),
VendorId::Netgear => NetgearInsightProvider.decode(payload),
VendorId::ElectricImp => ElectricImpProvider.decode(payload),
VendorId::RfSolutions => RfSolutionsProvider.decode(payload),
VendorId::Linksys => LinksysProvider.decode(payload),
VendorId::Luma => LumaOpenWrtProvider.decode(payload),
VendorId::GoogleNest => GoogleNestProvider.decode(payload),
VendorId::Wifigarden => WifigardenProvider.decode(payload),
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct VendorEventSnapshot {
pub event_type: &'static str,
pub source: String,
#[serde(flatten)]
pub event: VendorRfEvent,
}
impl VendorEventSnapshot {
pub fn from_event(event: VendorRfEvent) -> Result<Self, VendorEventError> {
let descriptor = descriptor_for(event.vendor);
event.validate(&descriptor)?;
let provenance = if event.synthetic { "simulated" } else { "live" };
Ok(Self {
event_type: "vendor_rf",
source: format!("vendor:{}:{provenance}", event.vendor.as_str()),
event,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_has_exactly_one_valid_descriptor_per_vendor() {
let values = descriptors();
assert_eq!(values.len(), VendorId::ALL.len());
for (vendor, descriptor) in VendorId::ALL.into_iter().zip(values) {
assert_eq!(descriptor.vendor, vendor);
descriptor.validate().unwrap();
}
}
#[test]
fn unsupported_providers_fail_closed() {
assert_eq!(
decode_provider(VendorId::Linksys, b"{}"),
Err(VendorEventError::Unsupported)
);
assert_eq!(
decode_provider(VendorId::Wifigarden, b"{}"),
Err(VendorEventError::ContractRequired)
);
}
}
@@ -449,6 +449,11 @@ impl FieldModel {
.map_or(0, |ls| ls.observation_count())
}
/// Minimum frames required before `finalize_calibration` will succeed.
pub fn min_calibration_frames(&self) -> usize {
self.config.min_calibration_frames
}
/// Feed a calibration frame (one CSI observation per link during empty room).
///
/// `observations` is `[n_links][n_subcarriers]` amplitude data.