Skip to content

Commit 5a372ae

Browse files
committed
feat(ruview): ingest RTL8720F radar frames
1 parent 627c8b9 commit 5a372ae

4 files changed

Lines changed: 231 additions & 3 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# RuView v0.9.0-realtek-beta.1
2+
3+
This prerelease introduces the Rust-first RTL8720F 2.4 GHz radar transport and
4+
RuView ingestion path. It is intentionally simulator-validated until Realtek
5+
hardware and the vendor SDK callback ABI arrive.
6+
7+
## Included
8+
9+
- ADR-263 records the upstream Ameba integration and licensing boundary.
10+
- ADR-264 defines a versioned, bounded, CRC-protected radar envelope.
11+
- `rtl8720f-sim` emits deterministic CFR, near-range, far-range, interference,
12+
and capability reports to UDP or replay files.
13+
- The sensing server validates RTL8720F datagrams, publishes bounded summaries
14+
over `/ws/sensing`, and exposes the latest report at
15+
`/api/v1/radar/latest`.
16+
- Synthetic provenance is retained end to end as `realtek:simulated`; simulator
17+
data is never presented as hardware data.
18+
19+
## Compatibility
20+
21+
The adapter tracks the radar control surface proposed by Ameba RTOS pull
22+
request #1336 (`wifi_radar_config`, `AT+RAD`, and `AT+RADDBG`). The stable Ameba
23+
RTOS v1.2.1 release does not yet expose the complete radar receive callback ABI,
24+
so no vendor-private headers or binary libraries are copied into this release.
25+
26+
## Validation status
27+
28+
- Rust codec round trips, corruption rejection, size bounds, and deterministic
29+
simulator tests pass.
30+
- RuView server ingestion, REST reporting, and source provenance were exercised
31+
end to end over loopback UDP.
32+
- Windows release binaries are built from this branch and accompanied by
33+
SHA-256 checksums.
34+
35+
## Known limitations
36+
37+
- No physical RTL8720F board has been flashed or measured.
38+
- The vendor report callback and exact report layouts remain an SDK/hardware
39+
validation gate; the adapter boundary may change when those arrive.
40+
- This beta exposes transport and aggregate radar observability. Radar-to-pose,
41+
vital-sign inference, RF calibration, and accuracy claims are not enabled.
42+
- 2.4 GHz radar reports are not mislabeled as mmWave or Wi-Fi CSI events.
43+
44+
Do not deploy this prerelease for safety-critical, medical, or occupancy billing
45+
uses. It is an integration beta for SDK and hardware bring-up.

v2/crates/wifi-densepose-hardware/src/rtl8720f.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ pub const RTL8720F_RADAR_MAGIC: u32 = 0x3152_5452; // "RTR1" in little endian
1515
pub const RTL8720F_RADAR_VERSION: u8 = 1;
1616
pub const RTL8720F_RADAR_HEADER_LEN: usize = 56;
1717
pub const RTL8720F_RADAR_CRC_LEN: usize = 4;
18-
pub const RTL8720F_RADAR_MAX_FRAME_LEN: usize = 64 * 1024;
18+
/// Largest payload that can be carried in one IPv4 UDP datagram.
19+
pub const RTL8720F_RADAR_MAX_FRAME_LEN: usize = 65_507;
1920
pub const RTL8720F_RADAR_MAX_ELEMENTS: usize = 16_384;
2021

2122
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]

v2/crates/wifi-densepose-sensing-server/src/main.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ mod field_bridge;
1717
mod field_localize;
1818
mod model_format;
1919
mod multistatic_bridge;
20+
mod realtek_radar;
2021
pub mod pose;
2122
mod rvf_container;
2223
mod rvf_pipeline;
@@ -1028,6 +1029,10 @@ struct AppStateInner {
10281029
source: String,
10291030
/// Instant of the last ESP32 UDP frame received (for offline detection).
10301031
last_esp32_frame: Option<std::time::Instant>,
1032+
/// Latest validated RTL8720F summary; raw radar samples are not retained here.
1033+
latest_realtek_radar: Option<realtek_radar::RealtekRadarSnapshot>,
1034+
/// Instant of the last validated RTL8720F UDP frame.
1035+
last_realtek_frame: Option<std::time::Instant>,
10311036
tx: broadcast::Sender<String>,
10321037
// ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state +
10331038
// a parallel broadcast topic (`/ws/introspection`) running alongside
@@ -1199,6 +1204,13 @@ impl AppStateInner {
11991204
}
12001205
}
12011206
}
1207+
if self.source.starts_with("realtek") {
1208+
if let Some(last) = self.last_realtek_frame {
1209+
if last.elapsed() > ESP32_OFFLINE_TIMEOUT {
1210+
return format!("{}:offline", self.source);
1211+
}
1212+
}
1213+
}
12021214
self.source.clone()
12031215
}
12041216
}
@@ -3351,6 +3363,14 @@ async fn latest(State(state): State<SharedState>) -> Json<serde_json::Value> {
33513363
}
33523364
}
33533365

3366+
async fn latest_realtek_radar(State(state): State<SharedState>) -> Json<serde_json::Value> {
3367+
let s = state.read().await;
3368+
match &s.latest_realtek_radar {
3369+
Some(snapshot) => Json(serde_json::to_value(snapshot).unwrap_or_default()),
3370+
None => Json(serde_json::json!({"status": "no Realtek radar data yet"})),
3371+
}
3372+
}
3373+
33543374
/// Generate WiFi-derived pose keypoints from sensing data.
33553375
///
33563376
/// 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) {
54455465
let addr = format!("0.0.0.0:{udp_port}");
54465466
let socket = match UdpSocket::bind(&addr).await {
54475467
Ok(s) => {
5448-
info!("UDP listening on {addr} for ESP32 CSI frames");
5468+
info!("UDP listening on {addr} for ESP32 CSI and RTL8720F radar frames");
54495469
s
54505470
}
54515471
Err(e) => {
@@ -5454,10 +5474,32 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
54545474
}
54555475
};
54565476

5457-
let mut buf = [0u8; 2048];
5477+
let mut buf = vec![0u8; wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAX_FRAME_LEN];
54585478
loop {
54595479
match socket.recv_from(&mut buf).await {
54605480
Ok((len, src)) => {
5481+
if len >= 4
5482+
&& u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice"))
5483+
== wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAGIC
5484+
{
5485+
match wifi_densepose_hardware::rtl8720f::RadarFrame::from_bytes(&buf[..len]) {
5486+
Ok((frame, consumed)) if consumed == len => {
5487+
let snapshot = realtek_radar::RealtekRadarSnapshot::from_frame(&frame);
5488+
debug!("RTL8720F radar from {src}: type={} seq={} elements={}", snapshot.report_type, snapshot.sequence, snapshot.element_count);
5489+
let json = serde_json::to_string(&snapshot).ok();
5490+
let mut s = state.write().await;
5491+
s.source = snapshot.source.to_string();
5492+
s.last_realtek_frame = Some(std::time::Instant::now());
5493+
s.latest_realtek_radar = Some(snapshot);
5494+
if let Some(json) = json {
5495+
let _ = s.tx.send(json);
5496+
}
5497+
}
5498+
Ok((_, consumed)) => warn!("RTL8720F radar datagram from {src} has trailing bytes: consumed={consumed} received={len}"),
5499+
Err(error) => warn!("Rejected RTL8720F radar datagram from {src}: {error}"),
5500+
}
5501+
continue;
5502+
}
54615503
// ADR-039: Try edge vitals packet first (magic 0xC511_0002).
54625504
if let Some(vitals) = parse_esp32_vitals(&buf[..len]) {
54635505
debug!(
@@ -7552,6 +7594,8 @@ async fn main() {
75527594
tick: 0,
75537595
source: source.into(),
75547596
last_esp32_frame: None,
7597+
latest_realtek_radar: None,
7598+
last_realtek_frame: None,
75557599
tx,
75567600
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
75577601
intro_tx,
@@ -7768,6 +7812,7 @@ async fn main() {
77687812
.route("/api/v1/metrics", get(health_metrics))
77697813
// Sensing endpoints
77707814
.route("/api/v1/sensing/latest", get(latest))
7815+
.route("/api/v1/radar/latest", get(latest_realtek_radar))
77717816
// Per-node health endpoint
77727817
.route("/api/v1/nodes", get(nodes_endpoint))
77737818
// ADR-110 iter 29 — per-node mesh sync state for HTTP clients.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//! Bounded, privacy-conscious summaries of RTL8720F radar transport frames.
2+
3+
use serde::Serialize;
4+
use wifi_densepose_hardware::rtl8720f::{RadarFlags, RadarFrame, RadarPayload, ReportType};
5+
6+
#[derive(Debug, Clone, PartialEq, Serialize)]
7+
pub(crate) struct RealtekRadarSnapshot {
8+
pub event_type: &'static str,
9+
pub source: &'static str,
10+
pub report_type: &'static str,
11+
pub sequence: u32,
12+
pub timestamp_us: u64,
13+
pub device_id: String,
14+
pub center_freq_khz: u32,
15+
pub bandwidth_mhz: u16,
16+
pub antenna_count: u8,
17+
pub element_count: usize,
18+
pub calibrated: bool,
19+
pub synthetic: bool,
20+
pub interference_detected: bool,
21+
pub saturated: bool,
22+
pub time_synchronized: bool,
23+
pub calibration_id: u32,
24+
pub bin_spacing: f32,
25+
pub peak_range_m: Option<f32>,
26+
pub peak_power: Option<f32>,
27+
pub mean_cfr_amplitude: Option<f32>,
28+
}
29+
30+
impl RealtekRadarSnapshot {
31+
pub(crate) fn from_frame(frame: &RadarFrame) -> Self {
32+
let synthetic = frame.flags.contains(RadarFlags::SYNTHETIC);
33+
let (peak_range_m, peak_power) = range_peak(frame);
34+
Self {
35+
event_type: "realtek_radar",
36+
source: if synthetic {
37+
"realtek:simulated"
38+
} else {
39+
"realtek"
40+
},
41+
report_type: report_type_name(frame.report_type),
42+
sequence: frame.sequence,
43+
timestamp_us: frame.timestamp_us,
44+
device_id: format!("{:016x}", frame.device_id),
45+
center_freq_khz: frame.center_freq_khz,
46+
bandwidth_mhz: frame.bandwidth_mhz,
47+
antenna_count: frame.antenna_count,
48+
element_count: frame.payload.len(),
49+
calibrated: frame.flags.contains(RadarFlags::CALIBRATED),
50+
synthetic,
51+
interference_detected: frame.flags.contains(RadarFlags::INTERFERENCE_DETECTED),
52+
saturated: frame.flags.contains(RadarFlags::SATURATED),
53+
time_synchronized: frame.flags.contains(RadarFlags::TIME_SYNCHRONIZED),
54+
calibration_id: frame.calibration_id,
55+
bin_spacing: frame.bin_spacing,
56+
peak_range_m,
57+
peak_power,
58+
mean_cfr_amplitude: mean_cfr_amplitude(frame),
59+
}
60+
}
61+
}
62+
63+
fn report_type_name(report_type: ReportType) -> &'static str {
64+
match report_type {
65+
ReportType::Cfr => "cfr",
66+
ReportType::RangeNear => "range_near",
67+
ReportType::RangeFar => "range_far",
68+
ReportType::Interference => "interference",
69+
ReportType::Capabilities => "capabilities",
70+
}
71+
}
72+
73+
fn range_peak(frame: &RadarFrame) -> (Option<f32>, Option<f32>) {
74+
let max = match &frame.payload {
75+
RadarPayload::PowerU16(values) => values
76+
.iter()
77+
.enumerate()
78+
.max_by_key(|(_, value)| *value)
79+
.map(|(index, value)| (index, *value as f32 * frame.scale)),
80+
RadarPayload::PowerF32(values) => values
81+
.iter()
82+
.enumerate()
83+
.max_by(|(_, a), (_, b)| a.total_cmp(b))
84+
.map(|(index, value)| (index, *value * frame.scale)),
85+
_ => None,
86+
};
87+
max.map_or((None, None), |(index, power)| {
88+
(Some(index as f32 * frame.bin_spacing), Some(power))
89+
})
90+
}
91+
92+
fn mean_cfr_amplitude(frame: &RadarFrame) -> Option<f32> {
93+
let (sum, count) = match &frame.payload {
94+
RadarPayload::ComplexI16(values) => (
95+
values
96+
.iter()
97+
.map(|[i, q]| ((*i as f32).hypot(*q as f32)) * frame.scale)
98+
.sum::<f32>(),
99+
values.len(),
100+
),
101+
RadarPayload::ComplexF32(values) => (
102+
values
103+
.iter()
104+
.map(|[i, q]| i.hypot(*q) * frame.scale)
105+
.sum::<f32>(),
106+
values.len(),
107+
),
108+
_ => return None,
109+
};
110+
(count != 0).then_some(sum / count as f32)
111+
}
112+
113+
#[cfg(test)]
114+
mod tests {
115+
use super::*;
116+
use wifi_densepose_hardware::rtl8720f::simulator::{Rtl8720fSimulator, SimulatorConfig};
117+
118+
#[test]
119+
fn synthetic_range_summary_has_peak_and_provenance() {
120+
let mut simulator = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap();
121+
let snapshot =
122+
RealtekRadarSnapshot::from_frame(&simulator.next_frame(ReportType::RangeNear));
123+
assert_eq!(snapshot.source, "realtek:simulated");
124+
assert!(snapshot.synthetic);
125+
assert!(snapshot.peak_range_m.is_some());
126+
assert!(snapshot.peak_power.unwrap() > 0.0);
127+
assert_eq!(snapshot.mean_cfr_amplitude, None);
128+
}
129+
130+
#[test]
131+
fn synthetic_cfr_summary_exposes_only_aggregate_amplitude() {
132+
let mut simulator = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap();
133+
let snapshot = RealtekRadarSnapshot::from_frame(&simulator.next_frame(ReportType::Cfr));
134+
assert!(snapshot.mean_cfr_amplitude.unwrap() > 0.0);
135+
assert_eq!(snapshot.peak_power, None);
136+
}
137+
}

0 commit comments

Comments
 (0)