This commit is contained in:
2026-09-13 13:30:21 +08:00
commit a6bbd520cf
2744 changed files with 1598795 additions and 0 deletions
+325
View File
@@ -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;
}
+577
View File
@@ -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);
}