diff --git a/v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs b/v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs index 8d176985..1558214b 100644 --- a/v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs +++ b/v2/crates/wifi-densepose-hardware/src/bin/aggregator.rs @@ -10,7 +10,7 @@ use std::net::UdpSocket; use std::process; use clap::Parser; -use wifi_densepose_hardware::Esp32CsiParser; +use wifi_densepose_hardware::{Esp32CsiParser, ParseError}; /// UDP aggregator for ESP32 CSI nodes (ADR-018). #[derive(Parser)] @@ -65,6 +65,15 @@ fn main() { mean_amp, ); } + // The firmware sends several packet types on this UDP port + // (ADR-039 vitals, ADR-081 feature state, ADR-095 temporal, …) + // alongside ADR-018 CSI frames. Those are expected, not errors — + // this CSI-only aggregator just skips them. (RuView#517) + Err(ParseError::NonCsiPacket { kind, .. }) => { + if cli.verbose { + eprintln!(" [skipped {} packet — not a CSI frame]", kind); + } + } Err(e) => { if cli.verbose { eprintln!(" parse error: {}", e); diff --git a/v2/crates/wifi-densepose-hardware/src/error.rs b/v2/crates/wifi-densepose-hardware/src/error.rs index 7ccc07e7..17f5146c 100644 --- a/v2/crates/wifi-densepose-hardware/src/error.rs +++ b/v2/crates/wifi-densepose-hardware/src/error.rs @@ -19,6 +19,18 @@ pub enum ParseError { got: u32, }, + /// A recognized RuView wire packet was received that is *not* an + /// ADR-018 raw CSI frame (e.g. ADR-039 vitals, ADR-081 feature state, + /// ADR-095 temporal classification). The firmware multiplexes several + /// packet types onto the same UDP port, so a CSI parser will see these + /// interleaved with CSI frames — that is expected, not a corruption. + /// Consumers should route the packet to the matching decoder or skip it. + #[error("Non-CSI RuView packet on CSI socket: {kind} (magic {magic:#010x})")] + NonCsiPacket { + magic: u32, + kind: &'static str, + }, + /// The frame indicates more subcarriers than physically possible. #[error("Invalid subcarrier count: {count} (max {max})")] InvalidSubcarrierCount { diff --git a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs index 22481215..f7ffedf7 100644 --- a/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs +++ b/v2/crates/wifi-densepose-hardware/src/esp32_parser.rs @@ -35,7 +35,43 @@ use crate::csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, Subcarri use crate::error::ParseError; /// ESP32 CSI binary frame magic number (ADR-018). -const ESP32_CSI_MAGIC: u32 = 0xC5110001; +pub const ESP32_CSI_MAGIC: u32 = 0xC5110001; + +// ── Sibling RuView wire packets ────────────────────────────────────────────── +// The ESP32 firmware multiplexes several packet types onto the same UDP port +// as ADR-018 raw CSI frames. A CSI-only consumer will therefore see these +// interleaved with CSI frames. They are *not* corruption — they just need a +// different decoder (or can be skipped). See firmware `rv_feature_state.h`. + +/// ADR-039 edge vitals packet (32 bytes: HR/BR/presence). +pub const RUVIEW_VITALS_MAGIC: u32 = 0xC5110002; +/// ADR-069 feature-vector packet. +pub const RUVIEW_FEATURE_MAGIC: u32 = 0xC5110003; +/// ADR-063 fused-vitals packet (multi-sensor fusion). +pub const RUVIEW_FUSED_VITALS_MAGIC: u32 = 0xC5110004; +/// ADR-039 compressed-CSI packet. +pub const RUVIEW_COMPRESSED_CSI_MAGIC: u32 = 0xC5110005; +/// ADR-081 compact feature-state packet (the default upstream payload). +pub const RUVIEW_FEATURE_STATE_MAGIC: u32 = 0xC5110006; +/// ADR-095 / #513 on-device temporal-classification packet. +pub const RUVIEW_TEMPORAL_MAGIC: u32 = 0xC5110007; + +/// If `magic` is a recognized RuView wire packet other than the ADR-018 raw +/// CSI frame, return a human-readable name for it; otherwise `None`. +/// +/// Used by CSI consumers to distinguish "a sibling packet I should route or +/// skip" from "genuine garbage on the wire". +pub fn ruview_sibling_packet_name(magic: u32) -> Option<&'static str> { + match magic { + RUVIEW_VITALS_MAGIC => Some("ADR-039 edge vitals"), + RUVIEW_FEATURE_MAGIC => Some("ADR-069 feature vector"), + RUVIEW_FUSED_VITALS_MAGIC => Some("ADR-063 fused vitals"), + RUVIEW_COMPRESSED_CSI_MAGIC => Some("ADR-039 compressed CSI"), + RUVIEW_FEATURE_STATE_MAGIC => Some("ADR-081 feature state"), + RUVIEW_TEMPORAL_MAGIC => Some("ADR-095 temporal classification"), + _ => None, + } +} /// ADR-018 header size in bytes (before I/Q data). const HEADER_SIZE: usize = 20; @@ -55,6 +91,18 @@ impl Esp32CsiParser { /// The buffer must contain at least the header (20 bytes) plus the I/Q data. /// Returns the parsed frame and the number of bytes consumed. pub fn parse_frame(data: &[u8]) -> Result<(CsiFrame, usize), ParseError> { + // A recognized sibling packet (ADR-039 vitals, ADR-081 feature state, …) + // multiplexed onto the CSI UDP port should be reported as such — not as + // "insufficient data" or "invalid magic" — so callers can route or skip + // it. These packets are all >= 4 bytes; classify before the CSI-frame + // length gate. (RuView#517) + if data.len() >= 4 { + let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + if let Some(kind) = ruview_sibling_packet_name(magic) { + return Err(ParseError::NonCsiPacket { magic, kind }); + } + } + if data.len() < HEADER_SIZE { return Err(ParseError::InsufficientData { needed: HEADER_SIZE, @@ -310,12 +358,50 @@ mod tests { #[test] fn test_parse_invalid_magic() { let mut data = build_test_frame(1, 1, &[(10, 20)]); - // Corrupt magic - data[0] = 0xFF; + // Corrupt magic to a value that isn't any known RuView packet. + data[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); let result = Esp32CsiParser::parse_frame(&data); assert!(matches!(result, Err(ParseError::InvalidMagic { .. }))); } + #[test] + fn test_sibling_vitals_packet_is_not_invalid_magic() { + // RuView#517: a 32-byte ADR-039 vitals packet (magic 0xC5110002) + // arrives on the same UDP port as CSI frames. It must be reported as + // a recognized sibling packet, not a corrupt CSI frame. + let mut data = vec![0u8; 32]; + data[0..4].copy_from_slice(&RUVIEW_VITALS_MAGIC.to_le_bytes()); + match Esp32CsiParser::parse_frame(&data) { + Err(ParseError::NonCsiPacket { magic, kind }) => { + assert_eq!(magic, RUVIEW_VITALS_MAGIC); + assert_eq!(kind, "ADR-039 edge vitals"); + } + other => panic!("expected NonCsiPacket, got {other:?}"), + } + } + + #[test] + fn test_all_sibling_magics_classified() { + for m in [ + RUVIEW_VITALS_MAGIC, + RUVIEW_FEATURE_MAGIC, + RUVIEW_FUSED_VITALS_MAGIC, + RUVIEW_COMPRESSED_CSI_MAGIC, + RUVIEW_FEATURE_STATE_MAGIC, + RUVIEW_TEMPORAL_MAGIC, + ] { + assert!(ruview_sibling_packet_name(m).is_some(), "{m:#010x} unclassified"); + let mut data = vec![0u8; 24]; + data[0..4].copy_from_slice(&m.to_le_bytes()); + assert!( + matches!(Esp32CsiParser::parse_frame(&data), Err(ParseError::NonCsiPacket { .. })), + "{m:#010x} should parse as NonCsiPacket" + ); + } + // The CSI magic itself is not a "sibling". + assert!(ruview_sibling_packet_name(ESP32_CSI_MAGIC).is_none()); + } + #[test] fn test_amplitude_phase_from_known_iq() { let pairs = vec![(100i8, 0i8), (0, 50), (30, 40)]; diff --git a/v2/crates/wifi-densepose-hardware/src/lib.rs b/v2/crates/wifi-densepose-hardware/src/lib.rs index a54b8157..23838ad9 100644 --- a/v2/crates/wifi-densepose-hardware/src/lib.rs +++ b/v2/crates/wifi-densepose-hardware/src/lib.rs @@ -49,7 +49,11 @@ pub mod radio_ops; pub use csi_frame::{CsiFrame, CsiMetadata, SubcarrierData, Bandwidth, AntennaConfig}; pub use error::ParseError; -pub use esp32_parser::Esp32CsiParser; +pub use esp32_parser::{ + Esp32CsiParser, ruview_sibling_packet_name, ESP32_CSI_MAGIC, RUVIEW_VITALS_MAGIC, + RUVIEW_FEATURE_MAGIC, RUVIEW_FUSED_VITALS_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC, + RUVIEW_FEATURE_STATE_MAGIC, RUVIEW_TEMPORAL_MAGIC, +}; pub use bridge::CsiData; pub use radio_ops::{ RadioOps, RadioMode, CaptureProfile, RadioHealth, RadioError, MockRadio,