mirror of
https://github.com/ruvnet/RuView
synced 2026-07-20 17:03:24 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 232b1c79f6 | |||
| 8a5af5dad4 | |||
| c7b3f0bcc5 | |||
| 8c0bdaef92 | |||
| 1692b16f6e | |||
| 4bab48e15f | |||
| 6f0114c488 | |||
| c8e990c36f | |||
| 7925aa94ab | |||
| c2cf180afe | |||
| 8c232d0894 |
@@ -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
|
||||
|
||||
@@ -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 (10–150 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 → 40–45 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 O1–O9): `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 O1–O9): `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 D1–D4): 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 40–840× 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).
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 5–6 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 10–19,
|
||||
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 1–3 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 11–19, 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)
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -242,7 +242,22 @@ impl PyBreathingExtractor {
|
||||
// ─── HeartRateExtractor ──────────────────────────────────────────────
|
||||
|
||||
/// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude
|
||||
/// residuals via 0.8–2.0 Hz bandpass + autocorrelation peak detection.
|
||||
/// residuals and per-subcarrier unwrapped phases (radians) via
|
||||
/// 0.8–2.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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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(())
|
||||
}
|
||||
@@ -53,6 +53,11 @@ pub mod sync_packet;
|
||||
// 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-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 +69,25 @@ 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 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,
|
||||
};
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ mod field_bridge;
|
||||
mod field_localize;
|
||||
mod model_format;
|
||||
mod multistatic_bridge;
|
||||
mod mediatek_csi;
|
||||
mod realtek_radar;
|
||||
pub mod pose;
|
||||
mod rvf_container;
|
||||
mod rvf_pipeline;
|
||||
@@ -1028,6 +1030,14 @@ 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>,
|
||||
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 +1209,20 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.source.clone()
|
||||
}
|
||||
}
|
||||
@@ -3351,6 +3375,22 @@ 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"})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate WiFi-derived pose keypoints from sensing data.
|
||||
///
|
||||
/// Keypoint positions are modulated by real signal features rather than a pure
|
||||
@@ -4991,6 +5031,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 +5084,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 +5485,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 CSI, MediaTek CSI, and RTL8720F radar frames");
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -5428,10 +5494,52 @@ 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 >= 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 +7634,10 @@ 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,
|
||||
tx,
|
||||
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
|
||||
intro_tx,
|
||||
@@ -7742,6 +7854,8 @@ 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))
|
||||
// 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 +7882,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,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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/ruvector updated: 138a2bee16...f3de1724fa
Reference in New Issue
Block a user