mirror of
https://github.com/ruvnet/RuView
synced 2026-07-20 17:03:24 +00:00
feat(ruview): ingest RTL8720F radar frames
This commit is contained in:
@@ -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.
|
||||
@@ -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)]
|
||||
|
||||
@@ -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