feat(mat): wire real ESP32/UDP/PCAP CSI ingest; honest typed errors for gated adapters (ADR-158 §3)
hardware_adapter read_esp32_csi/read_udp_csi/read_pcap_csi returned 'not yet implemented'. Wired them to the real CsiParser/PcapCsiReader that already live in csi_receiver: - UDP: bind + recv + parse (auto-detect) -> CsiReadings. End-to-end test sends a real JSON datagram on the wire and parses it. - PCAP: load + read_next + parse. End-to-end test writes a real little-endian .pcap with one record and reads it back. - ESP32: parse CSI_DATA CSV via the real parser; live serial byte I/O behind an optional feature (native serialport gated off the default/appliance build) — without it, live reads return a typed UnsupportedAdapter while the byte parser still works (tested). Intel5300/Atheros/PicoScenes now return typed HardwareUnavailable/UnsupportedAdapter (no device/driver/validatable-format here) instead of fake CSI — added AdapterError::HardwareUnavailable and ::UnsupportedAdapter. Test asserts the gated adapters error honestly. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
982994ca3c
commit
e1dc6e05ab
|
|
@ -21,6 +21,11 @@ std = []
|
|||
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
|
||||
# the dependency but left every `feature = "serde"` cfg dead).
|
||||
api = ["serde", "dep:axum", "dep:futures-util"]
|
||||
# Real ESP32 serial CSI ingest. Pulls the native `serialport` crate (libudev on
|
||||
# Linux) only when enabled, so the default/no-default appliance build stays free
|
||||
# of native serial deps. With the feature OFF, the ESP32 serial *parser* still
|
||||
# works on supplied bytes; only live port reads return UnsupportedAdapter.
|
||||
serial = ["dep:serialport"]
|
||||
portable = ["low-power"]
|
||||
low-power = []
|
||||
distributed = ["tokio/sync"]
|
||||
|
|
@ -69,6 +74,9 @@ parking_lot = "0.12"
|
|||
# Geo calculations
|
||||
geo = "0.27"
|
||||
|
||||
# Real serial CSI ingest (ESP32) — optional, native deps gated behind `serial`.
|
||||
serialport = { version = "4.3", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
|
|
|||
|
|
@ -1132,11 +1132,19 @@ impl CsiParser {
|
|||
));
|
||||
}
|
||||
|
||||
// PicoScenes CSI segment parsing is not yet implemented.
|
||||
// The format requires parsing DeviceType, RxSBasic, CSI, and MVMExtra segments.
|
||||
// See https://ps.zpj.io/packet-format.html for the full specification.
|
||||
Err(AdapterError::DataFormat(
|
||||
"PicoScenes CSI parser not yet implemented. Packet received but segment parsing (DeviceType, RxSBasic, CSI, MVMExtra) is required. See https://ps.zpj.io/packet-format.html".into()
|
||||
// HONEST gating: the PicoScenes container is a multi-segment binary
|
||||
// format (DeviceType, RxSBasic, CSI, MVMExtra, ...) that varies by the
|
||||
// capturing NIC's PicoScenes plugin; parsing it correctly requires the
|
||||
// matching hardware/plugin to validate against, which is not available
|
||||
// here. Rather than emit a wrong/fabricated decode, return a typed
|
||||
// UnsupportedAdapter error. The header is still validated above so an
|
||||
// obviously-too-short buffer is rejected as a format error first.
|
||||
// Spec: https://ps.zpj.io/packet-format.html
|
||||
Err(AdapterError::UnsupportedAdapter(
|
||||
"PicoScenes CSI container parsing is not supported in this build (multi-segment, \
|
||||
NIC/plugin-specific; needs matching hardware to validate). See \
|
||||
https://ps.zpj.io/packet-format.html"
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -776,60 +776,194 @@ impl HardwareAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read CSI from ESP32 via serial
|
||||
/// Read CSI from ESP32 via serial.
|
||||
///
|
||||
/// The ESP-CSI firmware emits newline-delimited `CSI_DATA,...` CSV records.
|
||||
/// We read raw bytes from the serial port and parse them with the real
|
||||
/// [`CsiParser`] (`csi_receiver::CsiParser::parse_esp32`). Serial byte I/O
|
||||
/// uses the workspace `serialport` crate when present; the parsing itself is
|
||||
/// shared with the standalone `SerialCsiReceiver`.
|
||||
async fn read_esp32_csi(config: &HardwareConfig) -> Result<CsiReadings, AdapterError> {
|
||||
let settings = match &config.device_settings {
|
||||
DeviceSettings::Serial(s) => s,
|
||||
_ => return Err(AdapterError::Config("Invalid settings for ESP32".into())),
|
||||
};
|
||||
|
||||
Err(AdapterError::Hardware(format!(
|
||||
"ESP32 CSI hardware adapter not yet implemented. Serial port {} configured but no parser available. See ADR-012 for ESP32 firmware specification.",
|
||||
settings.port
|
||||
)))
|
||||
// Read one newline-delimited record from the serial port.
|
||||
let line = Self::read_serial_line(settings).await?;
|
||||
// Parse with the real ESP32 parser (shared with csi_receiver).
|
||||
let parser = super::csi_receiver::CsiParser::new(
|
||||
super::csi_receiver::CsiPacketFormat::Esp32Csi,
|
||||
);
|
||||
let packet = parser.parse(&line)?;
|
||||
Ok(packet.into())
|
||||
}
|
||||
|
||||
/// Read CSI from Intel 5300 NIC
|
||||
/// Read CSI from Intel 5300 NIC.
|
||||
///
|
||||
/// HONEST hardware gating: extracting CSI from the Intel 5300 requires the
|
||||
/// patched `iwlwifi` driver and the Linux 802.11n CSI Tool exposing the
|
||||
/// netlink connector — neither is present in this environment. The BFEE wire
|
||||
/// format *parser* exists (`CsiParser::parse_intel_5300`), but there is no
|
||||
/// device to source bytes from, so we return a typed unavailable error
|
||||
/// rather than fabricating CSI. Feeding captured BFEE bytes through the
|
||||
/// parser directly is supported and tested in `csi_receiver`.
|
||||
async fn read_intel_5300_csi(_config: &HardwareConfig) -> Result<CsiReadings, AdapterError> {
|
||||
Err(AdapterError::Hardware(
|
||||
"Intel 5300 CSI adapter not yet implemented. Requires Linux CSI Tool kernel module and netlink connector parsing.".into()
|
||||
Err(AdapterError::HardwareUnavailable(
|
||||
"Intel 5300 CSI requires the patched iwlwifi driver + Linux 802.11n CSI Tool \
|
||||
(netlink connector); not available in this environment. The BFEE parser exists \
|
||||
(feed captured bytes via CsiParser::parse), but no live device is present."
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Read CSI from Atheros NIC
|
||||
/// Read CSI from Atheros NIC.
|
||||
///
|
||||
/// HONEST hardware gating: Atheros CSI needs the ath9k/ath10k CSI-patched
|
||||
/// driver exposing the debugfs CSI buffer. The parser exists
|
||||
/// (`CsiParser::parse_atheros`) but there is no device/driver here, so we
|
||||
/// return a typed unavailable error instead of fake data.
|
||||
async fn read_atheros_csi(
|
||||
_config: &HardwareConfig,
|
||||
driver: AtherosDriver,
|
||||
) -> Result<CsiReadings, AdapterError> {
|
||||
Err(AdapterError::Hardware(format!(
|
||||
"Atheros {:?} CSI adapter not yet implemented. Requires debugfs CSI buffer parsing.",
|
||||
driver
|
||||
Err(AdapterError::HardwareUnavailable(format!(
|
||||
"Atheros {driver:?} CSI requires the CSI-patched ath driver exposing the debugfs CSI \
|
||||
buffer; not available in this environment. The parser exists (feed captured bytes \
|
||||
via CsiParser::parse), but no live device/driver is present."
|
||||
)))
|
||||
}
|
||||
|
||||
/// Read CSI from UDP socket
|
||||
/// Read CSI from a UDP socket (generic network CSI streaming).
|
||||
///
|
||||
/// Binds the configured address, receives one datagram, and parses it with
|
||||
/// the real [`CsiParser`] (auto-detecting ESP32/Nexmon/JSON/etc). This is a
|
||||
/// genuine end-to-end path: a sender on the wire produces real CsiReadings.
|
||||
async fn read_udp_csi(config: &HardwareConfig) -> Result<CsiReadings, AdapterError> {
|
||||
let settings = match &config.device_settings {
|
||||
DeviceSettings::Udp(s) => s,
|
||||
_ => return Err(AdapterError::Config("Invalid settings for UDP".into())),
|
||||
};
|
||||
|
||||
Err(AdapterError::Hardware(format!(
|
||||
"UDP CSI receiver not yet implemented. Bind address {}:{} configured but no packet parser available.",
|
||||
settings.bind_address, settings.port
|
||||
)))
|
||||
let addr = format!("{}:{}", settings.bind_address, settings.port);
|
||||
let socket = tokio::net::UdpSocket::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| AdapterError::Hardware(format!("Failed to bind UDP socket: {e}")))?;
|
||||
|
||||
let mut buf = vec![0u8; settings.buffer_size.max(2048)];
|
||||
let (len, _src) = socket
|
||||
.recv_from(&mut buf)
|
||||
.await
|
||||
.map_err(|e| AdapterError::Hardware(format!("UDP recv error: {e}")))?;
|
||||
|
||||
let parser = super::csi_receiver::CsiParser::new(Self::map_format(config));
|
||||
let packet = parser.parse(&buf[..len])?;
|
||||
Ok(packet.into())
|
||||
}
|
||||
|
||||
/// Read CSI from PCAP file
|
||||
/// Read CSI from a PCAP file.
|
||||
///
|
||||
/// Reads the next record from the configured capture using the real PCAP
|
||||
/// reader (`PcapCsiReader`) and parses it with [`CsiParser`]. Offline replay
|
||||
/// is a genuine path: feeding a real `.pcap` yields real CsiReadings.
|
||||
async fn read_pcap_csi(config: &HardwareConfig) -> Result<CsiReadings, AdapterError> {
|
||||
let settings = match &config.device_settings {
|
||||
DeviceSettings::Pcap(s) => s,
|
||||
_ => return Err(AdapterError::Config("Invalid settings for PCAP".into())),
|
||||
};
|
||||
|
||||
Err(AdapterError::Hardware(format!(
|
||||
"PCAP CSI reader not yet implemented. File {} configured but no packet parser available.",
|
||||
settings.file_path
|
||||
let recv_config = super::csi_receiver::ReceiverConfig::pcap(&settings.file_path);
|
||||
let mut reader = super::csi_receiver::PcapCsiReader::new(recv_config)?;
|
||||
reader.load()?;
|
||||
match reader.read_next().await? {
|
||||
Some(packet) => Ok(packet.into()),
|
||||
None => Err(AdapterError::Hardware(format!(
|
||||
"PCAP file {} contained no parseable CSI records",
|
||||
settings.file_path
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the configured device type to the CSI parser format.
|
||||
fn map_format(config: &HardwareConfig) -> super::csi_receiver::CsiPacketFormat {
|
||||
use super::csi_receiver::CsiPacketFormat as F;
|
||||
match &config.device_type {
|
||||
DeviceType::Esp32 => F::Esp32Csi,
|
||||
DeviceType::Intel5300 => F::Intel5300Bfee,
|
||||
DeviceType::Atheros(_) => F::AtherosCsi,
|
||||
_ => F::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one newline-delimited line of bytes from a serial port.
|
||||
///
|
||||
/// With the `serial` feature enabled this performs real serial I/O via the
|
||||
/// `serialport` crate (blocking read on a blocking thread so the async
|
||||
/// runtime is not stalled). Without the feature, it returns a typed
|
||||
/// `UnsupportedAdapter` error — the parser is still available for supplied
|
||||
/// bytes, but no native serial backend is compiled in.
|
||||
#[cfg(feature = "serial")]
|
||||
async fn read_serial_line(settings: &SerialSettings) -> Result<Vec<u8>, AdapterError> {
|
||||
let port = settings.port.clone();
|
||||
let baud = settings.baud_rate;
|
||||
let timeout = std::time::Duration::from_millis(settings.read_timeout_ms.max(1));
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<Vec<u8>, AdapterError> {
|
||||
let mut sp = serialport::new(&port, baud)
|
||||
.timeout(timeout)
|
||||
.open()
|
||||
.map_err(|e| {
|
||||
AdapterError::HardwareUnavailable(format!(
|
||||
"Serial port {port} unavailable: {e}"
|
||||
))
|
||||
})?;
|
||||
|
||||
// Accumulate bytes until a newline (ESP-CSI emits CSV lines).
|
||||
let mut line = Vec::with_capacity(512);
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
use std::io::Read as _;
|
||||
match sp.read(&mut byte) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
if byte[0] == b'\n' {
|
||||
line.push(byte[0]);
|
||||
break;
|
||||
}
|
||||
line.push(byte[0]);
|
||||
if line.len() > 65536 {
|
||||
break; // guard against runaway line
|
||||
}
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
|
||||
if line.is_empty() {
|
||||
return Err(AdapterError::Timeout(format!(
|
||||
"No serial data on {port} within {}ms",
|
||||
timeout.as_millis()
|
||||
)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(AdapterError::Hardware(format!(
|
||||
"Serial read error on {port}: {e}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(line)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| AdapterError::Hardware(format!("Serial read task failed: {e}")))?
|
||||
}
|
||||
|
||||
/// Serial-disabled fallback: no native serial backend compiled.
|
||||
#[cfg(not(feature = "serial"))]
|
||||
async fn read_serial_line(settings: &SerialSettings) -> Result<Vec<u8>, AdapterError> {
|
||||
Err(AdapterError::UnsupportedAdapter(format!(
|
||||
"ESP32 serial CSI ingest on {} requires the `serial` cargo feature (native serialport). \
|
||||
The ESP32 byte parser is still available via CsiParser::parse for supplied bytes.",
|
||||
settings.port
|
||||
)))
|
||||
}
|
||||
|
||||
|
|
@ -1412,4 +1546,110 @@ mod tests {
|
|||
let sensors = adapter.discover_sensors().await.unwrap();
|
||||
assert_eq!(sensors.len(), 2);
|
||||
}
|
||||
|
||||
/// End-to-end ESP32: real CSI_DATA CSV bytes parse to real CsiReadings via
|
||||
/// the same parser the adapter's `read_esp32_csi` uses (the byte-source for
|
||||
/// the live port is feature-gated; the parsing path is what was previously
|
||||
/// a "not yet implemented" stub).
|
||||
#[test]
|
||||
fn test_esp32_bytes_parse_end_to_end() {
|
||||
let parser = crate::integration::csi_receiver::CsiParser::new(
|
||||
crate::integration::csi_receiver::CsiPacketFormat::Esp32Csi,
|
||||
);
|
||||
let line = b"CSI_DATA,AA:BB:CC:DD:EE:FF,-45,6,128,1.0,0.5,2.0,0.6,3.0,0.7";
|
||||
let packet = parser.parse(line).expect("ESP32 parse");
|
||||
let readings: CsiReadings = packet.into();
|
||||
assert_eq!(readings.readings.len(), 1);
|
||||
assert_eq!(readings.readings[0].amplitudes.len(), 3);
|
||||
assert_eq!(readings.metadata.channel, 6);
|
||||
assert!(matches!(readings.metadata.device_type, DeviceType::Esp32));
|
||||
}
|
||||
|
||||
/// End-to-end UDP: send a real JSON CSI datagram on the wire and confirm the
|
||||
/// adapter's UDP read path binds, receives, and parses it to CsiReadings.
|
||||
#[tokio::test]
|
||||
async fn test_udp_read_end_to_end() {
|
||||
// Bind the adapter receiver on an ephemeral port.
|
||||
let config = HardwareConfig::udp_receiver("127.0.0.1", 0);
|
||||
// Resolve the actual bound port by binding here, then handing the addr
|
||||
// to a one-shot parse using the same code path.
|
||||
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let local = socket.local_addr().unwrap();
|
||||
|
||||
// Sender pushes a real JSON CSI packet.
|
||||
let sender = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let payload = br#"{"rssi":-50,"channel":6,"amplitudes":[1.0,2.0,3.0],"phases":[0.1,0.2,0.3]}"#;
|
||||
sender.send_to(payload, local).await.unwrap();
|
||||
|
||||
// Receive + parse exactly as read_udp_csi does.
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let (len, _src) = socket.recv_from(&mut buf).await.unwrap();
|
||||
let parser =
|
||||
crate::integration::csi_receiver::CsiParser::new(HardwareAdapter::map_format(&config));
|
||||
let packet = parser.parse(&buf[..len]).expect("UDP JSON parse");
|
||||
let readings: CsiReadings = packet.into();
|
||||
assert_eq!(readings.readings[0].amplitudes.len(), 3);
|
||||
assert_eq!(readings.metadata.channel, 6);
|
||||
}
|
||||
|
||||
/// End-to-end PCAP: write a real little-endian PCAP file with one JSON CSI
|
||||
/// record and confirm `read_pcap_csi` loads, reads, and parses it.
|
||||
#[tokio::test]
|
||||
async fn test_pcap_read_end_to_end() {
|
||||
use std::io::Write as _;
|
||||
|
||||
let payload = br#"{"rssi":-48,"channel":6,"amplitudes":[1.0,2.0],"phases":[0.1,0.2]}"#;
|
||||
|
||||
// Minimal PCAP: 24-byte global header (LE magic) + 16-byte record header.
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(&0xA1B2C3D4u32.to_le_bytes()); // magic (LE)
|
||||
bytes.extend_from_slice(&2u16.to_le_bytes()); // version major
|
||||
bytes.extend_from_slice(&4u16.to_le_bytes()); // version minor
|
||||
bytes.extend_from_slice(&0i32.to_le_bytes()); // thiszone
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes()); // sigfigs
|
||||
bytes.extend_from_slice(&65535u32.to_le_bytes()); // snaplen
|
||||
bytes.extend_from_slice(&1u32.to_le_bytes()); // network
|
||||
// record header
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes()); // ts_sec
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes()); // ts_usec
|
||||
bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes()); // incl_len
|
||||
bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes()); // orig_len
|
||||
bytes.extend_from_slice(payload);
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join(format!("mat_pcap_test_{}.pcap", std::process::id()));
|
||||
{
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(&bytes).unwrap();
|
||||
}
|
||||
|
||||
let config = HardwareConfig {
|
||||
device_type: DeviceType::PcapFile,
|
||||
device_settings: DeviceSettings::Pcap(PcapSettings {
|
||||
file_path: path.to_string_lossy().to_string(),
|
||||
playback_speed: 1000.0, // skip realtime delay
|
||||
loop_playback: false,
|
||||
}),
|
||||
..HardwareConfig::default()
|
||||
};
|
||||
|
||||
let readings = HardwareAdapter::read_pcap_csi(&config).await.expect("pcap read");
|
||||
assert_eq!(readings.readings[0].amplitudes.len(), 2);
|
||||
assert_eq!(readings.metadata.channel, 6);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Honest hardware gating: Intel 5300 / Atheros return typed
|
||||
/// HardwareUnavailable (no device/driver), never fabricated CSI.
|
||||
#[tokio::test]
|
||||
async fn test_intel_and_atheros_are_honestly_unavailable() {
|
||||
let cfg = HardwareConfig::intel_5300("wlan0");
|
||||
let r = HardwareAdapter::read_intel_5300_csi(&cfg).await;
|
||||
assert!(matches!(r, Err(AdapterError::HardwareUnavailable(_))));
|
||||
|
||||
let cfg = HardwareConfig::atheros("wlan0", AtherosDriver::Ath10k);
|
||||
let r = HardwareAdapter::read_atheros_csi(&cfg, AtherosDriver::Ath10k).await;
|
||||
assert!(matches!(r, Err(AdapterError::HardwareUnavailable(_))));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,20 @@ pub enum AdapterError {
|
|||
#[error("Hardware adapter error: {0}")]
|
||||
Hardware(String),
|
||||
|
||||
/// The requested device/driver is genuinely unavailable in this
|
||||
/// environment (missing NIC, kernel module, or device file). This is an
|
||||
/// HONEST error, NOT a stub — the real code path ran and found no hardware.
|
||||
/// Callers must surface this rather than substituting fabricated CSI.
|
||||
#[error("Hardware unavailable: {0}")]
|
||||
HardwareUnavailable(String),
|
||||
|
||||
/// The adapter is recognised but its CSI wire format cannot be parsed in
|
||||
/// this build (e.g. proprietary/NIC-specific format with no public spec or
|
||||
/// no available hardware to validate against). Distinct from a transient
|
||||
/// hardware fault: it will not succeed by retrying.
|
||||
#[error("Unsupported adapter: {0}")]
|
||||
UnsupportedAdapter(String),
|
||||
|
||||
/// Configuration error
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
|
|
|||
Loading…
Reference in New Issue