Merge PR #2116 (aepod) into release integration

This commit is contained in:
ruv committed 2026-10-05 11:46:08 -04:00
commit b6c2fb5b7d
10 files changed
+2511 -1

No files matched your search

@@ -66,10 +66,64 @@ hardware claims.
timestamps, sequence behavior, calibration, maximum size, and report rate.
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
## Amendment 1 — vendor CSI on MT7981, still unvalidated
- **Date**: 2026-09-19, recorded here 2026-10-01
- **Status**: proposed. One board model (two units). The hardware gates below are still open.
The 2026-07-18 decision said the public `mt76` tree has no supported CSI export,
and that a firmware change might be required before physical CSI exists. Both
statements need a narrower reading.
Mainline `openwrt/mt76` still has no CSI export. MediaTek's public OpenWrt feed
does: vendor id `0x0ce7`, MCU event `0xc2`, patch
`1001-wifi-mt76-mt7915-csi-implement-csi-support`. On a Wavlink WL-WN586X3
Rev A (MT7981BA + MT7976CN + MT7531AE) that patch, rebased onto the `mt76`
revision pinned by OpenWrt 24.10.8, produced complex samples without a firmware
blob swap. The `0xc2` hook is already in the MT7981 firmware that the OpenWrt
24.10.8 image carries. The
rebased patch and the two userspace fixes are in `firmware/openwrt-wn586x3/`.
What the first capture was: one record, 2.4 GHz, 20 MHz, legacy OFDM, 64
subcarriers, a single chain. CSI arrived only while a client sent frames.
The rate is client traffic, not a sensor sample clock. The capture is not in
this repository.
What it was not:
- Not a calibration, a sequence check, a timestamp check, or a repeatability run.
The label on the wire is `mediatek:physical-unvalidated`. Plain `mediatek` stays
reserved for a frame that has passed those gates.
- Not a 4x4 array. Each radio is two chains. The four antennas are a 2.4 GHz pair
and a 5 GHz pair. Two routers are two receivers. Cross-box phase is not a
shared local oscillator, so it is not a beam.
- Not the OpenWrt One or the BPI-R3. Those remain the upstream-friendly targets
in the original decision. The WN586X3 is the board this path was actually run on.
`wifi-densepose-mtk-bridge` translates `mt76-vendor` JSON, or an MtkCSIdump
datagram, into ADR-267 MTC1 and sends it to the sensing server. The server keeps
one snapshot and a bounded ring of per-subcarrier amplitude and phase per
`device_id`. It runs `mediatek-amplitude-heuristic-v0` on mean and peak amplitude,
and `activity-index-v0` on per-subcarrier fluctuation. Both are `CLAIMED`. Neither
is the ESP32 vital-sign path.
One measured property supports further work, and nothing more. Offline, on six
local dump captures from two units (2.4 GHz, HT20, chains rx0 and rx1 of transmit
stream 0), the cross-chain phase `arg(H0·conj(H1))`, summed over subcarriers,
moved 0.014-0.039 rad (p50) between adjacent frames for well-sampled
transmitters. Raw per-chain phase moved about 1.56 rad (p50), which is what
uniformly random phase gives (MEASURED; reproducer
`scripts/mediatek-csi-phase-stability.py`). That shows a stable observable inside
one radio. It does not show that the observable tracks people.
The operator guide is [`docs/mediatek-router-csi.md`](../mediatek-router-csi.md).
## 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)
- [ADR-267: MediaTek MIMO CSI wire protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
- [MediaTek router CSI guide](../mediatek-router-csi.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/)
+361
View File
@@ -0,0 +1,361 @@
# MediaTek Router CSI (MT7981, experimental)
RuView can take channel state information (CSI) from a MediaTek Filogic
router, such as an MT7981B with an MT7976C radio running OpenWrt with
MediaTek's vendor CSI patch. The router reports CSI. A host-side bridge
converts it to RuView's MTC1 wire format (ADR-267). The sensing server ingests
it like any other UDP source.
**Status: experimental.** This page documents a working data path, measured
on one board model. It makes no sensing-quality claim. Frames from this path
carry the provenance label `mediatek:physical-unvalidated`. The ADR-266
hardware gates are still open: calibration, sequence, timestamp, and
repeatability.
Developed in whitsentry, a RuView-based project.
Evidence tags used below: **MEASURED** (with a reproducer), **CODE-DERIVED**
(read from source or build scripts, not run here), **CLAIMED** (stated, not
independently verified), **SYNTHETIC** (simulated or fabricated data).
## Contents
- [Supported hardware](#supported-hardware)
- [How the path works](#how-the-path-works)
- [Build the OpenWrt image](#build-the-openwrt-image)
- [Arm CSI on the router](#arm-csi-on-the-router)
- [Run the bridge](#run-the-bridge)
- [Start the sensing server](#start-the-sensing-server)
- [What the data is, and is not](#what-the-data-is-and-is-not)
- [Evidence](#evidence)
- [Troubleshooting](#troubleshooting)
- [Reference](#reference)
## Supported hardware
| Board | SoC + radio | Status |
|---|---|---|
| Wavlink WL-WN586X3 **Rev A** | MT7981BA + MT7976CN (switch MT7531AE) | Run on two units. CSI captured from both is analysed offline below (MEASURED). Live delivery to the sensing server is CLAIMED from local run logs that are not in the tree. |
| OpenWrt One | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table and ADR-266. Not run with this patch set. |
| Xiaomi AX3000T | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table. Not run with this patch set. |
| Other MT7981/MT7986 boards | — | Untested. The CSI patch targets the `mt7915` driver family, so other Filogic radios may work, but nothing here shows that. |
The board patch in `firmware/openwrt-wn586x3/` is specific to the WN586X3
**Rev A**. The CSI driver patch and the two userspace fixes do not depend on
the board.
Each MT7976C radio has **two receive chains**. The WN586X3's four external
antennas are a 2.4 GHz pair and a 5 GHz pair. It is not a 4x4 array, and two
routers are two independent receivers, not one phased array.
## How the path works
```
router (OpenWrt 24.10.8) host
┌────────────────────────────────┐ ┌──────────────────────────────┐
│ mt76 + MediaTek CSI patch │ │ wifi-densepose-mtk-bridge │
│ firmware MCU event 0xc2 │ JSON dump │ groups chains into frames, │
│ mt76-vendor dump csi ─────────┼──────────────►│ applies provenance gate, │
│ CSIdump (UDP, optional) ───────┼──── UDP ─────►│ emits ADR-267 MTC1 │
└────────────────────────────────┘ └──────────────┬───────────────┘
│ UDP :5005
┌──────────────▼───────────────┐
│ sensing-server │
│ --source mediatek │
│ /api/v1/csi/mediatek/* │
└──────────────────────────────┘
```
Two transports leave the router:
- **`mt76-vendor <iface> dump csi <n> <file>`** writes a JSON array of
per-chain records: timestamp, transmitter address, RSSI, SNR, bandwidth,
PPDU mode, tx/rx chain index, chain marker, and I/Q. This is the richer
path, and the one that carried sustained use. You copy the file to the host
(for example, a loop over SSH) and replay it through the bridge.
- **`CSIdump`** (MtkCSIdump) streams one antenna per UDP datagram and drops
RSSI, PPDU mode, and transmitter identity. The bridge emits one 1x1 frame per
datagram. This path was verified end to end once, as a recording. It has not
run as a standing service.
## Build the OpenWrt image
The patch set lives in [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md).
It is not a firmware image. Build and hash your own.
| Patch | Applies to |
|---|---|
| `1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch` | `mt76` package (`package/kernel/mt76/patches/`) |
| `mt76-vendor-csi-dump-bounds.patch` | `mt76-vendor` source (`feed/app/mt76-vendor/src/csi.c` in `mediatek/mtk-openwrt-feeds`) |
| `csidump-nl80211-attr-enum.patch` | MtkCSIdump `src/wifi_drv_api/mt76_api.cpp` (only if you use CSIdump) |
| `0001-wn586x3-reva-single-image-sysupgrade-and-generic-spi-nor.patch` | `target/linux/mediatek` (WN586X3 Rev A only) |
The route that produced the tested image (CODE-DERIVED from its build scripts)
used the stock OpenWrt **24.10.8** `mediatek/filogic` SDK and ImageBuilder in
an x86_64 Linux container:
1. **SDK.** Unpack `openwrt-sdk-24.10.8-mediatek-filogic`, then run
`./scripts/feeds update -a && ./scripts/feeds install -a`.
2. **CSI driver.** Copy the rebased `1001-...` patch into
`feeds/base/package/kernel/mt76/patches/`. Raise `PKG_RELEASE` in that
package's `Makefile` (the tested build used `99`) so the ImageBuilder
prefers your build over the CDN package of the same version.
3. **mt76-vendor.** Add MediaTek's `mt76-vendor` package from
`mtk-openwrt-feeds` as `package/mt76-vendor`, with the bounds patch applied
to `src/csi.c`.
4. **Configure and compile.** Select `CONFIG_TARGET_mediatek_filogic_DEVICE_wavlink_wl-wn586x3`,
`kmod-mt7915e`, `mt76-vendor`, and `libnl-tiny`. Then run
`make package/feeds/base/mt76/compile package/mt76-vendor/compile`.
5. **Image.** Copy the built `.ipk` files into the ImageBuilder's `packages/`
and run:
```bash
make image PROFILE=wavlink_wl-wn586x3 \
PACKAGES="kmod-mt7915e kmod-mt7981-firmware mt7981-wo-firmware mt76-vendor libnl-tiny"
```
6. **Check the manifest.** The image's `.manifest` must list `kmod-mt7915e`
at your raised release (for example `-r99`) and `mt76-vendor`. If it lists
the CDN release, the image has no CSI.
**Rev A board patch.** An ImageBuilder cannot compile a device tree, and its
`make image` reuses the prebuilt DTB and FIT kernel. The tested Rev A build
compiled the patched DTB itself, rebuilt the FIT kernel around it, added the
patched `lib/upgrade/platform.sh` as a rootfs overlay, then checked that the
final sysupgrade image embedded that DTB. A full OpenWrt buildroot applies
`target/` patches directly and avoids those steps. That route was not tested
here.
Flash only a Rev A unit, keep the stock firmware backup, and follow the
OpenWrt wiki and forum thread for this board. Neither this guide nor the patch
set names a LAN, an account, or a password.
**Firmware blobs.** The CSI report is MCU event `0xc2`. The MT7981 firmware in
the tested image (`kmod-mt7981-firmware`, `mt7981-wo-firmware` from OpenWrt
24.10.8) already emits it, so no firmware blob swap was needed on the tested
board. Other firmware versions are untested.
## Arm CSI on the router
CSI follows traffic from **associated clients**. With nothing associated and
transmitting, there is no CSI. Run these on the router, as root, against the AP
interface (`phy0-ap0` was the 2.4 GHz AP on the tested board). The sequence
below is CODE-DERIVED from the capture scripts used on the tested board:
```sh
mt76-vendor phy0-ap0 set csi ctrl=0,0,0,0 # disarm and reset
mt76-vendor phy0-ap0 set csi interval=1000
mt76-vendor phy0-ap0 set csi ctrl=2,3,0,34 # frame-type filter, as used on
mt76-vendor phy0-ap0 set csi ctrl=2,9,1,0 # the tested board (data frames)
mt76-vendor phy0-ap0 set csi ctrl=1,0,0,0 # arm
```
Check that the driver is collecting:
```sh
cat /sys/kernel/debug/ieee80211/phy0/mt76/csi_stats # data_cnt should climb
iw dev phy0-ap0 station dump | grep -E "Station|signal"
```
Pull a batch:
```sh
rm -f /tmp/csi.json # the tool APPENDS; remove first
mt76-vendor phy0-ap0 dump csi 64 /tmp/csi.json
```
`dump csi` needs the filename argument. Without it, the tool returns nothing.
## Run the bridge
```bash
cd v2
cargo build --release -p wifi-densepose-mtk-bridge
B=./target/release/wifi-densepose-mtk-bridge
```
**Dump path (recommended).** Copy `/tmp/csi.json` to the host, then replay it.
A file cannot prove it came off a radio, so `--replay` refuses to start until
you either attest the hardware or declare the file synthetic:
```bash
$B --replay csi.json --captured-on <model>/<firmware> \
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005
```
- `--node` is hashed into the 64-bit `device_id`. Give each router its own name.
- `--center-freq-khz` is the AP channel's centre frequency, for example
`2437000` for 2.4 GHz channel 6.
- `--captured-on` takes `<model>/<firmware>`, for example
`WN586X3-RevA/openwrt-24.10.8-csi`. It is logged at startup.
For a continuous feed, repeat dump, copy, and replay in a loop, one loop per
router. Keep a copy of each dump only if you need it, and never commit one
(see below).
**Streaming path (CSIdump).** Start `CSIdump phy0-ap0 <rate> 8888` on the
router. Then, on the host:
```bash
$B --listen 0.0.0.0:8888 --csidump <router-ip>:8888 --listen-allow <router-ip> \
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005 \
--record captures/<node-name>.mtkcap
```
`--listen-allow` is mandatory. Datagrams from any other source are dropped and
counted. CSIdump disarms CSI when it receives SIGTERM. The dump path and
CSIdump cannot share the driver's ring, so run one or the other.
**No router.** The fabricated fixture exercises the whole host path. It must
be replayed with `--synthetic`, and the server labels it `mediatek:simulated`:
```bash
$B --replay crates/wifi-densepose-mtk-bridge/fixtures/FABRICATED-mt76-vendor-dump-mt7981-bw80.synthetic.json \
--synthetic --node demo --sink 127.0.0.1:5005
```
The ADR-266 simulator is another synthetic source:
```bash
cargo run -p wifi-densepose-hardware --bin mediatek-csi-sim -- \
--profile mt7981 --udp 127.0.0.1:5005 --frames 1000 --realtime
```
Full flag reference: [`v2/crates/wifi-densepose-mtk-bridge/README.md`](../v2/crates/wifi-densepose-mtk-bridge/README.md).
## Start the sensing server
```bash
cd v2
cargo run --release -p wifi-densepose-sensing-server -- --source mediatek \
--activity-state <state-dir>/activity-floor.json
```
- `--source mediatek` binds the UDP receiver (loopback by default, ADR-296) and
does **not** start the ESP32-shaped simulator. Before this source existed,
`--source mediatek` bound nothing.
- If the bridge runs on another host, bind a routable address with
`--udp-bind` and name the bridge host in `--udp-allow`.
- `--activity-state` is optional. It persists each device's learned
quiet-room activity floor so that a restart does not relearn it.
- `--csi-ring <n>` sets frames retained per device for inspection (default
256, about 0.5 MiB per 2x2 device at 64 subcarriers).
Endpoints:
| Route | Returns |
|---|---|
| `GET /api/v1/csi/mediatek/latest` | Last MTC1 snapshot, any device |
| `GET /api/v1/csi/mediatek/devices` | One snapshot per `device_id`, with `age_ms`. Devices silent for over 30 s are omitted. |
| `GET /api/v1/csi/mediatek/devices/:device_id/frames?n=&fields=amp,phase` | Newest `n` frames of per-chain, per-subcarrier amplitude and phase |
| `GET /api/v1/csi/mediatek/devices/:device_id/summary` | Per-chain amplitude mean/std per subcarrier, RSSI per Rx chain, frame rate, heuristic window stats |
| `GET /api/v1/csi/mediatek/activity?seconds=300` | About 1 Hz history of the relative activity index |
| `GET /api/v1/sensing/latest` | Room update. On this source, `classifier` is `mediatek-amplitude-heuristic-v0`. |
Check the label on every snapshot:
| `source` | Meaning |
|---|---|
| `mediatek:simulated` | `SYNTHETIC` flag set. Nothing downstream can clear it. |
| `mediatek:physical-unvalidated` | Real silicon, not calibrated. Everything this path produces today. |
| `mediatek` | Real and calibrated. Reserved. Nothing emits it yet. |
## What the data is, and is not
**It is** complex channel estimates per receive chain from a real MT7976C
radio, carried end to end with provenance intact.
**It is not:**
- **A sensor with its own clock.** The report rate is the clients' traffic
rate. An idle phone gives a few frames per second, and a busy one gives
hundreds. With no associated, transmitting client, there is no data. A
stationary device that keeps transmitting to the AP (an "illuminator", for
example an ESP32 sending a steady packet stream) gives a regular rate
(CLAIMED: the 09-20 and 09-24 captures below include an illuminator's
frames; transmitter identity was not re-checked in this analysis).
- **Calibrated.** `scale` is 1.0 in raw firmware units. Nothing has been
checked against a reference. The ADR-266 gates are open.
- **Validated for presence, motion, or vital signs.** The room output on this
source comes from `mediatek-amplitude-heuristic-v0` (presence and motion from
mean amplitude) and `activity-index-v0` (fluctuation against a learned
floor). Both are **CLAIMED**. Neither has been scored against labelled ground
truth. `activity` blocks carry `is_occupancy_estimate: false`. These outputs
do not feed the validated ESP32 vital-sign pipeline.
- **Coherent across routers.** Each radio has its own oscillator. Cross-chain
phase is meaningful within one radio, not between two boxes.
- **Free of personal data.** A raw dump holds client transmitter addresses
(MACs) and channel samples that encode movement. RuView policy (ADR-299)
forbids committing captures. Keep them out of the repository, and run
`scripts/csi-data-policy-check.sh --staged` before committing near capture
directories.
## Evidence
| Claim | Tag | Basis |
|---|---|---|
| A real WL-WN586X3 frame decodes and reaches the server as `mediatek:physical-unvalidated` | MEASURED (path) | First record, 2026-09-19: legacy OFDM, 20 MHz, 64 subcarriers, one chain. Its shape is pinned by `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`, which uses a fabricated record. The capture itself is not in the tree. |
| Cross-chain phase is steady frame to frame on one MT7976C radio, and raw per-chain phase is not | MEASURED (offline) | See below |
| `mediatek-amplitude-heuristic-v0`, `activity-index-v0` | CLAIMED | No labelled evaluation |
| ADR-266 simulator and bridge fixtures | SYNTHETIC | Labelled on the wire |
**Cross-chain phase stability.** Six local dump captures from two WN586X3 units,
2.4 GHz, 20 MHz HT frames carrying chains (tx0, rx0) and (tx0, rx1), adjacent
frames under 100 ms apart, 56 subcarriers (DC and guard bins excluded). The
two 2026-09-19 captures were analysed whole; the four 09-20 and 09-24 captures
by their first 20 MB each.
| Quantity, per adjacent-frame step | Well-sampled transmitters (6, at 825 to 14,104 frames each) |
|---|---|
| Raw per-chain phase `arg(H0[k])`, per bin, p50 | 1.557 to 1.601 rad, with 49.6 to 50.9 % of steps over π/2 |
| Cross-chain phase `arg(H0[k]·conj(H1[k]))`, per bin, p50 | 0.023 to 0.056 rad |
| Cross-chain phase summed coherently over bins, p50 / p90 / p99 | 0.014–0.039 / 0.205–0.358 / 0.437–0.820 rad |
Uniformly random phase gives a step p50 of π/2 = 1.571 rad, and half of all
steps exceed π/2. Raw per-chain phase is indistinguishable from that, as
expected with per-packet CFO and phase offset. In the cross-chain term those
offsets cancel, and the step drops to hundredths of a radian. Steps are
wrapped, so p99 can never exceed π, and is not evidence by itself.
Transmitters with few, irregular frames (229 to 595 frames) were much worse:
coherent-sum p50 0.177 to 0.456 rad, p99 1.857 to 2.849 rad. So the result
holds only with a steady frame rate.
This shows a **stable phase observable exists** within one radio. It does
**not** show that the observable tracks people, breathing, or anything else.
Reproduce it on your own capture (one `dump csi` array per line):
```bash
python3 scripts/mediatek-csi-phase-stability.py <capture.jsonl> [max_MB]
```
The script prints transmitters as `TX-A`, `TX-B`, and so on, never addresses.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `csi_stats` `data_cnt` stays at 0 | No associated client transmitting, or CSI not armed | Associate a client and generate traffic, then re-run the arm sequence |
| `data_cnt` stopped after a client roamed or the filter changed | Changing the CSI filter stops capture | Re-run the arm sequence ending in `ctrl=1,0,0,0`. Re-arming periodically is a reasonable guard. |
| `mt76-vendor dump csi` prints nothing | No filename argument | Use `dump csi <n> <file>` |
| `mt76-vendor dump csi` segfaults | Unpatched tool: the driver can return n+1 records | Rebuild with `mt76-vendor-csi-dump-bounds.patch` |
| Bridge warns about repeated `(ta, ts, tx_idx, rx_idx)` chains | The dump file was not removed between dumps, and the tool appends | `rm -f` the file before every dump |
| `--replay` refuses to start | Provenance not declared | Add `--captured-on <model>/<firmware>`, or `--synthetic` for fabricated input |
| `--captured-on` is rejected | The input declares itself synthetic (sidecar, `.synthetic.` name, or capture header) | Expected. Synthetic is sticky. Use `--synthetic`. |
| `--listen` refuses to start | No `--listen-allow` | Name the router's address or subnet |
| `rejected_source` counts climb on `--listen` | The router's address is not in `--listen-allow` | Fix the allowlist |
| CSIdump runs but nothing arrives | Unpatched CSIdump uses an off-by-one netlink attribute enum against this driver | Rebuild with `csidump-nl80211-attr-enum.patch` |
| CSI stopped after CSIdump exited | CSIdump disarms CSI on SIGTERM | Re-run the arm sequence |
| `/api/v1/csi/mediatek/devices` is empty | Server not on `--source mediatek` (or `auto` without UDP), wrong `--sink` port, or a non-loopback bridge not in `--udp-allow` | Check `--source`, port 5005, `--udp-bind`, and `--udp-allow` |
| Two routers show as one device | Same `--node` on both bridges | Give each router its own `--node` (or `--device-id`) |
| A frame shows `ppdu_type: legacy` | Normal. Association and management traffic is pre-HT. | None. ADR-267 Amendment 1 carries these frames rather than dropping them. |
| Room status reads `unknown` for a device | The heuristic abstains below its confidence threshold | Expected with sparse frames. It is not an "absent" call. |
| Room reads `absent` with `confidence: 0.0` | Every device abstained, so there is no evidence either way | Treat confidence 0 as "no information", not as an empty room |
| Image flashes, but there is no `mt76-vendor` or CSI | The ImageBuilder used the CDN `kmod-mt7915e` | Check the manifest for your raised `PKG_RELEASE` |
## Reference
- [ADR-266: MediaTek Filogic CSI platform](adr/ADR-266-mediatek-filogic-csi-platform.md) (Amendment 1: vendor CSI on MT7981)
- [ADR-267: MediaTek MIMO CSI wire protocol](adr/ADR-267-mediatek-mimo-csi-wire-protocol.md) (Amendment 1: `Legacy` and `Unknown` PPDU types)
- [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md): patches, licences, origins
- [`v2/crates/wifi-densepose-mtk-bridge/`](../v2/crates/wifi-densepose-mtk-bridge/README.md): bridge transports, provenance rules, frame mapping
- [MediaTek OpenWrt feeds](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/), [MtkCSIdump](https://github.com/MtkWifiRev/MtkCSIdump), [OpenWrt One](https://openwrt.org/toh/openwrt/one)
+11
View File
@@ -21,6 +21,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
- [MediaTek Router CSI (Experimental)](#mediatek-router-csi-experimental)
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
- [Cognitum Spaces activation](#cognitum-spaces-activation)
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
@@ -422,6 +423,16 @@ The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turn
See [ADR-029](adr/ADR-029-ruvsense-multistatic-sensing-mode.md) and [ADR-032](adr/ADR-032-multistatic-mesh-security-hardening.md) for the full design.
### MediaTek Router CSI (Experimental)
An OpenWrt router with an MT7981B + MT7976C radio (tested on a Wavlink WL-WN586X3 Rev A) can report CSI through MediaTek's vendor CSI patch. A host-side bridge converts it to MTC1 (ADR-267) for the sensing server:
```bash
./target/release/sensing-server --source mediatek --udp-port 5005 --http-port 3000 --ws-port 3001
```
Frames are labelled `mediatek:physical-unvalidated`. The presence and activity outputs on this source are uncalibrated heuristics with no sensing-quality validation. CSI exists only while associated clients transmit. See the [MediaTek Router CSI guide](mediatek-router-csi.md) for the OpenWrt build, arming CSI, the bridge, and limitations.
### Connect Mesh Data to the Dashboard and Observatory
If a standalone `aggregator` command prints live packets, the ESP32 fleet is already reaching that host. To visualize the same data, stop the standalone aggregator and run `sensing-server` on that same host and UDP port. The sensing server is the aggregator used by the REST API, WebSocket stream, dashboard, and Observatory.
+78
View File
@@ -0,0 +1,78 @@
# WL-WN586X3 CSI patches
OpenWrt patches that made a Wavlink WL-WN586X3 Rev A emit CSI into
`wifi-densepose-mtk-bridge`. Chipset on the board that was flashed: MT7981BA,
MT7976CN, MT7531AE. Image base: OpenWrt 24.10.8. This directory is the patch
set. It is not a firmware image, a sysupgrade binary, or a capture.
Developed in whitsentry, a RuView-based project. The user guide, covering
the build, arming CSI, the bridge, the server, and limitations, is
[`docs/mediatek-router-csi.md`](../../docs/mediatek-router-csi.md).
Provenance of frames from this path is `mediatek:physical-unvalidated` until
the ADR-266 gates (calibration, sequence, timestamp, repeatability) pass on
this model. Two of these radios are two receivers. They are not one phased array.
## Files
| Patch | What it is | License of the base |
|---|---|---|
| `patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch` | MediaTek's 2022 mt7915 CSI patch, rebased onto `openwrt/mt76` `eb567bc7` (the revision OpenWrt 24.10.8 pins). Two dead-code warnings were removed so `-Werror` builds. | BSD-3-Clause-Clear, MediaTek |
| `patches/0001-wn586x3-reva-single-image-sysupgrade-and-generic-spi-nor.patch` | Board support ported from [dadogroove/openwrt `wl-wn586x3`](https://github.com/dadogroove/openwrt/tree/wl-wn586x3) @ `b080a655` onto the 24.10.8 tag. Single-image sysupgrade, generic SPI-NOR (the Boya flash path is a deletion of quad-SPI width, not a new chip driver), factory MAC offsets. | OpenWrt target patches, GPL-2.0 |
| `patches/mt76-vendor-csi-dump-bounds.patch` | `mt76-vendor dump csi` wrote past its buffer once a dump held more records than requested. The driver seeds its counter and then decrements, so a request for N can return N+1. Also bounds `snprintf` and checks attribute presence. Base: `mediatek/mtk-openwrt-feeds` @ `a15454c8`, `feed/app/mt76-vendor/src/csi.c`. | GPL-2.0, MediaTek |
| `patches/csidump-nl80211-attr-enum.patch` | [MtkCSIdump](https://github.com/MtkWifiRev/MtkCSIdump) @ `276b4b08` was one attribute off from this driver (`STA_INTERVAL`, `DATA_NUM`). The dump callback returned nothing and the ring sat full. | Apache-2.0 (MtkCSIdump `LICENSE.txt`) |
Do not copy `mt76` itself, the imagebuilder download cache, `out/`, stock
backups, or anything under a `captures/` directory into this repo.
### Licences
These files are patches against third-party code. Each one keeps the licence
of the code it modifies, as listed in the table above. They are not covered
by the repository's MIT/Apache-2.0 licence. In particular, the `0001-` board
patch and `mt76-vendor-csi-dump-bounds.patch` modify GPL-2.0 sources, and the
`1001-` CSI patch is MediaTek's BSD-3-Clause-Clear work with its original
author line kept. Nothing here is a firmware blob, a private header, or SDK
code (ADR-266 decision 5).
## What the radio actually did
CSI follows client traffic. An idle associated station is a few frames per
second. The vendor dump tool opens its output with `fopen(path, "a+")`, so a
loop that does not remove the file replays every earlier batch. Changing the
CSI filter stops capture until `ctrl=1,0,0,0` is sent again. `CSIdump` disarms
CSI on SIGTERM; an explicit disarm is the right stop, and `kill -9` is how the
lab kept the radio armed, which is a bug in the tool rather than a procedure
to keep.
`mt76-vendor` JSON is the path that keeps RSSI, SNR, transmit address, bandwidth,
PPDU mode, and per-chain indices. The CSIdump UDP datagram has none of those
and sends antennas one after another, so the bridge emits one 1x1 frame per
datagram. Prefer the dump path when the chain layout matters. Neither stock
tool prints `pkt_sn`, which the firmware event already carries, so the bridge
cannot see loss on an unpatched dumper.
Each radio is two chains. The four external antennas are a 2.4 GHz pair and a
5 GHz pair. Live CSI in the lab was 2x2 on 2.4 GHz. The 5 GHz radio was idle
for sensing.
## Build outline
Apply the rebased CSI patch to the `mt76` tree OpenWrt 24.10.8 builds, and the
board patch to `target/linux/mediatek`. Point the image at the patched
`mt76-vendor` and, if you use it, the patched CSIdump. Flash only a Rev A unit,
and only an image you built and hashed yourself. This README does not name a
LAN, an account, or a password.
Host side, after the radio is emitting:
```bash
cd v2
cargo build -p wifi-densepose-mtk-bridge
./target/debug/wifi-densepose-mtk-bridge --help
```
Replay stays on loopback unless you choose otherwise. `--listen` refuses to
start without `--listen-allow`. `--replay` of a file refuses to start until
you pass `--synthetic` or `--captured-on <model>/<firmware>`. A filename
containing `.synthetic.` cannot be attested as hardware.
@@ -0,0 +1,187 @@
Subject: [PATCH] mediatek: wl-wn586x3 Rev A - single-image sysupgrade, generic SPI-NOR
Origin: https://github.com/dadogroove/openwrt/tree/wl-wn586x3
branch wl-wn586x3 @ b080a6550ec6928400735dab4837601efaaf6243 (2026-09-19),
which tracks openwrt/main. Ported here onto the v24.10.8 tag.
Forum: https://forum.openwrt.org/t/wavlink-wl-wn586x3-ver-a-installation-advice/243893
Four separable changes, all in target/, none touching package/kernel/mt76, so
this is orthogonal to the CSI driver patch and composes with it.
1. Single-image sysupgrade (filogic.mk, platform.sh). Replaces the stock
wn586x3 recipe with the append-kernel/append-rootfs/append-metadata form
that wl-wn573hx3 already uses in 24.10.8, adds a `fit lzma` KERNEL, drops
IMAGE_SIZE 15424k to 14336k, marks the firmware partition `denx,fit`, and
adds the board to the default_do_upgrade case list. One sysupgrade.bin then
serves both the initial mtd write and later sysupgrades. This is the
soft-brick fix dadogroove landed 2026-04-25.
2. Generic SPI-NOR operation (the Boya fix). There is NO Boya BY25Q128AS
driver patch anywhere - the fix is a DELETION. Dropping
spi-tx-bus-width/spi-rx-bus-width = <4> stops the controller driving the
chip in quad mode, so the generic jedec,spi-nor binding is sufficient and
the chip no longer needs to be recognised by name. spi-max-frequency also
goes 25 -> 50 MHz.
3. MAC addressing. Sources both ethernet MACs from absolute factory offsets
0x3fff4/0x3fffa instead of the mac-base cell at hw+0x44e, and drops the
wifi mac-address cell so wifi takes its MAC from the EEPROM. Entangled with
(2) only by file, but NOT separable within this patch: the eth nodes and the
cell definitions must move together. This CHANGES the MACs the board
presents versus an official 24.10.8 image. Adopted because dadogroove has
the hardware and his build is the one known to boot on Boya units.
4. Cosmetic: gpio-keys node renamed button-reset -> button-0; mdio_pins
pinctrl moved onto &eth.
Adaptations made while porting to 24.10.8:
- platform.sh: the case list is ordered differently; wl-wn586x3 was inserted
next to the wl-wn573hx3 entry that is already there.
- &wifi: 24.10.8 takes the EEPROM via `mediatek,mtd-eeprom = <&factory 0x0>`,
not snapshot's eeprom_factory_0 nvmem cell, so only the mac-address cell
was removed.
- filogic.mk hunk landed at a -1824 line offset; content identical.
Verified: no dangling DTS labels. NOT yet verified: DTB compilation, and
nothing has been flashed.
---
diff --git a/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts b/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
index 62be0faa4af3..2260df89e9b5 100644
--- a/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
+++ b/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
@@ -24,7 +24,7 @@
gpio-keys {
compatible = "gpio-keys";
- button-reset {
+ button-0 {
label = "reset";
linux,code = <KEY_RESTART>;
gpios = <&pio 11 GPIO_ACTIVE_LOW>;
@@ -79,13 +79,17 @@
};
&eth {
+
+ pinctrl-names = "default";
+ pinctrl-0 = <&mdio_pins>;
+
status = "okay";
gmac0: mac@0 {
compatible = "mediatek,eth-mac";
reg = <0>;
phy-mode = "2500base-x";
- nvmem-cells = <&macaddr_hw_44e 0>;
+ nvmem-cells = <&macaddr_factory_3fff4>;
nvmem-cell-names = "mac-address";
fixed-link {
@@ -100,7 +104,7 @@
reg = <1>;
phy-mode = "gmii";
phy-handle = <&int_gbe_phy>;
- nvmem-cells = <&macaddr_hw_44e 1>;
+ nvmem-cells = <&macaddr_factory_3fffa>;
nvmem-cell-names = "mac-address";
};
};
@@ -118,8 +122,6 @@
};
&spi0 {
- pinctrl-names = "default";
- pinctrl-0 = <&spi0_flash_pins>;
status = "disabled";
};
@@ -135,9 +137,7 @@
compatible = "jedec,spi-nor";
reg = <0>;
- spi-max-frequency = <25000000>;
- spi-tx-bus-width = <4>;
- spi-rx-bus-width = <4>;
+ spi-max-frequency = <50000000>;
partitions {
compatible = "fixed-partitions";
@@ -166,10 +166,12 @@
#address-cells = <1>;
#size-cells = <1>;
- macaddr_factory_4: macaddr@4 {
- compatible = "mac-base";
- reg = <0x4 0x6>;
- #nvmem-cell-cells = <1>;
+ macaddr_factory_3fff4: macaddr@3fff4 {
+ reg = <0x3fff4 0x6>;
+ };
+
+ macaddr_factory_3fffa: macaddr@3fffa {
+ reg = <0x3fffa 0x6>;
};
};
};
@@ -181,6 +183,7 @@
};
partition@f0000 {
+ compatible = "denx,fit";
label = "firmware";
reg = <0x180000 0xe00000>;
};
@@ -189,17 +192,6 @@
label = "hw";
reg = <0xf80000 0x80000>;
- nvmem-layout {
- compatible = "fixed-layout";
- #address-cells = <1>;
- #size-cells = <1>;
-
- macaddr_hw_44e: macaddr@44e {
- compatible = "mac-base";
- reg = <0x44e 0x11>;
- #nvmem-cell-cells = <1>;
- };
- };
};
};
};
@@ -265,6 +257,4 @@
&wifi {
status = "okay";
mediatek,mtd-eeprom = <&factory 0x0>;
- nvmem-cells = <&macaddr_factory_4 0>;
- nvmem-cell-names = "mac-address";
};
diff --git a/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh b/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
index 8af61b028926..4a71285bea01 100644
--- a/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
+++ b/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
@@ -137,6 +137,7 @@ platform_do_upgrade() {
cudy,wr3000-v1|\
yuncore,ax835|\
wavlink,wl-wn573hx3|\
+ wavlink,wl-wn586x3|\
totolink,x6000r)
default_do_upgrade "$1"
;;
diff --git a/target/linux/mediatek/image/filogic.mk b/target/linux/mediatek/image/filogic.mk
index b7f8ffd47c51..756dc86b8541 100644
--- a/target/linux/mediatek/image/filogic.mk
+++ b/target/linux/mediatek/image/filogic.mk
@@ -1910,8 +1910,12 @@ define Device/wavlink_wl-wn586x3
DEVICE_DTS := mt7981b-wavlink-wl-wn586x3
DEVICE_DTS_DIR := ../dts
DEVICE_DTS_LOADADDR := 0x47000000
- IMAGE_SIZE := 15424k
DEVICE_PACKAGES := kmod-mt7915e kmod-mt7981-firmware mt7981-wo-firmware
+ IMAGE_SIZE := 14336k
+ IMAGES := sysupgrade.bin
+ KERNEL := kernel-bin | lzma | \
+ fit lzma $$(KDIR)/image-$$(firstword $$(DEVICE_DTS)).dtb
+ IMAGE/sysupgrade.bin := append-kernel | pad-to 128k | append-rootfs | pad-rootfs | check-size | append-metadata
endef
TARGET_DEVICES += wavlink_wl-wn586x3
@@ -0,0 +1,128 @@
Fix: CSIdump forwarded nothing against the mt76 CSI driver built here.
Base: github.com/MtkWifiRev/MtkCSIdump @ 276b4b0814a283dcbc6544a76033e0e37154f092
CSIdump was written against a DIFFERENT mt76 vendor ABI than the 2022
MediaTek CSI patch we build. Two enumerators are missing from its copies of
the attribute enums, so every attribute number past them was off by one:
CTRL: our driver has STA_INTERVAL between INTERVAL and DUMP_NUM
DATA: our driver has DATA_NUM between TA and I, and ends CHAIN_INFO not H_IDX
Failure chain. CSIdump put DUMP_NUM at attr 8, which is the driver's
STA_INTERVAL (policy NLA_U32); a 2-byte u16 payload failed the driver's
nla_parse minimum-length check, so the dumpit returned -EINVAL and emitted
NOTHING. The ring therefore never drained (a unit sat pinned at 3000) and the
dump callback never ran. Even had the request landed, the driver replies with
DATA at attr 10 while CSIdump looked at attr 9, so the callback would have
returned NL_SKIP at its first check with no message printed. And the error
print used a stale ret that was always 0, so any failure read as 'Success'.
---
--- /tmp/mt76_api.cpp.orig 2026-09-19 23:14:12
+++ src/wifi_drv_api/mt76_api.cpp 2026-09-19 23:14:12
@@ -47,10 +47,12 @@
MTK_VENDOR_ATTR_CSI_CTRL_CFG_VAL2,
MTK_VENDOR_ATTR_CSI_CTRL_MAC_ADDR,
MTK_VENDOR_ATTR_CSI_CTRL_INTERVAL,
+ MTK_VENDOR_ATTR_CSI_CTRL_STA_INTERVAL,
MTK_VENDOR_ATTR_CSI_CTRL_DUMP_NUM,
MTK_VENDOR_ATTR_CSI_CTRL_DATA,
+ MTK_VENDOR_ATTR_CSI_CTRL_DUMP_MAC_FILTER,
/* keep last */
NUM_MTK_VENDOR_ATTRS_CSI_CTRL,
@@ -70,6 +72,7 @@
MTK_VENDOR_ATTR_CSI_DATA_BW,
MTK_VENDOR_ATTR_CSI_DATA_CH_IDX,
MTK_VENDOR_ATTR_CSI_DATA_TA,
+ MTK_VENDOR_ATTR_CSI_DATA_NUM,
MTK_VENDOR_ATTR_CSI_DATA_I,
MTK_VENDOR_ATTR_CSI_DATA_Q,
MTK_VENDOR_ATTR_CSI_DATA_INFO,
@@ -80,7 +83,7 @@
MTK_VENDOR_ATTR_CSI_DATA_TX_ANT,
MTK_VENDOR_ATTR_CSI_DATA_RX_ANT,
MTK_VENDOR_ATTR_CSI_DATA_MODE,
- MTK_VENDOR_ATTR_CSI_DATA_H_IDX,
+ MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO,
/* keep last */
NUM_MTK_VENDOR_ATTRS_CSI_DATA,
@@ -123,7 +126,8 @@
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT].type = NLA_U8;
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT].type = NLA_U8;
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_MODE].type = NLA_U8;
- csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_H_IDX].type = NLA_U32;
+ csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_NUM].type = NLA_U32;
+ csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO].type = NLA_U32;
}
~MT76APIPrivate()
{
@@ -172,7 +176,7 @@
tb_data[MTK_VENDOR_ATTR_CSI_DATA_Q] &&
tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO] &&
tb_data[MTK_VENDOR_ATTR_CSI_DATA_MODE] &&
- tb_data[MTK_VENDOR_ATTR_CSI_DATA_H_IDX]))
+ tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]))
{
fprintf(stderr, "Attributes error for CSI data\n");
return NL_SKIP;
@@ -192,7 +196,7 @@
c->rx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]);
c->ext_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO]);
- c->h_idx = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_H_IDX]);
+ c->h_idx = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]);
c->ts = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TS]);
@@ -271,8 +275,9 @@
nla_nest_end(msg, data);
- if (unl_genl_request(&unl, msg, md_csi_dump_cb, NULL))
- fprintf(stderr, "nl80211 call failed: %s\n", strerror(-ret));
+ ret = unl_genl_request(&unl, msg, md_csi_dump_cb, NULL);
+ if (ret)
+ fprintf(stderr, "csi dump: nl80211 call failed: %s\n", strerror(-ret));
unl_free(&unl);
}
--- /tmp/motion_detector.cpp.orig 2026-09-19 23:14:58
+++ src/motion_detector.cpp 2026-09-19 23:14:58
@@ -1,3 +1,4 @@
+#include <cstdio>
#include "motion_detector.h"
#include "wifi_drv_api/mt76_api.h"
#include "parsers/parser_mt76.h"
@@ -35,6 +36,12 @@
std::vector<csi_data *> *list = wifi.motion_detection_dump(ifname.c_str(), pkt_num);
oldTime = currTime;
+ /* Visibility: without this a failing dump is completely silent, which is
+ * exactly how the enum/ABI mismatch went unnoticed. */
+ fprintf(stderr, "csi: dump returned %zu records (%d ms since last)\n",
+ list ? list->size() : (size_t)0, diff);
+
+ unsigned sentThisInterval = 0;
dataMutex.lock();
unsigned antIdx = antMonIdx;
dataMutex.unlock();
@@ -49,6 +56,7 @@
if (!packet.empty()) {
std::vector<std::vector<double>> singlePacket = { packet };
sendCsiDataUdp(singlePacket, i);
+ sentThisInterval++;
}
}
}
@@ -56,6 +64,9 @@
}
}
+ if (sentThisInterval)
+ fprintf(stderr, "csi: sent %u udp datagrams\n", sentThisInterval);
+
std::this_thread::sleep_for(std::chrono::milliseconds(interval));
}
}
@@ -0,0 +1,192 @@
Fix for mt76-vendor 'dump csi' SIGSEGV.
Base: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b
feed/app/mt76-vendor/src/csi.c
Root cause: the driver seeds its per-dump counter on the FIRST dumpit
callback and only then decrements it, so a request for CSI_DUMP_PER_NUM=3
yields FOUR records. Userspace sized csi[] for pkt_num but looped pkt_num/3
times, storing up to 4*(pkt_num/3) records with no bounds check, so any dump
with >= pkt_num records buffered wrote past the allocation. A dump that found
only one record buffered never overran, which is why the single association
record succeeded and every data-frame dump crashed.
Also fixed: the JSON writer passed the full MAX_BUF_SIZE to every snprintf
instead of the remaining space, and three attributes were dereferenced without
being in the presence check.
---
--- /tmp/csi.c.orig 2026-09-19 19:46:49
+++ src/csi.c 2026-09-19 19:47:16
@@ -6,6 +6,7 @@
struct csi_data *csi;
int csi_idx;
+int csi_max; /* entries allocated in csi[] */
static struct nla_policy csi_ctrl_policy[NUM_MTK_VENDOR_ATTRS_CSI_CTRL] = {
[MTK_VENDOR_ATTR_CSI_CTRL_CFG] = { .type = NLA_NESTED },
@@ -51,8 +52,13 @@
struct nlattr *cur;
size_t idx;
int rem;
- struct csi_data *c = &csi[csi_idx];
+ struct csi_data *c;
+ if (!csi || csi_idx >= csi_max)
+ return NL_SKIP;
+
+ c = &csi[csi_idx];
+
attr = unl_find_attr(&unl, msg, NL80211_ATTR_VENDOR_DATA);
if (!attr) {
fprintf(stderr, "Testdata attribute not found\n");
@@ -90,15 +96,20 @@
c->pri_ch_idx = nla_get_u8(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CH_IDX]);
c->rx_mode = nla_get_u8(tb_data[MTK_VENDOR_ATTR_CSI_DATA_MODE]);
- c->tx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT]);
- c->rx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]);
+ c->tx_idx = tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT] ?
+ nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT]) : 0;
+ c->rx_idx = tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT] ?
+ nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]) : 0;
c->ext_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO]);
c->chain_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]);
c->ts = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TS]);
- c->data_num = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM]);
+ c->data_num = tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM] ?
+ nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM]) : 0;
+ if (c->data_num > CSI_BW160_DATA_COUNT)
+ c->data_num = CSI_BW160_DATA_COUNT;
idx = 0;
nla_for_each_nested(cur, tb_data[MTK_VENDOR_ATTR_CSI_DATA_TA], rem) {
@@ -125,7 +136,7 @@
static int mt76_csi_to_json(const char *name)
{
-#define MAX_BUF_SIZE 6000
+#define MAX_BUF_SIZE 16384 /* 2 x 512 samples of "-32768," plus header */
FILE *f;
int i, ret = -ENOMEM;
@@ -142,7 +153,7 @@
for (i = 0; i < csi_idx; i++) {
struct csi_data *c = &csi[i];
- char *pos, *buf;
+ char *pos, *buf, *end;
int j;
buf = malloc(MAX_BUF_SIZE);
@@ -150,40 +161,51 @@
goto out;
pos = buf;
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
+ end = buf + MAX_BUF_SIZE;
+#define EMIT(...) do { \
+ int _n = snprintf(pos, (size_t)(end - pos), __VA_ARGS__); \
+ if (_n < 0 || _n >= (int)(end - pos)) { \
+ fprintf(stderr, "csi json: record %d truncated\n", i); \
+ free(buf); \
+ goto out; \
+ } \
+ pos += _n; \
+ } while (0)
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->ts);
- pos += snprintf(pos, MAX_BUF_SIZE, "\"%02x%02x%02x%02x%02x%02x\",", c->ta[0], c->ta[1], c->ta[2], c->ta[3], c->ta[4], c->ta[5]);
+ EMIT("%c", '[');
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->rssi);
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->snr);
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->data_bw);
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->pri_ch_idx);
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->rx_mode);
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->tx_idx);
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->rx_idx);
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->chain_info);
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->ext_info);
+ EMIT("%d,", c->ts);
+ EMIT("\"%02x%02x%02x%02x%02x%02x\",", c->ta[0], c->ta[1], c->ta[2], c->ta[3], c->ta[4], c->ta[5]);
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
- for (j = 0; j < c->data_num; j++) {
- pos += snprintf(pos, MAX_BUF_SIZE, "%d", c->data_i[j]);
- if (j != (c->data_num - 1))
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
- }
- pos += snprintf(pos, MAX_BUF_SIZE, "%c,", ']');
+ EMIT("%d,", c->rssi);
+ EMIT("%u,", c->snr);
+ EMIT("%u,", c->data_bw);
+ EMIT("%u,", c->pri_ch_idx);
+ EMIT("%u,", c->rx_mode);
+ EMIT("%d,", c->tx_idx);
+ EMIT("%d,", c->rx_idx);
+ EMIT("%d,", c->chain_info);
+ EMIT("%d,", c->ext_info);
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
+ EMIT("%c", '[');
for (j = 0; j < c->data_num; j++) {
- pos += snprintf(pos, MAX_BUF_SIZE, "%d", c->data_q[j]);
+ EMIT("%d", c->data_i[j]);
if (j != (c->data_num - 1))
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
+ EMIT(",");
}
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", ']');
+ EMIT("%c,", ']');
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", ']');
+ EMIT("%c", '[');
+ for (j = 0; j < c->data_num; j++) {
+ EMIT("%d", c->data_q[j]);
+ if (j != (c->data_num - 1))
+ EMIT(",");
+ }
+ EMIT("%c", ']');
+
+ EMIT("%c", ']');
if (i != csi_idx - 1)
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
+ EMIT(",");
if (fwrite(buf, 1, pos - buf, f) != (pos - buf)) {
perror("fwrite");
@@ -192,6 +214,7 @@
}
free(buf);
+#undef EMIT
}
if (fwrite("]", 1, 1, f) != 1) {
@@ -221,10 +244,21 @@
return -EINVAL;
#define CSI_DUMP_PER_NUM 3
+/* The driver seeds its per-dump counter on the first callback and only then
+ * decrements it, so a request for N yields N+1 records. Size for that. */
+#define CSI_DUMP_PER_REQ (CSI_DUMP_PER_NUM + 1)
csi_idx = 0;
- csi = (struct csi_data *)calloc(pkt_num, sizeof(*csi));
+ csi_max = pkt_num;
+ csi = (struct csi_data *)calloc(csi_max, sizeof(*csi));
+ if (!csi) {
+ fprintf(stderr, "csi: cannot allocate %d records\n", csi_max);
+ return -ENOMEM;
+ }
- for (i = 0; i < pkt_num / CSI_DUMP_PER_NUM; i++) {
+ for (i = 0; i < DIV_ROUND_UP(pkt_num, CSI_DUMP_PER_REQ); i++) {
+ if (csi_idx >= csi_max)
+ break;
+
if (unl_genl_init(&unl, "nl80211") < 0) {
fprintf(stderr, "Failed to connect to nl80211\n");
return 2;
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Cross-chain phase stability of MediaTek mt76 vendor CSI (offline, read-only).
Reproducer for the MEASURED figures in docs/mediatek-router-csi.md. Input is a
capture made on the router by repeated `mt76-vendor <iface> dump csi <n> <file>`,
one dump (a JSON array of 13/14-element records) per line. Captures hold client
transmitter addresses and raw channel samples: keep them out of git. This script
never prints an address; transmitters are reported as TX-A, TX-B, ...
For every HT (rx_mode 2) frame that carries both receive chains of transmit
stream 0, it compares:
raw per-chain phase arg(H0[k]) between adjacent frames, and
cross cross-chain phase arg(H0[k] * conj(H1[k])), per bin and summed
coherently over bins.
CFO, SFO and the per-packet phase offset are common to both chains of one
radio, so they cancel in the cross term. Adjacent means the frames are under
100 ms apart. Steps are wrapped to [-pi, pi], so p99 can never exceed pi; read
p50 against pi/2 (1.571 rad), which is what uniformly random phase gives.
usage: python3 mediatek-csi-phase-stability.py <capture.jsonl> [max_MB]
"""
import json
import sys
import numpy as np
U32 = 1 << 32
MAX_DT = 0.1 # s; consecutive frames closer than this count as adjacent
GUARD = set(range(29, 36)) # BW20 bins 29..35: guard tones
VALID = np.array([k for k in range(1, 64) if k not in GUARD]) # DC excluded
KPHYS = np.where(VALID < 32, VALID, VALID - 64).astype(float)
def iter_lines(path, max_bytes=None):
read = 0
with open(path, "rb") as fh:
for raw in fh:
read += len(raw)
raw = raw.strip()
if raw:
try:
yield json.loads(raw)
except json.JSONDecodeError:
pass # truncated tail line
if max_bytes and read >= max_bytes:
return
def frames_from_line(recs, txidx):
"""Group a dump's records into PPDUs: per transmitter, consecutive records
sharing ts, closed on chain_info BIT15 or a repeated (tx, rx) slot."""
frames, open_ = [], {}
for r in recs:
ts = r[0] % U32
if r[1] not in txidx:
n = len(txidx)
txidx[r[1]] = "TX-" + chr(ord("A") + n) if n < 26 else f"TX-{n}"
tx = txidx[r[1]]
key = (r[7], r[8])
h = np.asarray(r[11], np.float64) + 1j * np.asarray(r[12], np.float64)
cur = open_.get(tx)
if cur is not None and (cur["ts"] != ts or key in cur["H"]):
frames.append(cur)
cur = None
if cur is None:
cur = {"tx": tx, "ts": ts, "rx_mode": r[6], "H": {}}
open_[tx] = cur
cur["H"][key] = h
if r[9] & (1 << 15):
frames.append(cur)
del open_[tx]
frames.extend(open_.values())
return frames
def load(path, max_bytes=None):
"""Per transmitter: unwrapped time (s) and chains (0,0), (0,1) of HT frames.
Within a dump the signed ts delta is used; between dumps the unsigned delta
mod 2^32, so a gap over 71.6 min between dumps would alias."""
txidx, per = {}, {}
t_unwrapped, prev_ts = None, None
for recs in iter_lines(path, max_bytes):
first = True
for f in frames_from_line(recs, txidx):
ts = f["ts"]
if prev_ts is None:
t_unwrapped = 0
elif first:
t_unwrapped += (ts - prev_ts) % U32
else:
t_unwrapped += ((ts - prev_ts + (U32 >> 1)) % U32) - (U32 >> 1)
prev_ts, first = ts, False
H = f["H"]
if (0, 0) not in H or (0, 1) not in H or f["rx_mode"] != 2:
continue
d = per.setdefault(f["tx"], {"t": [], "h0": [], "h1": []})
d["t"].append(t_unwrapped / 1e6)
d["h0"].append(H[(0, 0)][VALID].astype(np.complex64))
d["h1"].append(H[(0, 1)][VALID].astype(np.complex64))
out = {}
for tx, d in per.items():
t = np.asarray(d["t"])
srt = np.argsort(t, kind="stable")
out[tx] = {"t": t[srt], "h0": np.vstack(d["h0"])[srt], "h1": np.vstack(d["h1"])[srt]}
return out
def wrap(x):
return (x + np.pi) % (2 * np.pi) - np.pi
def sanitize(phase_rows):
"""Remove per-frame linear phase (offset + slope over subcarrier index)."""
order = np.argsort(KPHYS)
k = KPHYS[order]
u = np.unwrap(phase_rows[:, order], axis=1)
A = np.vstack([k, np.ones_like(k)]).T
coef, *_ = np.linalg.lstsq(A, u.T, rcond=None)
res = u - (A @ coef).T
out = np.empty_like(res)
out[:, order] = res
return out
def step_stats(phase, t):
dt = np.diff(t)
ok = (dt > 0) & (dt < MAX_DT)
st = np.abs(wrap(np.diff(phase, axis=0)))[ok].ravel()
if st.size == 0:
return None
p = np.percentile(st, [50, 90, 99])
return {"n": int(st.size), "p50": round(float(p[0]), 3), "p90": round(float(p[1]), 3),
"p99": round(float(p[2]), 3), "frac_gt_pi_2": round(float((st > np.pi / 2).mean()), 4)}
def analyse(d):
t, h0, h1 = d["t"], d["h0"].astype(complex), d["h1"].astype(complex)
raw0 = np.angle(h0)
x = h0 * np.conj(h1)
dt = np.diff(t)
adj = dt[(dt > 0) & (dt < MAX_DT)]
return {
"frames": int(len(t)),
"span_s": round(float(t[-1] - t[0]), 1),
"adjacent_pairs": int(adj.size),
"adjacent_dt_ms_p50": round(float(np.median(adj) * 1e3), 2) if adj.size else None,
"steps": {
"raw_chain0_per_bin": step_stats(raw0, t),
"sanitized_chain0_per_bin": step_stats(sanitize(raw0), t),
"cross_per_bin": step_stats(np.angle(x), t),
"cross_coherent_sum": step_stats(np.angle(x.sum(1)), t),
},
}
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
path = sys.argv[1]
mb = float(sys.argv[2]) if len(sys.argv) > 2 else None
data = load(path, int(mb * 1e6) if mb else None)
out = {"sampled_MB": mb or "all", "tx": {}}
for tx, d in sorted(data.items()):
if len(d["t"]) >= 50:
out["tx"][tx] = analyse(d)
print(json.dumps(out, indent=1))
@@ -3,7 +3,8 @@
Bridges MediaTek MT7981 (`mt76`) vendor CSI into RuView's ADR-267 MTC1 frames and
sends them to the sensing server's UDP ingest (default `127.0.0.1:5005`).
Developed in whitsentry, a RuView-based project.
Developed in whitsentry, a RuView-based project. User guide:
[`docs/mediatek-router-csi.md`](../../../docs/mediatek-router-csi.md).
MT7981B + MT7976C is the silicon in the Wavlink WL-WN586X3, and per the OpenWrt
hardware tables also in the OpenWrt One and the Xiaomi AX3000T (CLAIMED: only the