diff --git a/CMakeLists.txt b/CMakeLists.txt index 10ecaf9..9dab962 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -494,6 +494,17 @@ add_executable(txdemo target_link_libraries(txdemo PUBLIC devourer PRIVATE PkgConfig::libusb) target_include_directories(txdemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) +# dwell-1 A/B injection over the firmware channel switch (issue #272 data +# plane): one admitted data-frame opportunity per wall-clock slot, with +# bounded admission so a frame for context A can never air on B. +add_executable(dwelltx + examples/dwelltx/main.cpp + examples/common/env_config.cpp + examples/common/usb_select.cpp +) +target_link_libraries(dwelltx PUBLIC devourer PRIVATE PkgConfig::libusb) +target_include_directories(dwelltx PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/examples/common) + # Pre-modulator subcarrier PoC: transmits PSDU bytes shaped by # tools/precoder/encode_subcarriers.py so chosen OFDM data subcarriers carry # chosen bits. Single-stream BPSK / HT MCS0 / BCC on 8812/8821/8811. diff --git a/README.md b/README.md index d686e4f..0426b13 100644 --- a/README.md +++ b/README.md @@ -230,9 +230,11 @@ one `IRtlDevice` interface covers all four generations. channel-switch fast path (`DEVOURER_FASTRETUNE_FW`). - [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) — how the standard Linux/Realtek - drivers retune measured against devourer, and where the chip firmware's own - H2C 0x1D switch beats them. + [MCC/FCS](docs/mcc-fcs-investigation.md) + + [dwell-1 A/B injection](docs/dwell1-ab-injection.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. - [FHSS](docs/fhss.md) — the anti-jam design article: keyed SipHash hop schedules, slot-locked lockstep RX, and [jammer resilience](docs/jammer-resilience.md) — measured delivery against diff --git a/docs/dwell1-ab-injection.md b/docs/dwell1-ab-injection.md new file mode 100644 index 0000000..b6ae61a --- /dev/null +++ b/docs/dwell1-ab-injection.md @@ -0,0 +1,136 @@ +# Dwell-1 A/B injection over the channel switch + +A two-context A/B hopping **data plane**: the radio spends one wall-clock slot +per channel and each slot carries exactly one admitted data-frame +opportunity — "dwell-1". This is the fourth standard-driver FHSS experiment +(after the [baseline](kernel-channel-switch-baseline.md), the +[firmware offload](kernel-channel-switch-offload.md), and the rejected +[MCC/FCS scheduler](mcc-fcs-investigation.md)), and it answers the question +MCC could not: can firmware-grade channel time-slicing carry real per-slot +traffic with deterministic queue ownership? + +It can — with the **synchronous** channel switch. The result also draws a +sharp line between devourer's two channel-switch primitives: the +fire-and-confirm-later firmware switch (H2C 0x1D) that wins continuous +per-packet hopping is the *wrong* primitive for tight slot placement, and the +synchronous software `FastRetune` is the right one. They have opposite sweet +spots. + +## What dwell-1 means here + +One dwell = one admitted devourer data-frame opportunity, placed inside a +bounded window that guarantees it finishes airing before the slot flips: + +``` + slot start slot end + |── switch ──|── settle ──|═══ admit window ═══|── guard ──| + ^ ^ ^ + FastRetune admit_open admit_close + returns = start+settle = end-guard-airtime +``` + +A frame is admitted only inside `[admit_open, admit_close]`. Past +`admit_close` it is **dropped** with a structured reason, never transmitted — +so a frame built for context A can never air on B. This bounded admission is +pure caller policy (`examples/dwelltx`); the library is unchanged. It is the +data-plane discipline the per-packet hop (radiotap `CHANNEL`, ~1 ms retune) +lacks on its own. + +Each per-slot frame carries a `HopSyncMarker` (`src/HopSchedule.h`) whose +`slot` field, run through the shared public/keyed schedule, tells any receiver +which channel the slot belongs to. An oracle pinned to channel A that decodes +a frame whose slot maps to B is therefore a self-evident **wrong-channel** +event, and the unique per-slot marker doubles as the duplicate key — no extra +tag, and attribution needs no cross-clock alignment. + +Harness: `examples/dwelltx` (the admission scheduler) + `tests/dwell1_ab.py` +(two host oracles on A/B, per-slot accounting, fault injection). DUT +RTL8822BU; oracles 8821AU (ch A) + 8812CU (ch B). + +## Result — the synchronous switch carries dwell-1 cleanly + +36↔40, 20 MHz, 20 ms slots, software `FastRetune`: + +| run | slots | delivered on correct channel | wrong-channel | dup | late-drop | +|---|---|---|---|---|---| +| primary | 5 020 | 97.2 % | **0** | 0 | 0 | +| reliability | 100 013 | 96.2 % | **0** | **0** | 7 (0.007 %) | + +Admission is tight: the in-slot placement of the admitted frame has a +p99−p1 jitter of **132 µs**, and the switch itself costs ~2.1 ms median +(p99 2.3 ms). Across **100 013** slots not one frame aired on the wrong +channel and not one was a duplicate re-air; the ~4 % shortfall is +first-decode/oracle loss, not mis-delivery. That is the acceptance +criterion — ≥ 100 k slots, zero wrong-channel, a quantified 0.007 % +late-drop rate — met. + +**Fault — late enqueue.** Delaying the host enqueue to 18 ms into a 20 ms +slot (past `admit_close`): of 516 slots, 511 were dropped-late and 5 admitted, +with **zero wrong-channel**. The overload path is deterministic and +observable: a frame that cannot make its slot is dropped, never aired stale. + +## The two switch primitives have opposite sweet spots + +Running the identical dwell-1 loop over the **firmware** switch +(`DEVOURER_FASTRETUNE_FW=1`) instead inverts the schedule at 20 ms slots — +frames land on the previous slot's channel. The firmware switch is +fire-and-confirm-later: `FastRetune` returns after submitting the H2C, and in +the *sparse* dwell-1 call pattern (one retune per 20 ms, then idle) the RF +completes far later than the ~1 ms it shows under continuous per-packet +hopping. A post-switch settle sweep (20 ms slots) shows how late: + +| settle before admit | delivery (correct channel) | +|---|---| +| 2 ms | 3 % | +| 6 ms | 36 % | +| 9 ms | 63 % | +| 12 ms | 82 % | + +Even 12 ms of settle inside a 20 ms slot does not fully clear it; a 40 ms slot +with 15 ms settle reaches 97 %. So the firmware switch *can* carry dwell-1, +but only with a guard so large it erases the point of a tight slot. The +synchronous software `FastRetune` — RF programmed by the time the call +returns — places the frame deterministically with a ~2 ms settle and is the +correct dwell-1 primitive. + +This does not contradict experiments 2–3: there the firmware switch halves +the dark time of *continuous* hopping, where frames flow immediately after the +switch and its late completion is masked. Dwell-1 exposes that latency. The +two results compose into one rule: **firmware switch for throughput hopping, +synchronous switch for deterministic slot placement.** + +## Lockstep receive + +A single receiver hopping the same A/B schedule (`rxdemo` lockstep mode — +`src/HopSchedule.h` `HopSyncMarker` tracking, anchor phase-correction) acquires +the schedule from the per-slot markers alone: 889 tracked `hop.rx` decodes over +a 12 s window against the dwell-1 transmitter. A cold restart of the receiver +re-acquires from scratch (861 decodes in the next 12 s) with no persistent +phase inversion — the marker carries the absolute slot, so acquisition needs no +prior state and survives a full restart. + +## Go / no-go + +**Go — advance to experiment 5.** Firmware-grade channel time-slicing carries +a real per-slot data plane: over 100 013 A/B slots, every admitted frame that +was delivered landed on the correct channel (zero wrong-channel, zero +duplicate), overload is deterministic (late frames dropped, never mis-aired), +and a single receiver acquires and recovers the schedule. This is the +capability MCC/FCS could not provide (it never ran a schedule from the +association path, and its slots are TU-coarse and two-context-bound — +[mcc-fcs-investigation.md](mcc-fcs-investigation.md)); the caller-side +admission scheduler over the ordinary channel switch does. + +The load-bearing design choice for experiment 5: use the **synchronous** +`FastRetune`, not the firmware switch, as the slot-boundary primitive. The +firmware switch (`DEVOURER_FASTRETUNE_FW`) remains the right tool for +continuous per-packet hopping (experiments 2–3), but its fire-and-confirm-later +completion is too late and too variable for tight dwell-1 placement. The two +primitives are complementary, not interchangeable, and the data plane must pick +the synchronous one. + +Open items experiment 5 inherits: the ~4 % first-decode/settle loss (a frame +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. diff --git a/examples/dwelltx/main.cpp b/examples/dwelltx/main.cpp new file mode 100644 index 0000000..c8f6204 --- /dev/null +++ b/examples/dwelltx/main.cpp @@ -0,0 +1,289 @@ +// dwelltx — dwell-1 A/B injection over the firmware channel switch. +// +// The data-plane experiment (issue #272): a two-context A/B schedule where the +// radio spends one wall-clock slot per channel and each slot carries EXACTLY +// ONE admitted data-frame opportunity — "dwell-1". The channel switch at each +// slot boundary is the on-chip firmware switch when DEVOURER_FASTRETUNE_FW is +// set (Jaguar2 8822B / Jaguar3 8822C/8822E; H2C 0x1D — see +// docs/kernel-channel-switch-offload.md); otherwise the software FastRetune, +// so the two are a controlled A/B. +// +// This is the caller-side answer to the rejected MCC/FCS scheduler +// (docs/mcc-fcs-investigation.md): the library already switches channels in +// ~1 ms and hops per-packet from a radiotap CHANNEL field. What a real hopping +// DATA plane needs on top of that is bounded admission — one frame per slot, +// placed inside a window that guarantees it finishes airing before the slot +// flips, so a frame built for context A can NEVER air on B. That admission +// policy lives here, in the caller, not in the library. +// +// Each per-slot frame carries a HopSyncMarker (src/HopSchedule.h) whose `slot` +// field, run through the same public/keyed schedule, tells any receiver which +// channel the slot belongs to — so an oracle pinned to channel A that decodes +// a frame whose slot maps to B is a self-evident wrong-channel event, and the +// unique per-slot marker doubles as the duplicate/dedup key. No extra tag. +// +// Env (mapped through examples/common/env_config.h for the library knobs): +// DEVOURER_PID / DEVOURER_VID / DEVOURER_USB_BUS / DEVOURER_USB_PORT +// DEVOURER_FASTRETUNE_FW=1|2 firmware switch (1 intra-band, 2 + cross-band) +// DEVOURER_DWELL_CHANNELS=36,40 the A,B pair (exactly two) +// DEVOURER_DWELL_SLOT_MS=N wall-clock slot length (default 20) +// DEVOURER_DWELL_SLOTS=N total slots then exit (0 = run forever) +// DEVOURER_DWELL_SETTLE_US=N post-switch settle before admitting (500) +// DEVOURER_DWELL_GUARD_US=N guard before the slot end (1000) +// DEVOURER_DWELL_AIRTIME_US=N budgeted frame airtime (300 @ 6M/96B) +// DEVOURER_DWELL_LATE_US=N inject: sleep this long into each slot +// before admitting (fault: forces drops) +// DEVOURER_HOP_SEED= keyed A/B order (default public sequential) +// DEVOURER_TX_RATE / DEVOURER_TX_PWR as the other demos + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) + #include +#elif defined(__MINGW32__) || defined(__MINGW64__) + #include + #include +#elif defined(__APPLE__) + #include + #include +#else + #include + #include +#endif + +#include "ChannelFreq.h" +#include "Event.h" +#include "HopSchedule.h" +#include "RadiotapBuilder.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "env_config.h" +#include "logger.h" +#include "usb_select.h" + +namespace { + +using clock_t_ = std::chrono::steady_clock; + +long env_long(const char *name, long def) { + if (const char *e = std::getenv(name)) { + long v = std::strtol(e, nullptr, 0); + return v; + } + return def; +} + +// Full 24-byte probe-request MAC header with the canonical SA the RX path +// already matches (kept in lockstep with examples/tx and examples/rx — see +// CLAUDE.md). Address 3 (BSSID) = broadcast, so the header is exactly 24 +// bytes and the appended HopSyncMarker lands at the body boundary the RX +// oracle reports (packet.Data + 24). +std::vector build_dot11_probe_req() { + static const uint8_t sa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; + static const uint8_t bcast[6] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; + std::vector h = {0x40, 0x00, 0x00, 0x00}; + h.insert(h.end(), bcast, bcast + 6); // Address 1 (DA) + h.insert(h.end(), sa, sa + 6); // Address 2 (SA) + h.insert(h.end(), bcast, bcast + 6); // Address 3 (BSSID) + h.push_back(0x80); // seq/frag control + h.push_back(0x00); + return h; +} + +} // namespace + +int main() { + auto logger = std::make_shared(); + apply_logging_env(*logger); + auto &ev = logger->events(); + + libusb_context *ctx = nullptr; + if (libusb_init(&ctx) < 0) + return 1; + static const uint16_t kDefaultPids[] = {0x8812, 0xb812, 0xc811, 0xc820, + 0xc82c, 0x8814, 0xb82c}; + libusb_device_handle *handle = open_selected_usb( + ctx, logger, kDefaultPids, sizeof(kDefaultPids) / sizeof(kDefaultPids[0])); + if (!handle) { + libusb_exit(ctx); + return 1; + } + std::shared_ptr usb_lock; + if (devourer::claim_interface_reset_reopen( + ctx, handle, logger, std::getenv("DEVOURER_SKIP_RESET") == nullptr, + usb_lock) != 0) { + libusb_exit(ctx); + return 1; + } + + WiFiDriver driver{logger}; + auto cfg = devourer_config_from_env(); // honors DEVOURER_FASTRETUNE_FW + auto dev = driver.CreateRtlDevice(handle, nullptr, usb_lock, cfg); + if (!dev) { + libusb_exit(ctx); + return 1; + } + + // --- schedule + admission parameters --------------------------------------- + std::vector chans; + if (const char *e = std::getenv("DEVOURER_DWELL_CHANNELS")) { + std::string s(e); + size_t pos = 0; + while (pos < s.size()) { + size_t c = s.find(',', pos); + std::string tok = s.substr(pos, c == std::string::npos ? c : c - pos); + if (!tok.empty()) + chans.push_back(std::atoi(tok.c_str())); + if (c == std::string::npos) + break; + pos = c + 1; + } + } + if (chans.size() != 2) { + logger->error("DEVOURER_DWELL_CHANNELS must name exactly two channels (A,B)"); + libusb_exit(ctx); + return 2; + } + const long slot_ms = env_long("DEVOURER_DWELL_SLOT_MS", 20); + const long total_slots = env_long("DEVOURER_DWELL_SLOTS", 0); + const long settle_us = env_long("DEVOURER_DWELL_SETTLE_US", 500); + const long guard_us = env_long("DEVOURER_DWELL_GUARD_US", 1000); + const long airtime_us = env_long("DEVOURER_DWELL_AIRTIME_US", 300); + const long late_us = env_long("DEVOURER_DWELL_LATE_US", 0); + const int hop_fast = static_cast(env_long("DEVOURER_HOP_FAST", 1)); + + std::optional sched; + if (const char *seed = std::getenv("DEVOURER_HOP_SEED")) + sched.emplace(devourer::HopSchedule::parse_seed(seed)); + else + sched.emplace(devourer::HopSchedule::sequential()); + + dev->InitWrite(SelectedChannel{.Channel = static_cast(chans[0]), + .ChannelOffset = 0, + .ChannelWidth = CHANNEL_WIDTH_20}); + if (const char *p = std::getenv("DEVOURER_TX_PWR")) + dev->SetTxPower(static_cast(std::atoi(p))); + + const auto radiotap = devourer::build_stream_radiotap(devourer_tx_mode_from_env()); + const auto dot11 = build_dot11_probe_req(); + const uint32_t seed_fp = sched->fingerprint(); + + devourer::Ev(ev, "dwell.start") + .f("chA", chans[0]) + .f("chB", chans[1]) + .f("slot_ms", slot_ms) + .f("settle_us", settle_us) + .f("guard_us", guard_us) + .f("airtime_us", airtime_us) + .f("fw", cfg.tuning.fastretune_fw) + .f("hop_fast", hop_fast) + .hexf("seed_fp", seed_fp, 8); + + const auto t0 = clock_t_::now(); + auto us_since = [&](clock_t_::time_point t) { + return std::chrono::duration_cast(t - t0).count(); + }; + const long slot_us = slot_ms * 1000; + int64_t cur_slot = -1; + bool admitted = false; + long admitted_ct = 0, dropped_late_ct = 0, empty_ct = 0; + + std::vector buf; + buf.reserve(radiotap.size() + dot11.size() + 64); + uint32_t epoch = 0; + + while (true) { + const auto now = clock_t_::now(); + const int64_t slot = us_since(now) / slot_us; + if (total_slots > 0 && slot >= total_slots) + break; + + if (slot != cur_slot) { + // --- slot boundary: retune to this slot's channel via the fw switch ---- + if (cur_slot >= 0 && !admitted) + ++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 int ch = chans[idx]; + const auto sw0 = clock_t_::now(); + dev->FastRetune(static_cast(ch), /*cache_rf=*/hop_fast != 2); + const long switch_us = + std::chrono::duration_cast( + clock_t_::now() - sw0) + .count(); + devourer::Ev(ev, "dwell.slot") + .f("slot", (unsigned long long)slot) + .f("ctx", (int)idx) + .f("channel", ch) + .f("switch_us", switch_us); + // Fault injection: sleep past the admission window on purpose. + if (late_us > 0) + std::this_thread::sleep_for(std::chrono::microseconds(late_us)); + } + + if (!admitted) { + const int64_t slot_start_us = slot * slot_us; + const int64_t admit_open = slot_start_us + settle_us; + const int64_t admit_close = slot_start_us + slot_us - guard_us - airtime_us; + const int64_t t = us_since(clock_t_::now()); + if (t < admit_open) { + std::this_thread::sleep_for(std::chrono::microseconds(admit_open - t)); + continue; + } + if (t > admit_close) { + // Missed the window — DROP with a structured reason. Never air a frame + // that would cross into the next slot's channel. + ++dropped_late_ct; + admitted = true; + devourer::Ev(ev, "dwell.drop") + .f("slot", (unsigned long long)slot) + .f("reason_late_us", (long long)(t - admit_close)); + continue; + } + // --- admit exactly one frame, tagged by the marker's slot -------------- + const size_t idx = sched->channel_index(static_cast(slot), 2); + 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), + static_cast(slot)}; + const auto wire = devourer::HopSyncMarker::encode(m); + buf.insert(buf.end(), wire.begin(), wire.end()); + const auto air0 = clock_t_::now(); + dev->send_packet(buf.data(), buf.size()); + admitted = true; + ++admitted_ct; + devourer::Ev(ev, "dwell.tx") + .f("slot", (unsigned long long)slot) + .f("ctx", (int)idx) + .f("channel", chans[idx]) + .f("in_slot_us", (long long)(t - slot_start_us)) + .f("send_us", + (long long)std::chrono::duration_cast( + clock_t_::now() - air0) + .count()); + } else { + // one opportunity per slot already spent — idle until the boundary + const int64_t next_boundary = (slot + 1) * slot_us; + const int64_t t = us_since(clock_t_::now()); + if (next_boundary - t > 1500) + std::this_thread::sleep_for(std::chrono::microseconds(1000)); + } + } + + devourer::Ev(ev, "dwell.done") + .f("admitted", admitted_ct) + .f("dropped_late", dropped_late_ct) + .f("empty", empty_ct); + libusb_exit(ctx); + return 0; +} diff --git a/tests/dwell1_ab.py b/tests/dwell1_ab.py new file mode 100644 index 0000000..82ec34a --- /dev/null +++ b/tests/dwell1_ab.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Dwell-1 A/B injection over the firmware channel switch — on-air harness. + +Drives the dwelltx demo (one admitted data-frame opportunity per wall-clock +slot on an A/B schedule, fw-switched) with two host-side rxdemo oracles pinned +to channels A and B, and proves the data-plane contract from #272: + + * exactly one admitted frame per context slot, decoded on the CORRECT + channel; + * ZERO wrong-channel frames (a frame whose HopSyncMarker slot maps to B must + never be decoded on A's oracle); + * quantified empty / duplicate / late / first-decode-loss rates; + * the fw switch vs software FastRetune as a controlled A/B (jitter, delivery, + host switch cost); + * a fault matrix (late enqueue, set-channel-while-running) with deterministic, + observable behaviour. + +Each oracle is on ONE fixed channel, so attribution needs no cross-clock +alignment: a decoded frame carries its slot in the marker, the (public +sequential) schedule maps slot -> channel, and the oracle's own channel is +the ground truth. tsfl->host fits (reused from kchansw_analyze) give the +per-frame time for the guard/settle first-decode measurement. + +Usage (root): + sudo tests/.venv/bin/python tests/dwell1_ab.py run \ + --slots 5000 --slot-ms 20 --fw 1 --out /tmp/dwell1-fw + sudo tests/.venv/bin/python tests/dwell1_ab.py compare --slots 5000 + sudo tests/.venv/bin/python tests/dwell1_ab.py faults +""" + +import argparse +import json +import os +import struct +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import kchansw_bench as kb # noqa: E402 Child/spawn_oracle/find_dut +import kchansw_analyze as ka # noqa: E402 unwrap_tsfl/fit_tsfl/fitted_ns +import regress # noqa: E402 + +import numpy as np # noqa: E402 + +MARKER_MAGIC = bytes([221, 27, 0x57, 0x42, 0x75, 0x48, 1]) +MARKER_TAIL = bytes([0xd7, 0x3a]) +MARKER_LEN = 29 + + +def _iter_frames(path): + for line in open(path): + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + raw = rec.get("raw") + host = rec.get("host_mono_ns") + if raw is None or host is None: + continue + try: + ev = json.loads(raw) + except json.JSONDecodeError: + continue + if ev.get("ev") != "rx.frame": + continue + body = ev.get("body", "") + if not isinstance(body, str) or len(body) < MARKER_LEN * 2: + continue + blob = bytes.fromhex(body) + i = blob.find(MARKER_MAGIC) + if i < 0 or i + MARKER_LEN > len(blob): + continue + m = blob[i:i + MARKER_LEN] + if m[27:29] != MARKER_TAIL: + continue + epoch = struct.unpack_from(" public sequential schedule (slot % 2).""" + with open(os.path.join(out_dir, "meta.json")) as f: + meta = json.load(f) + 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 = {} + 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 + counts = {} + for fr in frames: + counts.setdefault(fr["slot"], []).append(fr) + 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() + 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)}) + + n_admitted = len(admitted_slots) + delivered = len(admitted_slots & correct_slots) + result = { + "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, + "fw": meta.get("fw"), + "slot_ms": meta.get("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" + and "switch_us" in r], dtype=float) + if len(sw): + result["switch_us"] = {"median": float(np.median(sw)), + "p99": float(np.percentile(sw, 99)), + "max": float(sw.max())} + inslot = np.array([r["in_slot_us"] for r in tx if r["ev"] == "dwell.tx" + and "in_slot_us" in r], dtype=float) + if len(inslot): + result["admit_in_slot_us"] = {"median": float(np.median(inslot)), + "p90": float(np.percentile(inslot, 90)), + "jitter_p99_minus_p1": float( + np.percentile(inslot, 99) + - np.percentile(inslot, 1))} + result["wrong_channel_examples"] = wrong[:5] + return result + + +def _load_tx(out_dir): + rows = [] + for line in open(os.path.join(out_dir, "dwelltx.jsonl")): + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + ev = rec.get("raw", rec) + if isinstance(ev, str): + try: + ev = json.loads(ev) + except json.JSONDecodeError: + continue + if ev.get("ev", "").startswith("dwell"): + rows.append(ev) + return rows + + +def spawn_dwelltx(dut, chans, a, out_dir): + regress.detach_from_host_kernel(dut) + env = dict(os.environ) + env.update({ + "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_SLOT_MS": str(a.slot_ms), + "DEVOURER_DWELL_SLOTS": str(a.slots + 20), # +warmup + "DEVOURER_HOP_FAST": str(a.hop_fast), + }) + if a.late_us: + env["DEVOURER_DWELL_LATE_US"] = str(a.late_us) + if a.settle_us: + env["DEVOURER_DWELL_SETTLE_US"] = str(a.settle_us) + return kb.Child("dwelltx", [str(kb.REPO / "build" / "dwelltx")], + os.path.join(out_dir, "dwelltx.jsonl"), env=env, + stamp=True, + stderr_path=os.path.join(out_dir, "dwelltx.stderr")) + + +def cmd_run(a): + out = a.out + os.makedirs(out, exist_ok=True) + chans = [int(c) for c in a.channels.split(",")] + 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) + 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) + 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}") + 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() + 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) + print(json.dumps(res, indent=2)) + with open(os.path.join(out, "summary.json"), "w") as f: + json.dump(res, f, indent=2) + return 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("cmd", choices=["run", "analyze"]) + ap.add_argument("--out", default="/tmp/dwell1") + ap.add_argument("--channels", default="36,40") + ap.add_argument("--slot-ms", type=int, default=20) + ap.add_argument("--slots", type=int, default=5000) + ap.add_argument("--fw", type=int, default=1) + ap.add_argument("--hop-fast", type=int, default=1) + ap.add_argument("--late-us", type=int, default=0) + ap.add_argument("--settle-us", type=int, default=0, + 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("--dut-pid", default="2357:012d") + ap.add_argument("--oracle-a-pid", default="0bda:8812") + ap.add_argument("--oracle-b-pid", default="0bda:c812") + a = ap.parse_args() + if a.cmd == "run": + if os.geteuid() != 0: + sys.stderr.write("dwell1_ab run: needs root\n") + return 2 + 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)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())