From 8a5af5dad4dbc2474597f87c2b11fc5a2ea3d2a9 Mon Sep 17 00:00:00 2001 From: rUv Date: Sat, 18 Jul 2026 19:48:40 -0400 Subject: [PATCH] feat: add RTL8720F Realtek radar beta support (#1356) * docs(adr): plan RTL8720F radar SDK integration * feat(hardware): add Rust RTL8720F radar simulator * feat(ruview): ingest RTL8720F radar frames --- ...263-rtl8720f-2-4ghz-fmcw-radar-platform.md | 171 ++++ .../ADR-264-rtl8720f-radar-wire-protocol.md | 148 +++ docs/adr/README.md | 5 + docs/releases/v0.9.0-realtek-beta.1.md | 45 + v2/crates/wifi-densepose-hardware/README.md | 15 + .../src/bin/rtl8720f-sim.rs | 118 +++ v2/crates/wifi-densepose-hardware/src/lib.rs | 15 +- .../wifi-densepose-hardware/src/rtl8720f.rs | 852 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/main.rs | 49 +- .../src/realtek_radar.rs | 137 +++ 10 files changed, 1550 insertions(+), 5 deletions(-) create mode 100644 docs/adr/ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md create mode 100644 docs/adr/ADR-264-rtl8720f-radar-wire-protocol.md create mode 100644 docs/releases/v0.9.0-realtek-beta.1.md create mode 100644 v2/crates/wifi-densepose-hardware/src/bin/rtl8720f-sim.rs create mode 100644 v2/crates/wifi-densepose-hardware/src/rtl8720f.rs create mode 100644 v2/crates/wifi-densepose-sensing-server/src/realtek_radar.rs diff --git a/docs/adr/ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md b/docs/adr/ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md new file mode 100644 index 00000000..f6d984ec --- /dev/null +++ b/docs/adr/ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md @@ -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). diff --git a/docs/adr/ADR-264-rtl8720f-radar-wire-protocol.md b/docs/adr/ADR-264-rtl8720f-radar-wire-protocol.md new file mode 100644 index 00000000..99a82cca --- /dev/null +++ b/docs/adr/ADR-264-rtl8720f-radar-wire-protocol.md @@ -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). diff --git a/docs/adr/README.md b/docs/adr/README.md index 94c0f218..7c9e510e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.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? diff --git a/docs/releases/v0.9.0-realtek-beta.1.md b/docs/releases/v0.9.0-realtek-beta.1.md new file mode 100644 index 00000000..9040a077 --- /dev/null +++ b/docs/releases/v0.9.0-realtek-beta.1.md @@ -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. diff --git a/v2/crates/wifi-densepose-hardware/README.md b/v2/crates/wifi-densepose-hardware/README.md index 682bb10f..bf82880f 100644 --- a/v2/crates/wifi-densepose-hardware/README.md +++ b/v2/crates/wifi-densepose-hardware/README.md @@ -13,6 +13,21 @@ 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. + ## Features - **ESP32 binary parser** -- Parses ADR-018 binary CSI frames streamed over UDP from ESP32 and diff --git a/v2/crates/wifi-densepose-hardware/src/bin/rtl8720f-sim.rs b/v2/crates/wifi-densepose-hardware/src/bin/rtl8720f-sim.rs new file mode 100644 index 00000000..735d2780 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/bin/rtl8720f-sim.rs @@ -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, + /// Replay file; LE u32 length followed by ADR-264 bytes. + #[arg(long)] + output: Option, + #[arg(long)] + realtime: bool, +} + +fn parse_u64(value: &str) -> Result { + 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::().map_err(|error| error.to_string()) + } +} + +fn emit( + frame: RadarFrame, + socket: Option<&UdpSocket>, + destination: Option, + output: &mut Option, +) -> Result> { + 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> { + 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(()) +} diff --git a/v2/crates/wifi-densepose-hardware/src/lib.rs b/v2/crates/wifi-densepose-hardware/src/lib.rs index fb8754a4..863b2444 100644 --- a/v2/crates/wifi-densepose-hardware/src/lib.rs +++ b/v2/crates/wifi-densepose-hardware/src/lib.rs @@ -53,6 +53,9 @@ 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-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 +67,18 @@ 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 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, +}; diff --git a/v2/crates/wifi-densepose-hardware/src/rtl8720f.rs b/v2/crates/wifi-densepose-hardware/src/rtl8720f.rs new file mode 100644 index 00000000..a1ba7051 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/rtl8720f.rs @@ -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 for ReportType { + type Error = RadarParseError; + + fn try_from(value: u8) -> Result { + 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 for ElementFormat { + type Error = RadarParseError; + + fn try_from(value: u8) -> Result { + 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 { + 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, 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 { + 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), + ComplexI16(Vec<[i16; 2]>), + ComplexF32(Vec<[f32; 2]>), + PowerU16(Vec), + PowerF32(Vec), +} + +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) { + 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, 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 { + 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); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 419c5b10..19d9ce41 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -17,6 +17,7 @@ mod field_bridge; mod field_localize; mod model_format; mod multistatic_bridge; +mod realtek_radar; pub mod pose; mod rvf_container; mod rvf_pipeline; @@ -1028,6 +1029,10 @@ struct AppStateInner { source: String, /// Instant of the last ESP32 UDP frame received (for offline detection). last_esp32_frame: Option, + /// Latest validated RTL8720F summary; raw radar samples are not retained here. + latest_realtek_radar: Option, + /// Instant of the last validated RTL8720F UDP frame. + last_realtek_frame: Option, tx: broadcast::Sender, // ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state + // a parallel broadcast topic (`/ws/introspection`) running alongside @@ -1199,6 +1204,13 @@ 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); + } + } + } self.source.clone() } } @@ -3351,6 +3363,14 @@ async fn latest(State(state): State) -> Json { } } +async fn latest_realtek_radar(State(state): State) -> Json { + 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"})), + } +} + /// Generate WiFi-derived pose keypoints from sensing data. /// /// Keypoint positions are modulated by real signal features rather than a pure @@ -5445,7 +5465,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 and RTL8720F radar frames"); s } Err(e) => { @@ -5454,10 +5474,32 @@ 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::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!( @@ -7552,6 +7594,8 @@ async fn main() { tick: 0, source: source.into(), last_esp32_frame: None, + latest_realtek_radar: None, + last_realtek_frame: None, tx, intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(), intro_tx, @@ -7768,6 +7812,7 @@ 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)) // Per-node health endpoint .route("/api/v1/nodes", get(nodes_endpoint)) // ADR-110 iter 29 — per-node mesh sync state for HTTP clients. diff --git a/v2/crates/wifi-densepose-sensing-server/src/realtek_radar.rs b/v2/crates/wifi-densepose-sensing-server/src/realtek_radar.rs new file mode 100644 index 00000000..3e0825c2 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/realtek_radar.rs @@ -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, + pub peak_power: Option, + pub mean_cfr_amplitude: Option, +} + +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, Option) { + 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 { + let (sum, count) = match &frame.payload { + RadarPayload::ComplexI16(values) => ( + values + .iter() + .map(|[i, q]| ((*i as f32).hypot(*q as f32)) * frame.scale) + .sum::(), + values.len(), + ), + RadarPayload::ComplexF32(values) => ( + values + .iter() + .map(|[i, q]| i.hypot(*q) * frame.scale) + .sum::(), + 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); + } +}