feat(hardware): add MediaTek Filogic CSI simulator (#1358)

This commit is contained in:
rUv
2026-07-18 22:00:09 -04:00
committed by GitHub
parent 8a5af5dad4
commit 232b1c79f6
11 changed files with 1180 additions and 5 deletions
@@ -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 | **Unknown** — MediaTek has released CSI tools for some chips | Yes | Medium-High |
| MediaTek MT7661 | mt76 | **Unverified** — no supported public CSI capture API was found in upstream `mt76` or public MediaTek SDK material | Yes | Research only |
2. **CSI extraction architecture** (Linux kernel driver modification):
@@ -0,0 +1,75 @@
# 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/)
@@ -0,0 +1,61 @@
# 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)
+3 -3
View File
@@ -425,7 +425,7 @@ pub enum WifiChipset {
BroadcomBcm43455,
/// Realtek RTL8822CS via modified rtw88 driver.
RealtekRtl8822cs,
/// MediaTek MT7661 via mt76 driver modification.
/// Proposed MediaTek MT7661 research target; no public CSI export is verified.
MediatekMt7661,
}
@@ -455,7 +455,7 @@ pub struct Esp32CompatFrame {
```
**Domain Services:**
- `CsiExtractionService` — Reads raw CSI from patched driver via Netlink socket (BCM43455), procfs (RTL8822CS), or UDP (MT7661)
- `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.
- `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 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.
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.
### Armbian Platform ACL
+32
View File
@@ -0,0 +1,32 @@
# 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.
@@ -28,6 +28,21 @@ 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
@@ -0,0 +1,133 @@
//! 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(())
}
@@ -53,6 +53,8 @@ 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-264 host-side framing for Realtek RTL8720F CFR and FMCW radar reports.
/// This module has no dependency on the vendor SDK.
pub mod rtl8720f;
@@ -73,6 +75,13 @@ 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 rtl8720f::{
ElementFormat as Rtl8720fElementFormat, RadarFlags as Rtl8720fRadarFlags,
RadarFrame as Rtl8720fRadarFrame, RadarParseError as Rtl8720fRadarParseError,
@@ -0,0 +1,680 @@
//! 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]);
}
}
}
@@ -17,6 +17,7 @@ mod field_bridge;
mod field_localize;
mod model_format;
mod multistatic_bridge;
mod mediatek_csi;
mod realtek_radar;
pub mod pose;
mod rvf_container;
@@ -1033,6 +1034,10 @@ 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>,
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
@@ -1211,6 +1216,13 @@ 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);
}
}
}
self.source.clone()
}
}
@@ -3371,6 +3383,14 @@ 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"})),
}
}
/// Generate WiFi-derived pose keypoints from sensing data.
///
/// Keypoint positions are modulated by real signal features rather than a pure
@@ -5465,7 +5485,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 and RTL8720F radar frames");
info!("UDP listening on {addr} for ESP32 CSI, MediaTek CSI, and RTL8720F radar frames");
s
}
Err(e) => {
@@ -5478,6 +5498,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::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
@@ -7596,6 +7636,8 @@ async fn main() {
last_esp32_frame: None,
latest_realtek_radar: None,
last_realtek_frame: None,
latest_mediatek_csi: None,
last_mediatek_frame: None,
tx,
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
intro_tx,
@@ -7813,6 +7855,7 @@ 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))
// 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-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());
}
}