Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot] bd5d946475 chore: update vendor submodules to latest upstream 2026-07-19 00:43:27 +00:00
20 changed files with 6 additions and 2409 deletions
-1
View File
@@ -132,7 +132,6 @@ 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 ($610) | ~$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`
@@ -83,7 +83,7 @@ This ADR covers Phase 1 (TV box as aggregator) and Phase 2 (custom WiFi firmware
|---------|--------|-------------|--------------|--------|
| Broadcom BCM43455 | brcmfmac | **Proven** (Nexmon CSI) | Yes | Low — patches exist |
| Realtek RTL8822CS | rtw88 | **Moderate** — driver is open-source, CSI hooks need adding | Yes (patched) | Medium |
| MediaTek MT7661 | mt76 | **Unverified** — no supported public CSI capture API was found in upstream `mt76` or public MediaTek SDK material | Yes | Research only |
| MediaTek MT7661 | mt76 | **Unknown** — MediaTek has released CSI tools for some chips | Yes | Medium-High |
2. **CSI extraction architecture** (Linux kernel driver modification):
@@ -1,75 +0,0 @@
# ADR-266: MediaTek Filogic CSI Platform
- **Status**: accepted
- **Date**: 2026-07-18
- **Deciders**: RuView maintainers
- **Tags**: mediatek, filogic, mt76, csi, openwrt, rust
## Context
RuView needs a high-antenna-count, router-class Wi-Fi sensing path beyond ESP32.
MediaTek Filogic platforms are attractive because the upstream BSD-3-Clause
`mt76` driver supports MT7915/MT792x/MT7996 families and OpenWrt supports
MT7981/MT7986/MT7988 systems. The OpenWrt One (MT7981B + MT7976C) additionally
publishes schematics, platform datasheets, register documentation, serial, and
JTAG access. The BPI-R3 (MT7986 + MT7975N/P) offers dual-band 4x4 radios.
The current upstream `mt76` tree has testmode, debugfs, RX descriptors, and MCU
event plumbing, but no supported public interface for exporting per-packet
complex channel estimates. Public MediaTek SDK material likewise does not expose
an equivalent to Espressif's CSI callback. PHY computation of channel estimates
does not imply that firmware transfers those estimates to host memory.
Existing RuView documents that describe MT7661 CSI-over-UDP or released
MediaTek CSI tools are unverified architectural hypotheses, not supported
hardware claims.
## Decision
1. Use the OpenWrt One as the primary future hardware/upstreaming target and the
BPI-R3 as the secondary 4x4 validation target.
2. Build a Rust-first simulator and host transport before hardware arrives.
3. Keep the transport independent of private firmware structures. A future
`mt76` adapter must translate a documented kernel/firmware report into it.
4. Prefer Generic Netlink for capability/control messages and relayfs or a
bounded character-device stream if sustained CSI volume exceeds Netlink's
practical throughput.
5. Do not redistribute vendor firmware, private headers, or SDK components.
6. Label simulator frames end-to-end and never present them as physical capture.
7. Do not claim MediaTek hardware CSI support until complex CSI from a physical
device passes calibration, sequence, timestamp, and repeatability tests.
## Consequences
### Positive
- Development and integration testing can start without fabricating a vendor ABI.
- OpenWrt One provides a repairable, upstream-friendly hardware target.
- The same RuView ingestion path can accept simulator, replay, and future driver data.
- Rust bounds checking isolates untrusted kernel/network input from inference code.
### Negative
- The simulator cannot prove firmware export availability or sensing accuracy.
- A firmware change or MediaTek cooperation may be required before physical CSI exists.
- Router-class builds and driver iteration are slower than MCU firmware development.
### Neutral
- NeuroPilot may later accelerate inference but is unrelated to CSI capture.
- Wi-Fi 7/MLO support remains a later phase after a single-link contract is stable.
## Hardware gates
- Identify a firmware/host report containing complex channel estimates.
- Document dimensions, quantization, chain ordering, subcarrier indexing, lifetime,
timestamps, sequence behavior, calibration, maximum size, and report rate.
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
## Links
- [ADR-123: BFLD capture path](ADR-123-bfld-capture-path-nexmon-and-esp32.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- [upstream mt76](https://github.com/openwrt/mt76)
- [OpenWrt One](https://openwrt.org/toh/openwrt/one)
- [MediaTek OpenWrt feed](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/)
@@ -1,61 +0,0 @@
# ADR-267: MediaTek MIMO CSI Wire Protocol
- **Status**: accepted
- **Date**: 2026-07-18
- **Deciders**: RuView maintainers
- **Tags**: mediatek, csi, protocol, rust, udp, replay
## Context
The MediaTek simulator, captured regression fixtures, and a future `mt76` agent
need one safe host-side representation. Copying an undocumented firmware layout
would couple RuView to a private ABI and make malformed kernel/network data risky.
MIMO CSI also requires explicit Tx/Rx/subcarrier dimensions and per-Rx-chain RSSI.
## Decision
Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- 72-byte fixed header with magic, version, report kind, total length, sequence,
monotonic timestamp, device ID, chipset profile, frequency, bandwidth, flags,
Tx/Rx dimensions, numeric format, PPDU type, subcarrier count, noise floor,
scale, subcarrier spacing, calibration ID, and payload length.
- CSI payload begins with one signed RSSI byte per Rx chain, followed by
`tx_count * rx_count * subcarrier_count` complex values in Tx-major,
Rx-major, subcarrier-major order.
- Supported numeric formats are complex signed i16 and complex finite f32.
- Capability reports use bounded opaque TLVs until a public driver contract exists.
- CRC-32/IEEE covers header and payload; the final four bytes carry the checksum.
- One envelope maps to one UDP datagram, capped at the IPv4 UDP payload maximum
of 65,507 bytes. Replay files prefix each envelope with a little-endian `u32`.
- Parsers reject unknown versions/types/formats, invalid dimensions/bandwidth,
multiplication overflow, inconsistent payload lengths, non-finite floats,
bad CRC, trailing datagram bytes, and frames above the cap.
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
## Consequences
### Positive
- Deterministic simulator and future hardware use identical parsing and APIs.
- Explicit dimensions prevent ambiguous antenna or subcarrier interpretation.
- CRC, finite-value checks, and hard caps make network/replay ingestion robust.
- The format supports MT7981, MT7986, and MT7996 profiles without claiming their
undocumented firmware layouts.
### Negative
- A translation/copy step is required from a future kernel report.
- Maximum-size Wi-Fi 7 matrices may need segmentation in a later protocol version.
### Neutral
- Version 1 models one link per report; MLO correlation is a future extension.
- Capability TLVs are intentionally conservative until hardware metadata is known.
## Links
- [ADR-266: MediaTek Filogic CSI platform](ADR-266-mediatek-filogic-csi-platform.md)
- [ADR-018: ESP32 binary CSI framing](ADR-018-esp32-csi-frame-protocol.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
@@ -1,41 +0,0 @@
# 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)
@@ -1,40 +0,0 @@
# 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)
@@ -1,98 +0,0 @@
# 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)
+3 -3
View File
@@ -425,7 +425,7 @@ pub enum WifiChipset {
BroadcomBcm43455,
/// Realtek RTL8822CS via modified rtw88 driver.
RealtekRtl8822cs,
/// Proposed MediaTek MT7661 research target; no public CSI export is verified.
/// MediaTek MT7661 via mt76 driver modification.
MediatekMt7661,
}
@@ -455,7 +455,7 @@ pub struct Esp32CompatFrame {
```
**Domain Services:**
- `CsiExtractionService` — Reads raw CSI from a validated chipset adapter. Nexmon/BCM43455 is the established Linux example; RTL8822CS and MT7661 remain unverified research targets and must not be advertised as working capture paths.
- `CsiExtractionService` — Reads raw CSI from patched driver via Netlink socket (BCM43455), procfs (RTL8822CS), or UDP (MT7661)
- `SubcarrierResamplerService` — Resamples chipset-specific subcarrier counts to match ESP32 format (e.g., 256 → 128 via decimation or interpolation)
- `ProtocolTranslatorService` — Converts `ChipsetCsiFrame` to `Esp32CompatFrame` with ADR-018 binary encoding
- `CalibrationService` — Compensates for chipset-specific phase offsets, antenna spacing, and gain differences relative to ESP32 CSI
@@ -625,7 +625,7 @@ pub struct EspNodeConnection {
### ESP32 Protocol ACL (CSI Bridge)
The WiFi CSI Bridge translates validated chipset-specific CSI formats into a versioned RuView envelope. Nexmon is the established Linux example; rtw88 and mt76 require a verified complex-CSI export before implementation. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
The WiFi CSI Bridge translates chipset-specific CSI formats (Nexmon, rtw88, mt76) into the ESP32 binary protocol (ADR-018). The sensing server never knows whether frames came from a real ESP32 or a TV box WiFi chipset. Virtual node IDs (200-254) prevent collision with physical ESP32 IDs but are otherwise treated identically by the ingestion context.
### Armbian Platform ACL
-32
View File
@@ -1,32 +0,0 @@
# RuView v0.9.1-mediatek-beta.1
This simulator-first beta adds a Rust MediaTek Filogic MIMO CSI transport and
RuView ingestion path while preserving the boundary between demonstrated host
integration and unavailable physical CSI export.
## Included
- ADR-266 selects OpenWrt One (MT7981/MT7976) as the primary future hardware
target and BPI-R3 (MT7986/MT7975) as the secondary 4x4 target.
- ADR-267 defines the bounded, versioned, CRC-protected `MTC1` wire protocol.
- `mediatek-csi-sim` provides deterministic MT7981, MT7986, and MT7996 profiles,
complex MIMO CSI, per-chain RSSI, UDP streaming, and replay output.
- RuView validates MediaTek datagrams, publishes bounded WebSocket summaries,
and exposes `/api/v1/csi/mediatek/latest`.
- `mediatek:simulated` provenance is retained end to end.
## Validation
- Codec round trips, deterministic output, corruption/truncation rejection,
dimension limits, finite-value enforcement, and prefix parsing are tested.
- All hardware and sensing-server regression tests pass.
- All three profiles were streamed over loopback UDP and verified through the
RuView REST API.
## Hardware boundary
Upstream `mt76` and public MediaTek SDK material do not currently expose a
supported raw complex CSI API. This release does not redistribute private SDK
material, invent a firmware ABI, or claim physical MediaTek capture. Hardware
support requires a documented firmware/driver channel-estimate export followed
by calibration and repeatability validation.
-22
View File
@@ -1,22 +0,0 @@
# 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.
@@ -28,21 +28,6 @@ cargo run -p wifi-densepose-hardware --bin rtl8720f-sim -- `
Add `--udp 127.0.0.1:5005 --realtime` to stream one ADR-264 frame per UDP datagram. Replay files
contain a little-endian `u32` frame length followed by the encoded frame.
## MediaTek Filogic CSI simulator (ADR-266/267)
The Rust-only simulator models bounded MIMO CSI for MT7981/MT7976,
MT7986/MT7975, and MT7988/MT7996 profiles without claiming an undocumented
MediaTek firmware ABI. Every frame is marked `SYNTHETIC`.
```powershell
cargo run -p wifi-densepose-hardware --bin mediatek-csi-sim -- `
--profile mt7981 --frames 100 --output mediatek-synthetic.mtc
```
Add `--udp 127.0.0.1:5005 --realtime` to stream one CRC-protected ADR-267
frame per UDP datagram. Physical support remains gated on a documented `mt76`
or MediaTek firmware channel-estimate export.
## Features
- **ESP32 binary parser** -- Parses ADR-018 binary CSI frames streamed over UDP from ESP32 and
@@ -1,133 +0,0 @@
//! Deterministic MediaTek Filogic MIMO CSI simulator (ADR-266/267).
use clap::{Parser, ValueEnum};
use std::{
fs::File,
io::{self, Write},
net::{SocketAddr, UdpSocket},
path::PathBuf,
thread,
time::Duration,
};
use wifi_densepose_hardware::mediatek_csi::{
simulator::{MediatekCsiSimulator, SimulatorConfig},
ChipsetProfile, CsiFrame,
};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Profile {
Mt7981,
Mt7986,
Mt7996,
}
impl Profile {
fn chipset(self) -> ChipsetProfile {
match self {
Self::Mt7981 => ChipsetProfile::Mt7981Mt7976,
Self::Mt7986 => ChipsetProfile::Mt7986Mt7975,
Self::Mt7996 => ChipsetProfile::Mt7988Mt7996,
}
}
fn default_chains(self) -> u8 {
match self {
Self::Mt7981 => 3,
Self::Mt7986 | Self::Mt7996 => 4,
}
}
}
#[derive(Debug, Parser)]
#[command(
name = "mediatek-csi-sim",
about = "Emit synthetic ADR-267 MediaTek Filogic MIMO CSI frames"
)]
struct Args {
#[arg(long, value_enum, default_value_t=Profile::Mt7981)]
profile: Profile,
#[arg(long, default_value_t = 100)]
frames: u32,
#[arg(long, default_value="0x4d544b4353490001", value_parser=parse_u64)]
seed: u64,
#[arg(long, default_value_t = 80)]
bandwidth: u16,
#[arg(long, default_value_t = 2)]
tx: u8,
#[arg(long)]
rx: Option<u8>,
#[arg(long, default_value_t = 256)]
subcarriers: u16,
#[arg(long, default_value_t = 20)]
interval_ms: u64,
#[arg(long)]
udp: Option<SocketAddr>,
/// Replay: little-endian u32 length followed by one ADR-267 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,
tx_count: a.tx,
rx_count: a.rx.unwrap_or_else(|| a.profile.default_chains()),
subcarriers: a.subcarriers,
frame_period_us: a.interval_ms * 1000,
..Default::default()
};
let mut sim = MediatekCsiSimulator::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 MediaTek CSI frames ({} bytes, profile={}, seed={:#x})",
a.frames + 1,
bytes,
a.profile.chipset().name(),
a.seed
);
Ok(())
}
@@ -1,147 +0,0 @@
//! 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(())
}
@@ -53,10 +53,6 @@ pub mod sync_packet;
// coordinator-node Rust code drive the controller stack without
// touching any downstream signal/ruvector/train/mat crate.
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;
@@ -77,20 +73,6 @@ pub use radio_ops::{
RadioError, RadioHealth, RadioMode, RadioOps, MESH_HEADER_SIZE, MESH_MAGIC, MESH_MAX_PAYLOAD,
MESH_VERSION,
};
pub use mediatek_csi::{
ChipsetProfile as MediatekChipsetProfile, CsiFlags as MediatekCsiFlags,
CsiFrame as MediatekCsiFrame, CsiParseError as MediatekCsiParseError,
CsiPayload as MediatekCsiPayload, ElementFormat as MediatekElementFormat,
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,
@@ -1,680 +0,0 @@
//! Vendor-neutral MediaTek Filogic MIMO CSI transport and deterministic simulator.
//! This is not a MediaTek firmware ABI; see ADR-266/267.
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const MEDIATEK_CSI_MAGIC: u32 = 0x3143_544d; // "MTC1" little endian
pub const MEDIATEK_CSI_VERSION: u8 = 1;
pub const MEDIATEK_CSI_HEADER_LEN: usize = 72;
pub const MEDIATEK_CSI_CRC_LEN: usize = 4;
pub const MEDIATEK_CSI_MAX_FRAME_LEN: usize = 65_507;
pub const MEDIATEK_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 {
Mt7981Mt7976 = 1,
Mt7986Mt7975 = 2,
Mt7988Mt7996 = 3,
}
impl TryFrom<u16> for ChipsetProfile {
type Error = CsiParseError;
fn try_from(value: u16) -> Result<Self, Self::Error> {
match value {
1 => Ok(Self::Mt7981Mt7976),
2 => Ok(Self::Mt7986Mt7975),
3 => Ok(Self::Mt7988Mt7996),
_ => Err(CsiParseError::UnknownChipset(value)),
}
}
}
impl ChipsetProfile {
pub fn name(self) -> &'static str {
match self {
Self::Mt7981Mt7976 => "mt7981-mt7976",
Self::Mt7986Mt7975 => "mt7986-mt7975",
Self::Mt7988Mt7996 => "mt7988-mt7996",
}
}
pub fn max_chains(self) -> u8 {
match self {
Self::Mt7981Mt7976 => 3,
Self::Mt7986Mt7975 | Self::Mt7988Mt7996 => 4,
}
}
}
#[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 = MEDIATEK_CSI_HEADER_LEN
.checked_add(payload_len)
.and_then(|n| n.checked_add(MEDIATEK_CSI_CRC_LEN))
.ok_or(CsiParseError::LengthOverflow)?;
if frame_len > MEDIATEK_CSI_MAX_FRAME_LEN {
return Err(CsiParseError::FrameTooLarge(frame_len));
}
let mut out = Vec::with_capacity(frame_len);
out.extend_from_slice(&MEDIATEK_CSI_MAGIC.to_le_bytes());
out.push(MEDIATEK_CSI_VERSION);
out.push(self.report_kind as u8);
out.extend_from_slice(&(MEDIATEK_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(), MEDIATEK_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() < MEDIATEK_CSI_HEADER_LEN {
return Err(CsiParseError::InsufficientData {
needed: MEDIATEK_CSI_HEADER_LEN,
got: input.len(),
});
}
let magic = u32_at(input, 0);
if magic != MEDIATEK_CSI_MAGIC {
return Err(CsiParseError::InvalidMagic(magic));
}
if input[4] != MEDIATEK_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 != MEDIATEK_CSI_HEADER_LEN {
return Err(CsiParseError::InvalidHeaderLength(header_len));
}
let frame_len = u32_at(input, 8) as usize;
if frame_len > MEDIATEK_CSI_MAX_FRAME_LEN {
return Err(CsiParseError::FrameTooLarge(frame_len));
}
if frame_len < header_len + MEDIATEK_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) {
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 > MEDIATEK_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: 0x4d54_4b43_5349_0001,
device_id: 0x4f57_5254_4d54_4b31,
chipset: ChipsetProfile::Mt7981Mt7976,
bandwidth_mhz: 80,
center_freq_khz: 5_210_000,
tx_count: 2,
rx_count: 3,
subcarriers: 256,
frame_period_us: 20_000,
}
}
}
pub struct MediatekCsiSimulator {
config: SimulatorConfig,
rng: u64,
sequence: u32,
timestamp_us: u64,
motion_phase: f32,
}
impl MediatekCsiSimulator {
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 = MediatekCsiSimulator::new(cfg.clone()).unwrap();
let mut b = MediatekCsiSimulator::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 * 256);
}
#[test]
fn capabilities_round_trip() {
let s = MediatekCsiSimulator::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 = MediatekCsiSimulator::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 = MediatekCsiSimulator::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::Mt7981Mt7976,
..Default::default()
};
assert!(matches!(
MediatekCsiSimulator::new(cfg),
Err(CsiParseError::InvalidDimensions)
));
}
#[test]
fn non_finite_float_is_rejected() {
let mut s = MediatekCsiSimulator::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 * 256],
};
assert_eq!(f.to_bytes().unwrap_err(), CsiParseError::NonFiniteValue);
}
#[test]
fn parser_never_panics_on_prefixes() {
let mut s = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let w = s.next_frame().to_bytes().unwrap();
for end in 0..w.len() {
let _ = CsiFrame::from_bytes(&w[..end]);
}
}
}
@@ -1,700 +0,0 @@
//! 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))
));
}
}
@@ -17,8 +17,6 @@ mod field_bridge;
mod field_localize;
mod model_format;
mod multistatic_bridge;
mod mediatek_csi;
mod qualcomm_csi;
mod realtek_radar;
pub mod pose;
mod rvf_container;
@@ -1035,14 +1033,6 @@ struct AppStateInner {
latest_realtek_radar: Option<realtek_radar::RealtekRadarSnapshot>,
/// Instant of the last validated RTL8720F UDP frame.
last_realtek_frame: Option<std::time::Instant>,
/// Latest validated MediaTek CSI summary; raw matrices are not retained here.
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
@@ -1221,20 +1211,6 @@ impl AppStateInner {
}
}
}
if self.source.starts_with("mediatek") {
if let Some(last) = self.last_mediatek_frame {
if last.elapsed() > ESP32_OFFLINE_TIMEOUT {
return format!("{}:offline", self.source);
}
}
}
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()
}
}
@@ -3395,22 +3371,6 @@ async fn latest_realtek_radar(State(state): State<SharedState>) -> Json<serde_js
}
}
async fn latest_mediatek_csi(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
match &s.latest_mediatek_csi {
Some(snapshot) => Json(serde_json::to_value(snapshot).unwrap_or_default()),
None => Json(serde_json::json!({"status": "no MediaTek CSI data yet"})),
}
}
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
@@ -5505,7 +5465,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, MediaTek, Qualcomm CSI, and RTL8720F radar frames");
info!("UDP listening on {addr} for ESP32 CSI and RTL8720F radar frames");
s
}
Err(e) => {
@@ -5518,46 +5478,6 @@ 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
{
match wifi_densepose_hardware::mediatek_csi::CsiFrame::from_bytes(&buf[..len]) {
Ok((frame, consumed)) if consumed == len => {
let snapshot = mediatek_csi::MediatekCsiSnapshot::from_frame(&frame);
debug!("MediaTek 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_mediatek_frame = Some(std::time::Instant::now());
s.latest_mediatek_csi = Some(snapshot);
if let Some(json) = json { let _ = s.tx.send(json); }
}
Ok((_, consumed)) => warn!("MediaTek CSI datagram from {src} has trailing bytes: consumed={consumed} received={len}"),
Err(error) => warn!("Rejected MediaTek CSI datagram from {src}: {error}"),
}
continue;
}
if len >= 4
&& u32::from_le_bytes(buf[..4].try_into().expect("four-byte slice"))
== wifi_densepose_hardware::rtl8720f::RTL8720F_RADAR_MAGIC
@@ -7676,10 +7596,6 @@ async fn main() {
last_esp32_frame: None,
latest_realtek_radar: None,
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,
@@ -7897,8 +7813,6 @@ async fn main() {
// Sensing endpoints
.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.
@@ -1,127 +0,0 @@
//! Bounded summaries for ADR-267 MediaTek MIMO CSI frames.
use serde::Serialize;
use wifi_densepose_hardware::mediatek_csi::{CsiFlags, CsiFrame, CsiPayload, ReportKind};
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct MediatekCsiSnapshot {
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 MediatekCsiSnapshot {
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: "mediatek_csi",
source: if synthetic {
"mediatek:simulated"
} else {
"mediatek"
},
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::mediatek_csi::simulator::{MediatekCsiSimulator, SimulatorConfig};
#[test]
fn simulator_summary_preserves_dimensions_and_provenance() {
let mut sim = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = MediatekCsiSnapshot::from_frame(&sim.next_frame());
assert_eq!(snapshot.source, "mediatek:simulated");
assert_eq!(
(
snapshot.tx_count,
snapshot.rx_count,
snapshot.subcarrier_count
),
(2, 3, 256)
);
assert_eq!(snapshot.element_count, 1536);
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 = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
let snapshot = MediatekCsiSnapshot::from_frame(&sim.capabilities_frame());
assert_eq!(snapshot.report_kind, "capabilities");
assert_eq!(snapshot.mean_amplitude, None);
assert!(snapshot.rssi_dbm.is_empty());
}
}
@@ -1,127 +0,0 @@
//! 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());
}
}