diff --git a/docs/releases/v0.9.0-realtek-beta.1.md b/docs/releases/v0.9.0-realtek-beta.1.md new file mode 100644 index 00000000..9040a077 --- /dev/null +++ b/docs/releases/v0.9.0-realtek-beta.1.md @@ -0,0 +1,45 @@ +# RuView v0.9.0-realtek-beta.1 + +This prerelease introduces the Rust-first RTL8720F 2.4 GHz radar transport and +RuView ingestion path. It is intentionally simulator-validated until Realtek +hardware and the vendor SDK callback ABI arrive. + +## Included + +- ADR-263 records the upstream Ameba integration and licensing boundary. +- ADR-264 defines a versioned, bounded, CRC-protected radar envelope. +- `rtl8720f-sim` emits deterministic CFR, near-range, far-range, interference, + and capability reports to UDP or replay files. +- The sensing server validates RTL8720F datagrams, publishes bounded summaries + over `/ws/sensing`, and exposes the latest report at + `/api/v1/radar/latest`. +- Synthetic provenance is retained end to end as `realtek:simulated`; simulator + data is never presented as hardware data. + +## Compatibility + +The adapter tracks the radar control surface proposed by Ameba RTOS pull +request #1336 (`wifi_radar_config`, `AT+RAD`, and `AT+RADDBG`). The stable Ameba +RTOS v1.2.1 release does not yet expose the complete radar receive callback ABI, +so no vendor-private headers or binary libraries are copied into this release. + +## Validation status + +- Rust codec round trips, corruption rejection, size bounds, and deterministic + simulator tests pass. +- RuView server ingestion, REST reporting, and source provenance were exercised + end to end over loopback UDP. +- Windows release binaries are built from this branch and accompanied by + SHA-256 checksums. + +## Known limitations + +- No physical RTL8720F board has been flashed or measured. +- The vendor report callback and exact report layouts remain an SDK/hardware + validation gate; the adapter boundary may change when those arrive. +- This beta exposes transport and aggregate radar observability. Radar-to-pose, + vital-sign inference, RF calibration, and accuracy claims are not enabled. +- 2.4 GHz radar reports are not mislabeled as mmWave or Wi-Fi CSI events. + +Do not deploy this prerelease for safety-critical, medical, or occupancy billing +uses. It is an integration beta for SDK and hardware bring-up. diff --git a/v2/crates/wifi-densepose-hardware/src/rtl8720f.rs b/v2/crates/wifi-densepose-hardware/src/rtl8720f.rs index f49abaa5..a1ba7051 100644 --- a/v2/crates/wifi-densepose-hardware/src/rtl8720f.rs +++ b/v2/crates/wifi-densepose-hardware/src/rtl8720f.rs @@ -15,7 +15,8 @@ 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; +/// 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)] diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 419c5b10..19d9ce41 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -17,6 +17,7 @@ mod field_bridge; mod field_localize; mod model_format; mod multistatic_bridge; +mod realtek_radar; pub mod pose; mod rvf_container; mod rvf_pipeline; @@ -1028,6 +1029,10 @@ struct AppStateInner { source: String, /// Instant of the last ESP32 UDP frame received (for offline detection). last_esp32_frame: Option, + /// Latest validated RTL8720F summary; raw radar samples are not retained here. + latest_realtek_radar: Option, + /// Instant of the last validated RTL8720F UDP frame. + last_realtek_frame: Option, tx: broadcast::Sender, // ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state + // a parallel broadcast topic (`/ws/introspection`) running alongside @@ -1199,6 +1204,13 @@ impl AppStateInner { } } } + if self.source.starts_with("realtek") { + if let Some(last) = self.last_realtek_frame { + if last.elapsed() > ESP32_OFFLINE_TIMEOUT { + return format!("{}:offline", self.source); + } + } + } self.source.clone() } } @@ -3351,6 +3363,14 @@ async fn latest(State(state): State) -> Json { } } +async fn latest_realtek_radar(State(state): State) -> Json { + let s = state.read().await; + match &s.latest_realtek_radar { + Some(snapshot) => Json(serde_json::to_value(snapshot).unwrap_or_default()), + None => Json(serde_json::json!({"status": "no Realtek radar data yet"})), + } +} + /// Generate WiFi-derived pose keypoints from sensing data. /// /// Keypoint positions are modulated by real signal features rather than a pure @@ -5445,7 +5465,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { let addr = format!("0.0.0.0:{udp_port}"); let socket = match UdpSocket::bind(&addr).await { Ok(s) => { - info!("UDP listening on {addr} for ESP32 CSI frames"); + info!("UDP listening on {addr} for ESP32 CSI and RTL8720F radar frames"); s } Err(e) => { @@ -5454,10 +5474,32 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { } }; - let mut buf = [0u8; 2048]; + let mut buf = vec![0u8; wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAX_FRAME_LEN]; loop { match socket.recv_from(&mut buf).await { Ok((len, src)) => { + if len >= 4 + && u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice")) + == wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAGIC + { + match wifi_densepose_hardware::rtl8720f::RadarFrame::from_bytes(&buf[..len]) { + Ok((frame, consumed)) if consumed == len => { + let snapshot = realtek_radar::RealtekRadarSnapshot::from_frame(&frame); + debug!("RTL8720F radar from {src}: type={} seq={} elements={}", snapshot.report_type, snapshot.sequence, snapshot.element_count); + let json = serde_json::to_string(&snapshot).ok(); + let mut s = state.write().await; + s.source = snapshot.source.to_string(); + s.last_realtek_frame = Some(std::time::Instant::now()); + s.latest_realtek_radar = Some(snapshot); + if let Some(json) = json { + let _ = s.tx.send(json); + } + } + Ok((_, consumed)) => warn!("RTL8720F radar datagram from {src} has trailing bytes: consumed={consumed} received={len}"), + Err(error) => warn!("Rejected RTL8720F radar datagram from {src}: {error}"), + } + continue; + } // ADR-039: Try edge vitals packet first (magic 0xC511_0002). if let Some(vitals) = parse_esp32_vitals(&buf[..len]) { debug!( @@ -7552,6 +7594,8 @@ async fn main() { tick: 0, source: source.into(), last_esp32_frame: None, + latest_realtek_radar: None, + last_realtek_frame: None, tx, intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(), intro_tx, @@ -7768,6 +7812,7 @@ async fn main() { .route("/api/v1/metrics", get(health_metrics)) // Sensing endpoints .route("/api/v1/sensing/latest", get(latest)) + .route("/api/v1/radar/latest", get(latest_realtek_radar)) // Per-node health endpoint .route("/api/v1/nodes", get(nodes_endpoint)) // ADR-110 iter 29 — per-node mesh sync state for HTTP clients. diff --git a/v2/crates/wifi-densepose-sensing-server/src/realtek_radar.rs b/v2/crates/wifi-densepose-sensing-server/src/realtek_radar.rs new file mode 100644 index 00000000..3e0825c2 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/realtek_radar.rs @@ -0,0 +1,137 @@ +//! Bounded, privacy-conscious summaries of RTL8720F radar transport frames. + +use serde::Serialize; +use wifi_densepose_hardware::rtl8720f::{RadarFlags, RadarFrame, RadarPayload, ReportType}; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct RealtekRadarSnapshot { + pub event_type: &'static str, + pub source: &'static str, + pub report_type: &'static str, + pub sequence: u32, + pub timestamp_us: u64, + pub device_id: String, + pub center_freq_khz: u32, + pub bandwidth_mhz: u16, + pub antenna_count: u8, + pub element_count: usize, + pub calibrated: bool, + pub synthetic: bool, + pub interference_detected: bool, + pub saturated: bool, + pub time_synchronized: bool, + pub calibration_id: u32, + pub bin_spacing: f32, + pub peak_range_m: Option, + pub peak_power: Option, + pub mean_cfr_amplitude: Option, +} + +impl RealtekRadarSnapshot { + pub(crate) fn from_frame(frame: &RadarFrame) -> Self { + let synthetic = frame.flags.contains(RadarFlags::SYNTHETIC); + let (peak_range_m, peak_power) = range_peak(frame); + Self { + event_type: "realtek_radar", + source: if synthetic { + "realtek:simulated" + } else { + "realtek" + }, + report_type: report_type_name(frame.report_type), + sequence: frame.sequence, + timestamp_us: frame.timestamp_us, + device_id: format!("{:016x}", frame.device_id), + center_freq_khz: frame.center_freq_khz, + bandwidth_mhz: frame.bandwidth_mhz, + antenna_count: frame.antenna_count, + element_count: frame.payload.len(), + calibrated: frame.flags.contains(RadarFlags::CALIBRATED), + synthetic, + interference_detected: frame.flags.contains(RadarFlags::INTERFERENCE_DETECTED), + saturated: frame.flags.contains(RadarFlags::SATURATED), + time_synchronized: frame.flags.contains(RadarFlags::TIME_SYNCHRONIZED), + calibration_id: frame.calibration_id, + bin_spacing: frame.bin_spacing, + peak_range_m, + peak_power, + mean_cfr_amplitude: mean_cfr_amplitude(frame), + } + } +} + +fn report_type_name(report_type: ReportType) -> &'static str { + match report_type { + ReportType::Cfr => "cfr", + ReportType::RangeNear => "range_near", + ReportType::RangeFar => "range_far", + ReportType::Interference => "interference", + ReportType::Capabilities => "capabilities", + } +} + +fn range_peak(frame: &RadarFrame) -> (Option, Option) { + let max = match &frame.payload { + RadarPayload::PowerU16(values) => values + .iter() + .enumerate() + .max_by_key(|(_, value)| *value) + .map(|(index, value)| (index, *value as f32 * frame.scale)), + RadarPayload::PowerF32(values) => values + .iter() + .enumerate() + .max_by(|(_, a), (_, b)| a.total_cmp(b)) + .map(|(index, value)| (index, *value * frame.scale)), + _ => None, + }; + max.map_or((None, None), |(index, power)| { + (Some(index as f32 * frame.bin_spacing), Some(power)) + }) +} + +fn mean_cfr_amplitude(frame: &RadarFrame) -> Option { + let (sum, count) = match &frame.payload { + RadarPayload::ComplexI16(values) => ( + values + .iter() + .map(|[i, q]| ((*i as f32).hypot(*q as f32)) * frame.scale) + .sum::(), + values.len(), + ), + RadarPayload::ComplexF32(values) => ( + values + .iter() + .map(|[i, q]| i.hypot(*q) * frame.scale) + .sum::(), + values.len(), + ), + _ => return None, + }; + (count != 0).then_some(sum / count as f32) +} + +#[cfg(test)] +mod tests { + use super::*; + use wifi_densepose_hardware::rtl8720f::simulator::{Rtl8720fSimulator, SimulatorConfig}; + + #[test] + fn synthetic_range_summary_has_peak_and_provenance() { + let mut simulator = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap(); + let snapshot = + RealtekRadarSnapshot::from_frame(&simulator.next_frame(ReportType::RangeNear)); + assert_eq!(snapshot.source, "realtek:simulated"); + assert!(snapshot.synthetic); + assert!(snapshot.peak_range_m.is_some()); + assert!(snapshot.peak_power.unwrap() > 0.0); + assert_eq!(snapshot.mean_cfr_amplitude, None); + } + + #[test] + fn synthetic_cfr_summary_exposes_only_aggregate_amplitude() { + let mut simulator = Rtl8720fSimulator::new(SimulatorConfig::default()).unwrap(); + let snapshot = RealtekRadarSnapshot::from_frame(&simulator.next_frame(ReportType::Cfr)); + assert!(snapshot.mean_cfr_amplitude.unwrap() > 0.0); + assert_eq!(snapshot.peak_power, None); + } +}