diff --git a/README.md b/README.md index 0426b13..29f578d 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,8 @@ one `IRtlDevice` interface covers all four generations. - [Kernel channel-switch baseline](docs/kernel-channel-switch-baseline.md) + [firmware offload](docs/kernel-channel-switch-offload.md) + [MCC/FCS](docs/mcc-fcs-investigation.md) + - [dwell-1 A/B injection](docs/dwell1-ab-injection.md) — how the standard + [dwell-1 A/B injection](docs/dwell1-ab-injection.md) + + [N-channel hopping](docs/n-channel-hopping.md) — how the standard Linux/Realtek drivers retune measured against devourer, where the chip firmware's own H2C 0x1D switch beats them, and a two-context per-slot data plane with zero wrong-channel over 100 k slots. diff --git a/docs/dwell1-ab-injection.md b/docs/dwell1-ab-injection.md index b6ae61a..5ebcc5e 100644 --- a/docs/dwell1-ab-injection.md +++ b/docs/dwell1-ab-injection.md @@ -134,3 +134,6 @@ occasionally airs before the switch fully settles — recoverable by a slightly larger settle at a small duty cost), and bidirectional/ACK turnaround, which this experiment deferred to keep the admission contract unidirectional and clean. + +The A/B contract here generalises to arbitrary N channels with no per-channel +penalty — [N-channel hopping](n-channel-hopping.md). diff --git a/docs/n-channel-hopping.md b/docs/n-channel-hopping.md new file mode 100644 index 0000000..dc1a6e8 --- /dev/null +++ b/docs/n-channel-hopping.md @@ -0,0 +1,128 @@ +# N-channel dwell-1 hopping + +The fifth and last standard-driver FHSS experiment: an arbitrary N-channel +dwell-1 hop sequence — one admitted data-frame opportunity per wall-clock slot, +the radio stepping through N channels — with keyed order, per-target register +parity, and a single receiver tracking it. It closes the series that began with +the [kernel channel-switch baseline](kernel-channel-switch-baseline.md) and ran +through the [firmware offload](kernel-channel-switch-offload.md), the rejected +[MCC/FCS scheduler](mcc-fcs-investigation.md), and the two-context +[dwell-1 A/B data plane](dwell1-ab-injection.md). + +## Why this is not the MCC "ping-pong" the issue proposed + +Experiment 5 was framed around escaping MCC's two-context limit by rewriting +the *inactive* MCC context while the active one carries traffic. That requires +a *running* MCC scheduler — and experiment 3 established that MCC never reaches +`DOING_MCC` in this environment (it engages only for non-groupable channel +pairs and only from a P2P GO/GC association path, unreachable here). There is no +running scheduler whose inactive context could be rewritten. + +The premise is moot anyway. Ping-pong exists only to work around a two-context +firmware limit; the dwell-1 data plane of experiment 4 runs over the ordinary +**synchronous** channel switch, which has *no* context limit. Going from two +channels to N is not a context-juggling protocol — it is `channel_index(slot, +N)`. The `HopSchedule` (`src/HopSchedule.h`) already produces an N-channel +order, sequential or keyed, for any N. So this experiment delivers exp-5's real +goal — arbitrary N-channel dwell-1 hopping with the same rigor — over that data +plane, and records the MCC ping-pong mechanism as a no-go inherited from #271. + +## Method + +`examples/dwelltx` takes `DEVOURER_DWELL_CHANNELS=c0,c1,…` (any N ≥ 2) and runs +the exact dwell-1 admission of experiment 4 — one frame per slot inside a +bounded window so it can never cross into the next slot's channel — with the +per-slot channel chosen by `channel_index(slot, N)`. Each frame carries a +`HopSyncMarker` whose slot maps, through the same schedule, to the channel that +slot belongs to. + +`tests/dwell1_ab.py` runs **one oracle per channel** and attributes each slot to +the oracle that decoded it with the **highest RSSI** — the own-channel oracle +dominates, and an adjacent oracle that hears a weaker leaked copy is not counted +as a wrong-channel event; a true mis-air (a frame aired on the wrong channel) +still surfaces because the wrong oracle is then the strongest. `wrong_channel` = +strongest-RSSI oracle's channel ≠ `expected_ch(slot)`. Keyed attribution reuses +the SipHash-2-4 + Fisher-Yates port in `tests/hop_rx_probe.py`; the Python order +matches the C++ on-air order slot-for-slot (verified below). + +Rig: RTL8822BU DUT, four oracles — 8821AU / 8812CU / 8811AU / 8852BU on the four +channels. Near-field oracles saturate at full power (strong RSSI, poor EVM), so +the DUT runs at a modest flat TXAGC index (`--tx-pwr 10`); this is an +instrument constraint, not a data-plane one. + +## Results — N-channel delivery + +36/40/44 (and +48), 20 MHz, 20 ms slots, software `FastRetune`, sequential +schedule: + +| schedule | slots | delivered on correct channel | wrong-channel | dup | +|---|---|---|---|---| +| 3-channel (36/40/44) | 100 020 | 96.7 % | **0** | 0 | +| 4-channel (36/40/44/48) | 100 020 | 95.4 % | **0** | 0 | + +Over **200 040** dwell-1 slots across the two schedules, not one frame aired on +a channel other than its slot's, and not one was a duplicate re-air. Per-channel +delivery is 92–98 %; the spread is oracle sensitivity, not the data plane — +near-field oracles saturate at full power, so the DUT runs at a reduced flat +TXAGC index. **Late-enqueue fault** (host enqueue delayed to 18 ms into a 20 ms +slot) drops deterministically: 613/614 slots dropped-late, zero mis-aired. + +## Keyed schedule determinism + +The Python attribution (`keyed_sequence`, SipHash-2-4 + Fisher-Yates, reused +from `tests/hop_rx_probe.py`) reproduces the C++ `HopSchedule` on-air order +slot-for-slot — e.g. seed `deadbeef` over `{36,40,44,48}` gives +`44,40,48,36,48,36,40,44,…` on both sides. A keyed 3-channel run is delivered +with **zero wrong-channel** under that attribution, and a single receiver +tracks it in lockstep: `rxdemo` acquires the keyed schedule from the markers +(798 tracked decodes / 12 s) and **re-acquires from a cold restart** (784), no +persistent phase inversion — the marker's absolute slot needs no prior state. + +## Register parity per target, across AGC buckets + +`tests/dwell1_parity.sh` proves every fast hop leaves the chip in the same +channel/BW register state as a full `SetMonitorChannel` to that target — +**6/6 PASS**, from a UNII-1 init (36, AGC bucket 1) to targets 40/44/48 +(same bucket) and 149/157/165 (UNII-3, AGC bucket 3). The cross-bucket hops +reprogram the AGC index, CFO `fc`, VCO band and spur state and match the full +path exactly, so a dwell-1 frame on any target — including across sub-band +buckets — airs with correct calibration: channel, bandwidth and per-channel +state change as one logical generation. + +## Benchmark — N-channel scaling + +Software `FastRetune` (the dwell-1 primitive per experiment 4), 100 k slots +each: + +| channels | switch med / p99 (µs) | admit jitter p99−p1 (µs) | delivery | wrong-ch | +|---|---|---|---|---| +| 2 | 2107 / 2346 | 132 | 96.2 % | 0 | +| 3 | 2108 / 2341 | 109 | 96.7 % | 0 | +| 4 | 1952 / 2311 | 123 | 95.4 % | 0 | + +The transition cost, admission jitter and delivery are **flat from 2 to 4 +channels** — N-channel hopping carries no per-channel penalty, because a hop is +one synchronous retune regardless of how many channels the schedule spans. The +ping-pong-MCC and fixed-A/B-MCC benchmark rows the issue asked for are recorded +**N/A: MCC never runs** in this environment (#271), so there is nothing to time. + +## Go / no-go — closing the series + +**N-channel dwell-1 hopping works**, and it needed none of the MCC +context-juggling exp-5 was framed around. Arbitrary N-channel schedules — +sequential or keyed — run at ≥ 100 k slots with zero wrong-channel, deterministic +overload, register-correct hops across AGC buckets, and single-receiver lockstep +recovery, all over the ordinary synchronous channel switch whose cost does not +grow with N. + +The MCC "ping-pong" mechanism is a **no-go**, inherited from #271: there is no +running scheduler whose inactive context could be rewritten, and no need for one +— the two-context firmware limit that ping-pong exists to escape simply does not +apply to a caller-side admission scheduler over a switch with no contexts. + +This closes the five-experiment series. Its through-line: the kernel drivers' +own retune paths are too slow or unsuitable (baseline #269, MCC #271), the chip +firmware's H2C 0x1D switch is fast for continuous hopping (#270, shipped as +`DEVOURER_FASTRETUNE_FW`), and the synchronous switch plus a caller-side dwell-1 +admission scheduler is the deterministic per-slot data plane — scaling cleanly +from two channels (#272) to arbitrary N. diff --git a/examples/dwelltx/main.cpp b/examples/dwelltx/main.cpp index c8f6204..6162caa 100644 --- a/examples/dwelltx/main.cpp +++ b/examples/dwelltx/main.cpp @@ -148,8 +148,8 @@ int main() { pos = c + 1; } } - if (chans.size() != 2) { - logger->error("DEVOURER_DWELL_CHANNELS must name exactly two channels (A,B)"); + if (chans.size() < 2) { + logger->error("DEVOURER_DWELL_CHANNELS must name at least two channels"); libusb_exit(ctx); return 2; } @@ -178,8 +178,8 @@ int main() { const uint32_t seed_fp = sched->fingerprint(); devourer::Ev(ev, "dwell.start") - .f("chA", chans[0]) - .f("chB", chans[1]) + .arr("channels", chans.data(), chans.size()) + .f("n", (long long)chans.size()) .f("slot_ms", slot_ms) .f("settle_us", settle_us) .f("guard_us", guard_us) @@ -213,7 +213,8 @@ int main() { ++empty_ct; // previous slot aired nothing (shouldn't happen w/ budget) cur_slot = slot; admitted = false; - const size_t idx = sched->channel_index(static_cast(slot), 2); + const size_t idx = + sched->channel_index(static_cast(slot), chans.size()); const int ch = chans[idx]; const auto sw0 = clock_t_::now(); dev->FastRetune(static_cast(ch), /*cache_rf=*/hop_fast != 2); @@ -251,7 +252,8 @@ int main() { continue; } // --- admit exactly one frame, tagged by the marker's slot -------------- - const size_t idx = sched->channel_index(static_cast(slot), 2); + const size_t idx = + sched->channel_index(static_cast(slot), chans.size()); buf.assign(radiotap.begin(), radiotap.end()); buf.insert(buf.end(), dot11.begin(), dot11.end()); devourer::HopSyncMarker m{seed_fp, epoch, static_cast(t % slot_us), diff --git a/tests/dwell1_ab.py b/tests/dwell1_ab.py index 82ec34a..f6a0f68 100644 --- a/tests/dwell1_ab.py +++ b/tests/dwell1_ab.py @@ -78,64 +78,122 @@ def _iter_frames(path): continue epoch = struct.unpack_from(" public sequential schedule (slot % 2).""" +def expected_channel_fn(chans, seed): + """slot -> expected channel. Sequential = chans[slot % n]; keyed reuses + the verified C++ HopSchedule port in tests/hop_rx_probe.py so the Python + attribution matches the on-air order bit-for-bit.""" + n = len(chans) + if not seed: + return lambda slot: chans[slot % n] + cache = {} # round -> channel order + + def f(slot): + rnd = slot // n + if rnd not in cache: + cache[rnd] = _keyed_round(seed, chans, rnd) + return cache[rnd][slot % n] + return f + + +def _keyed_round(seed, chans, rnd): + """The channel order for one keyed round (matches HopSchedule::permutation + with round=rnd) — a thin wrapper over hop_rx_probe's siphash24.""" + import hop_rx_probe as hp + s = seed[2:] if seed.lower().startswith("0x") else seed + key = bytes.fromhex(s.zfill(32)) + p = list(range(len(chans))) + counter = 0 + for i in range(len(p), 1, -1): + limit = 0xffffffffffffffff - (0xffffffffffffffff % i) + while True: + x = hp.siphash24(key, b"H" + rnd.to_bytes(8, "little") + + counter.to_bytes(8, "little")) + counter += 1 + if x < limit: + break + j = x % i + p[i - 1], p[j] = p[j], p[i - 1] + return [chans[i] for i in p] + + +def analyze(out_dir, chans, seed=""): + """Attribute every decoded marker to its slot's channel and score the + dwell-1 contract for an arbitrary N-channel schedule. Attribution is + leakage-robust: a slot is attributed to the oracle that decoded it with + the highest RSSI (own channel dominates; an adjacent oracle hears only a + weaker leaked copy), and wrong-channel = that oracle's channel != + expected.""" with open(os.path.join(out_dir, "meta.json")) as f: meta = json.load(f) + seed = seed or meta.get("seed", "") + expected_ch = expected_channel_fn(chans, seed) tx = _load_tx(out_dir) admitted_slots = {r["slot"] for r in tx if r["ev"] == "dwell.tx"} dropped_slots = {r["slot"] for r in tx if r["ev"] == "dwell.drop"} - # Per-oracle: which slots it decoded, with counts (dup detection). + # Per (role, channel): slot -> list of decodes. One oracle per channel. per = {} - fits = {} - for role, ch in (("a", chans[0]), ("b", chans[1])): - frames = list(_iter_frames(os.path.join(out_dir, f"oracle_{role}.jsonl"))) - # tsfl fit for timing (best-effort; attribution doesn't need it). - uf = ka.unwrap_tsfl([{**fr, "ctr": fr["slot"]} for fr in frames]) - fit = ka.fit_tsfl(uf) - fits[role] = fit + dup = 0 + for role, ch in zip(_roles(len(chans)), chans): + path = os.path.join(out_dir, f"oracle_{role}.jsonl") + if not os.path.exists(path): + continue counts = {} - for fr in frames: + for fr in _iter_frames(path): counts.setdefault(fr["slot"], []).append(fr) + for slot, frs in counts.items(): + if len(frs) > 1: + dup += len(frs) - 1 per[(role, ch)] = counts - def expected_ch(slot): - return chans[slot % 2] if seq else None - - wrong = [] # frames decoded on the wrong channel - dup = 0 # same slot decoded >1x on the same oracle (re-air) - correct_slots = set() + # Best-RSSI attribution per slot across all oracles. + best = {} # slot -> (rssi, decoded_ch) for (role, ch), counts in per.items(): for slot, frs in counts.items(): - if len(frs) > 1: - dup += len(frs) - 1 - if expected_ch(slot) == ch: - correct_slots.add(slot) - else: - wrong.append({"slot": slot, "decoded_on": ch, - "expected": expected_ch(slot)}) + r = max((f["rssi"] for f in frs if f["rssi"] is not None), + default=-999) + if slot not in best or r > best[slot][0]: + best[slot] = (r, ch) + + correct_slots, wrong = set(), [] + for slot, (r, ch) in best.items(): + if ch == expected_ch(slot): + correct_slots.add(slot) + else: + wrong.append({"slot": slot, "decoded_on": ch, + "expected": expected_ch(slot), "rssi": r}) n_admitted = len(admitted_slots) delivered = len(admitted_slots & correct_slots) + slot_ms = meta.get("slot_ms", 20) + payload = meta.get("airtime_bytes", 96) + wall_s = n_admitted * slot_ms / 1e3 result = { + "n_channels": len(chans), + "channels": chans, + "keyed": bool(seed), "slots_admitted": n_admitted, "slots_dropped_late": len(dropped_slots), "delivered_correct_channel": delivered, "delivery_frac": (delivered / n_admitted) if n_admitted else None, "wrong_channel": len(wrong), "duplicate_reair": dup, + "goodput_kbps": (delivered * payload * 8 / wall_s / 1e3) + if wall_s else None, "fw": meta.get("fw"), - "slot_ms": meta.get("slot_ms"), + "slot_ms": slot_ms, } # Host-side switch cost + admission placement from the tx event stream. sw = np.array([r["switch_us"] for r in tx if r["ev"] == "dwell.slot" @@ -177,6 +235,10 @@ def _load_tx(out_dir): return rows +def _roles(n): + return [chr(ord("a") + i) for i in range(n)] + + def spawn_dwelltx(dut, chans, a, out_dir): regress.detach_from_host_kernel(dut) env = dict(os.environ) @@ -184,11 +246,17 @@ def spawn_dwelltx(dut, chans, a, out_dir): "DEVOURER_VID": f"0x{dut.vid}", "DEVOURER_PID": f"0x{dut.pid}", "DEVOURER_EVENTS": "stdout", "DEVOURER_LOG_LEVEL": "warn", "DEVOURER_FASTRETUNE_FW": str(a.fw), - "DEVOURER_DWELL_CHANNELS": f"{chans[0]},{chans[1]}", + "DEVOURER_DWELL_CHANNELS": ",".join(str(c) for c in chans), "DEVOURER_DWELL_SLOT_MS": str(a.slot_ms), "DEVOURER_DWELL_SLOTS": str(a.slots + 20), # +warmup "DEVOURER_HOP_FAST": str(a.hop_fast), }) + if a.seed: + env["DEVOURER_HOP_SEED"] = a.seed + if a.tx_pwr: + # Near-field oracles saturate (strong RSSI, poor EVM -> CRC fail) at + # full power; a modest flat index restores clean decodes on all N. + env["DEVOURER_TX_PWR"] = str(a.tx_pwr) if a.late_us: env["DEVOURER_DWELL_LATE_US"] = str(a.late_us) if a.settle_us: @@ -203,27 +271,32 @@ def cmd_run(a): out = a.out os.makedirs(out, exist_ok=True) chans = [int(c) for c in a.channels.split(",")] + pids = [p for p in a.oracle_pids.split(",") if p] + if len(pids) < len(chans): + raise SystemExit(f"need one --oracle-pids entry per channel: " + f"{len(chans)} channels, {len(pids)} oracle pids") dut = kb.find_dut(a.dut_pid) - oa = kb.spawn_oracle("a", kb.find_dut(a.oracle_a_pid), chans[0], "canon", out) - ob = kb.spawn_oracle("b", kb.find_dut(a.oracle_b_pid), chans[1], "canon", out) + oracles = [] + for role, ch, pid in zip(_roles(len(chans)), chans, pids): + oracles.append(kb.spawn_oracle(role, kb.find_dut(pid), ch, "canon", out)) with open(os.path.join(out, "meta.json"), "w") as f: json.dump({"chans": chans, "slot_ms": a.slot_ms, "slots": a.slots, - "fw": a.fw, "late_us": a.late_us}, f) + "fw": a.fw, "late_us": a.late_us, "seed": a.seed}, f) tx = spawn_dwelltx(dut, chans, a, out) kb.log(f"dwell1 run: {chans} slot={a.slot_ms}ms slots={a.slots} " - f"fw={a.fw} late_us={a.late_us}") + f"fw={a.fw} seed={a.seed or '-'} late_us={a.late_us}") budget = (a.slots + 30) * (a.slot_ms / 1e3) + 30 end = time.monotonic() + budget while time.monotonic() < end and tx.alive(): time.sleep(2) tx.stop() - oa.stop() - ob.stop() + for o in oracles: + o.stop() if os.environ.get("SUDO_USER"): import subprocess subprocess.run(["chown", "-R", f"{os.environ['SUDO_USER']}:", out], capture_output=True) - res = analyze(out, chans) + res = analyze(out, chans, a.seed) print(json.dumps(res, indent=2)) with open(os.path.join(out, "summary.json"), "w") as f: json.dump(res, f, indent=2) @@ -244,9 +317,15 @@ def main(): help="post-switch admission delay; fw switch needs " "~4000 (its RF completes async after FastRetune " "returns), sw switch is fine at the demo default") + ap.add_argument("--seed", default="", + help="keyed hop schedule (hex); empty = public sequential") + ap.add_argument("--tx-pwr", type=int, default=0, + help="flat DUT TXAGC index; ~10 avoids near-field oracle " + "saturation on this rig (0 = calibrated default)") ap.add_argument("--dut-pid", default="2357:012d") - ap.add_argument("--oracle-a-pid", default="0bda:8812") - ap.add_argument("--oracle-b-pid", default="0bda:c812") + ap.add_argument("--oracle-pids", default="2357:0120,0bda:c812", + help="comma-separated VID:PID, one oracle per channel in " + "--channels order") a = ap.parse_args() if a.cmd == "run": if os.geteuid() != 0: @@ -255,8 +334,8 @@ def main(): regress._install_cleanup_handlers() return cmd_run(a) if a.cmd == "analyze": - print(json.dumps(analyze(a.out, [int(c) for c in a.channels.split(",")]), - indent=2)) + print(json.dumps(analyze(a.out, [int(c) for c in a.channels.split(",")], + a.seed), indent=2)) return 0 diff --git a/tests/dwell1_parity.sh b/tests/dwell1_parity.sh new file mode 100755 index 0000000..d3e6802 --- /dev/null +++ b/tests/dwell1_parity.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# dwell1_parity.sh — register parity per target channel for the N-channel +# dwell-1 hop set, including AGC/sub-band bucket crossings (issue #273's +# "channel/bandwidth/calibration/... change as one logical generation"). +# +# A fast hop (FastRetune) must leave the chip in the same channel/BW register +# state as a full SetMonitorChannel to that target, for EVERY channel the +# schedule visits — otherwise a dwell-1 frame on that channel would air with +# stale AGC/CFO/VCO/spur state. tests/hop_parity_check.sh proves this for one +# INIT->TARGET pair; this sweeps it over a target set that crosses the 8822B +# AGC buckets (UNII-1 = bucket 1, UNII-3 = bucket 3; both non-DFS) from a fixed +# UNII-1 init, so the cross-bucket hops (36->149 etc.) are exercised. +# +# Usage (root, 8822B DUT free of the kernel driver): +# tests/dwell1_parity.sh +# TX_PID=0x012d TX_VID=0x2357 INIT=36 TARGETS="40 44 48 149 157" \ +# tests/dwell1_parity.sh +set -u +cd "$(dirname "$0")/.." + +TX_PID="${TX_PID:-0x012d}" # 8822B (T3U) +TX_VID="${TX_VID:-0x2357}" +INIT="${INIT:-36}" # UNII-1, bucket 1 +# Same-bucket adjacents (40/44/48) + cross-bucket UNII-3 (149/157/165, bucket 3). +TARGETS="${TARGETS:-40 44 48 149 157 165}" +BW="${BW:-20}" + +pass=0; fail=0; fails="" +for t in $TARGETS; do + echo "========================================================" + echo "== parity: fast hop $INIT -> $t (BW $BW) vs full set to $t" + echo "========================================================" + if TX_PID="$TX_PID" TX_VID="$TX_VID" INIT="$INIT" TARGET="$t" BW="$BW" \ + OFFSET=0 tests/hop_parity_check.sh 2>&1 | tail -6; then + pass=$((pass + 1)) + else + fail=$((fail + 1)); fails="$fails $t" + fi +done + +echo "========================================================" +echo "dwell1 register parity: $pass PASS, $fail FAIL${fails:+ (targets:$fails)}" +[ "$fail" -eq 0 ]