feat(hardware): add Rust RTL8720F radar simulator

This commit is contained in:
ruv
2026-07-18 18:58:53 -04:00
parent 09ca66057f
commit 627c8b9405
6 changed files with 1022 additions and 11 deletions
@@ -31,10 +31,14 @@ This is not a drop-in replacement for ESP32 CSI:
function signatures, callback ABI, binary layouts, toolchain version, licensing terms, or public
RTL8720F board package.
Realtek's public Ameba RTOS repository is a plausible base and its public release history includes
CSI and EDCCA APIs, but the reviewed public material does not establish that the RTL8720F radar
API or firmware blobs are publicly available. Therefore an honest integration must be split at a
vendor boundary.
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
@@ -69,7 +73,7 @@ claim millimetre-wave provenance.
### P0 — Vendor enablement
Obtain a redistributable RTL8720F SDK package, radar API headers/libraries, a supported evaluation
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
@@ -78,7 +82,10 @@ Range-FFT output. Until this passes, device firmware is `VENDOR_BLOCKED`, not im
### P1 — Host-first contract
Implement ADR-264 types, parsers, fixtures, fuzz tests, and replay support without linking vendor
code. Generate deterministic synthetic fixtures whose provenance is explicitly synthetic.
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.
@@ -156,6 +163,9 @@ alone. If it does not, ship it only as an independent presence/range sensor.
- 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; public release notes mention CSI and EDCCA APIs but do not document the deck's radar ABI.
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).
@@ -42,7 +42,7 @@ sent by `memcpy`; firmware serializes each field explicitly.
| 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 | 1 complex-i16, 2 complex-f32, 3 power-u16, 4 power-f32 |
| 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 |
@@ -123,6 +123,14 @@ No vendor-provided presence probability bypasses RuView privacy, provenance, or
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.
@@ -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,851 @@
//! 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;
pub const RTL8720F_RADAR_MAX_FRAME_LEN: usize = 64 * 1024;
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);
}
}