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
This commit is contained in:
rUv
2026-07-18 19:48:40 -04:00
committed by GitHub
parent c7b3f0bcc5
commit 8a5af5dad4
10 changed files with 1550 additions and 5 deletions
@@ -0,0 +1,171 @@
# ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform
- **Status**: proposed
- **Date**: 2026-07-18
- **Deciders**: ruv
- **Tags**: realtek, rtl8720f, ameba, fmcw, radar, cfr, csi, hardware
- **Relates to**: ADR-018, ADR-063, ADR-064, ADR-095, ADR-097, ADR-260, ADR-262
## Context
Realtek's `RTL8720F-2.4G-Radar-Advantages_EN.pptx` describes an RTL8720F mode that shares the
2.4 GHz radio between Wi-Fi, Bluetooth, and an active FMCW radar. It offers two data products that
are useful to RuView:
1. **CFR (Channel Frequency Report)**, described by Realtek as the same concept as Wi-Fi CSI.
2. **Near and far Range-FFT reports**, preserving near-field content while extending observation to
approximately 56 m.
The proposed radio uses one transmit and one receive antenna, 20/40/70 MHz sweeps, configurable
8/16/32/64 microsecond chirp symbols, a maximum 2.56 ms FMCW packet, and a configurable frame
interval above 15 ms. The deck recommends 40 MHz outside Japan and 20 MHz in Japan. It also
describes EDCCA/CTS channel access, Wi-Fi/BT/radar time division, interference reporting, and
priority arbitration in the driver.
This is not a drop-in replacement for ESP32 CSI:
- it is **active monostatic FMCW**, while the ESP32 path observes Wi-Fi packet CSI;
- one Tx/one Rx has no angle-of-arrival or native multi-target separation;
- the stated 40 MHz range resolution is about 3.15 m, despite a finer 0.59 m Range-FFT report step;
- the presentation is a capability description, not an SDK contract. It contains no header names,
function signatures, callback ABI, binary layouts, toolchain version, licensing terms, or public
RTL8720F board package.
Realtek's public Ameba RTOS repository is the base. Release v1.2.1 includes the CSI API and fixes a
CSI application-buffer semaphore issue, but does not expose the radar application surface. Open
upstream PR #1336 (2026-07-18 snapshot) adds RTL8720F project artifacts, `AT+RAD`, `AT+RADDBG`, and
the public configuration call `wifi_radar_config(struct rtw_radar_action_parm *)`. Its public
parameter struct confirms mode, channel, 70/40/20 MHz bandwidth selector, trigger period, and
enable/config actions. Report reception still crosses non-public/placeholder HAL symbols such as
`wifi_hal_radar_recv_data(frame_num, frame_type, data)`, so the report layout and buffer lifetime
remain vendor-gated. Therefore the integration stays split at that boundary.
## Decision
RuView will support RTL8720F radar as an **optional, capability-negotiated source**, without
replacing the ESP32 firmware or treating radar CFR as byte-compatible with ADR-018 CSI.
The integration has three layers:
1. **Realtek device firmware**: a small application built in the vendor-supported Ameba SDK calls
the radar API, owns coexistence configuration, and emits versioned reports. This code lives under
`firmware/rtl8720f-radar/` only after the redistributable SDK/API is available.
2. **Transport-neutral wire contract**: CFR and Range-FFT reports are framed independently from the
vendor ABI and sent over UDP, USB CDC, or UART. ADR-264 defines this boundary.
3. **Rust host adapter**: `wifi-densepose-hardware` parses reports from bytes and converts CFR into
the existing CSI-domain representation, while Range-FFT remains a radar modality and feeds the
RuField/RuView cross-modality bridge from ADR-260/262.
The two report types remain semantically distinct:
| RTL8720F output | RuView representation | Permitted use |
|---|---|---|
| CFR | `CsiFrame` through a Realtek calibration adapter | CSI feature extraction after validation |
| Range-FFT near/far | `RadarFrame` / RuField `mmwave_radar`-class event with a 2.4 GHz descriptor | range, motion, presence, fusion |
| Vendor AI presence probability | derived observation with model/version provenance | advisory input, never ground truth |
| Interference report | quality/provenance metadata | reject, down-weight, or mark contaminated frames |
The modality registry should eventually distinguish `fmcw_radar_2_4ghz` from `mmwave_radar`; until
that RuField schema revision is accepted, the adapter must attach `carrier_hz = 2.4e9` and must not
claim millimetre-wave provenance.
## Delivery phases and gates
### P0 — Vendor enablement
Obtain the PR #1336-or-newer RTL8720F SDK package, radar API headers/libraries, a supported evaluation
board, flashing/debug instructions, report definitions, and written redistribution terms.
**Gate:** compile and run Realtek's unmodified radar example and capture CFR plus near/far
Range-FFT output. Until this passes, device firmware is `VENDOR_BLOCKED`, not implemented.
### P1 — Host-first contract
Implement ADR-264 types, parsers, fixtures, fuzz tests, and replay support without linking vendor
code. Use the Rust `Rtl8720fSimulator` as the only pre-hardware live source. It emits deterministic
CFR, near/far Range-FFT, interference, and capabilities frames through the same ADR-264 encoder and
parser used by hardware. Every simulated frame sets `RadarFlags::SYNTHETIC`; simulation results are
never reported as device measurements.
**Gate:** malformed inputs never panic; encode/decode round trips; unknown versions and report
types fail closed.
### P2 — RTL8720F firmware adapter
Wrap only the minimum vendor API surface: initialization, profile configuration, start/stop,
callback acquisition, interference status, and report serialization. Keep vendor types out of the
wire protocol.
**Gate:** 30-minute simultaneous Wi-Fi telemetry and radar capture with no watchdog reset, bounded
loss, monotonic sequence numbers, and explicit coexistence/interference statistics.
### P3 — Calibration and signal validation
Calibrate CFR phase/amplitude, Range-FFT bin spacing, static leakage, and clock drift. Compare
reported range against measured targets at multiple distances and bandwidths.
**Gate:** publish measured error distributions. Do not infer accuracy from report-bin spacing and
do not advertise multi-person pose or vital signs from the vendor deck.
### P4 — Fusion and productization
Feed calibrated CFR through the CSI path and Range-FFT through RuField, retaining source, mode,
bandwidth, calibration, firmware, and interference provenance.
**Gate:** ablation shows whether the radar stream improves a named RuView metric over ESP32 CSI
alone. If it does not, ship it only as an independent presence/range sensor.
## Consequences
### Positive
- One low-cost radio can provide active radar and CSI-like CFR while retaining Wi-Fi connectivity.
- Range-FFT adds an independent physical measurement for presence/range fusion.
- The vendor SDK is isolated from the Rust sensing core and from the stable on-wire contract.
- Capability negotiation permits future Realtek parts without another application-level fork.
### Negative
- The first implementation is blocked on access to the actual RTL8720F radar SDK/API and hardware.
- Active 2.4 GHz transmission changes coexistence, privacy, power, and regional compliance concerns.
- 1T1R and limited sweep bandwidth cannot provide the spatial resolution of multi-antenna mmWave.
- A second embedded toolchain and firmware release process must be maintained.
### Neutral
- ESP32 remains the default CSI node.
- Existing consumers receive normalized frames and do not link against Realtek code.
- Vendor AI output is optional metadata; RuView retains responsibility for its own validation.
## Rejected alternatives
1. **Map Range-FFT directly to `CsiFrame`.** Rejected because range bins and channel-frequency
samples have different axes and physical meaning.
2. **Link the Realtek SDK into the Rust server.** Rejected because it couples host builds to a
proprietary embedded ABI and toolchain.
3. **Wait to define any interface until hardware arrives.** Rejected because the host protocol,
parser safety, replay, and provenance can be developed and reviewed independently.
4. **Replace ESP32 nodes.** Rejected because the modes are complementary and availability differs.
## Open vendor questions
- Exact RTL8720F part/board identifier and production availability.
- SDK repository/tag, compiler, RTOS, binary blobs, license, and redistribution permissions.
- Radar initialization/configuration/callback API signatures and threading/ISR constraints.
- CFR and near/far Range-FFT element type, complex ordering, scaling, endianness, and timestamps.
- Whether CFR is calibrated complex data and whether phase remains coherent across frames.
- Maximum report rates, buffer ownership, DMA/cache constraints, and Wi-Fi throughput impact.
- Region/channel enforcement and whether 70 MHz operation is allowed by the supplied firmware.
- Secure boot, signed OTA, unique device identity, and firmware attestation support.
## Sources
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 3 and 1019,
supplied 2026-07-18. This is product material, not measured RuView validation.
- [Ameba-AIoT/ameba-rtos releases](https://github.com/Ameba-AIoT/ameba-rtos/releases), reviewed
2026-07-18; v1.2.1 is the current QC release and includes a CSI buffer-semaphore fix.
- [Ameba-AIoT/ameba-rtos PR #1336](https://github.com/Ameba-AIoT/ameba-rtos/pull/1336), reviewed
2026-07-18; exposes RTL8720F build assets, `wifi_radar_config`, and radar AT commands while report
internals remain in binary/private layers.
- ADR-063 (mmWave sensor fusion), ADR-095/097 (source normalization), and ADR-260/262 (RuField
multimodal event model and live bridge).
@@ -0,0 +1,148 @@
# ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports
- **Status**: proposed
- **Date**: 2026-07-18
- **Deciders**: ruv
- **Tags**: realtek, rtl8720f, protocol, cfr, range-fft, udp, serial
- **Depends on**: ADR-263
- **Relates to**: ADR-018, ADR-095, ADR-097, ADR-099, ADR-260
## Context
ADR-263 adopts RTL8720F radar behind an anti-corruption boundary. The Realtek presentation names
CFR, near Range-FFT, far Range-FFT, and interference reports, but does not specify their binary ABI.
RuView needs a stable, testable contract that can be implemented before the vendor SDK arrives and
that will not expose vendor structs, pointer layouts, padding, or callback lifetime rules over the
network.
ADR-018 already defines ESP32 CSI framing. Reusing its magic or pretending that Realtek radar is an
ESP32 packet would make source detection ambiguous and erase radar-specific calibration metadata.
## Decision
Define a new little-endian `RtlRadarFrameV1` envelope with its own magic and explicit payload type.
This is a RuView protocol, not a claim about Realtek's native memory layout.
### Envelope
All integer fields are little-endian. Floating-point payloads use IEEE-754 binary32. No C struct is
sent by `memcpy`; firmware serializes each field explicitly.
| Offset | Size | Field | Meaning |
|---:|---:|---|---|
| 0 | 4 | magic | ASCII `RTR1` (`0x31525452`) |
| 4 | 1 | version | `1` |
| 5 | 1 | report_type | 1 CFR, 2 range-near, 3 range-far, 4 interference, 5 capabilities |
| 6 | 2 | header_len | complete header size, initially 56 |
| 8 | 4 | frame_len | header + payload + CRC |
| 12 | 4 | sequence | wraps modulo 2^32 |
| 16 | 8 | timestamp_us | monotonic device time at acquisition |
| 24 | 8 | device_id | stable pseudonymous identifier, not a MAC address |
| 32 | 4 | center_freq_khz | RF centre frequency |
| 36 | 2 | bandwidth_mhz | 20, 40, or 70 |
| 38 | 2 | flags | calibration/interference/saturation/time-sync flags |
| 40 | 2 | element_count | complex samples or range bins |
| 42 | 1 | element_format | 0 bytes/TLV, 1 complex-i16, 2 complex-f32, 3 power-u16, 4 power-f32 |
| 43 | 1 | antenna_count | expected to be 1 for the deck's 1T1R configuration |
| 44 | 4 | scale | quantized-to-physical multiplier; `1.0` for float payloads |
| 48 | 4 | bin_spacing | Hz for CFR, metres for Range-FFT |
| 52 | 4 | calibration_id | device calibration revision/hash prefix |
| 56 | variable | payload | determined by type, count, and format |
| final-4 | 4 | crc32 | IEEE CRC-32 over header and payload |
If vendor evidence shows that 56 bytes is too costly, a later protocol version may introduce a
compact header. V1 favors auditable provenance over premature byte savings.
### Payload semantics
- **CFR** contains ordered complex channel-frequency samples. The adapter must know the frequency
origin/order and must not fabricate missing phase. Uncalibrated frames carry the uncalibrated flag
and cannot enter phase-sensitive processing.
- **Range-near/range-far** contains ordered range bins. Near and far are separate report types so
filtering and leakage behavior are never hidden from consumers.
- **Interference** contains a versioned TLV set for channel-busy, detected-during-chirp, estimated
interference power, and packet jitter. Unknown TLVs are skipped by length.
- **Capabilities** is emitted at boot and on request. It declares supported report types, bandwidths,
chirp lengths, maximum elements/report, maximum frame rate, firmware version, and SDK identifier.
### Transport
The identical envelope is supported over:
- UDP datagrams for normal RuView ingestion;
- USB CDC or UART with COBS framing and a zero-byte delimiter;
- file replay as a length-prefixed sequence of envelopes.
One envelope must fit one UDP datagram. Fragmentation is not part of V1; firmware rejects a profile
whose maximum report exceeds the configured MTU and reports the required size through capabilities.
### Parser and trust rules
The host parser:
1. validates magic, version, lengths, enum values, element count/format multiplication, and CRC
before allocating or decoding the payload;
2. caps frames at 64 KiB and elements at a configured hardware maximum;
3. rejects non-finite float metadata/payload values;
4. tracks sequence gaps and timestamp regressions per device;
5. preserves unknown flags but never interprets them as trusted;
6. attaches transport source, firmware/SDK version, calibration ID, and interference state to
provenance;
7. labels fixture/generated frames as synthetic.
No vendor-provided presence probability bypasses RuView privacy, provenance, or quality gates.
## Consequences
### Positive
- Firmware, transport, parser, replay, and fusion can evolve independently.
- Fuzzing and golden fixtures require no Realtek SDK or board.
- CFR and Range-FFT retain correct axes and calibration provenance.
- A boot-time capabilities frame makes SDK/API drift observable.
### Negative
- Serialization adds CPU and bandwidth overhead compared with dumping a vendor buffer.
- V1 fields may need revision after the actual API and report limits are disclosed.
- UDP provides integrity/error detection, not authenticity or confidentiality.
### Neutral
- Authentication can be layered with ADR-032 device identity or a signed RuField receipt without
changing report semantics.
- ESP32 ADR-018 framing remains unchanged.
## Implementation plan
1. Add `rtl8720f` types/parser module to `wifi-densepose-hardware` behind no vendor dependency.
2. Add golden CFR, near/far Range-FFT, interference, and capabilities fixtures.
3. Add property/fuzz tests for length arithmetic, enum handling, CRC, and float validation.
4. Add a replay CLI that prints normalized metadata without running inference.
5. Once SDK access exists, implement the embedded serializer and verify captured frames against the
host golden decoder.
6. Revise this proposed ADR with measured element counts, rates, and API names before acceptance.
Host-side steps 13 are implemented in `wifi-densepose-hardware::rtl8720f`: typed report and
element enums, semantic type/format validation, bounded length arithmetic, CRC verification,
finite-float checks, encode/decode round trips, corruption/truncation tests, and deterministic
arbitrary-input panic checks. Cross-language vectors remain blocked on the vendor SDK callback ABI.
Bit 15 of `flags` is reserved by RuView as `SYNTHETIC`; the Rust simulator always sets it and real
firmware must never set it. The simulator is deterministic by seed and exercises the production
encoder/parser rather than a parallel mock representation.
## Acceptance criteria
- Rust encode/decode round-trip for every report type.
- Cross-language golden vector produced by the RTL8720F firmware.
- Zero parser panics over the fuzz corpus and arbitrary byte input.
- Detection of single-bit corruption, truncation, count overflow, timestamp regression, and gaps.
- Captured CFR frequency order and Range-FFT bin spacing verified against vendor documentation and a
measured target.
## Sources
- Realtek Semiconductor, `RTL8720F-2.4G-Radar-Advantages_EN.pptx`, slides 1119, supplied
2026-07-18.
- ADR-018 (ESP32 framing), ADR-095/097 (hardware normalization), ADR-260 (multimodal event model),
and ADR-263 (platform decision).
+5
View File
@@ -1,5 +1,10 @@
# Architecture Decision Records
Latest proposed decisions:
- [ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports](ADR-264-rtl8720f-radar-wire-protocol.md)
- [ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md)
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
## Why ADRs?
+45
View File
@@ -0,0 +1,45 @@
# RuView v0.9.0-realtek-beta.1
This prerelease introduces the Rust-first RTL8720F 2.4 GHz radar transport and
RuView ingestion path. It is intentionally simulator-validated until Realtek
hardware and the vendor SDK callback ABI arrive.
## Included
- ADR-263 records the upstream Ameba integration and licensing boundary.
- ADR-264 defines a versioned, bounded, CRC-protected radar envelope.
- `rtl8720f-sim` emits deterministic CFR, near-range, far-range, interference,
and capability reports to UDP or replay files.
- The sensing server validates RTL8720F datagrams, publishes bounded summaries
over `/ws/sensing`, and exposes the latest report at
`/api/v1/radar/latest`.
- Synthetic provenance is retained end to end as `realtek:simulated`; simulator
data is never presented as hardware data.
## Compatibility
The adapter tracks the radar control surface proposed by Ameba RTOS pull
request #1336 (`wifi_radar_config`, `AT+RAD`, and `AT+RADDBG`). The stable Ameba
RTOS v1.2.1 release does not yet expose the complete radar receive callback ABI,
so no vendor-private headers or binary libraries are copied into this release.
## Validation status
- Rust codec round trips, corruption rejection, size bounds, and deterministic
simulator tests pass.
- RuView server ingestion, REST reporting, and source provenance were exercised
end to end over loopback UDP.
- Windows release binaries are built from this branch and accompanied by
SHA-256 checksums.
## Known limitations
- No physical RTL8720F board has been flashed or measured.
- The vendor report callback and exact report layouts remain an SDK/hardware
validation gate; the adapter boundary may change when those arrive.
- This beta exposes transport and aggregate radar observability. Radar-to-pose,
vital-sign inference, RF calibration, and accuracy claims are not enabled.
- 2.4 GHz radar reports are not mislabeled as mmWave or Wi-Fi CSI events.
Do not deploy this prerelease for safety-critical, medical, or occupancy billing
uses. It is an integration beta for SDK and hardware bring-up.
@@ -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
@@ -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(())
}
+12 -3
View File
@@ -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,
};
@@ -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);
}
}
@@ -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<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>,
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 +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<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"})),
}
}
/// 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.
@@ -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);
}
}