diff --git a/CLAUDE.md b/CLAUDE.md
index a1592d5c..f75ec28f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -280,6 +280,12 @@ those are the ones listed below.
degradation predictor — `docs/warm-tx-degradation.md` has delivery scattered
63–83% with no relation to the meter, and inside one uninterrupted session
the meter stays pinned while delivery drifts.
+- `DEVOURER_RX_BUSY_MS=N` (rxdemo) — the vendor-neutral busy-airtime window
+ at a fixed cadence: arm, wait N ms, read, one `rx.busy` event per window
+ (`IRadio::ArmChannelBusy`/`GetChannelBusy`, so it runs on the MT7612U where
+ `DEVOURER_RX_ENERGY_MS` cannot). It is the survey executor's dwell shape,
+ which makes it the harness for "does polling at dwell cadence cost the
+ receiver anything".
- `DEVOURER_LINKHEALTH=1` (rxdemo, needs `DEVOURER_RX_ENERGY_MS=N`) — classify
the RX sensor tuple via `src/LinkHealth.h`. **EVM, not SNR, is the
saturation tell**: strong RSSI + poor EVM means back power OFF, which is the
diff --git a/docs/adapter-doctor.md b/docs/adapter-doctor.md
index 3b03ecf8..5f4c29ef 100644
--- a/docs/adapter-doctor.md
+++ b/docs/adapter-doctor.md
@@ -67,6 +67,16 @@ would contaminate first-touch), runs a radiation-verified beacon flood,
and invokes `doctor --expect-traffic`. Exit code is the worst verdict
across reps.
+A MediaTek DUT takes three env knobs the Realtek default does not need:
+`DOCTOR_RTW88_MOD=mt76x2u` (the module to keep away), `DOCTOR_DUT_VID=0x0e8d
+DOCTOR_DUT_PID=0x7612` (the doctor's default PID walk is Realtek-only), and
+`DOCTOR_MT7612U_FW_DIR=
` holding the decompressed `mt7662.bin` +
+`mt7662_rom_patch.bin` (passed to the doctor as `--mt7612u-fw-dir`, since
+the doctor reads no environment). The EFUSE leg reports "not probed" there
+(`IRtlRadio`-only) and the FW leg reports `fw_attempted=0` because the
+backend does not surface `GetFwBootStatus`; the verdict rests on bring-up
+and the RX smoke.
+
Validated on the bench pair: the healthy unit grades HEALTHY (stable
0x8129 EFUSE ×4, FW ready, thousands of frames) and the dying unit
grades FAILING on every cold rep — regardless of which face the
diff --git a/docs/mt7612u-ap-mode.md b/docs/mt7612u-ap-mode.md
index 306a35b4..4a81cf98 100644
--- a/docs/mt7612u-ap-mode.md
+++ b/docs/mt7612u-ap-mode.md
@@ -166,6 +166,21 @@ key interface is a much larger contract than a feature flag — key lifetime, GT
vs PTK, rekey, who owns the replay counter — and expensive to undo once callers
exist.
+What the software path costs, measured so the "hardware crypto is faster
+here" assumption has a number to argue with (`tests/ccmp_cost_bench.sh`: the
+harness's `ccm()` call whole — a fresh OpenSSL context per call, AES-128-CCM,
+free — timed in both directions on an i7-6700K, one core): encrypt 0.53 µs
+per 64-byte frame, 1.52 µs per 1500-byte frame, 2.54 µs at 3000 bytes;
+decrypt 0.58 / 1.54 / 2.60 µs — about 650 k frames/s or ~8 Gbit/s at MTU
+either way. Against this transport that is noise: the MT7612U's own send path
+measured 268 µs per 100-byte frame and 2.4 ms per 1500-byte frame (`txdemo`,
+`DEVOURER_TX_GAP_US=0`, rate-less 6 Mbps frames, 44849 and 4997 submitted in
+12 s), so software CCMP is 0.06 % of the per-frame budget at MTU and under
+1 % even at an MCS7 airtime. One host, and a favourable one (AES-NI); an ARM
+ground station without it is unmeasured — run the bench there before
+carrying the percentage over. GTK rekey, not throughput, is what hardware
+keys would buy on this host.
+
## Limitations and shortfalls of a userspace AP on MediaTek — and workarounds
1. **Power-save / TIM is the real fight — USB has no pre-TBTT interrupt.**
diff --git a/docs/mt7612u.md b/docs/mt7612u.md
index 2791ee41..e16447f7 100644
--- a/docs/mt7612u.md
+++ b/docs/mt7612u.md
@@ -632,20 +632,35 @@ For scale: the same stress on the shipping RTL8812AU path produces **8** TSan
reports, all in devourer's own Jaguar1 state (`RtlJaguarDevice.cpp`,
`RtlAdapter.h`). Retune-during-RX is race-free here and not there.
+## Cold boot
+
+Measured, not assumed: `tests/adapter_doctor_cold.sh` on the Comfast CF-922AC
+(hub port `4-2.3` / 2 on the main bench), three reps of a true VBUS power
+cycle via uhubctl with `mt76x2u` temp-blacklisted so devourer is the first
+touch, a radiation-verified flood from an RTL8822BU on ch6, and
+`doctor --expect-traffic` — HEALTHY on every rep: bring-up completed from
+cold each time, ROM patch + firmware loaded through the ZeroCD
+re-enumeration (the part comes up as mass storage `0e8d:2870` for about a
+second before it is a NIC), and the 8 s RX smoke heard 4200 / 2585 / 4175
+frames (53 / 27 / 59 flagged corrupt — the flood contends with ambient ch6
+traffic, so the count is a liveness figure, not a rate). The EFUSE and FW-boot legs report
+nothing on this backend (`IRtlRadio`-only, and `GetFwBootStatus` is not
+surfaced), so the verdict rests on bring-up and RX — which is what a
+cold-only pathology would show in. One unit, one host, three reps.
+
## Open list
Ordered, and honest about which are unknowns rather than typing:
1. `mt76x2_phy_tssi_compensate()` — periodic temperature correction. Without
it output power drifts with die temperature.
-2. Cold-boot verification on a host with switchable USB power.
-3. A witness on different silicon. The regression matrix added a second witness
+2. A witness on different silicon. The regression matrix added a second witness
*implementation* (`tcpdump` over `mt76x2u`), and a second *unit* of the part
— but not a second board revision, and not a decoder outside the MediaTek
family.
-4. 80 MHz; VHT and NSS=2 on air.
-5. Retune tuning — batch registers via `CMD_RANDOM_WRITE`, drop the inter-command
+3. 80 MHz; VHT and NSS=2 on air.
+4. Retune tuning — batch registers via `CMD_RANDOM_WRITE`, drop the inter-command
sleep, skip the `RXDCOC` on a fast path. Worth doing only if 10–20 ms is
useful to someone.
-6. Whether the single MCS6 frame in the rate-LUT control arm (1 of 84) is a
+5. Whether the single MCS6 frame in the rate-LUT control arm (1 of 84) is a
witness decode artefact or a real fallback. Unexplained.
diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp
index 9f83d5a5..6fe81496 100644
--- a/examples/common/env_config.cpp
+++ b/examples/common/env_config.cpp
@@ -162,6 +162,10 @@ devourer::DeviceConfig devourer_config_from_env() {
* library nor the device class consults ambient process state. */
if (const char *e = env_str("DEVOURER_MT7612U_FW_DIR"))
cfg.mt7612u.firmware_dir = std::string(e);
+ /* Only an explicit "0" disables the tick: a typo must not silently turn
+ * off the receiver's calibration. */
+ if (const char *e = env_str("DEVOURER_MT7612U_PHY_TICK"))
+ cfg.mt7612u.phy_tick = std::strcmp(e, "0") != 0;
/* ---- tuning ---- */
/* Defaults ON, so this reads the negation: only an explicit 0 disables it. */
diff --git a/examples/doctor/main.cpp b/examples/doctor/main.cpp
index fb1daab9..4479eeb2 100644
--- a/examples/doctor/main.cpp
+++ b/examples/doctor/main.cpp
@@ -38,6 +38,8 @@
* --listen-secs N RX smoke window (default 8; 0 = skip)
* --expect-traffic operator vouches for on-channel traffic:
* 0 frames heard upgrades to FAILING
+ * --mt7612u-fw-dir DIR MediaTek DUTs: where mt7662.bin and
+ * mt7662_rom_patch.bin live (decompressed)
*
* Bench protocol for a definitive verdict on a suspect unit: put it on a
* uhubctl-switchable hub port, VBUS-cycle, run doctor with a beacon flood on
@@ -90,6 +92,10 @@ struct Args {
int reads = 4;
int listen_secs = 8;
bool expect_traffic = false;
+ /* MediaTek firmware directory (mt7662.bin + mt7662_rom_patch.bin). The
+ * library reads no environment and this tool takes no environment either,
+ * so the one backend whose firmware is not embedded needs it on the CLI. */
+ std::string mt7612u_fw_dir;
};
bool parse_int(const char *s, int &out) {
@@ -124,6 +130,8 @@ bool parse_args(int argc, char **argv, Args &a) {
;
else if (k == "--expect-traffic")
a.expect_traffic = true;
+ else if (k == "--mt7612u-fw-dir" && i + 1 < argc)
+ a.mt7612u_fw_dir = argv[++i];
else {
std::fprintf(stderr, "devourer [W] unknown/incomplete arg: %s\n",
k.c_str());
@@ -226,6 +234,8 @@ int main(int argc, char **argv) {
* (informational only); enable_with_tx so Jaguar3's InitWrite keeps the RX
* filters open for the StartRxLoop smoke window. */
devourer::DeviceConfig cfg;
+ if (!a.mt7612u_fw_dir.empty())
+ cfg.mt7612u.firmware_dir = a.mt7612u_fw_dir;
cfg.rx.keep_corrupted = true;
cfg.rx.enable_with_tx = true;
diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp
index f19f059c..a0c34edf 100644
--- a/examples/rx/main.cpp
+++ b/examples/rx/main.cpp
@@ -105,7 +105,9 @@ static constexpr uint16_t kRealtekProductIds[] = {
0xb733, /* RTL8733BU combo module Wi-Fi function (vendor ID table) */
};
-static int g_rx_count = 0;
+/* Written on the RX callback thread, read by the main thread and the pollers
+ * as the "bring-up produced a frame" signal, so atomic. */
+static std::atomic g_rx_count{0};
#if defined(DEVOURER_HAVE_JAGUAR1)
static RtlJaguarDevice *g_rtl_device = nullptr;
#endif
@@ -303,6 +305,27 @@ static const int g_thermal_warn_delta = []() -> int {
return e ? std::atoi(e) : 15;
}();
+/* DEVOURER_RX_BUSY_MS=N: the vendor-neutral busy-airtime window
+ * (IRadio::ArmChannelBusy / GetChannelBusy) at a fixed cadence, one `rx.busy`
+ * event per window. Arms, sleeps the window, reads — the survey executor's
+ * dwell shape (src/sensing/), so this is what "polling at dwell cadence" costs
+ * a receiver. Works on every backend that reports busy_airtime_ok; elsewhere
+ * the event carries valid=false. 0 = disabled. Unlike DEVOURER_RX_ENERGY_MS
+ * this needs no IRtlRadio. */
+static const uint32_t g_rx_busy_ms = []() -> uint32_t {
+ const char *e = std::getenv("DEVOURER_RX_BUSY_MS");
+ if (!e)
+ return 0u;
+ char *end = nullptr;
+ const unsigned long long v = std::strtoull(e, &end, 0);
+ /* Reject anything but a whole non-negative number, and cap so that the
+ * microsecond form handed to ArmChannelBusy cannot wrap. */
+ if (end == e || *end != '\0' || *e == '-')
+ return 0u;
+ const unsigned long long cap = UINT32_MAX / 1000u;
+ return static_cast(v > cap ? cap : v);
+}();
+
/* DEVOURER_RX_DUMP_CSI=hex,hex,... (or "0x1a,0x20,0x40"): F2 research
* spike. On each canonical-SA RX frame (first N frames), read BB
* dbgport 0x8FC at each selector and emit a csi.hit event
@@ -1018,7 +1041,7 @@ static void packetProcessor(const Packet &packet) {
if (g_rx_count <= 10 || g_rx_count % 100 == 0) {
devourer::Ev(*g_ev, "rx.pkt")
- .f("n", g_rx_count)
+ .f("n", g_rx_count.load())
.f("len", packet.Data.size())
.f("rate", packet.RxAtrib.data_rate)
.f("rssi", packet.RxAtrib.rssi[0]);
@@ -1093,7 +1116,7 @@ static void packetProcessor(const Packet &packet) {
* RX decoded LDPC). */
devourer::Ev(*g_ev, "rx.txhit")
.f("hits", hits)
- .f("total_rx", g_rx_count)
+ .f("total_rx", g_rx_count.load())
.f("len", packet.Data.size())
.f("seq", packet.RxAtrib.seq_num)
.f("paggr", packet.RxAtrib.paggr ? 1 : 0)
@@ -1274,6 +1297,15 @@ int main(int argc, char **argv) {
/* SIGINT/SIGTERM -> clean shutdown (Stop() below). Without this the harness's
* `timeout` SIGTERM killed us mid-RX, leaving the chip's USB core hung. */
install_devourer_signal_handlers();
+ /* Knob combinations that cannot work are refused here, before any thread
+ * exists: a later return would destroy a joinable poller thread. */
+ if (g_rx_busy_ms > 0 &&
+ (!g_rx_sweep.empty() || std::getenv("DEVOURER_HOP_CHANNELS"))) {
+ logger->error("DEVOURER_RX_BUSY_MS: refused with DEVOURER_RX_SWEEP / "
+ "DEVOURER_HOP_CHANNELS — those retune from the main thread, "
+ "and the busy window is a single-control-thread contract");
+ return 2;
+ }
/* Owns the teardown order (device -> interface -> handle -> context; see
* DeviceSession.h). Declared before every thread below, so the threads are
@@ -1555,6 +1587,70 @@ int main(int argc, char **argv) {
}
#endif /* DEVOURER_HAVE_JAGUAR1 */
+ /* Vendor-neutral busy-airtime poller: arm, wait the window, read, emit.
+ * ArmChannelBusy/GetChannelBusy are control-plane calls with a
+ * single-control-thread contract, so this runs only where the main thread
+ * has nothing left to do to the radio once bring-up is done: it refuses the
+ * sweep and hop modes (which retune from the main thread — a retune spoils
+ * the window by contract, and interleaving two control callers is what the
+ * contract forbids), and its first arm waits for bring-up to have produced
+ * a frame, like the sweep does, instead of racing Init(). */
+ std::atomic busy_emitter_stop{false};
+ std::thread busy_emitter;
+ if (g_rx_busy_ms > 0) {
+ logger->info("DEVOURER_RX_BUSY_MS={} — starting channel-busy poller",
+ g_rx_busy_ms);
+ IRadio *dev = rtlDevice;
+ busy_emitter = std::thread([&busy_emitter_stop, dev, logger]() {
+ /* Bring-up runs on the main thread. The first RX frame is the proof it
+ * finished; on a silent channel the fallback is the window's own gate -
+ * every backend refuses ArmChannelBusy (returns 0) until it is brought
+ * up, so an arm that is refused is retried, never read. */
+ for (uint32_t s = 0;
+ s < 10000 && !busy_emitter_stop.load() && g_rx_count.load() == 0;
+ s += 50)
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ bool waited_on_gate = false;
+ while (!busy_emitter_stop.load()) {
+ const uint32_t armed = dev->ArmChannelBusy(g_rx_busy_ms * 1000u);
+ if (armed == 0) {
+ if (!waited_on_gate)
+ logger->info("DEVOURER_RX_BUSY_MS: arm refused (not brought up "
+ "yet, or no sensor) — retrying until accepted");
+ waited_on_gate = true;
+ for (uint32_t s = 0; s < 500 && !busy_emitter_stop.load(); s += 50)
+ std::this_thread::sleep_for(std::chrono::milliseconds(50));
+ continue;
+ }
+ const auto until = std::chrono::steady_clock::now() +
+ std::chrono::milliseconds(g_rx_busy_ms);
+ while (!busy_emitter_stop.load()) {
+ const auto left = until - std::chrono::steady_clock::now();
+ if (left <= std::chrono::steady_clock::duration::zero())
+ break;
+ const auto step = std::chrono::milliseconds(50);
+ std::this_thread::sleep_for(left < step ? left : step);
+ }
+ if (busy_emitter_stop.load())
+ break;
+ const devourer::ChannelBusy b = dev->GetChannelBusy();
+ devourer::Ev ev(*g_ev, "rx.busy");
+ ev.t().f("armed_us", armed).f("valid", b.valid);
+ if (b.valid_busy)
+ ev.f("busy_pct", b.busy_pct);
+ else
+ ev.f("busy_pct", nullptr);
+ if (b.valid_energy)
+ ev.f("energy_pct", b.energy_pct);
+ else
+ ev.f("energy_pct", nullptr);
+ ev.f("window_us", b.window_us)
+ .f("own_tx", b.own_tx_in_window)
+ .f("spoil", static_cast(b.spoil));
+ }
+ });
+ }
+
/* Cross-generation thermal telemetry. GetThermalStatus is part of IRadio
* (Jaguar1/2/3, Kestrel, and RTL8733B); using the base pointer is what makes
* DEVOURER_THERMAL_POLL_MS work on RTL8812EU and newer backends instead of
@@ -1787,6 +1883,9 @@ int main(int argc, char **argv) {
qd_emitter_stop = true;
#endif
energy_emitter_stop = true;
+ busy_emitter_stop = true;
+ if (busy_emitter.joinable())
+ busy_emitter.join();
if (therm_emitter.joinable())
therm_emitter.join();
#if defined(DEVOURER_HAVE_JAGUAR1)
diff --git a/src/DeviceConfig.h b/src/DeviceConfig.h
index 83549860..204ce622 100644
--- a/src/DeviceConfig.h
+++ b/src/DeviceConfig.h
@@ -554,6 +554,15 @@ struct DeviceConfig {
* device class both stay free of ambient process state; the demos fold the
* variable in, the way they do for every other knob in this file. */
std::optional firmware_dir;
+ /* env: DEVOURER_MT7612U_PHY_TICK — 0 disables the backend's 1 Hz PHY tick
+ * (MCU channel calibration + temperature calibration + RX gain tracking,
+ * mt7612u_phy_tick()). Measurement control only: without the tick a
+ * receiver under a fast peer collapses to a few frames per 10 s
+ * (docs/mt7612u.md), so the only reason to turn it off is to measure that
+ * arm - the benchmark that says whether some other periodic reader (a
+ * channel-busy poller, say) is disturbing the tick has no meaning without
+ * the no-tick control beside it. Default on. */
+ bool phy_tick = true;
/* No adapter selector here on purpose. devourer chooses the adapter before
* a backend exists (DEVOURER_USB_BUS / _PORT / _VID / _PID) and hands the
* backend an already-claimed handle, so a MediaTek-specific selector would
diff --git a/src/mt7612u/CLAUDE.md b/src/mt7612u/CLAUDE.md
index 254e366d..02bf983d 100644
--- a/src/mt7612u/CLAUDE.md
+++ b/src/mt7612u/CLAUDE.md
@@ -39,9 +39,19 @@ gap and is reported that way in `window_us`. The mark is stamped at arm rather
than zeroed — zeroing made the first read after an arm report `window_us=0`,
i.e. a percentage with no denominator.
-Still to measure: that polling at dwell cadence does not disturb
-`phy_tick`'s gain tracking. For
-the latter, reuse the tick's own controlled benchmark — with and without the
-poller, against a steady peer — reading the full figures, control arm included,
-from the `mt7612u_phy_tick` doc comment rather than a copy of its headline
-number.
+Polling at dwell cadence costs the receiver nothing measurable at survey
+load: `rxdemo` with `DEVOURER_RX_BUSY_MS=100` (arm / wait / read, the survey
+executor's dwell shape) against an RTL8822BU airing 1400-byte HT-MCS7 at its
+~700 fps ceiling on ch6, four arms × 2 reps × 30 s — tick+poll 14841 / 17534
+frames, tick alone 17417 / 17531, no tick + poll 18211 / 15097, neither 17810 /
+14201 — all inside the run-to-run spread, with the poller reading ~80% busy
+throughout (317 windows per run, none spoiled). The no-tick control is what
+limits the claim: at ~700 fps it does NOT collapse, so the regime the tick's
+own benchmark is about (a SuperSpeed peer at ~3000 fps, where no tick means 3
+frames in 10 s) was never entered, and whether the poll disturbs gain tracking
+*there* is still unmeasured. Nothing on this bench injects that fast: the
+8822BU's synchronous USB 2.0 send tops out near 700 fps at that size, the
+8812CU at ~670, a Kestrel 8832CU on the same hub at ~330, and the 8821AU
+submits 3500 fps host-side while an independent witness decodes none of it.
+`DEVOURER_MT7612U_PHY_TICK=0` is the control arm's switch; harness shape in
+the `mt7612u_phy_tick` doc comment.
diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp
index 23ba19be..0c268bce 100644
--- a/src/mt7612u/Mt7612uRadio.cpp
+++ b/src/mt7612u/Mt7612uRadio.cpp
@@ -189,7 +189,11 @@ void Mt7612uRadio::bring_up(SelectedChannel channel) {
throw std::runtime_error("MT7612U channel set failed");
_channel = channel;
apply_config();
- start_tick();
+ if (_cfg.mt7612u.phy_tick)
+ start_tick();
+ else
+ _logger->warn("MT7612U: 1 Hz PHY tick disabled by config - a receiver "
+ "under a fast peer will collapse; measurement control only");
}
/* Every DeviceConfig knob this backend can reach, and a loud line for each one
diff --git a/tests/README.md b/tests/README.md
index 915da4b5..50732879 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -105,7 +105,10 @@ probe on kernels 6.15+ (`failed to download firmware`, `error -22`), but
still builds and still refuses the adapter at `CreateRadio`, by design —
falling through to the Realtek path would misdetect it as a Jaguar1.
- `mt7662.bin` + `mt7662_rom_patch.bin`, decompressed, passed with
- `--mt7612u-fw-dir`. They ship as `.bin.zst` in linux-firmware.
+ `--mt7612u-fw-dir`. They ship as `.bin.zst` in linux-firmware. The same
+ flag exists on `build/doctor`, and `adapter_doctor_cold.sh` takes it as
+ `DOCTOR_MT7612U_FW_DIR` beside `DOCTOR_DUT_VID=0x0e8d DOCTOR_DUT_PID=0x7612
+ DOCTOR_RTW88_MOD=mt76x2u`.
- `mt76x2u` for the kernel-side cells.
`regress.py` checks the first two before the first cell — each otherwise
@@ -577,6 +580,14 @@ from `DEVOURER_HOP_BW`, not from the `/40` in `DEVOURER_TX_RATE` (that only
fills the descriptor field), and getting that wrong zeroes a cell for reasons
that have nothing to do with the DUT.
+### `ccmp_cost_bench.sh`: what software CCMP costs per frame
+
+Headless, no device. Builds `tests/ccmp_cost_bench.cpp` and times the exact
+OpenSSL AES-128-CCM call sequence the AP harnesses use, one JSON line per
+frame size. It exists to put a number under the hardware-crypto question
+(`docs/mt7612u-ap-mode.md`); compare it with a transport's per-frame send
+cost, not with another CPU.
+
### `mt7612u_tsf_wrap.sh`: the MT7612U TSF read across the low-word wrap
Wraps `bringup tsfwrap`, which is where the "two TSF halves are not latched"
diff --git a/tests/adapter_doctor_cold.sh b/tests/adapter_doctor_cold.sh
index a0380177..a1b12b00 100644
--- a/tests/adapter_doctor_cold.sh
+++ b/tests/adapter_doctor_cold.sh
@@ -21,6 +21,12 @@
# DOCTOR_VERIFY_ARGS rxdemo env for flood verify (default 8821CU @ 9/1.3)
# DOCTOR_CHANNEL bench channel (default 6)
# DOCTOR_DUT_VID DUT vendor id (default 0x0bda)
+# DOCTOR_DUT_PID DUT product id — required for a non-Realtek VID,
+# where the doctor's default PID walk finds nothing
+# (e.g. 0x7612 with DOCTOR_DUT_VID=0x0e8d)
+# DOCTOR_MT7612U_FW_DIR MediaTek DUT: decompressed mt7662.bin +
+# mt7662_rom_patch.bin directory (passed to the doctor,
+# which reads no environment)
# DOCTOR_SKIP_VBUS=1 no per-rep VBUS cycle — for a DUT on a ROOT port
# (NEVER uhubctl root ports on this rig: a root-port
# cycle once wedged a device past everything but a
@@ -40,6 +46,8 @@ BUS="${4:?usb bus}"; PP="${5:?dotted port path}"; REPS="${6:-3}"
MOD="${DOCTOR_RTW88_MOD:-rtw88_8812au}"
CHANNEL="${DOCTOR_CHANNEL:-6}"
DUT_VID="${DOCTOR_DUT_VID:-0x0bda}" # e.g. 0x2357 for TP-Link-branded DUTs
+DUT_PID="${DOCTOR_DUT_PID:-}" # empty = the doctor's Realtek PID walk
+MT_FW="${DOCTOR_MT7612U_FW_DIR:-}"
FLOOD_ARGS="${DOCTOR_FLOOD_ARGS:-DEVOURER_PID=0x8813 DEVOURER_USB_BUS=4 DEVOURER_USB_PORT=2.3.2}"
VERIFY_ARGS="${DOCTOR_VERIFY_ARGS:-DEVOURER_PID=0xc811 DEVOURER_USB_BUS=9 DEVOURER_USB_PORT=1.3}"
@@ -95,18 +103,31 @@ for i in $(seq 1 "$REPS"); do
uhubctl -l "$HUB" -p "$HPORT" -a off > /dev/null || { log "uhubctl off failed"; exit 3; }
sleep 5
uhubctl -l "$HUB" -p "$HPORT" -a on > /dev/null
- t0=$SECONDS
- while [ $((SECONDS - t0)) -lt 20 ]; do
- [ -e "/sys/bus/usb/devices/$SYSFS/idProduct" ] && break
+ # Wait for the DUT itself, not just any device at the path: a ZeroCD
+ # part (the MT7612U, some Realtek dongles) enumerates first under its
+ # installer-disk PID and only then as the NIC.
+ want="${DUT_PID:+$(printf '%04x' "$DUT_PID")}"
+ t0=$SECONDS; seen=0
+ while [ $((SECONDS - t0)) -lt 30 ]; do
+ if [ -e "/sys/bus/usb/devices/$SYSFS/idProduct" ]; then
+ if [ -z "$want" ] || [ "$(cat "/sys/bus/usb/devices/$SYSFS/idProduct")" = "$want" ]; then
+ seen=1; break
+ fi
+ fi
sleep 0.5
done
+ [ "$seen" = 1 ] || { log "FATAL: DUT ${want:+pid $want }did not re-enumerate at $SYSFS within 30 s"; exit 3; }
sleep 1.5
fi
- "$DOCTOR" --vid "$DUT_VID" --bus "$BUS" --port "$PP" --channel "$CHANNEL" \
- --expect-traffic > "$LOG/rep$i.log" 2>&1
+ "$DOCTOR" --vid "$DUT_VID" ${DUT_PID:+--pid "$DUT_PID"} --bus "$BUS" --port "$PP" \
+ ${MT_FW:+--mt7612u-fw-dir "$MT_FW"} \
+ --channel "$CHANNEL" --expect-traffic > "$LOG/rep$i.log" 2>&1
rc=$?
- [ "$rc" -gt "$worst" ] && [ "$rc" -le 2 ] && worst=$rc
+ # 0/1/2 are verdicts; anything else is the doctor failing to run (no adapter,
+ # claim failed) and must not be folded into a HEALTHY aggregate.
+ [ "$rc" -le 2 ] || { log "FATAL: doctor rc=$rc on rep $i (not a verdict)"; tail -3 "$LOG/rep$i.log"; exit 3; }
+ [ "$rc" -gt "$worst" ] && worst=$rc
log "rep $i: $(grep -F '"ev":"doctor.verdict"' "$LOG/rep$i.log" | head -1) (rc=$rc)"
done
diff --git a/tests/ccmp_cost_bench.cpp b/tests/ccmp_cost_bench.cpp
new file mode 100644
index 00000000..f3453b44
--- /dev/null
+++ b/tests/ccmp_cost_bench.cpp
@@ -0,0 +1,92 @@
+// ccmp_cost_bench — the per-frame cost of devourer's software CCMP path.
+//
+// The AP harnesses (tests/ap_wpa2.cpp, on every backend) encrypt and decrypt
+// data frames in software with OpenSSL's AES-128-CCM. Hardware CCMP on the
+// MT7612U is unreached, and the open question is whether reaching it would buy
+// anything: that needs the software path's cost per frame, which this measures
+// on the host it runs on. It is a number about this CPU, not about the radio -
+// compare it with the per-frame send cost of the transport it would sit in
+// front of (txdemo at DEVOURER_TX_GAP_US=0 gives that as frames/s).
+//
+// Each timed call is the harness's ccm() whole: a fresh EVP_CIPHER_CTX per
+// call, init, AAD, payload, tag, free. Encrypt and decrypt are timed
+// separately - the RX path pays the second one.
+//
+// Build + run: tests/ccmp_cost_bench.sh. Prints one JSON line per frame size
+// and direction.
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+// tests/ap_wpa2.cpp ccm(), verbatim in shape: context lifecycle included.
+static bool ccm(bool enc, const uint8_t *key, const uint8_t *nonce,
+ const uint8_t *aad, int aadlen, const uint8_t *in, int inlen,
+ uint8_t *out, uint8_t *tag) {
+ EVP_CIPHER_CTX *c = EVP_CIPHER_CTX_new();
+ int l;
+ bool ok = true;
+ if (enc) {
+ EVP_EncryptInit_ex(c, EVP_aes_128_ccm(), 0, 0, 0);
+ EVP_CIPHER_CTX_ctrl(c, EVP_CTRL_AEAD_SET_IVLEN, 13, 0);
+ EVP_CIPHER_CTX_ctrl(c, EVP_CTRL_AEAD_SET_TAG, 8, 0);
+ EVP_EncryptInit_ex(c, 0, 0, key, nonce);
+ EVP_EncryptUpdate(c, 0, &l, 0, inlen);
+ EVP_EncryptUpdate(c, 0, &l, aad, aadlen);
+ ok = EVP_EncryptUpdate(c, out, &l, in, inlen) == 1;
+ EVP_EncryptFinal_ex(c, out + l, &l);
+ EVP_CIPHER_CTX_ctrl(c, EVP_CTRL_AEAD_GET_TAG, 8, tag);
+ } else {
+ EVP_DecryptInit_ex(c, EVP_aes_128_ccm(), 0, 0, 0);
+ EVP_CIPHER_CTX_ctrl(c, EVP_CTRL_AEAD_SET_IVLEN, 13, 0);
+ EVP_CIPHER_CTX_ctrl(c, EVP_CTRL_AEAD_SET_TAG, 8, tag);
+ EVP_DecryptInit_ex(c, 0, 0, key, nonce);
+ EVP_DecryptUpdate(c, 0, &l, 0, inlen);
+ EVP_DecryptUpdate(c, 0, &l, aad, aadlen);
+ ok = EVP_DecryptUpdate(c, out, &l, in, inlen) == 1;
+ }
+ EVP_CIPHER_CTX_free(c);
+ return ok;
+}
+
+int main(int argc, char **argv) {
+ const int iters = argc > 1 ? std::atoi(argv[1]) : 20000;
+ const int sizes[] = {64, 256, 1024, 1500, 3000};
+ uint8_t key[16], nonce[13], aad[22], tag[8];
+ for (int i = 0; i < 16; i++) key[i] = (uint8_t)(0x11 * i);
+ for (int i = 0; i < 13; i++) nonce[i] = (uint8_t)i;
+ for (int i = 0; i < 22; i++) aad[i] = (uint8_t)(0xa0 + i);
+
+ for (int sz : sizes) {
+ std::vector in((size_t)sz, 0x5a), ct((size_t)sz), pt((size_t)sz);
+ for (int dir = 0; dir < 2; dir++) {
+ const bool enc = dir == 0;
+ // One real ciphertext + tag for the decrypt arm to verify against.
+ if (!ccm(true, key, nonce, aad, sizeof aad, in.data(), sz, ct.data(), tag))
+ return 1;
+ for (int i = 0; i < 200; i++) // warm up
+ if (!ccm(enc, key, nonce, aad, sizeof aad,
+ enc ? in.data() : ct.data(), sz, enc ? ct.data() : pt.data(),
+ tag))
+ return 1;
+ const auto t0 = std::chrono::steady_clock::now();
+ for (int i = 0; i < iters; i++) {
+ if (!ccm(enc, key, nonce, aad, sizeof aad,
+ enc ? in.data() : ct.data(), sz, enc ? ct.data() : pt.data(),
+ tag))
+ return 1;
+ }
+ const double us = std::chrono::duration(
+ std::chrono::steady_clock::now() - t0).count();
+ std::printf("{\"ev\":\"ccmp.bench\",\"dir\":\"%s\",\"bytes\":%d,"
+ "\"iters\":%d,\"us_per_frame\":%.2f,\"frames_per_s\":%.0f,"
+ "\"mbit_per_s\":%.0f}\n",
+ enc ? "encrypt" : "decrypt", sz, iters, us / iters,
+ iters / (us / 1e6), (double)sz * 8 * iters / us);
+ }
+ }
+ return 0;
+}
diff --git a/tests/ccmp_cost_bench.sh b/tests/ccmp_cost_bench.sh
new file mode 100755
index 00000000..f063eb35
--- /dev/null
+++ b/tests/ccmp_cost_bench.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+# Build + run tests/ccmp_cost_bench.cpp: per-frame cost of the software CCMP
+# the AP harnesses use, on this host. openssl (-lcrypto) required. No device.
+set -eu
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+BIN="${TMPDIR:-/tmp}/ccmp_cost_bench.$$"
+trap 'rm -f "$BIN"' EXIT
+g++ -std=c++20 -O2 "$REPO/tests/ccmp_cost_bench.cpp" -lcrypto -o "$BIN"
+echo "# $(grep -m1 'model name' /proc/cpuinfo | cut -d: -f2 | sed 's/^ //') — single core, $(nproc) online" >&2
+"$BIN" "${1:-20000}"