first
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
# sense — Wi-Fi motion sensing you can run on your own dongles
|
||||
|
||||
A runnable example built on the `devourer` library that turns two cheap Realtek
|
||||
Wi-Fi adapters into a **motion / presence sensor**. No SDR, no special AP — one
|
||||
adapter sounds, the other answers, and the demo reads human motion out of the
|
||||
802.11ac beamforming feedback the second adapter returns.
|
||||
|
||||
This document is written so you can **reproduce it, move it into your own
|
||||
environment, and change the maths**. If you only want the theory of *why* a
|
||||
beamforming report senses motion, read
|
||||
[`docs/beamforming-victim-sensing.md`](../../docs/beamforming-victim-sensing.md);
|
||||
this file is the hands-on half.
|
||||
|
||||
---
|
||||
|
||||
## 1. The one-paragraph idea
|
||||
|
||||
An 802.11ac beamformer (the *sounder*) asks a *beamformee* to measure the
|
||||
per-subcarrier channel between the sounder's two transmit antennas and report it
|
||||
back, compressed as **Givens rotation angles** (a phase `phi` and an amplitude
|
||||
angle `psi` per subcarrier). That report is a measurement *taken at the
|
||||
beamformee* of the radio channel between the two devices. When a person moves in
|
||||
that channel, the multipath changes, so the reported angles **jitter frame to
|
||||
frame**. Measure that jitter and you have a motion detector. This is the
|
||||
mechanism behind published work such as Wi-BFI, BeamSense and BFMSense.
|
||||
|
||||
The demo drives *both* ends itself — a sounder and a beamformee on the same host
|
||||
— so you don't need a cooperating AP. The sounder injects a short NDPA frame; the
|
||||
chip hardware-generates the sounding NDP; the beamformee answers with a
|
||||
compressed beamforming report; the sounder self-captures it on a concurrent RX
|
||||
loop. All of that is the `devourer` beamforming self-sounding path (see
|
||||
[`docs/beamforming-self-sounding.md`](../../docs/beamforming-self-sounding.md)).
|
||||
|
||||
---
|
||||
|
||||
## 2. Hardware you need
|
||||
|
||||
**Two USB adapters** that `devourer` supports, plugged into the same host:
|
||||
|
||||
| Role | Requirement | Good choice |
|
||||
|------|-------------|-------------|
|
||||
| **Sounder** | Can transmit + self-capture (TX-with-RX). Any supported generation works; Jaguar3 (8822CU `0bda:c812`, 8822EU `0bda:a81a`) is the best-tested. | RTL8822CU |
|
||||
| **Beamformee** | Must answer a VHT sounding with a compressed report. **Two receive antennas (2T2R) give a far cleaner signal** than a 1T1R part. | RTL8822BU / RTL8822CU (2T2R) |
|
||||
|
||||
A 1T1R beamformee (e.g. an 8811AU) *works* but produces a much noisier, weaker
|
||||
signal — its "relative channel between two antennas" is degenerate. If your
|
||||
readout is jumpy, suspect the beamformee first.
|
||||
|
||||
**Placement matters more than anything else** — see §6.
|
||||
|
||||
The demo is built on macOS/Linux (libusb). It does not need root on macOS; on
|
||||
Linux you may need to run as root or add a udev rule so libusb can claim the
|
||||
interface.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
cmake -S . -B build
|
||||
cmake --build build -j --target sense
|
||||
```
|
||||
|
||||
The decoder has a headless self-test that runs under `ctest` (no radio needed):
|
||||
|
||||
```sh
|
||||
ctest --test-dir build -R bf_report_decode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Run it
|
||||
|
||||
```sh
|
||||
./build/sense --channel 6 \
|
||||
--sounder 0x0bda:0xc812 \
|
||||
--beamformee 0x0bda:0xb82c
|
||||
```
|
||||
|
||||
Both selectors take `[VID:]PID` (VID defaults to `0x0bda`). Find your adapters'
|
||||
IDs with `lsusb` (Linux) or `system_profiler SPUSBDataType` / `ioreg -p IOUSB`
|
||||
(macOS).
|
||||
|
||||
Startup takes a few seconds: bring up both radios → calibrate the decoder split
|
||||
(~48 reports) → acquire the noise floor (~2.5 s). Then you get a live line:
|
||||
|
||||
```
|
||||
[ CLEAR ] 0.4σ | | now 0.0003 base 0.0003 1310/s
|
||||
[ MOTION ] 9.2σ |####################| now 0.0016 base 0.0003 1298/s
|
||||
```
|
||||
|
||||
Wave your hand near the adapters and the `σ` reading climbs; the verdict flips to
|
||||
`MOTION` and holds for ~1 s after you stop. `Ctrl-C` to quit.
|
||||
|
||||
### Reading the line
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `CLEAR` / `MOTION` | verdict. `MOTION·nb` = the rise is concentrated on a few tones (looks like a narrowband interferer, not broadband motion). |
|
||||
| `σ` | how many noise-floor standard deviations the current energy sits above the self-calibrated floor. The detector fires at `σ ≈ k` (see §7). |
|
||||
| bar | `σ` as a bar, saturating at 10σ. |
|
||||
| `now` | current motion energy (mean per-tone circular variance of `phi`). |
|
||||
| `base` | the self-calibrated still-floor it is measured against. |
|
||||
| `N/s` | beamforming reports captured per second (health indicator; a fast Jaguar3 sounder gives ~1000–1500/s). |
|
||||
|
||||
---
|
||||
|
||||
## 5. All the knobs
|
||||
|
||||
**Command-line flags**
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|------|---------|---------|
|
||||
| `--channel N` | `6` | Wi-Fi channel to sound on. Try 5 GHz (36, 149) for a different multipath environment. |
|
||||
| `--sounder [VID:]PID` | `0bda:8812` | sounder adapter selector |
|
||||
| `--beamformee [VID:]PID` | `0bda:c812` | beamformee adapter selector |
|
||||
| `--vid 0xNNNN` | `0x0bda` | default VID for both selectors (for OEM-rebadged dongles) |
|
||||
| `--sensitivity low\|med\|high` | `med` | detector threshold `k` = 6 / 4 / 2.5 sigmas |
|
||||
| `-v`, `--verbose` | off | show the library's bring-up logs (otherwise quieted to warnings) |
|
||||
|
||||
**Environment variables**
|
||||
|
||||
| Var | Purpose |
|
||||
|-----|---------|
|
||||
| `DEVOURER_SENSE_K=<sigmas>` | override the CFAR threshold `k` directly (finer than `--sensitivity`). |
|
||||
| `DEVOURER_SENSE_DUMP=1` | emit every captured report as a `bf.report_raw` event on **stderr** — the input for offline analysis (§8). |
|
||||
| `DEVOURER_SENSE_DEBUG=1` | print the first few captured reports' geometry (SA, Nc/Nr, MU, Ns) for a sanity check. |
|
||||
|
||||
---
|
||||
|
||||
## 6. Environment — the single biggest lever
|
||||
|
||||
The signal strength depends almost entirely on **how much a moving person changes
|
||||
the channel between the two adapters, relative to the static part of that
|
||||
channel.**
|
||||
|
||||
- **Separate the two adapters.** Two dongles side by side on the same hub share a
|
||||
strong, short, line-of-sight channel that a hand barely perturbs. Put one on a
|
||||
**USB extension a metre or more away**, ideally across the space you want to
|
||||
sense. This can turn a marginal signal into an obvious one.
|
||||
- **Put the person in the path.** Motion *between* or *near* the two antennas
|
||||
moves the needle most.
|
||||
- **Multipath helps.** A reflective room (walls, furniture) gives the channel more
|
||||
structure to perturb than an anechoic free-space shot.
|
||||
- **Channel width / band.** 20 MHz on 2.4 GHz is the default. 5 GHz and wider
|
||||
channels change the coherence bandwidth and the per-tone structure; worth
|
||||
experimenting.
|
||||
|
||||
Reference numbers from one indoor 20 MHz / channel-6 setup (2T2R↔1T1R, adapters
|
||||
~30 cm apart):
|
||||
|
||||
| condition | motion energy (mean per-tone circular variance of `phi`) |
|
||||
|-----------|----------------------------------------------------------|
|
||||
| still | 0.0003 (floor), window-to-window jitter ~0.00002 |
|
||||
| hand wave next to a dongle | 0.0006 – 0.002 (2–6× the floor) |
|
||||
|
||||
Your absolute numbers **will differ** — that is exactly why the detector
|
||||
self-calibrates rather than using a fixed threshold. Use these only as a rough
|
||||
scale.
|
||||
|
||||
---
|
||||
|
||||
## 7. How it works inside (so you can change the maths)
|
||||
|
||||
Everything below lives in two files:
|
||||
|
||||
- **`src/BfReportDecode.h`** — the report decoder + the `MotionMeter` metric.
|
||||
- **`examples/sense/main.cpp`** — the `AdaptiveDetector` + display.
|
||||
|
||||
### 7a. Decoding the report → angles
|
||||
|
||||
`parse_report()` matches a VHT/HT compressed beamforming report and locates the
|
||||
packed angle bits. `decode_angles()` unpacks, per subcarrier, one `phi` (phase)
|
||||
and one `psi` (amplitude) angle, LSB-first, and dequantises them:
|
||||
|
||||
```
|
||||
phi = (2q + 1) · π / 2^b_phi # dequant_phi
|
||||
psi = (2q + 1) · π / 2^(b_psi + 2) # dequant_psi
|
||||
```
|
||||
|
||||
**The bit split `(b_phi, b_psi)` matters and is easy to get wrong.** The 802.11
|
||||
compressed-Givens codebook always pairs `b_phi = b_psi + 2`. For the common
|
||||
10-bits-per-subcarrier 2×1 report that uniquely means **`(6, 4)`**. `pick_split()`
|
||||
enforces that relationship — do **not** replace it with a naive "minimise
|
||||
cross-frame variance" search: a too-coarse `psi` (e.g. 2 bits) is trivially
|
||||
constant, so an unconstrained search picks a bit-*misaligned* split whose `phi`
|
||||
decodes to garbage that jitters on a static channel and reads as constant motion.
|
||||
This was a real bug; the self-test now pins the split to `(6,4)`.
|
||||
|
||||
### 7b. The motion metric (`MotionMeter`)
|
||||
|
||||
For each report we keep the first `phi` of every subcarrier in a sliding window
|
||||
(`kWindow = 512` reports, ~0.3–0.5 s). The per-tone motion signal is the
|
||||
**circular variance** of that phase over the window:
|
||||
|
||||
```
|
||||
var[k] = 1 − |mean_over_window( e^{i·phi_k} )| # in [0, 1]
|
||||
motion_energy = mean_k var[k]
|
||||
```
|
||||
|
||||
Circular variance is 0 when a tone's phase is constant (static channel) and rises
|
||||
toward 1 as it scatters (a changing channel). It is CFO-robust in the sense that
|
||||
a *constant* phase offset cancels; what survives is frame-to-frame change.
|
||||
|
||||
Why `phi` (phase) and not `psi` (amplitude)? Measured: a hand wave moves the
|
||||
phase but barely moves the coarse 4-bit amplitude ratio, so `phi` is the sensitive
|
||||
signal. `psi` is decoded too, and the "broadband vs localized" flag
|
||||
(`MotionMeter::localized()`) uses the per-tone shape to distinguish human motion
|
||||
from a narrowband interferer.
|
||||
|
||||
**Things to try here:** a different aggregation (median/percentile instead of
|
||||
mean over tones), a frame-to-frame `|Δphi|` "velocity" metric, a shorter window
|
||||
for faster response, or weighting tones by their SNR.
|
||||
|
||||
### 7c. The detector (`AdaptiveDetector`)
|
||||
|
||||
A fixed threshold is wrong because the still-floor varies with hardware, channel
|
||||
and geometry. Instead the detector self-calibrates (a CFAR-style test):
|
||||
|
||||
- Track the **floor** (mean still energy) and its **jitter** `dev` with a
|
||||
rate-independent EMA (`kTauFloor = 4 s`; faster `kWarmTau = 0.4 s` during a
|
||||
`kWarmSec = 2.5 s` warm-up). The floor **freezes while motion is held**, so a
|
||||
moving subject can't drag it up and blind the detector.
|
||||
- Threshold: `floor + k · max(dev, kDevFloor)`. `k` is the sensitivity
|
||||
(`--sensitivity`/`DEVOURER_SENSE_K`); `kDevFloor` is a minimum jitter so the
|
||||
threshold can't collapse onto a perfectly-still floor.
|
||||
- **Hysteresis:** arm only after the energy stays over threshold for
|
||||
`kArmSec = 0.15 s` (a lone spike can't trigger), then **hold** `kHoldSec = 1.2 s`
|
||||
after it drops (no flicker; bridges the gaps in an intermittent wave).
|
||||
- `σ` shown in the UI is `(energy − floor) / max(dev, kDevFloor)`.
|
||||
|
||||
The tunable constants are all `static constexpr` at the top of
|
||||
`AdaptiveDetector` in `examples/sense/main.cpp`:
|
||||
|
||||
```
|
||||
kWindow = 512 # metric window (reports) [in main.cpp top-level]
|
||||
kWarmSec = 2.5 s # floor acquisition
|
||||
kWarmTau = 0.4 s # fast tracking during warm-up
|
||||
kTauFloor = 4.0 s # floor/jitter tracking constant
|
||||
kArmSec = 0.15 s # dwell over threshold before MOTION
|
||||
kHoldSec = 1.2 s # hold MOTION after last trigger
|
||||
kDevSeed = 0.00005 # initial jitter estimate
|
||||
kDevFloor = 0.00004 # minimum jitter → minimum sensitivity margin
|
||||
```
|
||||
|
||||
If your still-floor sits at a different scale than the reference in §6, the two
|
||||
numbers most likely to need adjusting are **`kDevFloor`** (raise it if you get
|
||||
false alarms at rest, lower it if real motion never crosses the threshold) and
|
||||
**`k`** (via `DEVOURER_SENSE_K`, no rebuild needed).
|
||||
|
||||
---
|
||||
|
||||
## 8. Experiment with your own formulas (capture → analyse loop)
|
||||
|
||||
You do not have to edit C++ to try new maths. Capture the raw reports and analyse
|
||||
them offline:
|
||||
|
||||
```sh
|
||||
# capture ~30 s of reports to a file (stderr carries the raw dump)
|
||||
DEVOURER_SENSE_DUMP=1 ./build/sense --channel 6 \
|
||||
--sounder 0x0bda:0xc812 --beamformee 0x0bda:0xb82c \
|
||||
2> capture.txt
|
||||
|
||||
# decode + inspect with the reference Python tool
|
||||
grep -F '"ev":"bf.report_raw"' capture.txt | tools/bf_report_decode.py
|
||||
```
|
||||
|
||||
`tools/bf_report_decode.py` prints the header (Nc/Nr/BW/Ng), the chosen split,
|
||||
per-stream SNR and the per-tone `|h_B/h_A|`. From the same `capture.txt` you can
|
||||
compute anything you like in a few lines of Python — per-tone variance, a
|
||||
different window, a spectrogram of `phi[k]` over time, a doppler estimate — and
|
||||
label a run by doing a clean **still segment then a moving segment** and comparing
|
||||
the two. That is exactly how the metric, window and thresholds in this demo were
|
||||
chosen.
|
||||
|
||||
> **A note on the reference tool:** `tools/bf_report_decode.py` currently selects
|
||||
> the split by cross-frame stability and can land on the degenerate `(8,2)` for a
|
||||
> 10-bit report. When comparing against the C++ path, force the correct Givens
|
||||
> split `(6,4)` (see §7a).
|
||||
|
||||
---
|
||||
|
||||
## 9. Limitations and honest caveats
|
||||
|
||||
- **Weak coupling on close adapters.** Side-by-side dongles barely see motion. §6
|
||||
is not optional advice — it is the difference between working and not.
|
||||
- **Coarse amplitude.** The Realtek compact codebook gives `psi` only ~4 bits, so
|
||||
amplitude-based sensing is limited; this demo leans on phase.
|
||||
- **Second-adapter flakiness.** Some cheap beamformees' firmware crashes on long
|
||||
runs and the adapter drops off the USB bus. The demo has a stall watchdog (§10);
|
||||
if it fires, replug that adapter.
|
||||
- **A single-radio (`--mode self`) variant** — the sounder acting as its own
|
||||
beamformee — is plausible and would remove the flaky second adapter, but is not
|
||||
implemented here.
|
||||
- **No passive/AP-sniffing mode.** Sensing an existing AP↔client sounding exchange
|
||||
(the Wi-BFI approach) is a natural extension but is deliberately **not** shipped:
|
||||
it needs an AP actively sounding VHT beamforming, which we could not validate
|
||||
against. Contributions welcome.
|
||||
|
||||
---
|
||||
|
||||
## 10. Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---------|-------------|
|
||||
| `could not open VVVV:PPPP` | Adapter not found. Check `lsusb`. A prior hang can drop an adapter off the bus — **unplug/replug it**. |
|
||||
| Stuck on `calibrating decoder… 0 reports (0/s)` | The beamformee isn't answering: wrong PID, it doesn't support VHT sounding, or it fell off the bus. Try `-v` to see bring-up, and confirm both adapters enumerate. |
|
||||
| `report stream stalled Ns — beamformee stopped responding` | The beamformee firmware crashed. The demo stops cleanly; replug that adapter before re-running. |
|
||||
| Constant `MOTION` at rest | Threshold too low for your floor: raise `DEVOURER_SENSE_K`, or `--sensitivity low`. If `base` reads `0.000` and never rises, that's the old split bug — make sure you're on a build with the `(6,4)` `pick_split`. |
|
||||
| Never triggers even on a strong wave | Threshold too high, or coupling too weak. `--sensitivity high`, and **separate the adapters** (§6). |
|
||||
| Needs root on Linux | libusb can't claim the interface. Run as root or add a udev rule for the adapter's VID:PID. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Files
|
||||
|
||||
| File | What |
|
||||
|------|------|
|
||||
| `examples/sense/main.cpp` | the `sense` binary: adapter bring-up, sounding loop, `AdaptiveDetector`, display, watchdog. |
|
||||
| `src/BfReportDecode.h` | header-only report decoder + `MotionMeter` (reusable; no libusb dependency). |
|
||||
| `examples/sense/bf_report_decode_selftest.cpp` | headless `ctest` that guards the decoder against a real captured report. |
|
||||
| `docs/beamforming-victim-sensing.md` | the theory: why a beamforming report measures the channel and senses motion. |
|
||||
| `tools/bf_report_decode.py` | reference Python decoder for offline analysis. |
|
||||
@@ -0,0 +1,157 @@
|
||||
/* Headless self-test for src/BfReportDecode.h — guards the C++ port of
|
||||
* tools/bf_report_decode.py against regressions. Registered as a ctest, so a
|
||||
* decode break fails CI instead of only surfacing on a radio.
|
||||
*
|
||||
* Checks: the LSB-first BitReader and the dequant formulas on known inputs, the
|
||||
* report header parse + fixed-split angle decode against a real captured MU
|
||||
* report (reference psi values computed offline with a fixed (8,2) split), and
|
||||
* that the split picker returns a valid split. */
|
||||
#include "BfReportDecode.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace devourer::bf;
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond, msg) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL: %s\n", msg); \
|
||||
++failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool approx(double a, double b, double tol = 1e-4) {
|
||||
return std::fabs(a - b) < tol;
|
||||
}
|
||||
|
||||
static std::vector<uint8_t> from_hex(const std::string &h) {
|
||||
std::vector<uint8_t> out;
|
||||
for (size_t i = 0; i + 1 < h.size(); i += 2)
|
||||
out.push_back((uint8_t)std::strtoul(h.substr(i, 2).c_str(), nullptr, 16));
|
||||
return out;
|
||||
}
|
||||
|
||||
/* A real VHT MU Compressed Beamforming report captured from an 8822CU beamformee
|
||||
* (20 MHz, Nr=2 Nc=1, MU). psi[0..5] below were computed offline from these bytes
|
||||
* with a fixed (b_phi=8, b_psi=2) split. */
|
||||
static const char *kReportHex =
|
||||
"e000000056427505d60000e04c8822ce00000000000010001500088c04c2a98dad97b09fbe"
|
||||
"addaadc7bfccbde1c9e5cfe8cdf3cdf1d1eacfe5cff0d5eed9f0d9e6e1ffd70cd820e017d2"
|
||||
"25d61ef093f0b9daa1ec91e687dc83e093e058f417ecfbebf9ebe9e1f1db03dc08de0dda11"
|
||||
"d814de0fd808d60dd219c815c605b811b01bac20b62ac40f01f00fef00100100ff0011111001cbbf1bd5";
|
||||
|
||||
int main() {
|
||||
/* 1. BitReader — LSB-first. byte 0xB4 = 1011 0100b; reading 3 bits gives the
|
||||
* low three bits in LSB order = 0b100 = 4; next 5 bits = 0b10110 = 22. */
|
||||
{
|
||||
uint8_t bytes[2] = {0xB4, 0x00};
|
||||
BitReader br(bytes, 2);
|
||||
CHECK(br.read(3) == 4u, "BitReader low 3 bits");
|
||||
CHECK(br.read(5) == 22u, "BitReader next 5 bits");
|
||||
}
|
||||
|
||||
/* 2. dequant — psi=(2q+1)pi/2^(b+2), phi=(2q+1)pi/2^b. */
|
||||
CHECK(approx(dequant_psi(0, 2), M_PI / 16.0), "dequant_psi q0 b2");
|
||||
CHECK(approx(dequant_psi(3, 2), 7.0 * M_PI / 16.0), "dequant_psi q3 b2");
|
||||
CHECK(approx(dequant_phi(0, 8), M_PI / 256.0), "dequant_phi q0 b8");
|
||||
|
||||
/* 3. parse_report on the real MU report. */
|
||||
std::vector<uint8_t> frame = from_hex(kReportHex);
|
||||
ReportHdr hdr;
|
||||
CHECK(parse_report(frame.data(), frame.size(), hdr), "parse_report matches");
|
||||
CHECK(hdr.nc == 1 && hdr.nr == 2, "nc/nr");
|
||||
CHECK(hdr.bw == 0 && hdr.ng == 1, "bw/ng");
|
||||
CHECK(hdr.mu && hdr.vht, "mu/vht");
|
||||
CHECK(hdr.ns == 52, "ns=52");
|
||||
CHECK(hdr.per_sc_bits == 10, "per_sc_bits=10 (MU)");
|
||||
CHECK(hdr.angle_len == 65, "angle_len=65 (52*10 bits)");
|
||||
|
||||
/* 3b. The same logical report delivered WITHOUT a trailing FCS - the
|
||||
* MediaTek MT7612U shape, where the MAC strips it.
|
||||
*
|
||||
* With slack in the buffer the MU path clamps angle_len to vbytes, so the
|
||||
* flag provably changes nothing there; that equivalence is the first check.
|
||||
* To show the flag actually DOES something, the second half trims the frame
|
||||
* to exactly (29 + nc) + vbytes - no slack at all - which is the shape a
|
||||
* real FCS-less capture has. There, believing a non-existent FCS eats four
|
||||
* angle bytes and the report must be REJECTED. A control that cannot fail is
|
||||
* not a control. */
|
||||
{
|
||||
std::vector<uint8_t> nofcs(frame.begin(), frame.end() - 4);
|
||||
ReportHdr h2;
|
||||
CHECK(parse_report(nofcs.data(), nofcs.size(), h2, /*fcs_present=*/false),
|
||||
"parse_report matches with no FCS");
|
||||
CHECK(h2.nc == hdr.nc && h2.nr == hdr.nr && h2.bw == hdr.bw &&
|
||||
h2.ng == hdr.ng && h2.mu == hdr.mu && h2.vht == hdr.vht &&
|
||||
h2.ns == hdr.ns && h2.per_sc_bits == hdr.per_sc_bits,
|
||||
"no-FCS header identical to FCS-present");
|
||||
CHECK(h2.angle_len == hdr.angle_len, "no-FCS angle_len identical");
|
||||
|
||||
/* Exactly the angle block, nothing after it. */
|
||||
/* CHECK only records a failure and returns, so this has to gate the
|
||||
* slicing too - otherwise a shorter fixture walks the iterator past the
|
||||
* end (UB) on exactly the path the check was meant to protect.
|
||||
*
|
||||
* The rejection below is MU-specific: it bites via the MU clamp
|
||||
* (vbytes 65 > ab_len 61). An SU fixture would instead depend on
|
||||
* (angle_len-4)*8 % ns, which is 0 for ns=16 - so this control would pass
|
||||
* spuriously there. It requires an MU fixture, which this one is. */
|
||||
const size_t tight = 29u + (size_t)hdr.nc + (size_t)hdr.angle_len;
|
||||
CHECK(tight <= frame.size(), "fixture long enough to build the tight case");
|
||||
if (tight <= frame.size()) {
|
||||
std::vector<uint8_t> snug(frame.begin(), frame.begin() + (long)tight);
|
||||
|
||||
ReportHdr h4;
|
||||
CHECK(parse_report(snug.data(), snug.size(), h4, /*fcs_present=*/false),
|
||||
"tight FCS-less report still decodes");
|
||||
CHECK(h4.angle_len == hdr.angle_len, "tight FCS-less angle_len intact");
|
||||
|
||||
ReportHdr h5;
|
||||
CHECK(!parse_report(snug.data(), snug.size(), h5, /*fcs_present=*/true),
|
||||
"assuming an FCS that is not there must reject, not silently shorten");
|
||||
}
|
||||
}
|
||||
|
||||
/* 4. fixed-split decode vs offline reference. */
|
||||
std::vector<double> psi;
|
||||
CHECK(decode_psi(hdr, 8, 2, psi), "decode_psi ok");
|
||||
CHECK(psi.size() == 52, "52 psi values");
|
||||
const double ref[6] = {0.589049, 1.374447, 0.589049,
|
||||
0.981748, 0.981748, 1.374447};
|
||||
for (int k = 0; k < 6; ++k)
|
||||
CHECK(approx(psi[k], ref[k]), "psi matches reference");
|
||||
bool in_range = true;
|
||||
for (double p : psi)
|
||||
if (p < 0.0 || p > M_PI / 2.0)
|
||||
in_range = false;
|
||||
CHECK(in_range, "all psi in (0, pi/2)");
|
||||
|
||||
/* 5. split picker returns the standard Givens split (b_phi = b_psi + 2),
|
||||
* i.e. (6,4) for this 10-bit/tone 2x1 report — NOT the degenerate (8,2) that a
|
||||
* naive min-variance search picks because a 2-bit psi is trivially constant. */
|
||||
{
|
||||
std::vector<ReportHdr> batch(8, hdr); /* same frame repeated is enough */
|
||||
int bphi = 0, bpsi = 0;
|
||||
CHECK(pick_split(batch, bphi, bpsi), "pick_split found a split");
|
||||
CHECK(bphi + bpsi == hdr.per_sc_bits, "split sums to per_sc_bits");
|
||||
CHECK(bphi == bpsi + 2, "split obeys Givens b_phi = b_psi + 2");
|
||||
CHECK(bphi == 6 && bpsi == 4, "10-bit/tone 2x1 split is (6,4)");
|
||||
}
|
||||
|
||||
/* 6. MotionMeter — identical reports => ~zero motion energy. */
|
||||
{
|
||||
MotionMeter m(52, 8, 2, 16);
|
||||
for (int i = 0; i < 8; ++i)
|
||||
m.push(hdr);
|
||||
CHECK(m.motion_energy() < 1e-9, "static channel => zero motion energy");
|
||||
}
|
||||
|
||||
if (failures == 0)
|
||||
std::printf("bf_report_decode_selftest: all checks passed\n");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
/* sense — a runnable Wi-Fi motion/presence sensor built on devourer.
|
||||
*
|
||||
* An 802.11ac beamforming report is a measurement taken at the beamformee: its
|
||||
* per-tone Givens angles track the channel, so a moving person perturbs them
|
||||
* frame-to-frame. This demo captures reports, decodes them (src/BfReportDecode.h),
|
||||
* and shows a live motion readout — the per-tone cross-frame variance of the
|
||||
* phase angle. See docs/beamforming-victim-sensing.md.
|
||||
*
|
||||
* One binary drives TWO adapters: a sounder that injects NDPAs (the MAC
|
||||
* hardware-generates the NDP) and self-captures the reports, and a beamformee
|
||||
* that responds in hardware. Two dongles; the effect is stronger when they are
|
||||
* physically separated (a static short channel barely moves).
|
||||
*/
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX /* keep windows.h (via libusb.h) from defining min/max macros */
|
||||
#endif
|
||||
|
||||
#if defined(__ANDROID__) || defined(_MSC_VER) || defined(__APPLE__)
|
||||
#include <libusb.h>
|
||||
#else
|
||||
#include <libusb-1.0/libusb.h>
|
||||
#endif
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "BfReportDecode.h"
|
||||
#include "DeviceSession.h"
|
||||
#include "RadiotapBuilder.h"
|
||||
#include "RxPacket.h"
|
||||
#include "SignalStop.h"
|
||||
#include "UsbOpen.h"
|
||||
#include "WiFiDriver.h"
|
||||
#include "env_config.h"
|
||||
#include "logger.h"
|
||||
|
||||
using devourer::bf::MotionMeter;
|
||||
using devourer::bf::parse_report;
|
||||
using devourer::bf::ReportHdr;
|
||||
|
||||
/* Portable environment set (the demo hands arming flags to the library via env):
|
||||
* POSIX setenv, or _putenv_s on Windows (MSVC + MinGW have no POSIX setenv). */
|
||||
static void set_env(const char *name, const char *value) {
|
||||
#ifdef _WIN32
|
||||
_putenv_s(name, value);
|
||||
#else
|
||||
::setenv(name, value, 1);
|
||||
#endif
|
||||
}
|
||||
|
||||
/* The sounder's TA and the address a beamformee arms to respond to (matches the
|
||||
* canonical SA used across devourer's TX path). */
|
||||
static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00};
|
||||
|
||||
/* Event sink for the demo's JSONL emissions (Sensor::feed runs on the RX
|
||||
* callback) — points at the main() Logger's sink, which sense routes to
|
||||
* stderr so the live stdout display stays clean. */
|
||||
static devourer::EventSink *g_ev = nullptr;
|
||||
static constexpr uint16_t REG_MACID = 0x0610;
|
||||
static constexpr int kCalReports = 48; /* reports to calibrate the bit split */
|
||||
static constexpr size_t kWindow = 512; /* variance window (~0.3 s at a fast
|
||||
* Jaguar3 sounding rate — long enough
|
||||
* to span human motion, short enough
|
||||
* to feel live) */
|
||||
static constexpr int kStallSec = 8; /* no reports for this long => the
|
||||
* beamformee stopped responding; stop
|
||||
* rather than sound into the void */
|
||||
|
||||
/* -------------------------------------------------------------- Detector ---- */
|
||||
/* Adaptive presence detector. Fed the MotionMeter's energy once per report, it
|
||||
* self-calibrates a noise floor and fires when the energy rises a configurable
|
||||
* number of standard deviations above it (a CFAR-style test), then holds the
|
||||
* verdict for a short time so a moving-then-pausing subject doesn't flicker.
|
||||
*
|
||||
* Why not a fixed threshold: the still-state energy floor varies with hardware,
|
||||
* channel and geometry, so a magic constant tuned on one rig is wrong on the
|
||||
* next. The floor tracks the quiet state with a rate-independent time constant
|
||||
* but FREEZES while motion is held — otherwise a subject who keeps moving would
|
||||
* slowly raise the floor and blind the detector (the classic motion-sensor
|
||||
* failure). During the initial warm-up the floor latches onto the quietest
|
||||
* instant seen, so a subject already moving at startup can't set a high floor. */
|
||||
class AdaptiveDetector {
|
||||
public:
|
||||
explicit AdaptiveDetector(double k) : _k(k) {}
|
||||
|
||||
/* Call only once the MotionMeter window is full, so `energy` is a real
|
||||
* still-state estimate and not the window-fill transient (which is ~0 and
|
||||
* would peg the floor at zero -> always-MOTION). */
|
||||
void update(double energy, double t) {
|
||||
_energy = energy;
|
||||
if (!_init) {
|
||||
_floor = energy;
|
||||
_dev = kDevSeed;
|
||||
_last = t;
|
||||
_warm_until = t + kWarmSec;
|
||||
_init = true;
|
||||
return;
|
||||
}
|
||||
double dt = t - _last;
|
||||
if (dt < 0)
|
||||
dt = 0;
|
||||
_last = t;
|
||||
bool warming = t < _warm_until;
|
||||
_warm = warming;
|
||||
/* Track the floor (mean still energy) and its jitter with an EMA — fast
|
||||
* during warm-up to converge, slow after. Freeze while motion is held so a
|
||||
* moving subject can't drag the floor up and blind the detector. */
|
||||
if (warming || !_active) {
|
||||
double tau = warming ? kWarmTau : kTauFloor;
|
||||
double a = 1.0 - std::exp(-dt / tau);
|
||||
double resid = energy - _floor;
|
||||
_floor += a * resid;
|
||||
_dev += a * (std::fabs(resid) - _dev);
|
||||
}
|
||||
_thr = _floor + _k * std::max(_dev, kDevFloor);
|
||||
if (warming)
|
||||
return; /* no verdict until the floor is acquired */
|
||||
/* Hysteresis: arm only after the energy stays over threshold for kArmSec
|
||||
* (a lone noise spike can't trigger), then hold kHoldSec after it drops (a
|
||||
* moving-then-pausing subject doesn't flicker). */
|
||||
if (energy > _thr) {
|
||||
if (_over_since < 0.0)
|
||||
_over_since = t;
|
||||
if (_active || (t - _over_since) >= kArmSec) {
|
||||
_active = true;
|
||||
_hold = kHoldSec;
|
||||
}
|
||||
} else {
|
||||
_over_since = -1.0;
|
||||
if (_active) {
|
||||
_hold -= dt;
|
||||
if (_hold <= 0.0)
|
||||
_active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool active() const { return _active; }
|
||||
bool warming() const { return _warm; }
|
||||
double energy() const { return _energy; }
|
||||
double floor() const { return _floor; }
|
||||
/* signal strength: how many floor-jitter sigmas the energy sits above the
|
||||
* floor. The detector fires at sigma == k. */
|
||||
double sigma() const { return (_energy - _floor) / std::max(_dev, kDevFloor); }
|
||||
|
||||
private:
|
||||
static constexpr double kWarmSec = 2.5; /* floor-acquisition window (s) */
|
||||
static constexpr double kWarmTau = 0.4; /* fast tracking during warm-up (s) */
|
||||
static constexpr double kTauFloor = 4.0; /* floor/jitter tracking constant (s) */
|
||||
static constexpr double kArmSec = 0.15; /* dwell over threshold before MOTION (s) */
|
||||
static constexpr double kHoldSec = 1.2; /* hold MOTION after last trigger (s) */
|
||||
/* Scaled to the measured signal: with the correct (6,4) split the still-channel
|
||||
* phi circular-variance floor is ~0.0003 and its window-to-window jitter is
|
||||
* ~0.00002; a hand wave lifts it to ~0.0006–0.002. So the minimum sensitivity
|
||||
* margin must be tens of micro-units, not milli-units. */
|
||||
static constexpr double kDevSeed = 0.00005; /* initial jitter estimate */
|
||||
static constexpr double kDevFloor = 0.00004; /* min jitter → min sensitivity margin */
|
||||
double _k;
|
||||
bool _init = false, _active = false, _warm = true;
|
||||
double _energy = 0, _floor = 0, _dev = 0, _thr = 0;
|
||||
double _last = 0, _warm_until = 0, _hold = 0, _over_since = -1.0;
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- Sensor ---- */
|
||||
/* Turns a stream of report frames into a live motion signal. Thread-safe: the
|
||||
* RX callback calls feed(); the display thread reads the snapshot. */
|
||||
class Sensor {
|
||||
public:
|
||||
explicit Sensor(double k) : _det(k) {}
|
||||
|
||||
/* No default: the single call site has the Packet and must pass the
|
||||
* frame's own flag. A default here would only let a future second
|
||||
* caller compile while silently applying the Realtek rule. */
|
||||
void feed(const uint8_t *frame, size_t n, bool fcs_present) {
|
||||
ReportHdr hdr;
|
||||
if (!parse_report(frame, n, hdr, fcs_present))
|
||||
return;
|
||||
if (std::getenv("DEVOURER_SENSE_DUMP") && g_ev) {
|
||||
/* python-tool-compatible raw dump (events ride stderr in sense, so the
|
||||
* stdout display is untouched): capture with 2>file, analyse with
|
||||
* tools/bf_report_decode.py */
|
||||
devourer::Ev(*g_ev, "bf.report_raw")
|
||||
.f("fcs", fcs_present ? 1 : 0)
|
||||
.hex("frame", frame, n);
|
||||
}
|
||||
if (std::getenv("DEVOURER_SENSE_DEBUG")) {
|
||||
static int dbg = 0;
|
||||
if (dbg < 8) {
|
||||
++dbg;
|
||||
std::fprintf(stderr,
|
||||
"[report] len=%zu SA=%02x:%02x:%02x:%02x:%02x:%02x nc=%d "
|
||||
"nr=%d mu=%d ns=%d\n",
|
||||
n, frame[10], frame[11], frame[12], frame[13], frame[14],
|
||||
frame[15], hdr.nc, hdr.nr, hdr.mu, hdr.ns);
|
||||
}
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(_mu);
|
||||
++_total;
|
||||
if (!_meter) {
|
||||
/* calibration: copy full frames until we can pick a stable split */
|
||||
_cal.push_back({std::vector<uint8_t>(frame, frame + n), fcs_present});
|
||||
_ns = hdr.ns;
|
||||
_per = hdr.per_sc_bits;
|
||||
if ((int)_cal.size() >= kCalReports)
|
||||
calibrate();
|
||||
return;
|
||||
}
|
||||
if (hdr.ns != _ns)
|
||||
return;
|
||||
if (!_meter->push(hdr))
|
||||
return;
|
||||
/* Wait for a full window before feeding the detector: a partial window
|
||||
* under-reports circular variance, and that transient would poison the
|
||||
* self-calibrating floor. */
|
||||
if (_meter->count() < kWindow)
|
||||
return;
|
||||
_det.update(_meter->motion_energy(), now_sec());
|
||||
_localized = _meter->localized();
|
||||
}
|
||||
|
||||
struct Snap {
|
||||
bool calibrated, warming, motion, localized;
|
||||
double energy, floor, sigma;
|
||||
long total;
|
||||
int ns;
|
||||
};
|
||||
Snap snapshot() {
|
||||
std::lock_guard<std::mutex> lk(_mu);
|
||||
bool cal = _meter != nullptr;
|
||||
return Snap{cal, cal && _det.warming(),
|
||||
_det.active(), _localized,
|
||||
_det.energy(), _det.floor(),
|
||||
cal ? _det.sigma() : 0.0,
|
||||
_total, _ns};
|
||||
}
|
||||
|
||||
private:
|
||||
static double now_sec() {
|
||||
using namespace std::chrono;
|
||||
return duration_cast<duration<double>>(
|
||||
steady_clock::now().time_since_epoch())
|
||||
.count();
|
||||
}
|
||||
|
||||
void calibrate() {
|
||||
/* re-parse the copies so ReportHdr.angles point into stable storage */
|
||||
std::vector<ReportHdr> batch;
|
||||
batch.reserve(_cal.size());
|
||||
for (auto &f : _cal) {
|
||||
ReportHdr h;
|
||||
/* Reparse under the SAME rule the frame arrived with. Defaulting to
|
||||
* fcs_present here would reject tight MU reports and mis-derive
|
||||
* per_sc_bits for SU ones on any backend that strips the FCS. */
|
||||
if (parse_report(f.first.data(), f.first.size(), h, f.second))
|
||||
batch.push_back(h);
|
||||
}
|
||||
int bphi = 0, bpsi = 0;
|
||||
if (!devourer::bf::pick_split(batch, bphi, bpsi)) {
|
||||
/* fallback for the 2x1 case: the standard Givens split (b_phi = b_psi + 2)
|
||||
* summing to per_sc_bits — NOT a coarse split like (8,2), whose
|
||||
* bit-misaligned phi decodes to garbage. */
|
||||
bpsi = (_per - 2) / 2;
|
||||
bphi = bpsi + 2;
|
||||
}
|
||||
_bphi = bphi;
|
||||
_bpsi = bpsi;
|
||||
_meter = std::make_unique<MotionMeter>(_ns, bphi, bpsi, kWindow);
|
||||
_cal.clear();
|
||||
_cal.shrink_to_fit();
|
||||
}
|
||||
|
||||
std::mutex _mu;
|
||||
/* frame bytes + whether they carry an FCS; calibrate() needs both. */
|
||||
std::vector<std::pair<std::vector<uint8_t>, bool>> _cal;
|
||||
std::unique_ptr<MotionMeter> _meter;
|
||||
AdaptiveDetector _det;
|
||||
int _ns = 0, _per = 0, _bphi = 0, _bpsi = 0;
|
||||
bool _localized = false;
|
||||
long _total = 0;
|
||||
};
|
||||
|
||||
/* --------------------------------------------------------------- Display ---- */
|
||||
static void run_display(Sensor &sensor) {
|
||||
using namespace std::chrono;
|
||||
auto last = steady_clock::now();
|
||||
long last_total = 0;
|
||||
std::printf("\n Wi-Fi motion sensor — move near the adapters to trigger; it "
|
||||
"stays CLEAR when the room is still.\n"
|
||||
" (self-calibrating; Ctrl-C to stop)\n\n");
|
||||
while (!g_devourer_should_stop) {
|
||||
std::this_thread::sleep_for(milliseconds(200));
|
||||
auto s = sensor.snapshot();
|
||||
auto now = steady_clock::now();
|
||||
double dt = duration_cast<duration<double>>(now - last).count();
|
||||
double rate = dt > 0 ? (s.total - last_total) / dt : 0;
|
||||
last = now;
|
||||
last_total = s.total;
|
||||
if (!s.calibrated) {
|
||||
std::printf("\r calibrating decoder… %ld reports (%.0f/s) ",
|
||||
s.total, rate);
|
||||
std::fflush(stdout);
|
||||
continue;
|
||||
}
|
||||
if (s.warming) {
|
||||
std::printf("\r acquiring noise floor — one moment… (%.0f rep/s) ",
|
||||
rate);
|
||||
std::fflush(stdout);
|
||||
continue;
|
||||
}
|
||||
/* Signal strength = floor-jitter sigmas above the adaptive floor; the
|
||||
* detector fires around a few sigma. Bar saturates at 10 sigma. */
|
||||
double sig = s.sigma;
|
||||
if (sig < 0)
|
||||
sig = 0;
|
||||
int bar = (int)(sig / 10.0 * 40);
|
||||
if (bar > 40)
|
||||
bar = 40;
|
||||
char b[41];
|
||||
for (int i = 0; i < 40; ++i)
|
||||
b[i] = i < bar ? '#' : ' ';
|
||||
b[40] = 0;
|
||||
const char *tag = s.motion ? (s.localized ? "MOTION·nb" : " MOTION ")
|
||||
: " CLEAR ";
|
||||
std::printf("\r [%s] %5.1fσ |%s| now %.4f base %.4f %.0f/s ", tag, sig,
|
||||
b, s.energy, s.floor, rate);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
std::printf("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ USB helpers --- */
|
||||
/* Each adapter owns its whole stack — device, interface, handle and its own
|
||||
* libusb context — through one DeviceSession, so the two of them unwind
|
||||
* independently and each in the required order (see DeviceSession.h). */
|
||||
struct Adapter {
|
||||
explicit Adapter(const Logger_t &logger) : session(logger) {}
|
||||
devourer::DeviceSession session;
|
||||
|
||||
libusb_device_handle *handle() const { return session.handle(); }
|
||||
IRadio *dev() const { return session.device(); }
|
||||
};
|
||||
|
||||
/* Open one adapter by VID:PID on its own libusb context, claim + reset, and build
|
||||
* the device (not yet brought up). Returns false (logged) on failure. */
|
||||
static bool open_adapter(Adapter &a, uint16_t vid, uint16_t pid,
|
||||
const Logger_t &logger) {
|
||||
libusb_context *ctx = nullptr;
|
||||
if (libusb_init(&ctx) < 0)
|
||||
return false;
|
||||
a.session.adopt_context(ctx);
|
||||
libusb_device_handle *handle = libusb_open_device_with_vid_pid(ctx, vid, pid);
|
||||
if (!handle) {
|
||||
logger->error("could not open {:04x}:{:04x} — is it plugged in? (a prior "
|
||||
"hang can drop an adapter off the bus; unplug/replug it)",
|
||||
vid, pid);
|
||||
return false;
|
||||
}
|
||||
std::shared_ptr<devourer::UsbDeviceLock> lock;
|
||||
int rc = devourer::claim_interface_then_reset(
|
||||
handle, devourer::find_wifi_interface(handle), logger, true, lock);
|
||||
if (rc != 0) {
|
||||
/* The claim failed, so nothing owns the handle yet — hand it to the
|
||||
* session purely so the unwind closes it. */
|
||||
a.session.adopt_handle(handle);
|
||||
return false;
|
||||
}
|
||||
a.session.adopt_handle(handle);
|
||||
a.session.adopt_lock(lock);
|
||||
WiFiDriver driver(logger);
|
||||
auto owned_device =
|
||||
driver.CreateRadio(handle, ctx, lock, devourer_config_from_env());
|
||||
if (!owned_device)
|
||||
return false;
|
||||
a.session.adopt_device(std::move(owned_device));
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool read_mac(libusb_device_handle *h, uint8_t mac[6]) {
|
||||
/* REG_MACID is IDR0 (4 bytes @ 0x0610) + IDR4 (2 bytes @ 0x0614). Realtek
|
||||
* vendor reads are 1/2/4-byte; a single 6-byte read returns garbage, so split
|
||||
* it the way rtw_read32 + rtw_read16 would. */
|
||||
int r1 = libusb_control_transfer(h, 0xC0, 5, REG_MACID, 0, mac, 4, 1000);
|
||||
int r2 = libusb_control_transfer(h, 0xC0, 5, REG_MACID + 4, 0, mac + 4, 2, 1000);
|
||||
return r1 == 4 && r2 == 2;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- active mode -- */
|
||||
static int run_active(uint16_t snd_vid, uint16_t snd_pid, uint16_t bfe_vid,
|
||||
uint16_t bfe_pid, int channel, const Logger_t &logger,
|
||||
Sensor &sensor) {
|
||||
/* Both adapters are declared before any thread below, so every thread is
|
||||
* joined before the session that owns the handle it touches is unwound. */
|
||||
Adapter bfe{logger}, snd{logger};
|
||||
|
||||
/* Beamformee first: arm it (env, read at Init), bring it up on a thread. */
|
||||
set_env("DEVOURER_BF_ARM_BFEE", "57:42:75:05:d6:00");
|
||||
set_env("DEVOURER_BF_ARM_BFEE_MU", "1");
|
||||
if (!open_adapter(bfe, bfe_vid, bfe_pid, logger)) {
|
||||
logger->error("active: failed to open beamformee {:04x}", bfe_pid);
|
||||
return 1;
|
||||
}
|
||||
std::thread bfe_thread([&bfe, channel]() {
|
||||
bfe.dev()->Init([](const Packet &) {}, /* responds in hardware; RX ignored */
|
||||
SelectedChannel{.Channel = (uint8_t)channel,
|
||||
.ChannelOffset = 0,
|
||||
.ChannelWidth = CHANNEL_WIDTH_20});
|
||||
});
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500)); /* bring-up + MAC */
|
||||
uint8_t bfe_mac[6];
|
||||
if (!read_mac(bfe.handle(), bfe_mac)) {
|
||||
logger->error("active: could not read beamformee MAC (REG_MACID)");
|
||||
g_devourer_should_stop = true;
|
||||
bfe_thread.join();
|
||||
return 1;
|
||||
}
|
||||
logger->info("active: beamformee MAC {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
|
||||
bfe_mac[0], bfe_mac[1], bfe_mac[2], bfe_mac[3], bfe_mac[4],
|
||||
bfe_mac[5]);
|
||||
|
||||
/* Sounder: arm the sounding engine (env, read at InitWrite), VHT2SS_MCS0.
|
||||
* DEVOURER_TX_WITH_RX=thread must be set BEFORE InitWrite so a Jaguar3 sounder
|
||||
* keeps its RX filters open for the self-capture (no-op on Jaguar1/2). */
|
||||
set_env("DEVOURER_BF_ARM_SOUNDER", "1");
|
||||
set_env("DEVOURER_TX_WITH_RX", "thread");
|
||||
if (!open_adapter(snd, snd_vid, snd_pid, logger)) {
|
||||
logger->error("active: failed to open sounder {:04x}", snd_pid);
|
||||
g_devourer_should_stop = true;
|
||||
bfe_thread.join();
|
||||
return 1;
|
||||
}
|
||||
snd.dev()->InitWrite(SelectedChannel{.Channel = (uint8_t)channel,
|
||||
.ChannelOffset = 0,
|
||||
.ChannelWidth = CHANNEL_WIDTH_20});
|
||||
snd.dev()->SetTxMode(devourer::parse_tx_mode_str("VHT2SS_MCS0"));
|
||||
set_env("DEVOURER_TX_NDPA", "1"); /* send_packet marks the NDPA descriptor */
|
||||
|
||||
/* Self-capture the returned reports on the sounder's RX loop. */
|
||||
std::thread snd_rx([&snd, &sensor]() {
|
||||
snd.dev()->StartRxLoop(
|
||||
[&sensor](const Packet &p) { sensor.feed(p.Data.data(), p.Data.size(), p.RxAtrib.fcs_present); });
|
||||
});
|
||||
std::thread disp(run_display, std::ref(sensor));
|
||||
|
||||
/* Build the NDPA once (10-byte rate-less radiotap + 19-byte VHT NDPA body,
|
||||
* RA = beamformee MAC, TA = canonical SA). Rate is the SetTxMode default. */
|
||||
std::vector<uint8_t> ndpa = {
|
||||
0x00, 0x00, 0x0a, 0x00, 0x00, 0x80, 0x00, 0x00, 0x08, 0x00, /* radiotap */
|
||||
0x54, 0x00, 0x64, 0x00, /* NDPA FC+dur */
|
||||
bfe_mac[0], bfe_mac[1], bfe_mac[2], bfe_mac[3], bfe_mac[4], bfe_mac[5],
|
||||
0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, /* TA */
|
||||
0x04, 0x00, 0x10 /* dialog token; STA Info: AID0, MU feedback, Nc0 */};
|
||||
|
||||
logger->info("active: sounding ch{} — move near the setup to see it react",
|
||||
channel);
|
||||
/* Sound in a loop, watching the report stream. The beamformee's firmware can
|
||||
* crash on a long run (and then drop off the USB bus); if reports stop
|
||||
* advancing, don't spin forever sounding into the void — report it and stop
|
||||
* cleanly, so a stalled stream can't wedge the adapter. */
|
||||
using clk = std::chrono::steady_clock;
|
||||
long last_total = 0;
|
||||
auto last_adv = clk::now();
|
||||
bool stalled = false;
|
||||
while (!g_devourer_should_stop) {
|
||||
if (!snd.dev()->send_packet(ndpa.data(), ndpa.size()))
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(3));
|
||||
long t = sensor.snapshot().total;
|
||||
auto now = clk::now();
|
||||
if (t != last_total) {
|
||||
last_total = t;
|
||||
last_adv = now;
|
||||
} else if (now - last_adv > std::chrono::seconds(kStallSec)) {
|
||||
logger->warn("report stream stalled {}s — beamformee stopped responding; "
|
||||
"stopping (unplug/replug it before re-running)",
|
||||
kStallSec);
|
||||
stalled = true;
|
||||
g_devourer_should_stop = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* Deadlock-proof shutdown: a wedged USB handle can make an RX-loop join block
|
||||
* forever. Arm a force-exit safety net so the process is guaranteed to die;
|
||||
* if the clean joins finish first (the normal case) this timer is killed with
|
||||
* the process on return and never fires. */
|
||||
std::thread([]() {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(4));
|
||||
std::_Exit(0);
|
||||
}).detach();
|
||||
snd.dev()->StopRxLoop();
|
||||
bfe.dev()->StopRxLoop();
|
||||
disp.join();
|
||||
snd_rx.join();
|
||||
bfe_thread.join();
|
||||
snd.dev()->Stop();
|
||||
bfe.dev()->Stop();
|
||||
return stalled ? 2 : 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- main ------ */
|
||||
static uint16_t hex16(const char *s) {
|
||||
return (uint16_t)std::strtoul(s, nullptr, 0);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
auto logger = std::make_shared<Logger>();
|
||||
apply_logging_env(*logger); /* DEVOURER_LOG_LEVEL / DEVOURER_EVENTS / ... */
|
||||
/* Events go to stderr regardless of DEVOURER_EVENTS' default: stdout is the
|
||||
* live motion display (\r progress lines), and a JSON line in the middle of
|
||||
* it would shred the readout. Capture events with 2>file. */
|
||||
logger->events().configure(stderr);
|
||||
g_ev = &logger->events();
|
||||
install_devourer_signal_handlers();
|
||||
|
||||
int channel = 6;
|
||||
bool verbose = false;
|
||||
double sens_k = 4.0; /* CFAR threshold in sigmas; --sensitivity overrides */
|
||||
uint16_t snd_vid = 0x0bda, bfe_vid = 0x0bda;
|
||||
uint16_t snd_pid = 0x8812, bfe_pid = 0xc812;
|
||||
/* selector: "0xVID:0xPID" or just "0xPID" (VID defaults to 0x0bda). */
|
||||
auto parse_sel = [](const std::string &s, uint16_t &v, uint16_t &p) {
|
||||
auto c = s.find(':');
|
||||
if (c != std::string::npos) {
|
||||
v = hex16(s.substr(0, c).c_str());
|
||||
p = hex16(s.substr(c + 1).c_str());
|
||||
} else {
|
||||
p = hex16(s.c_str());
|
||||
}
|
||||
};
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string a = argv[i];
|
||||
auto next = [&]() -> std::string { return i + 1 < argc ? argv[++i] : ""; };
|
||||
if (a == "--channel") channel = std::atoi(next().c_str());
|
||||
else if (a == "--vid") { uint16_t v = hex16(next().c_str()); snd_vid = bfe_vid = v; }
|
||||
else if (a == "--sounder") parse_sel(next(), snd_vid, snd_pid);
|
||||
else if (a == "--beamformee") parse_sel(next(), bfe_vid, bfe_pid);
|
||||
else if (a == "--sensitivity") {
|
||||
std::string s = next();
|
||||
sens_k = s == "high" ? 2.5 : s == "low" ? 6.0 : 4.0; /* default med */
|
||||
}
|
||||
else if (a == "--verbose" || a == "-v") verbose = true;
|
||||
else if (a == "-h" || a == "--help") {
|
||||
std::printf(
|
||||
"sense — Wi-Fi motion sensing from beamforming reports\n"
|
||||
" drives two adapters: a sounder + a beamformee, on one host.\n"
|
||||
" --channel N (default 6)\n"
|
||||
" --sensitivity low|med|high detector threshold (default med)\n"
|
||||
" --vid 0xNNNN default VID for both (default 0x0bda)\n"
|
||||
" --sounder [VID:]PID sounder adapter selector\n"
|
||||
" --beamformee [VID:]PID beamformee adapter selector\n"
|
||||
" -v, --verbose show the library's bring-up logs\n");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Quiet the library's per-operation info logging so the live display owns the
|
||||
* console; --verbose restores the full bring-up log, and an explicit
|
||||
* DEVOURER_LOG_LEVEL (already applied by apply_logging_env above) wins over
|
||||
* the quiet default. */
|
||||
if (!verbose && std::getenv("DEVOURER_LOG_LEVEL") == nullptr)
|
||||
logger->set_level(Logger::Level::Warn);
|
||||
|
||||
/* DEVOURER_SENSE_K overrides the detector threshold for on-rig fine-tuning. */
|
||||
if (const char *kenv = std::getenv("DEVOURER_SENSE_K"))
|
||||
sens_k = std::atof(kenv);
|
||||
|
||||
Sensor sensor(sens_k);
|
||||
return run_active(snd_vid, snd_pid, bfe_vid, bfe_pid, channel, logger, sensor);
|
||||
}
|
||||
Reference in New Issue
Block a user