mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +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_VERSION: u8 = 1;
|
||||||
pub const RTL8720F_RADAR_HEADER_LEN: usize = 56;
|
pub const RTL8720F_RADAR_HEADER_LEN: usize = 56;
|
||||||
pub const RTL8720F_RADAR_CRC_LEN: usize = 4;
|
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;
|
pub const RTL8720F_RADAR_MAX_ELEMENTS: usize = 16_384;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ mod field_bridge;
|
|||||||
mod field_localize;
|
mod field_localize;
|
||||||
mod model_format;
|
mod model_format;
|
||||||
mod multistatic_bridge;
|
mod multistatic_bridge;
|
||||||
|
mod realtek_radar;
|
||||||
pub mod pose;
|
pub mod pose;
|
||||||
mod rvf_container;
|
mod rvf_container;
|
||||||
mod rvf_pipeline;
|
mod rvf_pipeline;
|
||||||
@@ -1028,6 +1029,10 @@ struct AppStateInner {
|
|||||||
source: String,
|
source: String,
|
||||||
/// Instant of the last ESP32 UDP frame received (for offline detection).
|
/// Instant of the last ESP32 UDP frame received (for offline detection).
|
||||||
last_esp32_frame: Option<std::time::Instant>,
|
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>,
|
tx: broadcast::Sender<String>,
|
||||||
// ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state +
|
// ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state +
|
||||||
// a parallel broadcast topic (`/ws/introspection`) running alongside
|
// 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()
|
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.
|
/// Generate WiFi-derived pose keypoints from sensing data.
|
||||||
///
|
///
|
||||||
/// Keypoint positions are modulated by real signal features rather than a pure
|
/// 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 addr = format!("0.0.0.0:{udp_port}");
|
||||||
let socket = match UdpSocket::bind(&addr).await {
|
let socket = match UdpSocket::bind(&addr).await {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
info!("UDP listening on {addr} for ESP32 CSI frames");
|
info!("UDP listening on {addr} for ESP32 CSI and RTL8720F radar frames");
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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 {
|
loop {
|
||||||
match socket.recv_from(&mut buf).await {
|
match socket.recv_from(&mut buf).await {
|
||||||
Ok((len, src)) => {
|
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).
|
// ADR-039: Try edge vitals packet first (magic 0xC511_0002).
|
||||||
if let Some(vitals) = parse_esp32_vitals(&buf[..len]) {
|
if let Some(vitals) = parse_esp32_vitals(&buf[..len]) {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -7552,6 +7594,8 @@ async fn main() {
|
|||||||
tick: 0,
|
tick: 0,
|
||||||
source: source.into(),
|
source: source.into(),
|
||||||
last_esp32_frame: None,
|
last_esp32_frame: None,
|
||||||
|
latest_realtek_radar: None,
|
||||||
|
last_realtek_frame: None,
|
||||||
tx,
|
tx,
|
||||||
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
|
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
|
||||||
intro_tx,
|
intro_tx,
|
||||||
@@ -7768,6 +7812,7 @@ async fn main() {
|
|||||||
.route("/api/v1/metrics", get(health_metrics))
|
.route("/api/v1/metrics", get(health_metrics))
|
||||||
// Sensing endpoints
|
// Sensing endpoints
|
||||||
.route("/api/v1/sensing/latest", get(latest))
|
.route("/api/v1/sensing/latest", get(latest))
|
||||||
|
.route("/api/v1/radar/latest", get(latest_realtek_radar))
|
||||||
// Per-node health endpoint
|
// Per-node health endpoint
|
||||||
.route("/api/v1/nodes", get(nodes_endpoint))
|
.route("/api/v1/nodes", get(nodes_endpoint))
|
||||||
// ADR-110 iter 29 — per-node mesh sync state for HTTP clients.
|
// 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