From 76c80c33d7f9c0c8e4e711a1ece223d5fdf60f99 Mon Sep 17 00:00:00 2001 From: rUv Date: Sat, 18 Jul 2026 23:03:45 -0400 Subject: [PATCH] feat(hardware): add Qualcomm CSI simulator and vendor roadmap (#1359) --- README.md | 1 + .../ADR-268-qualcomm-atheros-csi-platform.md | 41 + .../adr/ADR-269-qualcomm-csi-wire-protocol.md | 40 + ...0-vendor-rf-sensing-integration-program.md | 98 +++ docs/releases/v0.9.2-qualcomm-beta.1.md | 22 + .../src/bin/qualcomm-csi-sim.rs | 147 ++++ v2/crates/wifi-densepose-hardware/src/lib.rs | 9 + .../src/qualcomm_csi.rs | 700 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/main.rs | 45 +- .../src/qualcomm_csi.rs | 127 ++++ 10 files changed, 1229 insertions(+), 1 deletion(-) create mode 100644 docs/adr/ADR-268-qualcomm-atheros-csi-platform.md create mode 100644 docs/adr/ADR-269-qualcomm-csi-wire-protocol.md create mode 100644 docs/adr/ADR-270-vendor-rf-sensing-integration-program.md create mode 100644 docs/releases/v0.9.2-qualcomm-beta.1.md create mode 100644 v2/crates/wifi-densepose-hardware/src/bin/qualcomm-csi-sim.rs create mode 100644 v2/crates/wifi-densepose-hardware/src/qualcomm_csi.rs create mode 100644 v2/crates/wifi-densepose-sensing-server/src/qualcomm_csi.rs diff --git a/README.md b/README.md index 7b8c77e4..50341ca2 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ pip install "ruview[client]" # or: pip install "wifi-densepose[clie > | **ESP32 Mesh** | 3-6× ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features | > | **ESP32-C6 research node** ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), [witness](docs/WITNESS-LOG-110.md), [reviewer guide](docs/ADR-110-REVIEW-GUIDE.md), [firmware v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)) | ESP32-C6-DevKit ($6–10) | ~$10 | Yes (Wi-Fi 6 capable) | Same CSI pipeline as S3 with the dual-target firmware. **Firmware-side ADR-110 substrate now closed** (v0.7.0): ESP-NOW cross-board mesh quantified at **99.56 % match / 104 µs smoothed offset stdev / 3.95× EMA suppression** over a 5-min two-board soak (witness §A0.10), 32-byte UDP sync packet with operator-tunable cadence (§A0.12), ADR-018 byte 19 bit 4 wire-fix sourced from the working ESP-NOW path (§A0.13). Wire format ready for HE-LTF PPDU tagging in ADR-018 bytes 18-19 (firmware encoder + Rust + Python decoders verified end-to-end across 23 unit tests). LP-core motion-gate RISC-V program and Wi-Fi 6 soft-AP with TWT Responder both ship as opt-in code paths (default off). **Hardware-gated for measurement**: HE-LTF live subcarrier capture needs an 11ax AP (IDF v5.4 doesn't expose AP-side HE config — §A0.6); ~5 µA LP-core hibernation needs an INA meter to capture; 802.15.4 raw RX is broken in IDF v5.4 (workaround: ESP-NOW transport, shipped + measured). See witness log for the empirical / claimed split. | > | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO | +> | **Qualcomm CSI beta** ([ADR-268](docs/adr/ADR-268-qualcomm-atheros-csi-platform.md)) | QCA9300 now; QCN9074/QCN9274 experimental | ~$30-200 | Simulator now; hardware adapter gated | Rust `QCS1` codec, deterministic replay, UDP/API integration; modern ath11k/ath12k profiles do not claim public CSI export | > | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) | > > No hardware? Verify the signal processing pipeline with the deterministic reference signal: `python archive/v1/data/proof/verify.py` diff --git a/docs/adr/ADR-268-qualcomm-atheros-csi-platform.md b/docs/adr/ADR-268-qualcomm-atheros-csi-platform.md new file mode 100644 index 00000000..df515065 --- /dev/null +++ b/docs/adr/ADR-268-qualcomm-atheros-csi-platform.md @@ -0,0 +1,41 @@ +# ADR-268: Qualcomm Atheros CSI Platform Strategy + +- **Status**: accepted +- **Date**: 2026-07-18 +- **Tags**: qualcomm, atheros, csi, ath9k, ath11k, ath12k, simulator + +## Context + +RuView needs a Qualcomm path that is useful before vendor hardware access while +remaining honest about firmware boundaries. QCA9300 has demonstrated CSI tooling +through ath9k/PicoScenes-class systems. QCN9074 and QCN9274 have upstream Linux +connectivity drivers, but upstream ath11k/ath12k support does not by itself prove +that raw per-packet complex CSI is exported by public firmware. + +## Decision + +1. Use QCA9300 as the first physical baseline: 802.11n, up to 3x3 MIMO and + 20/40 MHz. Accept translated captures from established research tooling. +2. Model QCN9074 (Wi-Fi 6/6E, 4x4, up to 160 MHz) and QCN9274 (Wi-Fi 7, 4x4, + up to 160 MHz in protocol v1) as explicitly experimental simulator profiles. +3. Keep firmware/kernel formats behind a Rust adapter. RuView ingests only the + validated QCS1 application envelope defined by ADR-269. +4. Never label simulated frames as hardware. Physical support requires captured + fixtures, firmware provenance, antenna ordering, scaling and repeatability tests. +5. Prefer an upstream-reviewed Generic Netlink or relay-style export if modern + Qualcomm firmware exposes CFR/CSI; do not depend on undisclosed structs. + +## Consequences + +- Development, APIs and downstream sensing can be tested immediately. +- QCA9300 offers the shortest path to real Qualcomm data. +- Modern profiles may remain simulator-only until firmware cooperation exists. +- A translation copy is accepted in exchange for a stable, fuzzable boundary. + +## Links + +- [ADR-269: QCS1 wire protocol](ADR-269-qualcomm-csi-wire-protocol.md) +- [Linux ath11k supported devices](https://wireless.docs.kernel.org/en/latest/en/users/drivers/ath11k.html) +- [PicoScenes supported hardware](https://ps.zpj.io/manual/hardware.html) +- [ADR-270: vendor integration portfolio and acceptance gates](ADR-270-vendor-rf-sensing-integration-program.md) + diff --git a/docs/adr/ADR-269-qualcomm-csi-wire-protocol.md b/docs/adr/ADR-269-qualcomm-csi-wire-protocol.md new file mode 100644 index 00000000..a2ff83bd --- /dev/null +++ b/docs/adr/ADR-269-qualcomm-csi-wire-protocol.md @@ -0,0 +1,40 @@ +# ADR-269: Qualcomm CSI Wire Protocol + +- **Status**: accepted +- **Date**: 2026-07-18 +- **Tags**: qualcomm, csi, protocol, rust, udp, replay + +## Decision + +Define `QCS1` version 1 as a vendor-boundary envelope, not a Qualcomm firmware ABI. +It uses a 72-byte little-endian header plus payload and CRC-32/IEEE. The header +records report kind, total length, sequence, monotonic timestamp, device ID, +chipset profile, center frequency, bandwidth, flags, Tx/Rx counts, numeric format, +PPDU type, subcarrier count, noise floor, scale, subcarrier spacing, calibration +ID and payload length. + +CSI payloads contain one signed RSSI byte per receive chain followed by +`tx * rx * subcarriers` complex i16 or finite f32 values in Tx-major, Rx-major, +subcarrier-major order. Capability reports carry bounded opaque bytes. One QCS1 +frame maps to one UDP datagram; replay files prefix each frame with a little-endian +u32 length. + +Parsers fail closed on unknown enums, bad CRC, truncation, trailing datagram data, +non-finite values, inconsistent dimensions, chipset chain/bandwidth violations, +payload mismatches, arithmetic overflow and the IPv4 UDP payload ceiling. A +synthetic flag provides end-to-end simulator provenance. + +Version 1 profiles are QCA9300, QCN9074 and QCN9274. QCA9300 is capped at three +chains and 40 MHz; modern profiles are capped at four chains and 160 MHz. + +## Consequences + +- Simulator, replay and future hardware adapters share one validated Rust API. +- No private firmware layout is represented or redistributed. +- 320 MHz/EHT matrices require segmentation or a later protocol revision. + +## Links + +- [ADR-268: Qualcomm platform strategy](ADR-268-qualcomm-atheros-csi-platform.md) +- [ADR-267: MediaTek MTC1 protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md) + diff --git a/docs/adr/ADR-270-vendor-rf-sensing-integration-program.md b/docs/adr/ADR-270-vendor-rf-sensing-integration-program.md new file mode 100644 index 00000000..e68d3b16 --- /dev/null +++ b/docs/adr/ADR-270-vendor-rf-sensing-integration-program.md @@ -0,0 +1,98 @@ +# ADR-270: Vendor RF Sensing Integration Program + +- **Status**: accepted +- **Date**: 2026-07-18 +- **Deciders**: RuView maintainers +- **Tags**: vendors, csi, telemetry, simulator, rust, hardware-validation + +## Context + +RuView is evaluating Qualcomm, RF Solutions, Origin AI, Plume, Linksys, +Electric Imp, Mist/Juniper, Luma, Google Nest, NETGEAR and Wifigarden. These +names do not represent equivalent integration surfaces: some expose raw CSI, +some expose derived sensing events or network telemetry, and some expose no +supported developer interface. A repeated implementation process must not turn +brand compatibility, Linux connectivity or synthetic fixtures into a false CSI +claim. + +## Decision + +Adopt a Rust-first provider portfolio with explicit capability negotiation: + +- `ComplexCsi`: calibrated per-packet complex channel matrices. +- `DerivedSensing`: vendor-produced motion, occupancy or location events. +- `RfTelemetry`: RSSI, radio, client and topology observations. +- `NetworkOnly`: useful as excitation/AP infrastructure but not a sensor. +- `Unsupported`: no stable, lawful or supportable integration surface. + +Every provider follows the same gated loop: + +1. Verify an authoritative API/SDK, exact model/chipset and licensing boundary. +2. Write provider and wire/contract ADRs before coupling core code to a vendor. +3. Implement bounded Rust types, explicit capabilities and synthetic provenance. +4. Test deterministic replay, corruption, loss, reconnect, backpressure, schema + evolution and secrets handling. +5. Promote to hardware support only after lawful physical capture on an exact + model/firmware, calibration and repeatability tests, and fixture publication + rights. Simulator success never satisfies this gate. +6. Publish code/release and an upstream or vendor collaboration announcement + that states the measured-versus-simulated boundary. + +### Portfolio decisions + +| Provider | Classification | Decision | +|---|---|---| +| Qualcomm QCA9300 | `ComplexCsi` candidate | Implement first physical baseline via established ath9k research tooling; QCS1 adapter ships simulator-first. | +| Qualcomm QCN9074/QCN9274 | experimental `ComplexCsi` | Simulator and protocol now; require confirmed ath11k/ath12k firmware export before hardware claim. | +| Origin AI | commercial `DerivedSensing`, possible CSI | Pursue NDA sandbox/API and raw-data rights; isolate proprietary engine behind provider trait/service boundary. | +| Plume/OpenSync | `RfTelemetry`; Plume Sense is gated `DerivedSensing` | Build optional OVSDB/control-plane adapter; negotiate Sense separately and do not infer raw CSI. | +| Mist/Juniper | `RfTelemetry` + location | Conditional read-only REST/webhook adapter for occupancy, RSSI and coordinates; no CSI claim. | +| NETGEAR | partner-gated `RfTelemetry` | Insight adapter only after API access; exact legacy OpenWrt models remain community experiments. | +| Luma | discontinued OpenWrt salvage target | Generic OpenWrt telemetry/pcap fixture only when already owned; no procurement or Luma CSI source. | +| Google Nest Wifi | `NetworkOnly` | Use as traffic/AP infrastructure; Device Access does not expose router CSI or radio telemetry. | +| Linksys | `Unsupported` for sensing | Linksys Aware reached end of support in 2024; record capability probe only, if needed. | +| Electric Imp | scalar IoT/RSSI telemetry | Optional agent/impCentral bridge for existing fleets; reject as CSI acquisition hardware. | +| RF Solutions | non-Wi-Fi RF/IoT telemetry | Exclude from sensing backend; optional RIoT environmental fusion is a separate future concern. | +| Wifigarden | commercial OEM, capability unknown | Hold implementation pending chipset, schema, offline, calibration and data-rights disclosure. | + +### Provider boundary + +Core code consumes a vendor-neutral `RfSource`-style contract whose capability +set prevents RSSI, location or derived occupancy from being represented as CSI. +Cloud adapters use bounded async queues, regional endpoints, secret-provider +credentials and explicit data provenance. Proprietary device SDKs live behind a +feature-gated FFI or sidecar boundary and are never redistributed without rights. + +## Consequences + +### Positive + +- The integration loop can be repeated without duplicating unsafe parsers. +- Product integrations remain useful even when only telemetry is available. +- Public releases make hardware confidence and simulator confidence distinct. + +### Negative + +- Several named vendors cannot produce a legitimate CSI implementation today. +- Commercial providers require contracts, subscriptions, test vectors or NDAs. +- Exact hardware revisions and firmware provenance increase validation effort. + +### Neutral + +- A no-go or telemetry-only ADR is a completed research outcome, not a failed port. +- Vendor status and APIs must be rechecked before each implementation begins. + +## Evidence and Links + +- [ADR-268: Qualcomm strategy](ADR-268-qualcomm-atheros-csi-platform.md) +- [OpenSync developer sandbox](https://www.opensync.io/developer) +- [Origin AI Wi-Fi sensing architecture](https://www.originwirelessai.com/wifi-sensing/) +- [Juniper Mist webhook hierarchy](https://www.juniper.net/documentation/us/en/software/mist/automation-integration/topics/topic-map/webhook-hierarchy.html) +- [Linksys product end-of-life](https://www.linksys.com/pages/linksys-product-end-of-life) +- [Google Nest Device Access supported devices](https://developers.google.com/nest/device-access/supported-devices) +- [OpenWrt Luma WRTQ-329ACN](https://openwrt.org/toh/hwdata/luma/luma_wrtq-329acn) +- [NETGEAR Insight compatible devices](https://kb.netgear.com/000048452/What-devices-can-I-discover-monitor-and-manage-with-Insight) +- [Electric Imp imp005 hardware guide](https://developer.electricimp.com/hardware/imp/imp005_hardware_guide) +- [RF Solutions company portfolio](https://www.rfsolutions.co.uk/about-us-i1/) +- [Wifigarden service terms](https://policies.wifigarden.com/en-us/terms-of-service) + diff --git a/docs/releases/v0.9.2-qualcomm-beta.1.md b/docs/releases/v0.9.2-qualcomm-beta.1.md new file mode 100644 index 00000000..2d16179a --- /dev/null +++ b/docs/releases/v0.9.2-qualcomm-beta.1.md @@ -0,0 +1,22 @@ +# RuView v0.9.2-qualcomm-beta.1 + +This simulator-first beta adds a Rust Qualcomm Atheros CSI boundary without +claiming modern Qualcomm firmware exports that have not been physically verified. + +## Included + +- ADR-268 selects QCA9300 as the first physical baseline and treats QCN9074 and + QCN9274 as experimental modern profiles. +- ADR-269 defines the bounded, versioned, CRC-protected `QCS1` protocol. +- `qualcomm-csi-sim` emits deterministic MIMO CSI over UDP or replay files. +- The sensing server validates QCS1 datagrams, broadcasts bounded summaries and + exposes `/api/v1/csi/qualcomm/latest`. +- `qualcomm:simulated` provenance is retained end to end. + +## Validation boundary + +Codec, corruption, truncation, finite-value, dimensions, chipset bandwidth, +determinism and prefix parsing are automated. Loopback UDP/API validation covers +all profiles. Physical QCA9300 comparison and modern firmware export validation +remain hardware gates and will be published with firmware and calibration details. + diff --git a/v2/crates/wifi-densepose-hardware/src/bin/qualcomm-csi-sim.rs b/v2/crates/wifi-densepose-hardware/src/bin/qualcomm-csi-sim.rs new file mode 100644 index 00000000..9f44e91f --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/bin/qualcomm-csi-sim.rs @@ -0,0 +1,147 @@ +//! Deterministic Qualcomm Atheros MIMO CSI simulator (ADR-268/269). + +use clap::{Parser, ValueEnum}; +use std::{ + fs::File, + io::{self, Write}, + net::{SocketAddr, UdpSocket}, + path::PathBuf, + thread, + time::Duration, +}; +use wifi_densepose_hardware::qualcomm_csi::{ + simulator::{QualcommCsiSimulator, SimulatorConfig}, + ChipsetProfile, CsiFrame, +}; + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum Profile { + Qca9300, + Qcn9074, + Qcn9274, +} +impl Profile { + fn chipset(self) -> ChipsetProfile { + match self { + Self::Qca9300 => ChipsetProfile::Qca9300, + Self::Qcn9074 => ChipsetProfile::Qcn9074, + Self::Qcn9274 => ChipsetProfile::Qcn9274, + } + } + fn default_chains(self) -> u8 { + match self { + Self::Qca9300 => 3, + Self::Qcn9074 | Self::Qcn9274 => 4, + } + } + fn default_bandwidth(self) -> u16 { + match self { + Self::Qca9300 => 40, + Self::Qcn9074 | Self::Qcn9274 => 80, + } + } + fn default_subcarriers(self) -> u16 { + match self { + Self::Qca9300 => 114, + Self::Qcn9074 | Self::Qcn9274 => 256, + } + } +} + +#[derive(Debug, Parser)] +#[command( + name = "qualcomm-csi-sim", + about = "Emit synthetic ADR-269 Qualcomm Atheros MIMO CSI frames" +)] +struct Args { + #[arg(long, value_enum, default_value_t=Profile::Qca9300)] + profile: Profile, + #[arg(long, default_value_t = 100)] + frames: u32, + #[arg(long, default_value="0x5143414353490001", value_parser=parse_u64)] + seed: u64, + #[arg(long)] + bandwidth: Option, + #[arg(long, default_value_t = 2)] + tx: u8, + #[arg(long)] + rx: Option, + #[arg(long)] + subcarriers: Option, + #[arg(long, default_value_t = 20)] + interval_ms: u64, + #[arg(long)] + udp: Option, + /// Replay: little-endian u32 length followed by one ADR-269 envelope. + #[arg(long)] + output: Option, + #[arg(long)] + realtime: bool, +} +fn parse_u64(v: &str) -> Result { + if let Some(h) = v.strip_prefix("0x").or_else(|| v.strip_prefix("0X")) { + u64::from_str_radix(h, 16).map_err(|e| e.to_string()) + } else { + v.parse() + .map_err(|e: std::num::ParseIntError| e.to_string()) + } +} +fn emit( + frame: CsiFrame, + socket: Option<&UdpSocket>, + destination: Option, + output: &mut Option, +) -> Result> { + let wire = frame.to_bytes()?; + if let (Some(s), Some(d)) = (socket, destination) { + if s.send_to(&wire, d)? != wire.len() { + return Err(io::Error::new(io::ErrorKind::WriteZero, "partial UDP datagram").into()); + } + } + if let Some(f) = output { + f.write_all(&(wire.len() as u32).to_le_bytes())?; + f.write_all(&wire)?; + } + Ok(wire.len()) +} +fn main() -> Result<(), Box> { + let a = Args::parse(); + if a.udp.is_none() && a.output.is_none() { + return Err("select at least one sink with --udp or --output".into()); + } + let cfg = SimulatorConfig { + seed: a.seed, + chipset: a.profile.chipset(), + bandwidth_mhz: a.bandwidth.unwrap_or_else(|| a.profile.default_bandwidth()), + tx_count: a.tx, + rx_count: a.rx.unwrap_or_else(|| a.profile.default_chains()), + subcarriers: a + .subcarriers + .unwrap_or_else(|| a.profile.default_subcarriers()), + frame_period_us: a.interval_ms * 1000, + ..Default::default() + }; + let mut sim = QualcommCsiSimulator::new(cfg)?; + let socket = a.udp.map(|_| UdpSocket::bind("0.0.0.0:0")).transpose()?; + let mut output = a.output.as_ref().map(File::create).transpose()?; + let mut bytes = emit( + sim.capabilities_frame(), + socket.as_ref(), + a.udp, + &mut output, + )?; + for _ in 0..a.frames { + bytes += emit(sim.next_frame(), socket.as_ref(), a.udp, &mut output)?; + if a.realtime { + thread::sleep(Duration::from_millis(a.interval_ms)); + } + } + eprintln!( + "emitted {} synthetic Qualcomm CSI frames ({} bytes, profile={}, seed={:#x})", + a.frames + 1, + bytes, + a.profile.chipset().name(), + a.seed + ); + Ok(()) +} diff --git a/v2/crates/wifi-densepose-hardware/src/lib.rs b/v2/crates/wifi-densepose-hardware/src/lib.rs index e049d299..338b311e 100644 --- a/v2/crates/wifi-densepose-hardware/src/lib.rs +++ b/v2/crates/wifi-densepose-hardware/src/lib.rs @@ -55,6 +55,8 @@ pub mod sync_packet; pub mod radio_ops; /// ADR-267 vendor-neutral MediaTek Filogic MIMO CSI framing and simulator. pub mod mediatek_csi; +/// ADR-269 vendor-neutral Qualcomm Atheros CSI framing and simulator. +pub mod qualcomm_csi; /// ADR-264 host-side framing for Realtek RTL8720F CFR and FMCW radar reports. /// This module has no dependency on the vendor SDK. pub mod rtl8720f; @@ -82,6 +84,13 @@ pub use mediatek_csi::{ PpduType as MediatekPpduType, ReportKind as MediatekReportKind, MEDIATEK_CSI_HEADER_LEN, MEDIATEK_CSI_MAGIC, MEDIATEK_CSI_VERSION, }; +pub use qualcomm_csi::{ + ChipsetProfile as QualcommChipsetProfile, CsiFlags as QualcommCsiFlags, + CsiFrame as QualcommCsiFrame, CsiParseError as QualcommCsiParseError, + CsiPayload as QualcommCsiPayload, ElementFormat as QualcommElementFormat, + PpduType as QualcommPpduType, ReportKind as QualcommReportKind, + QUALCOMM_CSI_HEADER_LEN, QUALCOMM_CSI_MAGIC, QUALCOMM_CSI_VERSION, +}; pub use rtl8720f::{ ElementFormat as Rtl8720fElementFormat, RadarFlags as Rtl8720fRadarFlags, RadarFrame as Rtl8720fRadarFrame, RadarParseError as Rtl8720fRadarParseError, diff --git a/v2/crates/wifi-densepose-hardware/src/qualcomm_csi.rs b/v2/crates/wifi-densepose-hardware/src/qualcomm_csi.rs new file mode 100644 index 00000000..60949c1d --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/qualcomm_csi.rs @@ -0,0 +1,700 @@ +//! Vendor-neutral Qualcomm Atheros MIMO CSI transport and deterministic simulator. +//! This is not a Qualcomm firmware ABI; see ADR-268/269. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const QUALCOMM_CSI_MAGIC: u32 = 0x3153_4351; // "QCS1" little endian +pub const QUALCOMM_CSI_VERSION: u8 = 1; +pub const QUALCOMM_CSI_HEADER_LEN: usize = 72; +pub const QUALCOMM_CSI_CRC_LEN: usize = 4; +pub const QUALCOMM_CSI_MAX_FRAME_LEN: usize = 65_507; +pub const QUALCOMM_CSI_MAX_ELEMENTS: usize = 16_384; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum ReportKind { + Csi = 1, + Capabilities = 2, +} + +impl TryFrom for ReportKind { + type Error = CsiParseError; + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::Csi), + 2 => Ok(Self::Capabilities), + _ => Err(CsiParseError::UnknownReportKind(value)), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u16)] +pub enum ChipsetProfile { + Qca9300 = 1, + Qcn9074 = 2, + Qcn9274 = 3, +} + +impl TryFrom for ChipsetProfile { + type Error = CsiParseError; + fn try_from(value: u16) -> Result { + match value { + 1 => Ok(Self::Qca9300), + 2 => Ok(Self::Qcn9074), + 3 => Ok(Self::Qcn9274), + _ => Err(CsiParseError::UnknownChipset(value)), + } + } +} + +impl ChipsetProfile { + pub fn name(self) -> &'static str { + match self { + Self::Qca9300 => "qca9300", + Self::Qcn9074 => "qcn9074", + Self::Qcn9274 => "qcn9274", + } + } + pub fn max_chains(self) -> u8 { + match self { + Self::Qca9300 => 3, + Self::Qcn9074 | Self::Qcn9274 => 4, + } + } + pub fn max_bandwidth_mhz(self) -> u16 { + match self { + Self::Qca9300 => 40, + Self::Qcn9074 | Self::Qcn9274 => 160, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum ElementFormat { + ComplexI16 = 1, + ComplexF32 = 2, + Bytes = 3, +} + +impl TryFrom for ElementFormat { + type Error = CsiParseError; + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::ComplexI16), + 2 => Ok(Self::ComplexF32), + 3 => Ok(Self::Bytes), + _ => Err(CsiParseError::UnknownElementFormat(value)), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum PpduType { + Ht = 1, + Vht = 2, + HeSu = 3, + HeMu = 4, + Eht = 5, +} + +impl TryFrom for PpduType { + type Error = CsiParseError; + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::Ht), + 2 => Ok(Self::Vht), + 3 => Ok(Self::HeSu), + 4 => Ok(Self::HeMu), + 5 => Ok(Self::Eht), + _ => Err(CsiParseError::UnknownPpduType(value)), + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CsiFlags(pub u16); + +impl CsiFlags { + pub const CALIBRATED: u16 = 1 << 0; + pub const SATURATED: u16 = 1 << 1; + pub const TIME_SYNCHRONIZED: u16 = 1 << 2; + pub const DROPPED_PREDECESSOR: u16 = 1 << 3; + pub const SYNTHETIC: u16 = 1 << 15; + pub fn contains(self, flag: u16) -> bool { + self.0 & flag != 0 + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum CsiPayload { + ComplexI16 { + rssi_dbm: Vec, + values: Vec<[i16; 2]>, + }, + ComplexF32 { + rssi_dbm: Vec, + values: Vec<[f32; 2]>, + }, + Bytes(Vec), +} + +impl CsiPayload { + pub fn len(&self) -> usize { + match self { + Self::ComplexI16 { values, .. } => values.len(), + Self::ComplexF32 { values, .. } => values.len(), + Self::Bytes(values) => values.len(), + } + } + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + pub fn rssi_dbm(&self) -> &[i8] { + match self { + Self::ComplexI16 { rssi_dbm, .. } | Self::ComplexF32 { rssi_dbm, .. } => rssi_dbm, + Self::Bytes(_) => &[], + } + } + fn format(&self) -> ElementFormat { + match self { + Self::ComplexI16 { .. } => ElementFormat::ComplexI16, + Self::ComplexF32 { .. } => ElementFormat::ComplexF32, + Self::Bytes(_) => ElementFormat::Bytes, + } + } + fn encoded_len(&self) -> usize { + match self { + Self::ComplexI16 { rssi_dbm, values } => rssi_dbm.len() + values.len() * 4, + Self::ComplexF32 { rssi_dbm, values } => rssi_dbm.len() + values.len() * 8, + Self::Bytes(values) => values.len(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CsiFrame { + pub report_kind: ReportKind, + pub sequence: u32, + pub timestamp_us: u64, + pub device_id: u64, + pub chipset: ChipsetProfile, + pub bandwidth_mhz: u16, + pub center_freq_khz: u32, + pub flags: CsiFlags, + pub tx_count: u8, + pub rx_count: u8, + pub ppdu_type: PpduType, + pub subcarrier_count: u16, + pub noise_floor_dbm: i8, + pub scale: f32, + pub subcarrier_spacing_hz: f32, + pub calibration_id: u32, + pub payload: CsiPayload, +} + +impl CsiFrame { + pub fn to_bytes(&self) -> Result, CsiParseError> { + self.validate()?; + let payload_len = self.payload.encoded_len(); + let frame_len = QUALCOMM_CSI_HEADER_LEN + .checked_add(payload_len) + .and_then(|n| n.checked_add(QUALCOMM_CSI_CRC_LEN)) + .ok_or(CsiParseError::LengthOverflow)?; + if frame_len > QUALCOMM_CSI_MAX_FRAME_LEN { + return Err(CsiParseError::FrameTooLarge(frame_len)); + } + let mut out = Vec::with_capacity(frame_len); + out.extend_from_slice(&QUALCOMM_CSI_MAGIC.to_le_bytes()); + out.push(QUALCOMM_CSI_VERSION); + out.push(self.report_kind as u8); + out.extend_from_slice(&(QUALCOMM_CSI_HEADER_LEN as u16).to_le_bytes()); + out.extend_from_slice(&(frame_len as u32).to_le_bytes()); + out.extend_from_slice(&self.sequence.to_le_bytes()); + out.extend_from_slice(&self.timestamp_us.to_le_bytes()); + out.extend_from_slice(&self.device_id.to_le_bytes()); + out.extend_from_slice(&(self.chipset as u16).to_le_bytes()); + out.extend_from_slice(&self.bandwidth_mhz.to_le_bytes()); + out.extend_from_slice(&self.center_freq_khz.to_le_bytes()); + out.extend_from_slice(&self.flags.0.to_le_bytes()); + out.push(self.tx_count); + out.push(self.rx_count); + out.push(self.payload.format() as u8); + out.push(self.ppdu_type as u8); + out.extend_from_slice(&self.subcarrier_count.to_le_bytes()); + out.push(self.payload.rssi_dbm().len() as u8); + out.push(self.noise_floor_dbm as u8); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&self.scale.to_le_bytes()); + out.extend_from_slice(&self.subcarrier_spacing_hz.to_le_bytes()); + out.extend_from_slice(&self.calibration_id.to_le_bytes()); + out.extend_from_slice(&(payload_len as u32).to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + debug_assert_eq!(out.len(), QUALCOMM_CSI_HEADER_LEN); + match &self.payload { + CsiPayload::ComplexI16 { rssi_dbm, values } => { + out.extend(rssi_dbm.iter().map(|v| *v as u8)); + for [i, q] in values { + out.extend_from_slice(&i.to_le_bytes()); + out.extend_from_slice(&q.to_le_bytes()); + } + } + CsiPayload::ComplexF32 { rssi_dbm, values } => { + out.extend(rssi_dbm.iter().map(|v| *v as u8)); + for [i, q] in values { + out.extend_from_slice(&i.to_le_bytes()); + out.extend_from_slice(&q.to_le_bytes()); + } + } + CsiPayload::Bytes(values) => out.extend_from_slice(values), + } + out.extend_from_slice(&crc32_ieee(&out).to_le_bytes()); + Ok(out) + } + + pub fn from_bytes(input: &[u8]) -> Result<(Self, usize), CsiParseError> { + if input.len() < QUALCOMM_CSI_HEADER_LEN { + return Err(CsiParseError::InsufficientData { + needed: QUALCOMM_CSI_HEADER_LEN, + got: input.len(), + }); + } + let magic = u32_at(input, 0); + if magic != QUALCOMM_CSI_MAGIC { + return Err(CsiParseError::InvalidMagic(magic)); + } + if input[4] != QUALCOMM_CSI_VERSION { + return Err(CsiParseError::UnsupportedVersion(input[4])); + } + let report_kind = ReportKind::try_from(input[5])?; + let header_len = u16_at(input, 6) as usize; + if header_len != QUALCOMM_CSI_HEADER_LEN { + return Err(CsiParseError::InvalidHeaderLength(header_len)); + } + let frame_len = u32_at(input, 8) as usize; + if frame_len > QUALCOMM_CSI_MAX_FRAME_LEN { + return Err(CsiParseError::FrameTooLarge(frame_len)); + } + if frame_len < header_len + QUALCOMM_CSI_CRC_LEN { + return Err(CsiParseError::InvalidFrameLength(frame_len)); + } + if input.len() < frame_len { + return Err(CsiParseError::InsufficientData { + needed: frame_len, + got: input.len(), + }); + } + let expected_crc = u32_at(input, frame_len - 4); + let actual_crc = crc32_ieee(&input[..frame_len - 4]); + if expected_crc != actual_crc { + return Err(CsiParseError::CrcMismatch { + expected: expected_crc, + actual: actual_crc, + }); + } + let chipset = ChipsetProfile::try_from(u16_at(input, 32))?; + let format = ElementFormat::try_from(input[44])?; + let ppdu_type = PpduType::try_from(input[45])?; + let tx_count = input[42]; + let rx_count = input[43]; + let subcarrier_count = u16_at(input, 46); + let rssi_count = input[48] as usize; + let payload_len = u32_at(input, 64) as usize; + if header_len + payload_len + 4 != frame_len { + return Err(CsiParseError::PayloadLengthMismatch); + } + let payload_bytes = &input[header_len..header_len + payload_len]; + let elements = (tx_count as usize) + .checked_mul(rx_count as usize) + .and_then(|n| n.checked_mul(subcarrier_count as usize)) + .ok_or(CsiParseError::LengthOverflow)?; + let payload = match format { + ElementFormat::Bytes => CsiPayload::Bytes(payload_bytes.to_vec()), + ElementFormat::ComplexI16 => { + if rssi_count > payload_bytes.len() + || payload_bytes.len() - rssi_count != elements * 4 + { + return Err(CsiParseError::PayloadLengthMismatch); + } + let rssi_dbm = payload_bytes[..rssi_count] + .iter() + .map(|v| *v as i8) + .collect(); + let values = payload_bytes[rssi_count..] + .chunks_exact(4) + .map(|b| { + [ + i16::from_le_bytes([b[0], b[1]]), + i16::from_le_bytes([b[2], b[3]]), + ] + }) + .collect(); + CsiPayload::ComplexI16 { rssi_dbm, values } + } + ElementFormat::ComplexF32 => { + if rssi_count > payload_bytes.len() + || payload_bytes.len() - rssi_count != elements * 8 + { + return Err(CsiParseError::PayloadLengthMismatch); + } + let rssi_dbm = payload_bytes[..rssi_count] + .iter() + .map(|v| *v as i8) + .collect(); + let mut values = Vec::with_capacity(elements); + for b in payload_bytes[rssi_count..].chunks_exact(8) { + let i = f32::from_le_bytes(b[0..4].try_into().unwrap()); + let q = f32::from_le_bytes(b[4..8].try_into().unwrap()); + if !i.is_finite() || !q.is_finite() { + return Err(CsiParseError::NonFiniteValue); + } + values.push([i, q]); + } + CsiPayload::ComplexF32 { rssi_dbm, values } + } + }; + let frame = Self { + report_kind, + sequence: u32_at(input, 12), + timestamp_us: u64_at(input, 16), + device_id: u64_at(input, 24), + chipset, + bandwidth_mhz: u16_at(input, 34), + center_freq_khz: u32_at(input, 36), + flags: CsiFlags(u16_at(input, 40)), + tx_count, + rx_count, + ppdu_type, + subcarrier_count, + noise_floor_dbm: input[49] as i8, + scale: f32_at(input, 52), + subcarrier_spacing_hz: f32_at(input, 56), + calibration_id: u32_at(input, 60), + payload, + }; + frame.validate()?; + Ok((frame, frame_len)) + } + + fn validate(&self) -> Result<(), CsiParseError> { + if !matches!(self.bandwidth_mhz, 20 | 40 | 80 | 160) + || self.bandwidth_mhz > self.chipset.max_bandwidth_mhz() + { + return Err(CsiParseError::InvalidBandwidth(self.bandwidth_mhz)); + } + if self.tx_count == 0 + || self.rx_count == 0 + || self.tx_count > self.chipset.max_chains() + || self.rx_count > self.chipset.max_chains() + { + return Err(CsiParseError::InvalidDimensions); + } + if !self.scale.is_finite() + || self.scale <= 0.0 + || !self.subcarrier_spacing_hz.is_finite() + || self.subcarrier_spacing_hz <= 0.0 + { + return Err(CsiParseError::NonFiniteValue); + } + match (&self.report_kind, &self.payload) { + (ReportKind::Csi, CsiPayload::ComplexI16 { rssi_dbm, values }) => { + self.validate_csi(rssi_dbm, values.len()) + } + (ReportKind::Csi, CsiPayload::ComplexF32 { rssi_dbm, values }) => { + if !values.iter().flatten().all(|v| v.is_finite()) { + return Err(CsiParseError::NonFiniteValue); + } + self.validate_csi(rssi_dbm, values.len()) + } + (ReportKind::Capabilities, CsiPayload::Bytes(v)) if !v.is_empty() => Ok(()), + _ => Err(CsiParseError::PayloadTypeMismatch), + } + } + fn validate_csi(&self, rssi: &[i8], values: usize) -> Result<(), CsiParseError> { + let expected = + self.tx_count as usize * self.rx_count as usize * self.subcarrier_count as usize; + if expected == 0 || expected > QUALCOMM_CSI_MAX_ELEMENTS { + return Err(CsiParseError::InvalidDimensions); + } + if values != expected || rssi.len() != self.rx_count as usize { + return Err(CsiParseError::PayloadLengthMismatch); + } + Ok(()) + } +} + +#[derive(Debug, Error, PartialEq)] +pub enum CsiParseError { + #[error("insufficient data: needed {needed}, got {got}")] + InsufficientData { needed: usize, got: usize }, + #[error("invalid magic {0:#010x}")] + InvalidMagic(u32), + #[error("unsupported version {0}")] + UnsupportedVersion(u8), + #[error("unknown report kind {0}")] + UnknownReportKind(u8), + #[error("unknown chipset profile {0}")] + UnknownChipset(u16), + #[error("unknown element format {0}")] + UnknownElementFormat(u8), + #[error("unknown PPDU type {0}")] + UnknownPpduType(u8), + #[error("invalid header length {0}")] + InvalidHeaderLength(usize), + #[error("invalid frame length {0}")] + InvalidFrameLength(usize), + #[error("frame too large: {0}")] + FrameTooLarge(usize), + #[error("length arithmetic overflow")] + LengthOverflow, + #[error("payload length mismatch")] + PayloadLengthMismatch, + #[error("payload type does not match report kind")] + PayloadTypeMismatch, + #[error("invalid MIMO dimensions")] + InvalidDimensions, + #[error("invalid bandwidth {0} MHz")] + InvalidBandwidth(u16), + #[error("non-finite or non-positive numeric metadata/value")] + NonFiniteValue, + #[error("CRC mismatch: expected {expected:#010x}, actual {actual:#010x}")] + CrcMismatch { expected: u32, actual: u32 }, +} + +pub mod simulator { + use super::*; + #[derive(Debug, Clone)] + pub struct SimulatorConfig { + pub seed: u64, + pub device_id: u64, + pub chipset: ChipsetProfile, + pub bandwidth_mhz: u16, + pub center_freq_khz: u32, + pub tx_count: u8, + pub rx_count: u8, + pub subcarriers: u16, + pub frame_period_us: u64, + } + impl Default for SimulatorConfig { + fn default() -> Self { + Self { + seed: 0x5143_4143_5349_0001, + device_id: 0x5255_5651_4341_3031, + chipset: ChipsetProfile::Qca9300, + bandwidth_mhz: 40, + center_freq_khz: 5_210_000, + tx_count: 2, + rx_count: 3, + subcarriers: 114, + frame_period_us: 20_000, + } + } + } + pub struct QualcommCsiSimulator { + config: SimulatorConfig, + rng: u64, + sequence: u32, + timestamp_us: u64, + motion_phase: f32, + } + impl QualcommCsiSimulator { + pub fn new(config: SimulatorConfig) -> Result { + let s = Self { + rng: config.seed, + config, + sequence: 0, + timestamp_us: 0, + motion_phase: 0.0, + }; + s.csi_frame()?.validate()?; + Ok(s) + } + pub fn capabilities_frame(&self) -> CsiFrame { + self.base( + ReportKind::Capabilities, + CsiPayload::Bytes(vec![ + 1, + 1, + self.config.chipset.max_chains(), + 2, + 1, + 0b0000_1111, + 3, + 2, + (self.config.subcarriers & 255) as u8, + (self.config.subcarriers >> 8) as u8, + ]), + ) + } + pub fn next_frame(&mut self) -> CsiFrame { + let frame = self.csi_frame().expect("validated simulator config"); + self.sequence = self.sequence.wrapping_add(1); + self.timestamp_us = self.timestamp_us.wrapping_add(self.config.frame_period_us); + self.motion_phase += 0.037; + frame + } + fn csi_frame(&self) -> Result { + let mut rng = self.rng ^ self.sequence as u64; + let count = self.config.tx_count as usize + * self.config.rx_count as usize + * self.config.subcarriers as usize; + let values = (0..count) + .map(|idx| { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + let noise = ((rng >> 48) as i16 % 24) as f32; + let sc = (idx % self.config.subcarriers as usize) as f32; + let chain = (idx / self.config.subcarriers as usize) as f32; + let phase = sc * 0.031 + chain * 0.23 + self.motion_phase; + [ + ((phase.cos() * 1800.0) + noise) as i16, + ((phase.sin() * 1800.0) - noise) as i16, + ] + }) + .collect(); + Ok(self.base( + ReportKind::Csi, + CsiPayload::ComplexI16 { + rssi_dbm: (0..self.config.rx_count) + .map(|i| -42 - i as i8 * 2) + .collect(), + values, + }, + )) + } + fn base(&self, kind: ReportKind, payload: CsiPayload) -> CsiFrame { + CsiFrame { + report_kind: kind, + sequence: self.sequence, + timestamp_us: self.timestamp_us, + device_id: self.config.device_id, + chipset: self.config.chipset, + bandwidth_mhz: self.config.bandwidth_mhz, + center_freq_khz: self.config.center_freq_khz, + flags: CsiFlags(CsiFlags::CALIBRATED | CsiFlags::SYNTHETIC), + tx_count: self.config.tx_count, + rx_count: self.config.rx_count, + ppdu_type: PpduType::HeSu, + subcarrier_count: self.config.subcarriers, + noise_floor_dbm: -95, + scale: 1.0 / 2048.0, + subcarrier_spacing_hz: 312_500.0, + calibration_id: 1, + payload, + } + } + } +} + +fn u16_at(b: &[u8], o: usize) -> u16 { + u16::from_le_bytes([b[o], b[o + 1]]) +} +fn u32_at(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes(b[o..o + 4].try_into().unwrap()) +} +fn u64_at(b: &[u8], o: usize) -> u64 { + u64::from_le_bytes(b[o..o + 8].try_into().unwrap()) +} +fn f32_at(b: &[u8], o: usize) -> f32 { + f32::from_le_bytes(b[o..o + 4].try_into().unwrap()) +} +fn crc32_ieee(data: &[u8]) -> u32 { + let mut crc = 0xffff_ffffu32; + for &byte in data { + crc ^= byte as u32; + for _ in 0..8 { + crc = (crc >> 1) ^ ((0u32.wrapping_sub(crc & 1)) & 0xedb8_8320); + } + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + use simulator::*; + #[test] + fn simulator_round_trip_is_deterministic() { + let cfg = SimulatorConfig::default(); + let mut a = QualcommCsiSimulator::new(cfg.clone()).unwrap(); + let mut b = QualcommCsiSimulator::new(cfg).unwrap(); + let wa = a.next_frame().to_bytes().unwrap(); + assert_eq!(wa, b.next_frame().to_bytes().unwrap()); + let (decoded, n) = CsiFrame::from_bytes(&wa).unwrap(); + assert_eq!(n, wa.len()); + assert!(decoded.flags.contains(CsiFlags::SYNTHETIC)); + assert_eq!(decoded.payload.len(), 2 * 3 * 114); + } + #[test] + fn capabilities_round_trip() { + let s = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap(); + let f = s.capabilities_frame(); + let w = f.to_bytes().unwrap(); + assert_eq!(CsiFrame::from_bytes(&w).unwrap().0, f); + } + #[test] + fn crc_corruption_is_rejected() { + let mut s = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap(); + let mut w = s.next_frame().to_bytes().unwrap(); + w[80] ^= 1; + assert!(matches!( + CsiFrame::from_bytes(&w), + Err(CsiParseError::CrcMismatch { .. }) + )); + } + #[test] + fn truncation_is_rejected() { + let mut s = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap(); + let w = s.next_frame().to_bytes().unwrap(); + assert!(matches!( + CsiFrame::from_bytes(&w[..w.len() - 1]), + Err(CsiParseError::InsufficientData { .. }) + )); + } + #[test] + fn invalid_dimensions_are_rejected() { + let cfg = SimulatorConfig { + rx_count: 4, + chipset: ChipsetProfile::Qca9300, + ..Default::default() + }; + assert!(matches!( + QualcommCsiSimulator::new(cfg), + Err(CsiParseError::InvalidDimensions) + )); + } + #[test] + fn non_finite_float_is_rejected() { + let mut s = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap(); + let mut f = s.next_frame(); + f.payload = CsiPayload::ComplexF32 { + rssi_dbm: vec![-40, -42, -44], + values: vec![[f32::NAN, 0.0]; 2 * 3 * 114], + }; + assert_eq!(f.to_bytes().unwrap_err(), CsiParseError::NonFiniteValue); + } + #[test] + fn parser_never_panics_on_prefixes() { + let mut s = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap(); + let w = s.next_frame().to_bytes().unwrap(); + for end in 0..w.len() { + let _ = CsiFrame::from_bytes(&w[..end]); + } + } + + #[test] + fn qca9300_rejects_wifi6_bandwidths() { + let cfg = SimulatorConfig { + bandwidth_mhz: 80, + ..Default::default() + }; + assert!(matches!( + QualcommCsiSimulator::new(cfg), + Err(CsiParseError::InvalidBandwidth(80)) + )); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 7b2e67cf..78f3e98e 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -18,6 +18,7 @@ mod field_localize; mod model_format; mod multistatic_bridge; mod mediatek_csi; +mod qualcomm_csi; mod realtek_radar; pub mod pose; mod rvf_container; @@ -1038,6 +1039,10 @@ struct AppStateInner { latest_mediatek_csi: Option, /// Instant of the last validated MediaTek CSI UDP frame. last_mediatek_frame: Option, + /// Latest validated Qualcomm CSI summary; raw matrices are not retained here. + latest_qualcomm_csi: Option, + /// Instant of the last validated Qualcomm CSI UDP frame. + last_qualcomm_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 @@ -1223,6 +1228,13 @@ impl AppStateInner { } } } + if self.source.starts_with("qualcomm") { + if let Some(last) = self.last_qualcomm_frame { + if last.elapsed() > ESP32_OFFLINE_TIMEOUT { + return format!("{}:offline", self.source); + } + } + } self.source.clone() } } @@ -3391,6 +3403,14 @@ async fn latest_mediatek_csi(State(state): State) -> Json) -> Json { + let s = state.read().await; + match &s.latest_qualcomm_csi { + Some(snapshot) => Json(serde_json::to_value(snapshot).unwrap_or_default()), + None => Json(serde_json::json!({"status": "no Qualcomm CSI data yet"})), + } +} + /// Generate WiFi-derived pose keypoints from sensing data. /// /// Keypoint positions are modulated by real signal features rather than a pure @@ -5485,7 +5505,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, MediaTek CSI, and RTL8720F radar frames"); + info!("UDP listening on {addr} for ESP32, MediaTek, Qualcomm CSI, and RTL8720F radar frames"); s } Err(e) => { @@ -5498,6 +5518,26 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { 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::qualcomm_csi::QUALCOMM_CSI_MAGIC + { + match wifi_densepose_hardware::qualcomm_csi::CsiFrame::from_bytes(&buf[..len]) { + Ok((frame, consumed)) if consumed == len => { + let snapshot = qualcomm_csi::QualcommCsiSnapshot::from_frame(&frame); + debug!("Qualcomm CSI from {src}: profile={} seq={} dimensions={}x{}x{}", snapshot.chipset, snapshot.sequence, snapshot.tx_count, snapshot.rx_count, snapshot.subcarrier_count); + let json = serde_json::to_string(&snapshot).ok(); + let mut s = state.write().await; + s.source = snapshot.source.to_string(); + s.last_qualcomm_frame = Some(std::time::Instant::now()); + s.latest_qualcomm_csi = Some(snapshot); + if let Some(json) = json { let _ = s.tx.send(json); } + } + Ok((_, consumed)) => warn!("Qualcomm CSI datagram from {src} has trailing bytes: consumed={consumed} received={len}"), + Err(error) => warn!("Rejected Qualcomm CSI datagram from {src}: {error}"), + } + continue; + } if len >= 4 && u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice")) == wifi_densepose_hardware::mediatek_csi::MEDIATEK_CSI_MAGIC @@ -7638,6 +7678,8 @@ async fn main() { last_realtek_frame: None, latest_mediatek_csi: None, last_mediatek_frame: None, + latest_qualcomm_csi: None, + last_qualcomm_frame: None, tx, intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(), intro_tx, @@ -7856,6 +7898,7 @@ async fn main() { .route("/api/v1/sensing/latest", get(latest)) .route("/api/v1/radar/latest", get(latest_realtek_radar)) .route("/api/v1/csi/mediatek/latest", get(latest_mediatek_csi)) + .route("/api/v1/csi/qualcomm/latest", get(latest_qualcomm_csi)) // 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/qualcomm_csi.rs b/v2/crates/wifi-densepose-sensing-server/src/qualcomm_csi.rs new file mode 100644 index 00000000..88d12afe --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/qualcomm_csi.rs @@ -0,0 +1,127 @@ +//! Bounded summaries for ADR-269 Qualcomm MIMO CSI frames. + +use serde::Serialize; +use wifi_densepose_hardware::qualcomm_csi::{CsiFlags, CsiFrame, CsiPayload, ReportKind}; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct QualcommCsiSnapshot { + pub event_type: &'static str, + pub source: &'static str, + pub report_kind: &'static str, + pub sequence: u32, + pub timestamp_us: u64, + pub device_id: String, + pub chipset: &'static str, + pub center_freq_khz: u32, + pub bandwidth_mhz: u16, + pub tx_count: u8, + pub rx_count: u8, + pub subcarrier_count: u16, + pub element_count: usize, + pub ppdu_type: String, + pub rssi_dbm: Vec, + pub noise_floor_dbm: i8, + pub calibrated: bool, + pub synthetic: bool, + pub saturated: bool, + pub time_synchronized: bool, + pub dropped_predecessor: bool, + pub calibration_id: u32, + pub subcarrier_spacing_hz: f32, + pub mean_amplitude: Option, + pub peak_amplitude: Option, +} + +impl QualcommCsiSnapshot { + pub(crate) fn from_frame(frame: &CsiFrame) -> Self { + let synthetic = frame.flags.contains(CsiFlags::SYNTHETIC); + let (mean_amplitude, peak_amplitude) = amplitude_summary(frame); + Self { + event_type: "qualcomm_csi", + source: if synthetic { + "qualcomm:simulated" + } else { + "qualcomm" + }, + report_kind: match frame.report_kind { + ReportKind::Csi => "csi", + ReportKind::Capabilities => "capabilities", + }, + sequence: frame.sequence, + timestamp_us: frame.timestamp_us, + device_id: format!("{:016x}", frame.device_id), + chipset: frame.chipset.name(), + center_freq_khz: frame.center_freq_khz, + bandwidth_mhz: frame.bandwidth_mhz, + tx_count: frame.tx_count, + rx_count: frame.rx_count, + subcarrier_count: frame.subcarrier_count, + element_count: frame.payload.len(), + ppdu_type: format!("{:?}", frame.ppdu_type).to_ascii_lowercase(), + rssi_dbm: frame.payload.rssi_dbm().to_vec(), + noise_floor_dbm: frame.noise_floor_dbm, + calibrated: frame.flags.contains(CsiFlags::CALIBRATED), + synthetic, + saturated: frame.flags.contains(CsiFlags::SATURATED), + time_synchronized: frame.flags.contains(CsiFlags::TIME_SYNCHRONIZED), + dropped_predecessor: frame.flags.contains(CsiFlags::DROPPED_PREDECESSOR), + calibration_id: frame.calibration_id, + subcarrier_spacing_hz: frame.subcarrier_spacing_hz, + mean_amplitude, + peak_amplitude, + } + } +} + +fn amplitude_summary(frame: &CsiFrame) -> (Option, Option) { + let amplitudes: Vec = match &frame.payload { + CsiPayload::ComplexI16 { values, .. } => values + .iter() + .map(|[i, q]| (*i as f32).hypot(*q as f32) * frame.scale) + .collect(), + CsiPayload::ComplexF32 { values, .. } => values + .iter() + .map(|[i, q]| i.hypot(*q) * frame.scale) + .collect(), + CsiPayload::Bytes(_) => return (None, None), + }; + if amplitudes.is_empty() { + return (None, None); + } + let mean = amplitudes.iter().sum::() / amplitudes.len() as f32; + let peak = amplitudes.into_iter().max_by(f32::total_cmp); + (Some(mean), peak) +} + +#[cfg(test)] +mod tests { + use super::*; + use wifi_densepose_hardware::qualcomm_csi::simulator::{QualcommCsiSimulator, SimulatorConfig}; + + #[test] + fn simulator_summary_preserves_dimensions_and_provenance() { + let mut sim = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap(); + let snapshot = QualcommCsiSnapshot::from_frame(&sim.next_frame()); + assert_eq!(snapshot.source, "qualcomm:simulated"); + assert_eq!( + ( + snapshot.tx_count, + snapshot.rx_count, + snapshot.subcarrier_count + ), + (2, 3, 114) + ); + assert_eq!(snapshot.element_count, 684); + assert!(snapshot.mean_amplitude.unwrap() > 0.0); + assert!(snapshot.peak_amplitude.unwrap() >= snapshot.mean_amplitude.unwrap()); + } + + #[test] + fn capability_summary_does_not_invent_signal_statistics() { + let sim = QualcommCsiSimulator::new(SimulatorConfig::default()).unwrap(); + let snapshot = QualcommCsiSnapshot::from_frame(&sim.capabilities_frame()); + assert_eq!(snapshot.report_kind, "capabilities"); + assert_eq!(snapshot.mean_amplitude, None); + assert!(snapshot.rssi_dbm.is_empty()); + } +}