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