mirror of
https://github.com/ruvnet/RuView
synced 2026-07-19 16:53:18 +00:00
feat(hardware): add Qualcomm CSI simulator and vendor roadmap (#1359)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<u16>,
|
||||
#[arg(long, default_value_t = 2)]
|
||||
tx: u8,
|
||||
#[arg(long)]
|
||||
rx: Option<u8>,
|
||||
#[arg(long)]
|
||||
subcarriers: Option<u16>,
|
||||
#[arg(long, default_value_t = 20)]
|
||||
interval_ms: u64,
|
||||
#[arg(long)]
|
||||
udp: Option<SocketAddr>,
|
||||
/// Replay: little-endian u32 length followed by one ADR-269 envelope.
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
realtime: bool,
|
||||
}
|
||||
fn parse_u64(v: &str) -> Result<u64, String> {
|
||||
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<SocketAddr>,
|
||||
output: &mut Option<File>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<u8> for ReportKind {
|
||||
type Error = CsiParseError;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
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<u16> for ChipsetProfile {
|
||||
type Error = CsiParseError;
|
||||
fn try_from(value: u16) -> Result<Self, Self::Error> {
|
||||
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<u8> for ElementFormat {
|
||||
type Error = CsiParseError;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
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<u8> for PpduType {
|
||||
type Error = CsiParseError;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
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<i8>,
|
||||
values: Vec<[i16; 2]>,
|
||||
},
|
||||
ComplexF32 {
|
||||
rssi_dbm: Vec<i8>,
|
||||
values: Vec<[f32; 2]>,
|
||||
},
|
||||
Bytes(Vec<u8>),
|
||||
}
|
||||
|
||||
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<Vec<u8>, 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<Self, CsiParseError> {
|
||||
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<CsiFrame, CsiParseError> {
|
||||
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))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<mediatek_csi::MediatekCsiSnapshot>,
|
||||
/// Instant of the last validated MediaTek CSI UDP frame.
|
||||
last_mediatek_frame: Option<std::time::Instant>,
|
||||
/// Latest validated Qualcomm CSI summary; raw matrices are not retained here.
|
||||
latest_qualcomm_csi: Option<qualcomm_csi::QualcommCsiSnapshot>,
|
||||
/// Instant of the last validated Qualcomm CSI UDP frame.
|
||||
last_qualcomm_frame: Option<std::time::Instant>,
|
||||
tx: broadcast::Sender<String>,
|
||||
// ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state +
|
||||
// a parallel broadcast topic (`/ws/introspection`) running alongside
|
||||
@@ -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<SharedState>) -> Json<serde_jso
|
||||
}
|
||||
}
|
||||
|
||||
async fn latest_qualcomm_csi(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
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.
|
||||
|
||||
@@ -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<i8>,
|
||||
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<f32>,
|
||||
pub peak_amplitude: Option<f32>,
|
||||
}
|
||||
|
||||
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<f32>, Option<f32>) {
|
||||
let amplitudes: Vec<f32> = 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::<f32>() / 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user