From 3821fb4ef53eca1a32bee36f760c41cabca9f90b Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:54:33 +0300 Subject: [PATCH] =?UTF-8?q?M0:=20four=20measured=20feasibility=20contracts?= =?UTF-8?q?=20=E2=80=94=20DL=20guard,=20dynamic=20beacon=20grants,=20ACK/T?= =?UTF-8?q?xReport=20matrix,=20per-UE=20RX=20attribution=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract 1 — DL departure (docs/dl-departure.md): TD tag v2 adds host_ns (steady_clock at the send_packet call) so the witness fit measures the host-submit->air quantity directly; txegress_analyze.py gains tail percentiles + a machine txeg.verdict; dl_departure_matrix.sh sweeps TX over J1/J2/J3 USB + 8821CE PCIe against a fixed witness. Measured: transport floor 11-26 us on every transport, but the p99.9 tail is 1-3 ms channel deferral everywhere -> fine sub-ms DL slots refuted; M1 schedules submission-ahead with the measured guard. Contract 2 — dynamic grant delivery (docs/beacon-grant.md): new IRtlDevice::UpdateBeaconPayload (in-place content swap for an active beacon, riding the steer re-download; ~20 lines/gen) and StopBeacon (the chip beacons autonomously after process death — bench-bitten). Measured GO on all three generations: 30/30 updates aired per gen, zero excess skips, zero torn frames (CRC-versioned vendor IE probe), update->air latency TBTT-quantized (p50 40-72 ms at 100 TU). Static-grant fallback not needed. Contract 3 — unicast ACK + TxReport matrix (docs/ack-txreport.md): ack_txreport_matrix.sh sweeps on/retarget/off phases per TX generation against a SetAckResponder peer, with report coverage via tx.stats + SW_DEFINE tag gaps. J1/J3 TX perfect (100% ACK, retries ~0.2, arbitrary-MAC retarget, no-ACK visible per frame); J2 8812BU TX marginal (12-91% run-to-run) — recorded open. Responders: 8814AU best, 8812AU degraded, 8821AU never closes the loop. txdemo now emits a final tx.stats tally. Contract 4 — per-UE RX attribution (docs/ue-rx-attribution.md): src/cell/UeRxAttribution.h — TA-keyed windowed accumulator (drain-snapshot, RxQualityAccumulator conventions, bounded with eviction accounting), the seed of M1's UeRegistry; headless selftest in ctest + on-air two-TX check (frame ratio 4.0 exactly matching the 4x cadence split). Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 11 ++ docs/ack-txreport.md | 64 ++++++++ docs/beacon-grant.md | 62 ++++++++ docs/dl-departure.md | 65 ++++++++ docs/ue-rx-attribution.md | 52 ++++++ examples/tdma/tdma.h | 25 ++- examples/tx/main.cpp | 13 ++ src/IRtlDevice.h | 25 +++ src/cell/UeRxAttribution.h | 191 ++++++++++++++++++++++ src/jaguar1/RtlJaguarDevice.cpp | 35 +++++ src/jaguar1/RtlJaguarDevice.h | 4 + src/jaguar2/RtlJaguar2Device.cpp | 33 ++++ src/jaguar2/RtlJaguar2Device.h | 4 + src/jaguar3/RtlJaguar3Device.cpp | 36 +++++ src/jaguar3/RtlJaguar3Device.h | 4 + tests/ack_txreport_analyze.py | 116 ++++++++++++++ tests/ack_txreport_matrix.sh | 81 ++++++++++ tests/beacon_update_analyze.py | 227 +++++++++++++++++++++++++++ tests/beacon_update_check.sh | 60 +++++++ tests/beacon_update_probe.cpp | 205 ++++++++++++++++++++++++ tests/dl_departure_matrix.sh | 105 +++++++++++++ tests/dl_departure_tx.cpp | 84 ++++++++++ tests/pcie_txegress_tx.cpp | 17 +- tests/tdma_tsf_selftest.cpp | 23 +++ tests/txegress_analyze.py | 66 ++++++-- tests/txegress_witness.cpp | 7 +- tests/ue_rx_attribution_check.sh | 85 ++++++++++ tests/ue_rx_attribution_selftest.cpp | 149 ++++++++++++++++++ tests/ue_rx_probe.cpp | 89 +++++++++++ 29 files changed, 1917 insertions(+), 21 deletions(-) create mode 100644 docs/ack-txreport.md create mode 100644 docs/beacon-grant.md create mode 100644 docs/dl-departure.md create mode 100644 docs/ue-rx-attribution.md create mode 100644 src/cell/UeRxAttribution.h create mode 100644 tests/ack_txreport_analyze.py create mode 100644 tests/ack_txreport_matrix.sh create mode 100644 tests/beacon_update_analyze.py create mode 100644 tests/beacon_update_check.sh create mode 100644 tests/beacon_update_probe.cpp create mode 100644 tests/dl_departure_matrix.sh create mode 100644 tests/dl_departure_tx.cpp create mode 100644 tests/ue_rx_attribution_check.sh create mode 100644 tests/ue_rx_attribution_selftest.cpp create mode 100644 tests/ue_rx_probe.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c9ffc35..580ce10 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -573,6 +573,17 @@ target_link_libraries(AdapterCapsSelftest PRIVATE devourer) add_test(NAME adapter_caps_derive COMMAND AdapterCapsSelftest) +# Headless guard for the per-UE RX attribution seed (src/cell/UeRxAttribution.h) +# — TA extraction over the addr2-carrying frame types, the per-TA windowed +# aggregate folding, drain semantics, and the bounded-table eviction accounting +# the M1 UeRegistry will build on. +add_executable(UeRxAttributionSelftest + tests/ue_rx_attribution_selftest.cpp +) +target_link_libraries(UeRxAttributionSelftest PRIVATE devourer) + +add_test(NAME ue_rx_attribution_derive COMMAND UeRxAttributionSelftest) + # Headless guard for the TsfSync one-way time-distribution fit (src/TsfSync.h): # skew/offset recovery from beacon {egress, arrival} TSF pairs, clock # translation, the local 32-bit TSF wrap, and the Packet::TxEgressTsf pick. diff --git a/docs/ack-txreport.md b/docs/ack-txreport.md new file mode 100644 index 0000000..1ca6fc3 --- /dev/null +++ b/docs/ack-txreport.md @@ -0,0 +1,64 @@ +# Unicast ACK + TxReport capability matrix (M0 contract 3) + +A scheduled MAC's reliability layer (per-UE delivery detection, HARQ-style +retransmission, link adaptation) rests on two capabilities per generation: +an **injected unicast descriptor solicits a hardware ACK** (with autonomous +MAC retransmission until it arrives), and **`TxReport` reports the per-frame +ACK / no-ACK outcome** to the host. This measures both, per generation, plus +the report delivery rate — `tests/ack_txreport_matrix.sh` / +`tests/ack_txreport_analyze.py` (`--selftest` covers the verdict logic). + +## Method + +Fixed hardware-ACK responder (`SetAckResponder` on a second adapter); per TX +generation three phases: **on** (responder armed with MAC1, unicast QoS-Data +to MAC1 → expect ~100% `tx.report ok`, retries ~0), **retarget** (responder +re-armed to a different MAC2, TX to MAC2 → proves RA and responder MAC are +arbitrary), **off** (no responder → expect 0% ok, retries pinned at the +descriptor limit: the no-ACK outcome must be *visible*, per frame). +`report_coverage` = reports / frames sent (`tx.stats.submitted`); HalMAC adds +SW_DEFINE tag-echo gap counting. + +TX sessions run `DEVOURER_TX_WITH_RX=thread`: CCX reports arrive on the C2H +RX path, so J1/J2 TX-only sessions never see them (measured: J2 TX-only = 0 +reports; only J3 drains C2H off its coex runtime without an RX loop). A +scheduled MAC runs TX+RX anyway, so this is the relevant session shape. + +## Measured (ch36, MCS3, unicast TA, 8814AU responder, ~8 s/phase) + +| TX generation | on: ACK rate / mean retries | retarget | off: retries pinned | report coverage | tag gaps | +|---|---|---|---|---|---| +| Jaguar1 8812AU | 1.00 / 0.34 | 1.00 / 0.25 | yes (12) | 1.00 | n/a (8812 fmt) | +| Jaguar2 8812BU | 0.91 / 2.1 (run-to-run 0.12–0.91) | 0.64 / 5.3 | yes (12) | 0.86 | 0 | +| Jaguar3 8822CU | 1.00 / 0.24 | 1.00 / 0.13 | yes (12) | 0.96 | 0 | + +Responder-side capability (same bench, J3 TX as the reference soliciting +station): **8814AU** closes the loop at retries ~0.1 (the bench responder of +choice); **8812AU** works but degraded (97% delivery at ~7 mean retries — +its SIFS ACKs only land intermittently); **8821AU never closed the loop** +(TX retries stayed pinned with it armed); the 8812BU responder was +previously proven (`tests/ack_responder_check.sh`). + +## The contract + +1. **Per-frame delivery detection is GO on all three generations**: the OFF + phase pins retries at the descriptor limit with `state=1` on every report — + a no-ACK outcome is unambiguously visible per frame, which is all M2's + software retransmission logic needs. Report coverage 86–100% with zero + HalMAC tag gaps (interior losses); the reliability layer must tolerate a + ~5–15% report-less frame tail (treat missing report as "unknown", not + "delivered"). +2. **Closed-loop hardware ACK + autonomous retry is GO on Jaguar1 and + Jaguar3** (100% delivery, retries ≈ 0.2–0.3) including retargeting an + arbitrary UE MAC mid-session (re-arm `SetAckResponder`, change the + descriptor RA — both fully dynamic). +3. **Jaguar2 as the soliciting TX is MARGINAL on this bench**: ACK closure + varied 12–91% across identical runs (mean retries 2–11) against both + 8814AU and 8812AU responders, and its TX pace in the TX+RX-thread shape is + ~24 ms/frame regardless of the requested gap (~37 fps vs J1/J3's ~150). + As a *responder* J2 is proven good. An M1 cell should prefer J1/J3 (or the + 8821CE) for the DU role until the J2 TX anomaly is root-caused; treat it + as open, not as silicon folklore. +4. Bench quirk recorded: the J3 report's `missed` field reads a constant 4× + the report count while tag continuity shows zero loss — the 8822C + `missed_rpt` offset likely decodes something else; trust `tag` gaps on J3. diff --git a/docs/beacon-grant.md b/docs/beacon-grant.md new file mode 100644 index 0000000..f25b02e --- /dev/null +++ b/docs/beacon-grant.md @@ -0,0 +1,62 @@ +# Dynamic beacon-content delivery (M0 contract 2) + +A scheduled MAC (5G-NR RAN epic) carries its DCI-style grant map in the beacon +body, so the DU must be able to **change the airing beacon's content** without +missing, duplicating or tearing beacons. The primitive is +`IRtlDevice::UpdateBeaconPayload(beacon, len)` — an in-place content swap for +an active `StartBeacon` (same buffer contract; interval, TBTT phase and port +identity untouched) riding the same reserved-page re-download the TBTT steers +use. Its companion `StopBeacon()` silences the beacon function: the chip +beacons **autonomously**, so a beaconing session that ends without a device +power-cycle must call it — a killed process leaves the beacon airing +indefinitely (bench-bitten: a stale beacon with the same SA contaminated the +next test's witness). + +Per generation: Jaguar2 replaces the retained `_bcn_mpdu` and re-downloads via +the steer path (the J2 engine loses the bcn-valid latch on re-latch, so the +download is also the re-arm); Jaguar1 is a fresh BCNQ-boundary store bracket +(no re-ignite needed — the port stays configured); Jaguar3 is a fresh HalMAC +`download_beacon_page` (its latch is stable — no steer machinery involved). + +## Measured (ch36, 100 TU interval, 30 updates/gen every ~1 s, 8821AU witness) + +`tests/beacon_update_check.sh` — the probe beacons a versioned vendor IE +(u32 version + version-derived 32-byte pattern + CRC16, so one frame proves a +torn swap) and calls `UpdateBeaconPayload` every 10 intervals; the witness +records `(hw seq, tsfl, version, crc_ok)` per beacon and +`tests/beacon_update_analyze.py` reconstructs the TBTT grid from `tsfl`, +separating update-caused missing slots from background witness loss. + +| generation | updates aired | excess skips/update | torn | version regress | update→air p50 | p99 | +|---|---|---|---|---|---|---| +| Jaguar1 8812AU | 30/30 | 0.0 | 0 | 0 | 40 ms | 67 ms | +| Jaguar2 8812BU | 30/30 | 0.0 | 0 | 0 | 63 ms | 174 ms | +| Jaguar3 8822CU | 30/30 | 0.0 | 0 | 0 | 72 ms | 99 ms | + +## The contract + +1. **Dynamic beacon grants are GO on all three generations.** Every update + aired; the background-corrected skip cost was 0 in this bench (the ≤ 1 + skipped beacon per re-download that TBTT steers pay was not even resolvable + above witness loss at this cadence). No torn frames — the swap is + frame-atomic as observed on air (the CRC never caught a half-old, + half-new body). No old content re-airing after the new version's first + appearance. +2. **Update→air latency is TBTT-quantized**: content lands on the next (or + next-but-one) beacon — p50 ≈ half a period to a period, p99 ≤ ~2 periods at + 100 TU. A grant map published via the beacon is therefore *effective* one + to two beacon intervals after the scheduler decides it. M1's grant timing + must budget that pipeline (grants for slot epoch N+2, decided at epoch N). +3. **Not atomic versus TBTT by design**: a beacon airing during the download + may still carry the previous content, and the API guarantees only + whole-version frames (measured, via the CRC), not a bounded switchover + instant. The `effective_tbtt` discipline lives in the grant-map payload + (epoch field), not in the radio primitive. +4. The M0 fallback (static beacon grant + scheduled unicast deltas) is NOT + needed. Kept as the escape hatch if a future generation/transport shows a + real per-update skip cost. + +Tooling: `tests/beacon_update_probe.cpp` (TX + witness modes; build line in +header), `tests/beacon_update_analyze.py` (`--selftest` covers the +skip/dup/late/stale/torn classifier on synthetic streams), +`tests/beacon_update_check.sh` (per-generation orchestration). diff --git a/docs/dl-departure.md b/docs/dl-departure.md new file mode 100644 index 0000000..e516440 --- /dev/null +++ b/docs/dl-departure.md @@ -0,0 +1,65 @@ +# DL departure: the send_packet→air guard-time contract (M0 contract 1) + +A scheduled MAC (5G-NR RAN epic) promises collision-free downlink slots, which +requires knowing how long after the host calls `send_packet` the frame is +actually on the air — per transport, as a **distribution**, not an average. +This is that measurement and its per-transport contract. + +## Method + +One witness receiver captures, from the SAME frame, the transmitter's embedded +submit stamps and its own hardware RX timestamp (`RxAtrib.tsfl`, MAC-latched): + +- **TD v2 tag** (`examples/tdma/tdma.h`, version byte 2): `tx_tsf` = ReadTsf + near send (TX hardware clock) + `host_ns` = `steady_clock` immediately + before the `send_packet` call (the clock a host-side slot scheduler actually + controls). +- `tests/txegress_analyze.py` least-squares fits `rx_tsfl` against each stamp; + the line absorbs clock offset, crystal skew and propagation, so the residual + is per-frame submit→air jitter. Robust (3×MAD) rejection separates the + **floor** (frames that aired immediately = transport + MAC-pipeline jitter) + from the **tail** (channel deferral — which a scheduler must budget too). +- The contract number: `guard_us` = p99.9 of all host-clock residuals above + the median, emitted as a machine-checkable `txeg.verdict` JSONL line with + p50/p90/p99/p99.9/max and a `fine_dl_slots` go/no-go against `--slot-us`. + +Probes: `tests/dl_departure_tx.cpp` (any USB adapter), +`tests/pcie_txegress_tx.cpp` (8821CE over vfio). Orchestration: +`tests/dl_departure_matrix.sh` (fixed witness, TX swept over the transports; +the PCIe cell ships the probe to the remote rig, vfio-binds, runs, restores). + +## Measured (ch36, 6M legacy, ~2000 frames/cell, 8821AU witness) + +| transport | floor RMS | p90 | p99 | p99.9 (=guard) | max | +|---|---|---|---|---|---| +| Jaguar1 8812AU (async USB2) | 22 µs | 28 µs | 101 µs | 0.76 ms | 2.1 ms | +| Jaguar2 8812BU (sync USB3) | 14 µs | 64 µs | 1.7 ms | 3.1 ms | 3.3 ms | +| Jaguar3 8822CU (sync USB3) | 16 µs | 61 µs | 2.2 ms | 3.2 ms | 3.3 ms | +| 8821CE (PCIe, vfio) | 11 µs | 54 µs | 2.4 ms | 3.2 ms | 3.3 ms | + +Crystal ppm sane (−17 ppm, same reference witness) on every cell — the robust +fits locked. The tail is run-to-run ambient-dependent: the same J1 cell has +measured p99.9 between ~0.8 ms and ~3.2 ms across runs on the same channel. + +## The contract + +1. **The transport floor is NOT the bottleneck.** All four transports place + the bulk of frames within tens of µs of nominal (floor RMS 11–26 µs; p90 + ≤ 64 µs). PCIe is the tightest (11 µs) but the USB floors are the same + order — transport choice does not gate slot design at ≥ ms slot sizes. +2. **The tail is channel deferral, and it does not go away.** Even at 5 GHz on + a mostly-idle channel, ~1% of frames air 0.1–2.4 ms late (ambient beacons + + CSMA — `SetCcaMode` relaxes energy-CCA, not preamble deferral). p99.9 sits + at ~1–3 ms on every transport. +3. **Go/no-go: fine (sub-ms) DL slots are REFUTED on all transports** for a + p99.9-grade deadline on a real channel. The M1 design consequence is the + fallback the epic anticipated: a **submission-ahead scheduler** — submit a + slot's frame `guard_us` before the slot boundary and size slots ≥ ~2× the + measured guard (i.e. multi-ms slots), OR accept a bounded deadline-miss + ratio (~1% at a 1 ms guard, per the p99 row) and let HARQ absorb it. + Re-measure `guard_us` per deployment environment; the verdict line exists + so that check is one script run. + +The hardware-beacon path (MAC-timed TBTT, `PinBeaconTbtt`) is unaffected by +any of this — beacons depart on the TBTT grid below the CSMA/queueing layer, +which is why scheduled **UL** rides beacon-steered timing, not `send_packet`. diff --git a/docs/ue-rx-attribution.md b/docs/ue-rx-attribution.md new file mode 100644 index 0000000..1f13a5e --- /dev/null +++ b/docs/ue-rx-attribution.md @@ -0,0 +1,52 @@ +# Per-UE RX attribution (M0 contract 4) + +`GetRxQuality()` is device-wide by design: one draining accumulator fed by +every decoded frame, whoever sent it. A cell scheduler adapting per-UE rate and +power needs the same windowed statistics **attributed to each transmitter** — +that is `devourer::cell::UeRxAttribution` (`src/cell/UeRxAttribution.h`), the +seed of the scheduled-MAC `UeRegistry` (5G-NR RAN epic, `src/cell/` = the +per-cell DU library). + +## The contract + +Pure caller-side logic — the device RX loops are untouched and `GetRxQuality` +stays the radio-wide diagnostic. Everything needed is already per-frame in the +`Packet` callback: + +- **key** — the transmitter address (802.11 addr2/TA), extracted by + `cell::extract_ta`: bytes [10..16) of the MPDU for every frame type except + the two control subtypes that end at addr1 (CTS, ACK). +- **values** — `rx_pkt_attrib`'s path-A RSSI/SNR/EVM plus the hardware RX + timestamp `tsfl`, folded with the exact `RxQualityAccumulator` conventions + (`rssi_raw <= 0` is not a sample; SNR/EVM folded only when present; passive + noise floor = `(rssi_raw − 110) − snr_raw/2`). + +`add()` (or `add_mpdu()`, which extracts the TA itself) per frame; +`snapshot()` drains the whole table into one `UeRxWindow` per TA (delta +semantics, like `GetRxQuality`) with converted units, window mean/extremes and +`last_tsfl` for staleness. The table is bounded (default 64 TAs per window); +overflow frames are counted in `evicted_frames`, never silently lost. + +## Measured (bench) + +`tests/ue_rx_attribution_check.sh`: two transmitters with distinct unicast SAs +(8812AU at a 2 ms inter-frame gap, 8822CU at 8 ms) against one `ue_rx_probe` +witness (8812BU), 12 s. The probe attributed the streams separately — +TX1 2955 frames at −51 dBm mean, TX2 741 frames at −44 dBm mean, a 4.0× +count ratio exactly matching the 4× cadence ratio — confirming per-UE frame +counts and per-UE signal statistics don't bleed between transmitters. + +## Tooling + +- `tests/ue_rx_probe.cpp` — on-air probe: feeds every decoded frame into a + `UeRxAttribution`, drains once a second, emits one `ue.rx` JSONL event per + UE per window (`ta`, `frames`, `rssi_dbm`, `rssi_max_dbm`, `snr_db`, + `snr_min_db`, `evm_db`, `nf_dbm`, `last_tsfl`) plus `ue.rx.evicted` when the + cap was hit. Build line in the header. +- `tests/ue_rx_attribution_selftest.cpp` — headless ctest guard + (`ue_rx_attribution_derive`): TA extraction over frame types, folding + conventions, drain semantics, eviction accounting. +- `tests/ue_rx_attribution_check.sh` — the two-TX on-air validation above. + +M1 wraps this into `UeRegistry` (association state, timing advance, and the +per-UE RX window as the link-adaptation input). diff --git a/examples/tdma/tdma.h b/examples/tdma/tdma.h index 055d5be..583c63f 100644 --- a/examples/tdma/tdma.h +++ b/examples/tdma/tdma.h @@ -49,40 +49,51 @@ static const uint8_t kSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; static constexpr size_t kHdrLen = 24; // 802.11 header up to the body // 'T''D' ver cls seq[4] burst[4] tx_tsf[8]. The 8-byte TX-TSF stamp lets the // TSF-sync RX measure the TX↔RX crystal drift (0 when the TX can't read TSF). +// v2 appends host_ns[8] — the transmitter's steady_clock at the send_packet +// call, so a witness can fit air-arrival directly against the HOST clock (the +// quantity a host-side slot scheduler actually controls), uncontaminated by +// the ReadTsf control-read round-trip. v1 frames stay parseable. static constexpr size_t kTagLen = 20; +static constexpr size_t kTagLenV2 = 28; static constexpr size_t kMinData = kHdrLen + kTagLen; // Build a full TX buffer: [radiotap for this class][802.11 header][TD tag]. +// A nonzero host_ns selects the v2 tag. inline std::vector build_frame(const std::vector& radiotap, Class cls, uint32_t seq, uint32_t burst, - uint64_t tx_tsf = 0) { + uint64_t tx_tsf = 0, + uint64_t host_ns = 0) { static const uint8_t hdr[kHdrLen] = { 0x40, 0x00, 0x00, 0x00, // FC + duration 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // addr1 broadcast 0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr2 = SA 0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr3 0x80, 0x00}; // seq ctl + const size_t tag_len = host_ns ? kTagLenV2 : kTagLen; std::vector f; - f.reserve(radiotap.size() + kHdrLen + kTagLen); + f.reserve(radiotap.size() + kHdrLen + tag_len); f.insert(f.end(), radiotap.begin(), radiotap.end()); f.insert(f.end(), hdr, hdr + kHdrLen); - uint8_t tag[kTagLen] = { - 'T', 'D', 1, static_cast(cls), + uint8_t tag[kTagLenV2] = { + 'T', 'D', static_cast(host_ns ? 2 : 1), static_cast(cls), static_cast(seq), static_cast(seq >> 8), static_cast(seq >> 16), static_cast(seq >> 24), static_cast(burst), static_cast(burst >> 8), static_cast(burst >> 16),static_cast(burst >> 24)}; for (int i = 0; i < 8; ++i) tag[12 + i] = static_cast(tx_tsf >> (8 * i)); - f.insert(f.end(), tag, tag + kTagLen); + for (int i = 0; i < 8; ++i) tag[20 + i] = static_cast(host_ns >> (8 * i)); + f.insert(f.end(), tag, tag + tag_len); return f; } struct Parsed { bool ok = false; + uint8_t ver = 0; Class cls = Class::Bulk; uint32_t seq = 0; uint32_t burst = 0; uint64_t tx_tsf = 0; + uint64_t host_ns = 0; // v2 only: TX host steady_clock at send (0 on v1) }; // Parse an RX Packet.Data span (802.11 MPDU) into a TD tag, if it is one of ours. @@ -92,6 +103,7 @@ inline Parsed parse_frame(const uint8_t* data, size_t len) { if (std::memcmp(data + 10, kSa, 6) != 0) return p; // not our SA const uint8_t* t = data + kHdrLen; if (t[0] != 'T' || t[1] != 'D') return p; + p.ver = t[2]; p.cls = static_cast(t[3]); p.seq = static_cast(t[4]) | (static_cast(t[5]) << 8) | (static_cast(t[6]) << 16) | (static_cast(t[7]) << 24); @@ -99,6 +111,9 @@ inline Parsed parse_frame(const uint8_t* data, size_t len) { (static_cast(t[10]) << 16) | (static_cast(t[11]) << 24); for (int i = 0; i < 8; ++i) p.tx_tsf |= static_cast(t[12 + i]) << (8 * i); + if (p.ver >= 2 && len >= kHdrLen + kTagLenV2) + for (int i = 0; i < 8; ++i) + p.host_ns |= static_cast(t[20 + i]) << (8 * i); p.ok = true; return p; } diff --git a/examples/tx/main.cpp b/examples/tx/main.cpp index 2c85a26..7a5b225 100644 --- a/examples/tx/main.cpp +++ b/examples/tx/main.cpp @@ -1342,6 +1342,19 @@ int main(int argc, char **argv) { if (th.joinable()) th.join(); + { /* Final TX-submission tally — the periodic tx.stats above emits only + * every 500 frames, so a short run would otherwise never report its true + * count (test scripts read the last tx.stats as the frames-sent + * denominator). */ + auto ts = rtlDevice->GetTxStats(); + devourer::Ev(*g_ev, "tx.stats") + .f("submitted", (unsigned long long)ts.submitted) + .f("failed", (unsigned long long)ts.failed) + .f("was_timeout", ts.last_was_timeout ? 1 : 0) + .f("last_rc", ts.last_error_rc) + .f("final", 1); + } + /* Bounded hop mode (DEVOURER_HOP_ROUNDS>0) reaches here when its rounds * complete; the signal and back-off paths also fall through. */ if (!hop_channels.empty()) { diff --git a/src/IRtlDevice.h b/src/IRtlDevice.h index ffcf201..c6e5c79 100644 --- a/src/IRtlDevice.h +++ b/src/IRtlDevice.h @@ -289,6 +289,31 @@ class IRtlDevice { return false; } + /* Replace the ACTIVE beacon's content in place — the dynamic-grant delivery + * primitive (a scheduled MAC carries its DCI-style grant map in the beacon + * body). Same buffer contract as StartBeacon (a leading radiotap header is + * stripped; the raw 802.11 MPDU lands in the reserved page); the beacon + * interval, TBTT phase and port identity are NOT touched — changing + * addr2/addr3 mid-flight is unsupported (the port registers keep the + * StartBeacon identity). Requires an active StartBeacon; returns false + * otherwise. Rides the same reserved-page re-download the TBTT steers use, + * so the cost bound is the steer's: at most one skipped beacon per update + * while the valid latch re-arms, and the swap is NOT atomic versus TBTT (a + * beacon airing during the download may still carry the previous content). + * Measured per-generation skip/latency numbers: docs/beacon-grant.md. */ + virtual bool UpdateBeaconPayload(const uint8_t *beacon, size_t len) { + (void)beacon; (void)len; + return false; + } + + /* Stop the hardware beacon: EN_BCN_FUNCTION off + net_type back to No Link. + * The chip beacons AUTONOMOUSLY once StartBeacon arms it — killing the host + * process does NOT silence it (bench-bitten: a killed probe's beacon kept + * airing and contaminated the next test's witness) — so any beaconing + * session that ends without a device power-cycle must call this. Idempotent; + * returns false when no beacon was active. */ + virtual bool StopBeacon() { return false; } + /* Disable / restore the MAC EDCCA energy-detect gate (the vendor dis_cca * recipe). With EDCCA off the MAC does not defer TX to carrier-sense, so a * TBTT beacon airs exactly on schedule instead of after a CSMA backoff — the diff --git a/src/cell/UeRxAttribution.h b/src/cell/UeRxAttribution.h new file mode 100644 index 0000000..91c5c84 --- /dev/null +++ b/src/cell/UeRxAttribution.h @@ -0,0 +1,191 @@ +/* Per-UE RX attribution — the M0 seed of the scheduled-MAC UeRegistry (5G-NR + * RAN epic, src/cell/ = the per-cell DU library). + * + * GetRxQuality() is deliberately device-wide: one draining accumulator fed by + * every decoded frame, regardless of transmitter. A cell scheduler adapting + * per-UE rate/power needs the same window statistics ATTRIBUTED to each + * transmitter (the 802.11 addr2 / TA). All the primitives already exist per + * frame — RSSI/SNR/EVM in rx_pkt_attrib, the raw MPDU (and thus the TA) in + * Packet.Data — so this stays pure caller-side logic: the demos/probes feed + * add() from the Packet callback, the device RX loops are untouched. + * + * Same conventions as RxQualityAccumulator (src/RxQuality.h): raw path-A + * units in, drain-and-reset snapshot() out (delta semantics), rssi_raw <= 0 + * is not a sample, SNR/EVM folded only when present, passive noise floor = + * (rssi_raw - 110) - snr_raw/2 per OFDM frame. */ +#ifndef DEVOURER_CELL_UE_RX_ATTRIBUTION_H +#define DEVOURER_CELL_UE_RX_ATTRIBUTION_H + +#include +#include +#include +#include +#include + +namespace devourer { +namespace cell { + +/* Extract the transmitter address (addr2) from an 802.11 MPDU. Every frame + * type carries addr2 at bytes [10..16) EXCEPT the two shortest control + * subtypes — CTS (fc0 0xc4) and ACK (0xd4) — which end at addr1. Returns + * false for those, for truncated buffers, and for a zero TA (never a legal + * transmitter; a parser artifact). */ +inline bool extract_ta(const uint8_t *mpdu, size_t len, uint8_t out[6]) { + if (!mpdu || len < 16) + return false; + const uint8_t fc0 = mpdu[0]; + if ((fc0 & 0x0c) == 0x04) { /* control frame */ + const uint8_t subtype = fc0 & 0xf0; + if (subtype == 0xc0 /* CTS */ || subtype == 0xd0 /* ACK */) + return false; + } + std::memcpy(out, mpdu + 10, 6); + static const uint8_t zero[6] = {0, 0, 0, 0, 0, 0}; + return std::memcmp(out, zero, 6) != 0; +} + +/* One UE's drained window — RxQualitySnapshot's frame-driven half, keyed and + * converted (dBm / dB, so a scheduler doesn't repeat the raw conversions). */ +struct UeRxWindow { + std::array ta{}; + uint32_t frames = 0; + int rssi_mean_dbm = 0; /* window mean of path-A PWDB (raw - 110) */ + int rssi_max_dbm = 0; + double snr_mean_db = 0.0; + double snr_min_db = 0.0; + double evm_mean_db = 0.0; /* 0 when evm_valid is false */ + bool evm_valid = false; + double noise_floor_dbm = 0.0; /* passive: mean rssi_dbm - snr_db */ + bool nf_valid = false; + uint32_t last_tsfl = 0; /* hardware RX TSF of the newest frame (staleness) */ +}; + +/* A drained attribution window: per-UE stats plus the frames the table had to + * drop because it was full (a nonzero count means the cap is too small for + * the environment, or a foreign-traffic filter belongs upstream). */ +struct UeRxSnapshot { + std::vector ues; + uint32_t evicted_frames = 0; +}; + +/* Thread-safe per-TA windowed accumulator. add() from the Packet callback for + * every decoded frame; snapshot() drains the whole table (delta semantics — + * UEs reappear with their next frame). Bounded: at most `cap` distinct TAs + * per window; frames from further TAs are counted in evicted_frames, never + * silently lost. */ +class UeRxAttribution { +public: + explicit UeRxAttribution(size_t cap = 64) : cap_(cap) {} + + /* Raw path-A values straight off rx_pkt_attrib, plus the frame's TA and + * hardware RX timestamp. Mirrors RxQualityAccumulator::add. */ + void add(const uint8_t ta[6], int rssi_raw, int snr_raw, int evm_raw, + uint32_t tsfl) { + if (rssi_raw <= 0) + return; + std::lock_guard lk(mu_); + Entry *e = find_or_insert(ta); + if (!e) { + ++evicted_; + return; + } + ++e->n; + e->rssi_sum += rssi_raw; + if (rssi_raw > e->rssi_max) + e->rssi_max = rssi_raw; + e->snr_sum += snr_raw; + if (snr_raw < e->snr_min) + e->snr_min = snr_raw; + if (evm_raw != 0) { + e->evm_sum += evm_raw; + ++e->evm_n; + } + if (snr_raw != 0) { + e->nf_sum += (rssi_raw - 110) - snr_raw / 2.0; + ++e->nf_n; + } + e->last_tsfl = tsfl; + } + + /* Convenience: extract the TA from the MPDU and add. Returns false when the + * frame carries no TA (CTS/ACK/truncated) — not a quality sample. */ + bool add_mpdu(const uint8_t *mpdu, size_t len, int rssi_raw, int snr_raw, + int evm_raw, uint32_t tsfl) { + uint8_t ta[6]; + if (!extract_ta(mpdu, len, ta)) + return false; + add(ta, rssi_raw, snr_raw, evm_raw, tsfl); + return true; + } + + /* Drain the window: one UeRxWindow per TA seen, then reset the table. */ + UeRxSnapshot snapshot() { + UeRxSnapshot s; + std::lock_guard lk(mu_); + s.ues.reserve(entries_.size()); + for (const Entry &e : entries_) { + UeRxWindow w; + w.ta = e.ta; + w.frames = e.n; + if (e.n) { + w.rssi_mean_dbm = static_cast(e.rssi_sum / static_cast(e.n)) - 110; + w.rssi_max_dbm = e.rssi_max - 110; + w.snr_mean_db = (e.snr_sum / static_cast(e.n)) / 2.0; + w.snr_min_db = e.snr_min / 2.0; + } + if (e.evm_n) { + w.evm_mean_db = (e.evm_sum / static_cast(e.evm_n)) / 2.0; + w.evm_valid = true; + } + if (e.nf_n) { + w.noise_floor_dbm = e.nf_sum / static_cast(e.nf_n); + w.nf_valid = true; + } + w.last_tsfl = e.last_tsfl; + s.ues.push_back(w); + } + s.evicted_frames = evicted_; + entries_.clear(); + evicted_ = 0; + return s; + } + +private: + struct Entry { + std::array ta{}; + uint32_t n = 0; + int64_t rssi_sum = 0; + int rssi_max = -128; + int64_t snr_sum = 0; + int snr_min = 127; + int64_t evm_sum = 0; + uint32_t evm_n = 0; + double nf_sum = 0.0; + uint32_t nf_n = 0; + uint32_t last_tsfl = 0; + }; + + /* Linear scan — the table is small (cap defaults to 64) and drains every + * window, so a map's overhead buys nothing. Caller holds mu_. */ + Entry *find_or_insert(const uint8_t ta[6]) { + for (Entry &e : entries_) + if (std::memcmp(e.ta.data(), ta, 6) == 0) + return &e; + if (entries_.size() >= cap_) + return nullptr; + Entry e; + std::memcpy(e.ta.data(), ta, 6); + entries_.push_back(e); + return &entries_.back(); + } + + std::mutex mu_; + size_t cap_; + std::vector entries_; + uint32_t evicted_ = 0; +}; + +} // namespace cell +} // namespace devourer + +#endif /* DEVOURER_CELL_UE_RX_ATTRIBUTION_H */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 5f066c1..e136b2a 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -486,6 +486,41 @@ bool RtlJaguarDevice::StartBeacon(const uint8_t *beacon, size_t len, return true; } +bool RtlJaguarDevice::UpdateBeaconPayload(const uint8_t *beacon, size_t len) { + if (_bcn_mpdu.empty()) { + _logger->error("beacon(J1): UpdateBeaconPayload without an active beacon"); + return false; + } + /* Same buffer contract as StartBeacon: strip a leading radiotap header. */ + size_t rt = (len >= 4) ? (size_t)(beacon[2] | (beacon[3] << 8)) : 0; + if (rt > len) rt = 0; + /* A fresh BCNQ-boundary store replaces the TBTT engine's buffer (the same + * bracket the steers re-download through); the port stays configured and the + * TBTT grid is untouched, so no re-ignite is needed. */ + if (!download_rsvd_beacon(beacon + rt, len - rt)) { + _logger->error("beacon(J1): UpdateBeaconPayload rsvd-page store failed"); + return false; + } + _bcn_mpdu.assign(beacon + rt, beacon + len); + return true; +} + +bool RtlJaguarDevice::StopBeacon() { + if (_bcn_mpdu.empty()) + return false; + /* EN_BCN_FUNCTION off (keep DIS_TSF_UDT), StopTxBeacon (0x422[6] clear — + * the ResumeTxBeacon inverse), net_type back to No Link. */ + _device.rtw_write8(0x0550 /* REG_BCN_CTRL */, 0x10); + _device.rtw_write8(0x0422, static_cast( + _device.rtw_read8(0x0422) & ~0x40u)); + uint8_t nt = _device.rtw_read8(0x0102); + _device.rtw_write8(0x0102, static_cast(nt & ~0x03u)); + _bcn_mpdu.clear(); + _bcn_interval_tu = 0; + _logger->info("beacon(J1): stopped (EN_BCN off, StopTxBeacon, net_type->NoLink)"); + return true; +} + int32_t RtlJaguarDevice::AdjustBeaconTiming(int32_t microseconds) { int nominal = _bcn_interval_tu; if (nominal <= 0) return 0; // no active beacon diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 7824ba5..d55f0b6 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -234,6 +234,10 @@ class RtlJaguarDevice : public IRtlDevice { * stored beacon airs with the hardware sequence pinned at 0 (kernel rtw88 * parity). `interval_tu` is the beacon interval in TU (1024 µs). */ bool StartBeacon(const uint8_t* beacon, size_t len, int interval_tu) override; + /* In-place beacon content swap (IRtlDevice contract): retain the new MPDU + + * a fresh BCNQ-boundary store; interval/TBTT/port identity untouched. */ + bool UpdateBeaconPayload(const uint8_t* beacon, size_t len) override; + bool StopBeacon() override; /* Beacon-TBTT steering (IRtlDevice contract) — the Jaguar2 steer-then- * re-download pattern: re-download the retained MPDU after the re-latch to * re-arm the bcn-valid latch. */ diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index adeca7f..b7f37a4 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -1447,6 +1447,39 @@ bool RtlJaguar2Device::StartBeacon(const uint8_t *beacon, size_t len, * "re-latch, then re-download": the TBTT re-derives from the steered timebase * and the fresh download re-asserts the valid latch (its poll is the success * signal). Costs one skipped beacon per correction. Caller holds _reg_mu. */ +bool RtlJaguar2Device::UpdateBeaconPayload(const uint8_t *beacon, size_t len) { + std::lock_guard lk(_reg_mu); + if (_bcn_mpdu.empty()) { + _logger->error("beacon(J2): UpdateBeaconPayload without an active beacon"); + return false; + } + /* Same buffer contract as StartBeacon: strip a leading radiotap header. */ + size_t rt = (len >= 4) ? (size_t)(beacon[2] | (beacon[3] << 8)) : 0; + if (rt > len) rt = 0; + _bcn_mpdu.assign(beacon + rt, beacon + len); + /* The steer re-download path, with new content: the fresh rsvd-page store + * replaces the TBTT engine's buffer and re-arms the valid latch (its poll is + * the success signal). Interval/TBTT/port identity untouched. */ + return redownload_beacon_locked(); +} + +bool RtlJaguar2Device::StopBeacon() { + std::lock_guard lk(_reg_mu); + if (_bcn_mpdu.empty()) + return false; + /* EN_BCN_FUNCTION off (keep DIS_TSF_UDT), beacon-queue download off, + * net_type back to No Link — the StartBeacon enables, reversed. */ + _device.rtw_write8(0x0550 /* REG_BCN_CTRL */, (1u << 4)); + uint32_t txq = _device.rtw_read(0x0420 /* REG_FWHW_TXQ_CTRL */); + _device.rtw_write(0x0420, txq & ~(1u << 22) /* BIT_EN_BCNQ_DL */); + uint8_t nt = _device.rtw_read8(0x0102); + _device.rtw_write8(0x0102, static_cast(nt & ~0x03u)); + _bcn_mpdu.clear(); + _bcn_interval_tu = 0; + _logger->info("beacon(J2): stopped (EN_BCN off, EN_BCNQ_DL off, net_type->NoLink)"); + return true; +} + bool RtlJaguar2Device::redownload_beacon_locked() { if (_bcn_mpdu.empty()) return false; diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 454a85a..04627c7 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -86,6 +86,10 @@ class RtlJaguar2Device : public IRtlDevice { uint64_t ReadTsf() override; void WriteTsf(uint64_t tsf) override; bool StartBeacon(const uint8_t *beacon, size_t len, int interval_tu) override; + /* In-place beacon content swap (IRtlDevice contract): retain the new MPDU + + * ride the steer re-download; interval/TBTT/port identity untouched. */ + bool UpdateBeaconPayload(const uint8_t *beacon, size_t len) override; + bool StopBeacon() override; /* Disable/restore the MAC EDCCA gate (BIT_DIS_EDCCA 0x520[15] + EDCCA-mask * 0x524[11] — HalMAC-common with J3) so a TBTT beacon airs on schedule. */ void SetCcaMode(bool disabled) override; diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 0d062e6..9a5e515 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -1732,6 +1732,42 @@ bool RtlJaguar3Device::StartBeacon(const uint8_t *beacon, size_t len, return true; } +bool RtlJaguar3Device::UpdateBeaconPayload(const uint8_t *beacon, size_t len) { + std::lock_guard lk(_reg_mu); + if (_bcn_interval_tu <= 0) { + _logger->error("beacon-tbtt(J3): UpdateBeaconPayload without an active beacon"); + return false; + } + /* Same buffer contract as StartBeacon: strip a leading radiotap header. */ + size_t rt = (len >= 4) ? (size_t)(beacon[2] | (beacon[3] << 8)) : 0; + if (rt > len) rt = 0; + /* A fresh rsvd-page download replaces the TBTT engine's buffer; the J3 + * latch is stable across it (no steer-style re-latch), so the enable path, + * interval, TBTT phase and port identity are all untouched. */ + if (!_hal.download_beacon_page(beacon + rt, static_cast(len - rt))) { + _logger->error("beacon-tbtt(J3): UpdateBeaconPayload rsvd-page download failed"); + return false; + } + return true; +} + +bool RtlJaguar3Device::StopBeacon() { + std::lock_guard lk(_reg_mu); + if (_bcn_interval_tu <= 0) + return false; + /* EN_BCN_FUNCTION off (keep DIS_TSF_UDT), beacon-queue download off, + * net_type back to No Link — the StartBeacon enables, reversed. */ + _device.rtw_write8(0x0550 /* REG_BCN_CTRL */, (1u << 4)); + uint32_t txq = _device.rtw_read(0x0420 /* REG_FWHW_TXQ_CTRL */); + _device.rtw_write(0x0420, txq & ~(1u << 22) /* BIT_EN_BCNQ_DL */); + uint8_t nt = _device.rtw_read8(0x0102); + _device.rtw_write8(0x0102, static_cast(nt & ~0x03u)); + _bcn_interval_tu = 0; + _logger->info("beacon-tbtt(J3): stopped (EN_BCN off, EN_BCNQ_DL off, " + "net_type->NoLink)"); + return true; +} + int32_t RtlJaguar3Device::AdjustBeaconTiming(int32_t microseconds) { int nominal; { diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index 00323e4..53cd596 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -85,6 +85,10 @@ class RtlJaguar3Device : public IRtlDevice { uint64_t ReadTsf() override; void WriteTsf(uint64_t tsf) override; bool StartBeacon(const uint8_t *beacon, size_t len, int interval_tu) override; + /* In-place beacon content swap (IRtlDevice contract): a fresh + * download_beacon_page; interval/TBTT/port identity untouched. */ + bool UpdateBeaconPayload(const uint8_t *beacon, size_t len) override; + bool StopBeacon() override; int32_t AdjustBeaconTiming(int32_t microseconds) override; int32_t AdjustBeaconTimingFine(int32_t microseconds) override; /* TSF-preserving absolute TBTT pin (IRtlDevice contract; the J2 pattern — diff --git a/tests/ack_txreport_analyze.py b/tests/ack_txreport_analyze.py new file mode 100644 index 0000000..278642c --- /dev/null +++ b/tests/ack_txreport_analyze.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# ack_txreport_analyze.py — M0 contract 3: turn one matrix cell's TX-side +# stream (tx.report JSONL + a sent-frame count) into a capability verdict: +# ack_rate — fraction of reports with state==0 (hardware-ACKed) +# retries mean/max— hardware retransmission counts (pinned at the descriptor +# limit when nobody ACKs) +# report_coverage — reports received / frames sent (the C2H delivery rate) +# tag_loss — HalMAC only: gaps in the rotating 8-bit SW_DEFINE tag +# echo (per-frame correlation loss), plus the firmware's +# own missed_rpt tally +# Cell semantics (the matrix): responder ON -> expect ack_rate ~1, retries ~0; +# responder OFF -> expect ack_rate ~0, retries pinned; retarget cell = ON with +# a different unicast RA (SetAckResponder re-armed) -> expect ON behavior. +# +# Usage: python3 ack_txreport_analyze.py --sent N --cell NAME \ +# --expect on|off +# python3 ack_txreport_analyze.py --selftest +import json, sys + +def load(path): + out = [] + for line in open(path): + line = line.strip() + if not line.startswith("{"): + continue + try: + r = json.loads(line) + except Exception: + continue + if r.get("ev") == "tx.report": + out.append(r) + return out + +def analyze(reports, sent, cell="", expect=None): + n = len(reports) + v = {"ev": "ackrep.verdict", "cell": cell, "sent": sent, "reports": n} + if n == 0: + v["report_coverage"] = 0.0 + v["capability_ok"] = False + v["error"] = "no tx.report events (C2H path dead in this session shape)" + return v + acked = sum(1 for r in reports if r.get("ok")) + rts = [r.get("retries", 0) for r in reports] + v["ack_rate"] = round(acked / n, 3) + v["retries_mean"] = round(sum(rts) / n, 2) + v["retries_max"] = max(rts) + v["report_coverage"] = round(n / sent, 3) if sent else None + # HalMAC per-frame correlation: the descriptor stamps a rotating 8-bit tag; + # count sequence gaps in the echo (mod 256) + the fw's own missed counter. + tags = [r["tag"] for r in reports if "tag" in r] + if tags: + gaps = 0 + for a, b in zip(tags, tags[1:]): + gaps += (b - a - 1) % 256 + v["tag_gaps"] = gaps + v["fw_missed"] = sum(r.get("missed", 0) for r in reports) + if expect == "on": + v["capability_ok"] = bool(v["ack_rate"] >= 0.9 and v["retries_mean"] < 2) + elif expect == "off": + # Nobody ACKs: delivery must FAIL and retries pin at the limit — this + # proves the no-ACK outcome is visible, not that the link is bad. + v["capability_ok"] = bool(v["ack_rate"] <= 0.1 and v["retries_max"] >= 8) + else: + v["capability_ok"] = None + return v + +def selftest(): + fails = 0 + def check(cond, msg): + nonlocal fails + if not cond: + print("FAIL:", msg); fails += 1 + on = [{"ev": "tx.report", "ok": True, "state": 0, "retries": 0, + "tag": i % 256, "missed": 0} for i in range(100)] + v = analyze(on, 100, "on", expect="on") + check(v["capability_ok"] and v["ack_rate"] == 1.0 and + v["report_coverage"] == 1.0 and v["tag_gaps"] == 0, "clean ON passes") + off = [{"ev": "tx.report", "ok": False, "state": 1, "retries": 12, + "tag": i % 256, "missed": 0} for i in range(100)] + v = analyze(off, 100, "off", expect="off") + check(v["capability_ok"] and v["retries_max"] == 12, "pinned OFF passes") + v = analyze(off, 100, "off-as-on", expect="on") + check(not v["capability_ok"], "OFF behavior fails an ON expectation") + v = analyze([], 100, "dead", expect="on") + check(not v["capability_ok"] and "error" in v, "no reports = no capability") + # Tag gaps: every 10th report lost — 9 interior gaps are visible (a + # trailing loss has no successor; coverage-vs-sent catches it instead). + lossy = [r for i, r in enumerate(on) if i % 10 != 9] + v = analyze(lossy, 100, "lossy", expect="on") + check(v["tag_gaps"] == 9, "tag gaps count lost reports (got %s)" % v["tag_gaps"]) + # J1 (no tag field) still verdicts on coverage alone. + j1 = [{"ev": "tx.report", "ok": True, "state": 0, "retries": 1} + for _ in range(90)] + v = analyze(j1, 100, "j1", expect="on") + check(v["capability_ok"] and "tag_gaps" not in v, "8812 format (no tag) ok") + print("ack_txreport_analyze selftest:", + "%d FAILURE(S)" % fails if fails else "all passed") + return 1 if fails else 0 + +def main(): + if "--selftest" in sys.argv: + sys.exit(selftest()) + args = sys.argv[1:] + sent, cell, expect = 0, "", None + if "--sent" in args: + i = args.index("--sent"); sent = int(args[i + 1]); del args[i:i + 2] + if "--cell" in args: + i = args.index("--cell"); cell = args[i + 1]; del args[i:i + 2] + if "--expect" in args: + i = args.index("--expect"); expect = args[i + 1]; del args[i:i + 2] + v = analyze(load(args[0]), sent, cell, expect) + print(json.dumps(v)) + sys.exit(0 if v.get("capability_ok") else 1) + +if __name__ == "__main__": + main() diff --git a/tests/ack_txreport_matrix.sh b/tests/ack_txreport_matrix.sh new file mode 100644 index 0000000..6b23eae --- /dev/null +++ b/tests/ack_txreport_matrix.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# ack_txreport_matrix.sh — M0 contract 3: the unicast-ACK + TxReport capability +# matrix. For each TX generation, three phases against a fixed hardware-ACK +# responder (SetAckResponder on a second adapter): +# on — responder armed with MAC1, TX injects unicast QoS-Data to MAC1: +# expect tx.report ok~1, retries~0 (hardware ACK closes the loop). +# retarget — responder re-armed with a DIFFERENT unicast MAC2, TX targets +# MAC2: proves the injected descriptor's RA and the responder MAC +# are both arbitrary, not baked-in. +# off — no responder, TX to MAC1: expect ok~0, retries pinned at the +# descriptor limit — the no-ACK outcome is VISIBLE per frame. +# Every phase also measures report_coverage (reports / frames sent) and, on +# HalMAC (J2/J3), SW_DEFINE tag-echo gaps + the firmware missed counter. +# +# TX sessions run with DEVOURER_TX_WITH_RX=thread: the CCX reports arrive on +# the C2H RX path, so a TX-only session without an RX loop never sees them on +# J1/J2 (J3 alone drains C2H off its coex runtime) — this matrix measures the +# capability with the RX loop up, the shape a scheduled MAC runs in anyway. +# +# bash tests/ack_txreport_matrix.sh +# CELLS="j3-8822cu:0x0bda:0xc812" SECS=10 bash tests/ack_txreport_matrix.sh +set -uo pipefail +cd "$(dirname "$0")/.." + +# 8814AU responder. NOT the 8821AU: bench-measured, an armed 8821AU never +# closed the loop (TX retries stayed pinned) while the 8814AU ACKs ~100%. +RESP_VID=${RESP_VID:-0x0bda}; RESP_PID=${RESP_PID:-0x8813} +CH=${CH:-36}; SECS=${SECS:-8}; GAP_US=${GAP_US:-5000} +MAC1=${MAC1:-02:12:34:56:78:9a} +MAC2=${MAC2:-02:12:34:56:78:9b} +TX_SA=${TX_SA:-02:aa:bb:cc:dd:01} # unicast TA (the ACK RA I/G footgun) +OUT=${OUT:-/tmp/ack_txreport} +CELLS=${CELLS:-"j1-8812au:0x0bda:0x8812 j2-8812bu:0x2357:0x012d j3-8822cu:0x0bda:0xc812"} + +cleanup(){ sudo pkill -9 -x rxdemo 2>/dev/null; sudo pkill -9 -x txdemo 2>/dev/null; return 0; } +trap cleanup EXIT +mkdir -p "$OUT" +VERDICTS="$OUT/verdicts.jsonl"; : >"$VERDICTS" + +run_phase() { # $1 cell $2 phase $3 tx vid $4 tx pid $5 RA mac $6 responder mac (""=off) $7 expect + local cell="$1" phase="$2" vid="$3" pid="$4" ra="$5" resp="$6" expect="$7" + local tag="${cell}_${phase}" + cleanup; sleep 1 + if [ -n "$resp" ]; then + sudo env DEVOURER_VID=$RESP_VID DEVOURER_PID=$RESP_PID DEVOURER_CHANNEL=$CH \ + DEVOURER_ACK_RESPONDER=$resp DEVOURER_LOG_LEVEL=info \ + ./build/rxdemo >"$OUT/resp_$tag.jsonl" 2>"$OUT/resp_$tag.err" & + sleep 6 # responder bring-up + fi + sudo env DEVOURER_VID=$vid DEVOURER_PID=$pid DEVOURER_CHANNEL=$CH \ + DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_RA=$ra DEVOURER_TX_SA=$TX_SA \ + DEVOURER_TX_RATE=MCS3 DEVOURER_TX_PAYLOAD_BYTES=200 \ + DEVOURER_TX_GAP_US=$GAP_US DEVOURER_TX_REPORT=1 \ + DEVOURER_TX_WITH_RX=thread DEVOURER_LOG_LEVEL=warn \ + timeout -s INT $SECS ./build/txdemo \ + >"$OUT/tx_$tag.jsonl" 2>"$OUT/tx_$tag.err" || true + sleep 1 + cleanup; sleep 1 + # Frames sent = the last tx.stats 'submitted' counter (GetTxStats, emitted + # every 500 frames) — the per-send stderr lines differ per generation. + local sent + sent=$(grep '"ev":"tx.stats"' "$OUT/tx_$tag.jsonl" | tail -1 | + sed -n 's/.*"submitted":\([0-9]*\).*/\1/p') + sent=${sent:-0} + echo "-- $tag: sent=$sent reports=$(grep -c '"ev":"tx.report"' "$OUT/tx_$tag.jsonl" || true)" + python3 tests/ack_txreport_analyze.py "$OUT/tx_$tag.jsonl" \ + --sent "$sent" --cell "$tag" --expect "$expect" | tee -a "$VERDICTS" || true +} + +for cell in $CELLS; do + name=${cell%%:*}; rest=${cell#*:}; vid=${rest%%:*}; pid=${rest#*:} + echo + echo "==== cell $name (TX $vid:$pid, ch$CH) ====" + run_phase "$name" on "$vid" "$pid" "$MAC1" "$MAC1" on + run_phase "$name" retarget "$vid" "$pid" "$MAC2" "$MAC2" on + run_phase "$name" off "$vid" "$pid" "$MAC1" "" off +done + +echo +echo "==== MATRIX VERDICTS ($VERDICTS) ====" +cat "$VERDICTS" diff --git a/tests/beacon_update_analyze.py b/tests/beacon_update_analyze.py new file mode 100644 index 0000000..537b333 --- /dev/null +++ b/tests/beacon_update_analyze.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# beacon_update_analyze.py — M0 contract 2 detector: turn a beacon_update_probe +# TX stream (bcn.update calls) + witness stream (bcn.rx per aired beacon) into +# the dynamic-grant-delivery verdict. +# +# The witness's tsfl (hardware RX timestamp) reconstructs the TBTT grid: +# consecutive-beacon deltas cluster at interval_tu*1024 µs; a delta of k +# periods means k-1 grid slots carried no decoded beacon. A missing slot is +# EITHER a real skip (the chip didn't air) or witness RX loss — indistinguishable +# per-slot, so the verdict separates the loss rate NEAR updates (within +# ±attrib_periods of an update call) from the BACKGROUND rate away from them: +# the excess near updates is the update's real cost. +# skips_per_update = near-update missing slots / updates (background-corrected) +# dup = same TBTT slot decoded twice +# late = beacon >lateness_us off its grid slot +# stale/latency = per update, host_ns delta from the update call to the +# first aired beacon carrying the NEW version (both +# processes share one host CLOCK_MONOTONIC) +# torn = crc_ok=false frames (a partial content swap) +# ver_regress = version going backward (old content re-airing) +# +# Usage: python3 beacon_update_analyze.py +# [--gen NAME] [--interval-tu 100] +# python3 beacon_update_analyze.py --selftest +import json, sys + +def load(path, ev): + out = [] + for line in open(path): + line = line.strip() + if not line.startswith("{"): + continue + try: + r = json.loads(line) + except Exception: + continue + if r.get("ev") == ev: + out.append(r) + return out + +def unwrap32(xs): + out, hi, prev = [], 0, None + for x in xs: + if prev is not None and x < prev - (1 << 31): + hi += 1 << 32 + out.append(x + hi); prev = x + return out + +def pct(sorted_xs, p): + if not sorted_xs: + return 0.0 + k = min(len(sorted_xs) - 1, max(0, int(round(p / 100.0 * (len(sorted_xs) - 1))))) + return sorted_xs[k] + +def analyze(rx, updates, gen="", interval_tu=100, attrib_periods=3, + lateness_us=2000): + period = interval_tu * 1024 + if len(rx) < 10: + return {"ev": "bcn.verdict", "gen": gen, "error": "too few beacons", + "n_rx": len(rx)} + tsf = unwrap32([r["tsfl"] for r in rx]) + + # Grid walk over consecutive decodes: slot deltas, dups, lateness. + missing = [] # (tsf_of_gap_start, n_missing_slots) + dups = 0 + late = 0 + phases = [] + for i in range(1, len(tsf)): + d = tsf[i] - tsf[i - 1] + k = round(d / period) + if k == 0: + dups += 1 + continue + off = d - k * period + phases.append(off) + if abs(off) > lateness_us: + late += 1 + if k > 1: + missing.append((tsf[i - 1], k - 1)) + + # host_ns <-> tsf line through the decoded beacons (same host for both + # streams, so update host_ns maps onto the witness grid through this fit). + hs = [r["host_ns"] / 1000.0 for r in rx] # µs + n = len(tsf) + mt = sum(tsf) / n; mh = sum(hs) / n + sxx = sum((t - mt) ** 2 for t in tsf) + sxy = sum((t - mt) * (h - mh) for t, h in zip(tsf, hs)) + a = sxy / sxx if sxx else 1.0 + b = mh - a * mt + def host_us_of_tsf(t): + return a * t + b + + # Near-update vs background missing-slot attribution. + upd_us = [u["host_ns"] / 1000.0 for u in updates] + near_missing = 0 + far_missing = 0 + win = attrib_periods * period + for t0, k in missing: + h0 = host_us_of_tsf(t0) + if any(abs(h0 - u) <= win for u in upd_us): + near_missing += k + else: + far_missing += k + total_slots = round((tsf[-1] - tsf[0]) / period) + 1 + far_slots = max(1, total_slots - len(upd_us) * (2 * attrib_periods + 1)) + bg_rate = far_missing / far_slots + n_upd = max(1, len(upd_us)) + # Background-corrected: excess missing slots near updates, per update. + skips_per_update = max(0.0, (near_missing - bg_rate * + len(upd_us) * (2 * attrib_periods + 1)) / n_upd) + + # Update -> first-new-version-on-air latency; version regressions. + lat_us = [] + regress = 0 + prev_ver = None + for r in rx: + if prev_ver is not None and r["ver"] < prev_ver: + regress += 1 + prev_ver = r["ver"] + for u in updates: + first = next((r for r in rx if r["ver"] >= u["ver"] and + r["host_ns"] >= u["host_ns"]), None) + if first is not None: + lat_us.append((first["host_ns"] - u["host_ns"]) / 1000.0) + lat_us.sort() + + torn = sum(1 for r in rx if not r.get("crc_ok", True)) + v = {"ev": "bcn.verdict", "gen": gen, "n_rx": len(rx), + "n_updates": len(upd_us), + "updates_ok": sum(1 for u in updates if u.get("ok", False)), + "skips_per_update": round(skips_per_update, 2), + "bg_loss_rate": round(bg_rate, 4), + "near_missing": near_missing, "far_missing": far_missing, + "dups": dups, "late": late, "torn": torn, "ver_regress": regress, + "lat_p50_ms": round(pct(lat_us, 50) / 1000.0, 1) if lat_us else None, + "lat_p99_ms": round(pct(lat_us, 99) / 1000.0, 1) if lat_us else None, + "lat_max_ms": round(lat_us[-1] / 1000.0, 1) if lat_us else None, + "updates_seen_on_air": len(lat_us)} + # Go/no-go: every update aired, no torn frames, no regressions, and the + # per-update cost within the documented steer bound (<= 1 skipped beacon + # + measurement slack). + v["dynamic_grants_ok"] = bool( + len(lat_us) == len(upd_us) and torn == 0 and regress == 0 and + skips_per_update <= 1.5) + return v + +def selftest(): + period = 102400 # 100 TU in µs + fails = 0 + def check(cond, msg): + nonlocal fails + if not cond: + print("FAIL:", msg); fails += 1 + + # Clean run: 200 beacons, updates every 20 periods, one skipped slot per + # update, new version airs on the next decoded beacon. + rx, updates = [], [] + ver = 1 + t = 0 + host0 = 5_000_000_000 # ns + skip_next = False + for i in range(200): + if i and i % 20 == 0: + ver += 1 + updates.append({"ver": ver, "ok": True, + "host_ns": host0 + t * 1000 - 5_000_000}) + skip_next = True + if skip_next: + skip_next = False # the update's one skipped beacon + else: + rx.append({"seq": i & 0xfff, "tsfl": t % (1 << 32), "ver": ver, + "crc_ok": True, "host_ns": host0 + t * 1000}) + t += period + v = analyze(rx, updates, gen="selftest-clean") + check(v["dynamic_grants_ok"], "clean run passes") + check(0.5 <= v["skips_per_update"] <= 1.5, + "one skip per update measured (got %s)" % v["skips_per_update"]) + check(v["torn"] == 0 and v["ver_regress"] == 0, "clean run: no torn/regress") + check(v["updates_seen_on_air"] == len(updates), "every update airs") + check(v["lat_p50_ms"] is not None and v["lat_p50_ms"] < 3 * period / 1000.0, + "latency ~ a couple periods") + + # Torn frame + version regression must fail the verdict. + rx2 = [dict(r) for r in rx] + rx2[50]["crc_ok"] = False + v2 = analyze(rx2, updates, gen="selftest-torn") + check(v2["torn"] == 1 and not v2["dynamic_grants_ok"], "torn frame fails") + rx3 = [dict(r) for r in rx] + rx3[60]["ver"] = 1 + v3 = analyze(rx3, updates, gen="selftest-regress") + check(v3["ver_regress"] >= 1 and not v3["dynamic_grants_ok"], + "version regression fails") + + # Background witness loss away from updates must NOT count against updates. + rx4 = [r for i, r in enumerate(rx) if i % 20 != 7] # periodic far loss + v4 = analyze(rx4, updates, gen="selftest-bgloss") + check(v4["skips_per_update"] <= 1.5, + "background loss not attributed to updates (got %s)" % + v4["skips_per_update"]) + + # 32-bit tsfl wrap mid-run parses fine. + rx5 = [dict(r, tsfl=(r["tsfl"] + (1 << 32) - 50 * period) % (1 << 32)) + for r in rx] + v5 = analyze(rx5, updates, gen="selftest-wrap") + check(v5["n_rx"] == len(rx5) and v5["dups"] == 0, "tsfl wrap handled") + + print("beacon_update_analyze selftest:", + "%d FAILURE(S)" % fails if fails else "all passed") + return 1 if fails else 0 + +def main(): + if "--selftest" in sys.argv: + sys.exit(selftest()) + args = sys.argv[1:] + gen, interval_tu = "", 100 + if "--gen" in args: + i = args.index("--gen"); gen = args[i + 1]; del args[i:i + 2] + if "--interval-tu" in args: + i = args.index("--interval-tu"); interval_tu = int(args[i + 1]); del args[i:i + 2] + rx = load(args[0], "bcn.rx") + updates = load(args[1], "bcn.update") + v = analyze(rx, updates, gen=gen, interval_tu=interval_tu) + print(json.dumps(v)) + sys.exit(0 if v.get("dynamic_grants_ok") else 1) + +if __name__ == "__main__": + main() diff --git a/tests/beacon_update_check.sh b/tests/beacon_update_check.sh new file mode 100644 index 0000000..28ac578 --- /dev/null +++ b/tests/beacon_update_check.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# beacon_update_check.sh — M0 contract 2 bench: dynamic beacon-content delivery +# (UpdateBeaconPayload) per generation. For each TX cell: start the versioned +# grant-map beacon (beacon_update_probe), watch it with a witness on a second +# adapter, swap the content N times, and run beacon_update_analyze.py — one +# bcn.verdict JSONL row per generation (skips/update, update→air latency, +# torn/regression atomicity). Results table: docs/beacon-grant.md. +# +# bash tests/beacon_update_check.sh +# CELLS="j2-8812bu:0x2357:0x012d" N_UPDATES=50 bash tests/beacon_update_check.sh +set -uo pipefail +cd "$(dirname "$0")/.." + +WIT_VID=${WIT_VID:-0x2357}; WIT_PID=${WIT_PID:-0x0120} # 8821AU witness +CH=${CH:-36}; N_UPDATES=${N_UPDATES:-30}; K_INTERVALS=${K_INTERVALS:-10} +OUT=${OUT:-/tmp/beacon_update} +# name:vid:pid — one TX cell per generation. +CELLS=${CELLS:-"j1-8812au:0x0bda:0x8812 j2-8812bu:0x2357:0x012d j3-8822cu:0x0bda:0xc812"} + +# -f, not -x: the comm name truncates at 15 chars ("beacon_update_p"), so an +# exact-name pkill never matches. Killed TX probes leave the chip beaconing +# (no StopBeacon ran) — witness cells only, TX cells run to completion. +cleanup(){ sudo pkill -9 -f 'build/beacon_update_probe' 2>/dev/null; return 0; } +trap cleanup EXIT +mkdir -p "$OUT" + +if [ ! -x build/beacon_update_probe ] || [ tests/beacon_update_probe.cpp -nt build/beacon_update_probe ]; then + echo "== building beacon_update_probe" + g++ -std=c++20 -O2 -Isrc -Iexamples/common tests/beacon_update_probe.cpp \ + examples/common/env_config.cpp build/libdevourer.a \ + $(pkg-config --cflags --libs libusb-1.0) -lpthread -o build/beacon_update_probe +fi + +# TX runtime: bring-up (~5s) + baseline (3s) + N*K intervals + tail (3s). +TX_SECS=$(( 11 + N_UPDATES * K_INTERVALS / 9 )) +VERDICTS="$OUT/verdicts.jsonl"; : >"$VERDICTS" + +for cell in $CELLS; do + name=${cell%%:*}; rest=${cell#*:}; vid=${rest%%:*}; pid=${rest#*:} + echo + echo "==== cell $name (TX $vid:$pid, ch$CH, $N_UPDATES updates x $K_INTERVALS intervals) ====" + cleanup; sleep 1 + sudo env MODE=rx DEVOURER_VID=$WIT_VID DEVOURER_PID=$WIT_PID DEVOURER_CHANNEL=$CH \ + DEVOURER_LOG_LEVEL=warn \ + ./build/beacon_update_probe $((TX_SECS + 10)) >"$OUT/wit_$name.jsonl" 2>"$OUT/wit_$name.err" & + sleep 6 # witness bring-up + sudo env DEVOURER_VID=$vid DEVOURER_PID=$pid DEVOURER_CHANNEL=$CH \ + DEVOURER_LOG_LEVEL=warn \ + ./build/beacon_update_probe "$N_UPDATES" "$K_INTERVALS" \ + >"$OUT/tx_$name.jsonl" 2>"$OUT/tx_$name.err" + sleep 2 + cleanup; sleep 1 + echo " beacons: $(grep -c '"ev":"bcn.rx"' "$OUT/wit_$name.jsonl" || true), updates: $(grep -c '"ev":"bcn.update"' "$OUT/tx_$name.jsonl" || true)" + python3 tests/beacon_update_analyze.py "$OUT/wit_$name.jsonl" "$OUT/tx_$name.jsonl" \ + --gen "$name" | tee -a "$VERDICTS" || true +done + +echo +echo "==== VERDICTS ($VERDICTS) ====" +cat "$VERDICTS" diff --git a/tests/beacon_update_probe.cpp b/tests/beacon_update_probe.cpp new file mode 100644 index 0000000..62e80f9 --- /dev/null +++ b/tests/beacon_update_probe.cpp @@ -0,0 +1,205 @@ +// beacon_update_probe.cpp — M0 contract 2 probe: dynamic beacon-content +// delivery via UpdateBeaconPayload (the DCI-style grant-map carrier). +// +// TX mode (default): starts a hardware beacon whose body carries a versioned +// vendor IE — 'G','M' magic, a monotonically-increasing u32 version, a 32-byte +// version-derived pattern, and a CRC16 over all of it (so a torn/partial swap +// is detectable from ONE frame). Every K beacon intervals it bumps the version +// and calls UpdateBeaconPayload, emitting {"ev":"bcn.update","ver":.., +// "host_ns":..,"ok":..} per call. +// +// Witness mode (MODE=rx): a monitor RX on another adapter records every beacon +// from the probe's SA as {"ev":"bcn.rx","seq":..,"tsfl":..,"ver":.., +// "crc_ok":..,"host_ns":..}. Both processes run on ONE host, so host_ns +// (CLOCK_MONOTONIC) is directly comparable and update→air latency falls out. +// tests/beacon_update_analyze.py turns the two streams into the skip / dup / +// late / stale / torn verdict; tests/beacon_update_check.sh orchestrates. +// +// Build: g++ -std=c++20 -O2 -Isrc -Iexamples/common \ +// tests/beacon_update_probe.cpp examples/common/env_config.cpp \ +// build/libdevourer.a $(pkg-config --cflags --libs libusb-1.0) -lpthread \ +// -o build/beacon_update_probe +// TX: sudo DEVOURER_PID=0x012d DEVOURER_CHANNEL=36 build/beacon_update_probe [n_updates] [k_intervals] +// witness: sudo MODE=rx DEVOURER_PID=0x8812 DEVOURER_CHANNEL=36 build/beacon_update_probe [secs] +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "RxPacket.h" +#include "SelectedChannel.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "env_config.h" +#include "logger.h" + +static const uint8_t kSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; +static constexpr uint8_t kOui[3] = {0x00, 0x57, 0x42}; +static constexpr size_t kPatLen = 32; + +static int64_t host_ns() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +// CRC-16/CCITT-FALSE — one frame proves the swap wasn't torn. +static uint16_t crc16(const uint8_t* d, size_t n) { + uint16_t c = 0xffff; + for (size_t i = 0; i < n; ++i) { + c ^= (uint16_t)d[i] << 8; + for (int b = 0; b < 8; ++b) + c = (c & 0x8000) ? (uint16_t)((c << 1) ^ 0x1021) : (uint16_t)(c << 1); + } + return c; +} + +// [radiotap][beacon MPDU with the versioned grant-map vendor IE]. +static std::vector build_beacon(uint32_t ver, int interval_tu) { + std::vector f = { + 0x00, 0x00, 0x0a, 0x00, 0x00, 0x80, 0x00, 0x00, 0x08, 0x00, // radiotap + 0x80, 0x00, 0x00, 0x00, // FC + dur + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // addr1 + 0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr2 = SA + 0x57, 0x42, 0x75, 0x05, 0xd6, 0x00, // addr3 + 0x00, 0x00, // seq + 0, 0, 0, 0, 0, 0, 0, 0, // timestamp + static_cast(interval_tu & 0xff), + static_cast((interval_tu >> 8) & 0xff), + 0x00, 0x00, // capability + 0x00, 0x03, 'G', 'N', 'T', // SSID + 0x01, 0x01, 0x82}; // rates + // Vendor IE: DD len | OUI | 'G' 'M' | ver u32 LE | pat[32] | crc16 LE. + std::vector body; + body.push_back('G'); body.push_back('M'); + for (int i = 0; i < 4; ++i) body.push_back((uint8_t)(ver >> (8 * i))); + for (size_t i = 0; i < kPatLen; ++i) body.push_back((uint8_t)(ver + i)); + uint16_t c = crc16(body.data(), body.size()); + body.push_back((uint8_t)(c & 0xff)); body.push_back((uint8_t)(c >> 8)); + f.push_back(0xdd); + f.push_back((uint8_t)(3 + body.size())); + f.insert(f.end(), kOui, kOui + 3); + f.insert(f.end(), body.begin(), body.end()); + return f; +} + +// Find our vendor IE in an RX beacon MPDU; return ver + crc verdict. +static bool parse_beacon(const uint8_t* mpdu, size_t len, uint32_t* ver, + bool* crc_ok) { + if (len < 38 || mpdu[0] != 0x80 || std::memcmp(mpdu + 10, kSa, 6) != 0) + return false; + size_t off = 36; // header(24) + timestamp(8) + interval(2) + cap(2) + while (off + 2 <= len) { + uint8_t tag = mpdu[off], tl = mpdu[off + 1]; + if (off + 2 + tl > len) break; + const uint8_t* v = mpdu + off + 2; + if (tag == 0xdd && tl >= 3 + 2 + 4 + 2 && std::memcmp(v, kOui, 3) == 0 && + v[3] == 'G' && v[4] == 'M') { + const uint8_t* body = v + 3; + size_t blen = (size_t)tl - 3; + uint32_t x = 0; + for (int i = 0; i < 4; ++i) x |= (uint32_t)body[2 + i] << (8 * i); + uint16_t want = (uint16_t)(body[blen - 2] | (body[blen - 1] << 8)); + *ver = x; + *crc_ok = crc16(body, blen - 2) == want; + return true; + } + off += 2 + tl; + } + return false; +} + +static std::atomic g_rx{0}; + +int main(int argc, char** argv) { + const char* mode = std::getenv("MODE"); + const bool rx_mode = mode && std::strcmp(mode, "rx") == 0; + uint8_t ch = 36; + if (const char* c = std::getenv("DEVOURER_CHANNEL")) ch = (uint8_t)atoi(c); + const int interval_tu = 100; + + auto logger = std::make_shared(); + apply_logging_env(*logger); + libusb_context* ctx = nullptr; + if (libusb_init(&ctx) < 0) return 1; + libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING); + uint16_t vid = 0x0bda, pid = 0; + if (const char* v = std::getenv("DEVOURER_VID")) vid = (uint16_t)strtoul(v, 0, 0); + if (const char* p = std::getenv("DEVOURER_PID")) pid = (uint16_t)strtoul(p, 0, 0); + auto* h = libusb_open_device_with_vid_pid(ctx, vid, pid); + if (!h) { fprintf(stderr, "open %04x:%04x failed\n", vid, pid); return 1; } + std::shared_ptr lock; + if (devourer::claim_interface_then_reset(h, 0, logger, true, lock) != 0) + return 1; + WiFiDriver wifi(logger); + auto dev = wifi.CreateRtlDevice(h, ctx, lock, devourer_config_from_env()); + if (!dev) { fprintf(stderr, "no driver\n"); return 1; } + + if (rx_mode) { + int secs = argc > 1 ? atoi(argv[1]) : 60; + auto cb = [](const Packet& p) { + if (p.RxAtrib.crc_err) return; + uint32_t ver = 0; bool crc_ok = false; + if (!parse_beacon(p.Data.data(), p.Data.size(), &ver, &crc_ok)) return; + uint16_t seq = (uint16_t)((p.Data[22] | (p.Data[23] << 8)) >> 4); + ++g_rx; + printf("{\"ev\":\"bcn.rx\",\"seq\":%u,\"tsfl\":%u,\"ver\":%u," + "\"crc_ok\":%s,\"host_ns\":%lld}\n", + seq, p.RxAtrib.tsfl, ver, crc_ok ? "true" : "false", + (long long)host_ns()); + fflush(stdout); + }; + std::thread rx([&] { dev->Init(cb, SelectedChannel{ch, 0, CHANNEL_WIDTH_20}); }); + std::this_thread::sleep_for(std::chrono::seconds(secs)); + fprintf(stderr, "beacon_update_probe(rx): %llu beacons\n", + (unsigned long long)g_rx.load()); + _exit(0); + } + + int n_updates = argc > 1 ? atoi(argv[1]) : 30; + int k_intervals = argc > 2 ? atoi(argv[2]) : 10; + dev->InitWrite(SelectedChannel{ch, 0, CHANNEL_WIDTH_20}); + std::this_thread::sleep_for(std::chrono::seconds(2)); + dev->SetCcaMode(true); + + uint32_t ver = 1; + auto f = build_beacon(ver, interval_tu); + if (!dev->StartBeacon(f.data(), f.size(), interval_tu)) { + fprintf(stderr, "StartBeacon FAILED\n"); + return 1; + } + printf("{\"ev\":\"bcn.start\",\"ver\":%u,\"interval_tu\":%d,\"host_ns\":%lld}\n", + ver, interval_tu, (long long)host_ns()); + fflush(stdout); + std::this_thread::sleep_for(std::chrono::seconds(3)); // baseline window + + const auto gap = std::chrono::microseconds((int64_t)k_intervals * interval_tu * 1024); + for (int i = 0; i < n_updates; ++i) { + std::this_thread::sleep_for(gap); + ++ver; + auto nf = build_beacon(ver, interval_tu); + int64_t t0 = host_ns(); + bool ok = dev->UpdateBeaconPayload(nf.data(), nf.size()); + int64_t t1 = host_ns(); + printf("{\"ev\":\"bcn.update\",\"ver\":%u,\"ok\":%s,\"host_ns\":%lld," + "\"call_us\":%lld}\n", + ver, ok ? "true" : "false", (long long)t0, + (long long)((t1 - t0) / 1000)); + fflush(stdout); + } + std::this_thread::sleep_for(std::chrono::seconds(3)); // tail window + /* The chip beacons autonomously — silence it before exiting, or the stale + * beacon (same SA) contaminates the next test's witness. */ + dev->StopBeacon(); + fprintf(stderr, "beacon_update_probe(tx): %d updates, final ver %u\n", + n_updates, ver); + return 0; +} diff --git a/tests/dl_departure_matrix.sh b/tests/dl_departure_matrix.sh new file mode 100644 index 0000000..014c3b1 --- /dev/null +++ b/tests/dl_departure_matrix.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# dl_departure_matrix.sh — M0 contract 1: the per-transport send_packet→air +# guard-time matrix. One fixed witness receiver; the TX role sweeps the +# transports (Jaguar1 async-USB, Jaguar2 sync-USB, Jaguar3 sync-USB, and the +# 8821CE PCIe cell on a remote rig). Every TX frame is a TD v2 tag carrying +# tx_tsf (ReadTsf) + host_ns (steady_clock immediately before send_packet); the +# witness records them with its hardware rx_tsfl, and txegress_analyze.py's +# host-clock fit turns the residual tail into the per-transport guard contract +# (one txeg.verdict JSONL line per cell). Results table: docs/dl-departure.md. +# +# sudo bash tests/dl_departure_matrix.sh +# SECS=60 SLOT_US=1000 sudo bash tests/dl_departure_matrix.sh +# SKIP_PCIE=1 sudo bash tests/dl_departure_matrix.sh # USB cells only +set -uo pipefail +cd "$(dirname "$0")/.." + +WIT_VID=${WIT_VID:-0x2357}; WIT_PID=${WIT_PID:-0x0120} # 8821AU witness +CH=${CH:-6}; SECS=${SECS:-40}; N=${N:-2000}; GAP_US=${GAP_US:-15000} +SLOT_US=${SLOT_US:-1000} # go/no-go bound for "fine DL slots" +OUT=${OUT:-/tmp/dl_departure} +# name:vid:pid — the USB TX cells (transport is per generation: J1 async USB2, +# J2/J3 sync USB3). +CELLS=${CELLS:-"j1-8812au-usb:0x0bda:0x8812 j2-8812bu-usb:0x2357:0x012d j3-8822cu-usb:0x0bda:0xc812"} +# PCIe cell (remote rig; SKIP_PCIE=1 to skip). +PCIE_HOST=${PCIE_HOST:-radxa-x4} +PCIE_DIR=${PCIE_DIR:-devourer} # remote tree (rsync'd, not git) +PCIE_BDF=${PCIE_BDF:-0000:01:00.0} + +cleanup(){ + sudo pkill -9 -x dl_departure_tx 2>/dev/null + sudo pkill -9 -x txegress_witness 2>/dev/null + return 0 +} +trap cleanup EXIT +mkdir -p "$OUT" + +build_local() { # $1 = src basename (no .cpp), extra include dirs baked in + echo "== building $1" + g++ -std=c++20 -O2 -Isrc -Iexamples/common -Iexamples/tdma "tests/$1.cpp" \ + examples/common/env_config.cpp build/libdevourer.a \ + $(pkg-config --cflags --libs libusb-1.0) -lpthread -o "build/$1" +} +[ -x build/dl_departure_tx ] && [ ! tests/dl_departure_tx.cpp -nt build/dl_departure_tx ] || build_local dl_departure_tx +[ -x build/txegress_witness ] && [ ! tests/txegress_witness.cpp -nt build/txegress_witness ] \ + && [ ! examples/tdma/tdma.h -nt build/txegress_witness ] || build_local txegress_witness + +run_witness() { # $1 = out file — witness capped at N frames or SECS+15 s + sudo env DEVOURER_VID=$WIT_VID DEVOURER_PID=$WIT_PID DEVOURER_CHANNEL=$CH \ + timeout $((SECS + 15)) ./build/txegress_witness "$N" 2>"$1.err" >"$1" +} + +VERDICTS="$OUT/verdicts.jsonl"; : >"$VERDICTS" + +for cell in $CELLS; do + name=${cell%%:*}; rest=${cell#*:}; vid=${rest%%:*}; pid=${rest#*:} + echo + echo "==== cell $name (TX $vid:$pid, ch$CH, ${SECS}s) ====" + cleanup; sleep 1 + sudo env DEVOURER_VID=$vid DEVOURER_PID=$pid DEVOURER_CHANNEL=$CH \ + GAP_US=$GAP_US DEVOURER_LOG_LEVEL=warn \ + ./build/dl_departure_tx "$SECS" >"$OUT/tx_$name.log" 2>&1 & + sleep 6 # TX bring-up + run_witness "$OUT/wit_$name.jsonl" + cleanup; sleep 1 + echo " frames: $(grep -c '"ev":"txeg"' "$OUT/wit_$name.jsonl" || true)" + python3 tests/txegress_analyze.py "$OUT/wit_$name.jsonl" \ + --transport "$name" --slot-us "$SLOT_US" | tee "$OUT/an_$name.txt" + grep '"ev": *"txeg.verdict"' "$OUT/an_$name.txt" >>"$VERDICTS" || true +done + +if [ "${SKIP_PCIE:-0}" != "1" ]; then + name=pcie-8821ce + echo + echo "==== cell $name ($PCIE_HOST $PCIE_BDF, ch$CH, ${SECS}s) ====" + cleanup; sleep 1 + # Ship the TD-v2 sources, build the probe remotely against the remote + # libdevourer.a, vfio-bind, run, restore the kernel driver. + scp -q examples/tdma/tdma.h "$PCIE_HOST:$PCIE_DIR/examples/tdma/tdma.h" + scp -q tests/pcie_txegress_tx.cpp "$PCIE_HOST:$PCIE_DIR/tests/pcie_txegress_tx.cpp" + if ssh "$PCIE_HOST" "cd $PCIE_DIR && \ + g++ -std=c++20 -O2 -DDEVOURER_HAVE_PCIE=1 -Isrc -Iexamples/tdma tests/pcie_txegress_tx.cpp \ + build-pcie/libdevourer.a \$(pkg-config --cflags --libs libusb-1.0) \ + -lpthread -o build-pcie/pcie_txegress_tx && \ + sudo bash tests/pcie_vfio_bind.sh $PCIE_BDF"; then + ssh "$PCIE_HOST" "cd $PCIE_DIR && \ + sudo DEVOURER_PCIE_BDF=$PCIE_BDF DEVOURER_CHANNEL=$CH GAP_US=$GAP_US \ + build-pcie/pcie_txegress_tx $SECS" >"$OUT/tx_$name.log" 2>&1 & + PCIE_SSH=$! + sleep 8 # remote bring-up + run_witness "$OUT/wit_$name.jsonl" + wait $PCIE_SSH 2>/dev/null + ssh "$PCIE_HOST" "cd $PCIE_DIR && sudo bash tests/pcie_vfio_bind.sh $PCIE_BDF --restore" \ + >/dev/null 2>&1 || true + echo " frames: $(grep -c '"ev":"txeg"' "$OUT/wit_$name.jsonl" || true)" + python3 tests/txegress_analyze.py "$OUT/wit_$name.jsonl" \ + --transport "$name" --slot-us "$SLOT_US" | tee "$OUT/an_$name.txt" + grep '"ev": *"txeg.verdict"' "$OUT/an_$name.txt" >>"$VERDICTS" || true + else + echo " PCIe cell SKIPPED (remote build/bind failed)" + fi +fi + +echo +echo "==== MATRIX VERDICTS ($VERDICTS) ====" +cat "$VERDICTS" diff --git a/tests/dl_departure_tx.cpp b/tests/dl_departure_tx.cpp new file mode 100644 index 0000000..626da5f --- /dev/null +++ b/tests/dl_departure_tx.cpp @@ -0,0 +1,84 @@ +// dl_departure_tx.cpp — DL-departure probe transmitter (M0 contract 1): airs TD +// v2 data frames over ANY USB adapter, each carrying BOTH submit-time stamps — +// * tx_tsf — ReadTsf() near the send call (the TX hardware clock), and +// * host_ns — steady_clock immediately before send_packet (the HOST clock a +// slot scheduler actually controls). +// Paired with tests/txegress_witness on a co-located receiver: the witness's +// hardware rx_tsfl is the true-air proxy, txegress_analyze.py fits it against +// each stamp, and the host_ns residual tail IS the send_packet→air guard-time +// contract for this adapter/transport. PCIe counterpart: +// tests/pcie_txegress_tx.cpp; matrix orchestration: tests/dl_departure_matrix.sh. +// +// Build: g++ -std=c++20 -O2 -Isrc -Iexamples/common -Iexamples/tdma \ +// tests/dl_departure_tx.cpp examples/common/env_config.cpp build/libdevourer.a \ +// $(pkg-config --cflags --libs libusb-1.0) -lpthread -o build/dl_departure_tx +// Run: sudo DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 build/dl_departure_tx [secs] +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "RadiotapBuilder.h" +#include "SelectedChannel.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "env_config.h" +#include "logger.h" +#include "tdma.h" + +static uint64_t host_ns() { + return (uint64_t)std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +int main(int argc, char** argv) { + int secs = argc > 1 ? atoi(argv[1]) : 30; + int gap_us = 8000; + if (const char* g = std::getenv("GAP_US")) gap_us = atoi(g); + uint16_t vid = 0x0bda, pid = 0x8812; + if (const char* p = std::getenv("DEVOURER_PID")) pid = (uint16_t)strtoul(p, 0, 0); + if (const char* v = std::getenv("DEVOURER_VID")) vid = (uint16_t)strtoul(v, 0, 0); + uint8_t ch = 6; + if (const char* c = std::getenv("DEVOURER_CHANNEL")) ch = atoi(c); + + auto logger = std::make_shared(); + libusb_context* ctx = nullptr; + libusb_init(&ctx); + libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING); + auto* h = libusb_open_device_with_vid_pid(ctx, vid, pid); + if (!h) { fprintf(stderr, "open fail %04x:%04x\n", vid, pid); return 1; } + std::shared_ptr lk; + if (devourer::claim_interface_then_reset(h, 0, logger, true, lk) != 0) return 1; + WiFiDriver wifi(logger); + auto dev = wifi.CreateRtlDevice(h, ctx, lk, devourer_config_from_env()); + if (!dev) return 1; + + dev->InitWrite(SelectedChannel{ch, 0, CHANNEL_WIDTH_20}); + dev->SetCcaMode(true); // disable EDCCA — suppress CSMA backoff so the residual + // reflects the transport + MAC-pipeline floor + std::this_thread::sleep_for(std::chrono::seconds(2)); + + const auto rt = devourer::build_stream_radiotap( + devourer::parse_tx_mode_str("6M")); // legacy 6M, widely heard + uint32_t seq = 0; + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(secs); + while (std::chrono::steady_clock::now() < deadline) { + uint64_t tsf = dev->ReadTsf(); // TX-clock submit stamp + auto f = tdma::build_frame(rt, tdma::Class::Marker, seq++, 0, tsf, host_ns()); + // Re-stamp host_ns as the LAST thing before send: bytes [20..28) of the tag. + uint64_t hn = host_ns(); + for (int i = 0; i < 8; ++i) + f[f.size() - 8 + i] = (uint8_t)(hn >> (8 * i)); + dev->send_packet(f.data(), f.size()); + std::this_thread::sleep_for(std::chrono::microseconds(gap_us)); + } + fprintf(stderr, "dl_departure_tx: %u frames on ch%d (%04x:%04x)\n", seq, ch, + vid, pid); + return 0; +} diff --git a/tests/pcie_txegress_tx.cpp b/tests/pcie_txegress_tx.cpp index d54af14..235111d 100644 --- a/tests/pcie_txegress_tx.cpp +++ b/tests/pcie_txegress_tx.cpp @@ -8,8 +8,9 @@ // number is comparable), but opens the 8821CE through PcieTransport / vfio. // // Build (on a DEVOURER_PCIE=ON tree): -// g++ -std=c++20 -O2 -Isrc -Iexamples/tdma tests/pcie_txegress_tx.cpp \ -// build-pcie/libdevourer.a $(pkg-config --cflags --libs libusb-1.0) \ +// g++ -std=c++20 -O2 -DDEVOURER_HAVE_PCIE=1 -Isrc -Iexamples/tdma \ +// tests/pcie_txegress_tx.cpp build-pcie/libdevourer.a \ +// $(pkg-config --cflags --libs libusb-1.0) \ // -lpthread -o build-pcie/pcie_txegress_tx // Run: sudo DEVOURER_PCIE_BDF=0000:01:00.0 DEVOURER_CHANNEL=36 \ // build-pcie/pcie_txegress_tx [secs] @@ -50,11 +51,21 @@ int main(int argc, char **argv) { const auto rt = devourer::build_stream_radiotap( devourer::parse_tx_mode_str("6M")); // legacy 6M, widely heard + auto host_ns = [] { + return (uint64_t)std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + }; uint32_t seq = 0; auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(secs); while (std::chrono::steady_clock::now() < deadline) { uint64_t tsf = dev->ReadTsf(); // submit-time stamp (TX clock) - auto f = tdma::build_frame(rt, tdma::Class::Marker, seq++, 0, tsf); + // TD v2: host_ns re-stamped as the LAST thing before send (tag tail bytes), + // so the host-clock fit measures the send_packet call itself. + auto f = tdma::build_frame(rt, tdma::Class::Marker, seq++, 0, tsf, host_ns()); + uint64_t hn = host_ns(); + for (int i = 0; i < 8; ++i) + f[f.size() - 8 + i] = (uint8_t)(hn >> (8 * i)); dev->send_packet(f.data(), f.size()); std::this_thread::sleep_for(std::chrono::microseconds(gap_us)); } diff --git a/tests/tdma_tsf_selftest.cpp b/tests/tdma_tsf_selftest.cpp index 4c9678c..2b9863b 100644 --- a/tests/tdma_tsf_selftest.cpp +++ b/tests/tdma_tsf_selftest.cpp @@ -73,6 +73,29 @@ int main() { std::vector junk(60, 0x00); // wrong SA / no TD magic CHECK(!tdma::parse_frame(junk.data(), junk.size()).ok, "foreign frame rejected"); CHECK(!tdma::parse_frame(rt.data(), 8).ok, "short buffer rejected"); + + // v2 tag: host_ns round-trips; v1 frames parse with host_ns = 0. + { + const uint64_t txtsf = 0x1122334455667788ULL; + const uint64_t hns = 0xfedcba9876543210ULL; + auto v2 = tdma::build_frame(rt, tdma::Class::Marker, 7, 3, txtsf, hns); + CHECK(v2.size() == rt.size() + tdma::kHdrLen + tdma::kTagLenV2, + "v2 frame is 8 bytes longer"); + auto p2 = tdma::parse_frame(v2.data() + rt.size(), v2.size() - rt.size()); + CHECK(p2.ok && p2.ver == 2, "v2 parses with ver=2"); + CHECK(p2.tx_tsf == txtsf, "v2 tx_tsf round-trips"); + CHECK(p2.host_ns == hns, "v2 host_ns round-trips"); + auto v1 = tdma::build_frame(rt, tdma::Class::Marker, 7, 3, txtsf); + auto p1 = tdma::parse_frame(v1.data() + rt.size(), v1.size() - rt.size()); + CHECK(p1.ok && p1.ver == 1 && p1.host_ns == 0, + "v1 parses with ver=1, host_ns=0"); + // A truncated v2 (ver byte says 2 but only 20 tag bytes) must not read + // past the buffer — host_ns stays 0. + auto trunc = v2; + trunc.resize(rt.size() + tdma::kHdrLen + tdma::kTagLen); + auto pt = tdma::parse_frame(trunc.data() + rt.size(), trunc.size() - rt.size()); + CHECK(pt.ok && pt.host_ns == 0, "truncated v2 parses safely, host_ns=0"); + } } // --- 3. schedule phase math ----------------------------------------------- diff --git a/tests/txegress_analyze.py b/tests/txegress_analyze.py index 7a71cc8..0f8a1a8 100644 --- a/tests/txegress_analyze.py +++ b/tests/txegress_analyze.py @@ -21,7 +21,15 @@ # recovers the true transport jitter. A sane crystal ppm (~tens of ppm # or less) confirms the robust fit locked onto the real line. # -# Usage: python3 txegress_analyze.py +# The M0 DL-departure contract rides the same fit: TD v2 frames also carry +# tx_host_ns (the transmitter's steady_clock immediately before send_packet), +# and the residual TAIL of the host-clock fit is the guard a host-side slot +# scheduler must budget. --transport labels the run and --slot-us sets the +# go/no-go bound; both emit a machine-readable txeg.verdict JSONL line with +# p50/p90/p99/p99.9/max of the residuals against the robust line (guard_us = +# p99.9 - p50 over ALL frames, i.e. deferral tail included). +# +# Usage: python3 txegress_analyze.py [--transport NAME] [--slot-us N] import json, sys, math def unwrap32(xs): @@ -62,9 +70,21 @@ def robust_fit(tx, rx): idx = keep return idx +def pct(sorted_xs, p): + if not sorted_xs: + return 0.0 + k = min(len(sorted_xs) - 1, max(0, int(round(p / 100.0 * (len(sorted_xs) - 1))))) + return sorted_xs[k] + def main(): + args = sys.argv[1:] + transport, slot_us = "", None + if "--transport" in args: + i = args.index("--transport"); transport = args[i + 1]; del args[i:i + 2] + if "--slot-us" in args: + i = args.index("--slot-us"); slot_us = float(args[i + 1]); del args[i:i + 2] recs = [] - for line in open(sys.argv[1]): + for line in open(args[0]): line = line.strip() if not line.startswith("{"): continue @@ -74,12 +94,13 @@ def main(): continue recs = [r for r in recs if r.get("ev") == "txeg"][3:] # drop warm-up if len(recs) < 20: - print("too few frames (%d)" % len(recs)); return + print("too few frames (%d)" % len(recs)); return 1 rx = unwrap32([r["rx_tsfl"] for r in recs]) - for label, key in (("software (ReadTsf near send)", "tx_sw"), - ("hardware (MAC beacon egress)", "tx_hw")): - sub = [(r[key], x) for r, x in zip(recs, rx) if r.get(key, 0) > 0] + for label, key, scale in (("software (ReadTsf near send)", "tx_sw", 1.0), + ("hardware (MAC beacon egress)", "tx_hw", 1.0), + ("host (steady_clock at send)", "tx_host_ns", 1e-3)): + sub = [(r[key] * scale, x) for r, x in zip(recs, rx) if r.get(key, 0) > 0] if len(sub) < 20: print("%-30s : n=%d (skipped)" % (label, len(sub))); continue tx = [s[0] for s in sub]; rxs = [s[1] for s in sub] @@ -91,13 +112,38 @@ def main(): idx = robust_fit(tx, rxs) a, b = fit(tx, rxs, idx) fl_rms, fl_p2p = rms_p2p(resid(tx, rxs, idx, a, b)) + ppm = (a - 1.0) * 1e6 # tx pre-scaled to µs, so slope ~1 for all stamps print("%-30s : n=%4d raw RMS=%8.1f µs | floor=%7.1f µs " "(%d inliers, p2p %.1f µs) xtal %+6.1f ppm" - % (label, n, raw_rms, fl_rms, len(idx), fl_p2p, (a - 1.0) * 1e6)) + % (label, n, raw_rms, fl_rms, len(idx), fl_p2p, ppm)) + + if key == "tx_host_ns": + # The DL-departure guard: residuals of ALL frames against the robust + # line (deferral tail included — a scheduler eats deferral too), + # measured from the median so the guard is "how much later than + # nominal can a frame air". + res = sorted(resid(tx, rxs, list(range(n)), a, b)) + med = pct(res, 50) + ps = {p: pct(res, p) - med for p in (50, 90, 99, 99.9)} + guard = ps[99.9] + v = {"ev": "txeg.verdict", "transport": transport, "n": n, + "floor_rms_us": round(fl_rms, 1), + "p50_us": round(ps[50], 1), "p90_us": round(ps[90], 1), + "p99_us": round(ps[99], 1), "p999_us": round(ps[99.9], 1), + "max_us": round(res[-1] - med, 1), + "guard_us": round(guard, 1), + "xtal_ppm": round(ppm, 1)} + if slot_us is not None: + v["slot_us"] = slot_us + v["fine_dl_slots"] = bool(guard <= slot_us) + print(json.dumps(v)) print("\nfloor = the transport + MAC-pipeline submit-to-air jitter (deferral") - print("outliers rejected); raw includes channel deferral. Compare floors across") - print("transports (USB ~93 µs vs PCIe ~12 µs on this bench).") + print("outliers rejected); raw includes channel deferral. The txeg.verdict") + print("line is the M0 DL-departure contract: guard_us = host-clock p99.9") + print("residual above the median, i.e. the submission-ahead margin a slot") + print("scheduler must budget on this transport.") + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/tests/txegress_witness.cpp b/tests/txegress_witness.cpp index bd715b5..e3af2a0 100644 --- a/tests/txegress_witness.cpp +++ b/tests/txegress_witness.cpp @@ -76,10 +76,11 @@ int main(int argc, char** argv) { if (p.RxAtrib.crc_err || p.Data.size() < 32) return; // Parse the TD tag (software tx_tsf); accept only frames that carry it. tdma::Parsed td = tdma::parse_frame(p.Data.data(), p.Data.size()); - uint64_t tx_sw = 0, tx_hw = 0; + uint64_t tx_sw = 0, tx_hw = 0, tx_host = 0; static const uint8_t kSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; if (td.ok) { tx_sw = td.tx_tsf; // software-mode: the transmitter's ReadTsf() + tx_host = td.host_ns; // TD v2: TX host steady_clock at send (0 on v1) } else if (p.Data[0] == 0x80 && std::memcmp(p.Data.data() + 10, kSa, 6) == 0) { // hwbeacon-mode standard beacon (our SA): bytes 24-31 = MAC-inserted egress TSF for (int i = 0; i < 8; ++i) tx_hw |= (uint64_t)p.Data[24 + i] << (8 * i); @@ -88,9 +89,9 @@ int main(int argc, char** argv) { } ++g_n; printf("{\"ev\":\"txeg\",\"seq\":%u,\"tx_sw\":%llu,\"tx_hw\":%llu," - "\"rx_tsfl\":%u,\"host_ns\":%lld}\n", + "\"tx_host_ns\":%llu,\"rx_tsfl\":%u,\"host_ns\":%lld}\n", td.seq, (unsigned long long)tx_sw, (unsigned long long)tx_hw, - p.RxAtrib.tsfl, (long long)host_ns()); + (unsigned long long)tx_host, p.RxAtrib.tsfl, (long long)host_ns()); fflush(stdout); }; diff --git a/tests/ue_rx_attribution_check.sh b/tests/ue_rx_attribution_check.sh new file mode 100644 index 0000000..8d90e5e --- /dev/null +++ b/tests/ue_rx_attribution_check.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# ue_rx_attribution_check.sh — on-air validation of the per-UE RX attribution +# seed (src/cell/UeRxAttribution.h, M0 contract 4 of the 5G-NR RAN epic). +# +# Two transmitters with DISTINCT unicast SAs air frames at different cadences +# against one ue_rx_probe receiver. Pass = the probe's ue.rx windows attribute +# both TAs separately, with per-TA frame counts that reflect the cadence ratio +# — the per-transmitter view the device-wide GetRxQuality cannot give. +# +# sudo bash tests/ue_rx_attribution_check.sh +# TX1_PID=0x8812 TX2_PID=0xc812 RX_PID=0x012d CH=6 sudo bash tests/ue_rx_attribution_check.sh +set -uo pipefail +cd "$(dirname "$0")/.." + +TX1_VID=${TX1_VID:-0x0bda}; TX1_PID=${TX1_PID:-0x8812} # 8812AU (Jaguar1) +TX2_VID=${TX2_VID:-0x0bda}; TX2_PID=${TX2_PID:-0xc812} # 8822CU (Jaguar3) +RX_VID=${RX_VID:-0x2357}; RX_PID=${RX_PID:-0x012d} # 8812BU witness +CH=${CH:-6}; SECS=${SECS:-12} +# Distinct unicast TAs (I/G clear) — the attribution keys under test. +TA1=${TA1:-02:aa:bb:cc:dd:01} +TA2=${TA2:-02:aa:bb:cc:dd:02} +OUT=${OUT:-/tmp/ue_rx_attribution} + +cleanup(){ sudo pkill -9 -x txdemo 2>/dev/null; sudo pkill -9 -x ue_rx_probe 2>/dev/null; return 0; } +trap cleanup EXIT +mkdir -p "$OUT" + +if [ ! -x build/ue_rx_probe ] || [ tests/ue_rx_probe.cpp -nt build/ue_rx_probe ]; then + echo "== building ue_rx_probe" + g++ -std=c++20 -O2 -Isrc -Iexamples/common tests/ue_rx_probe.cpp \ + examples/common/env_config.cpp build/libdevourer.a \ + $(pkg-config --cflags --libs libusb-1.0) -lpthread -o build/ue_rx_probe +fi + +cleanup; sleep 1 + +# QoS-Data frames (DEVOURER_TX_QOS_DATA — the SA override rides that shape), +# broadcast RA so neither TX solicits ACKs from the other. +echo "== TX1 ($TX1_VID:$TX1_PID SA=$TA1, 2 ms gap)" +sudo env DEVOURER_VID=$TX1_VID DEVOURER_PID=$TX1_PID DEVOURER_CHANNEL=$CH \ + DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_SA=$TA1 \ + DEVOURER_TX_GAP_US=2000 DEVOURER_LOG_LEVEL=warn \ + ./build/txdemo >"$OUT/tx1.jsonl" 2>"$OUT/tx1.err" & +echo "== TX2 ($TX2_VID:$TX2_PID SA=$TA2, 8 ms gap)" +sudo env DEVOURER_VID=$TX2_VID DEVOURER_PID=$TX2_PID DEVOURER_CHANNEL=$CH \ + DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_SA=$TA2 \ + DEVOURER_TX_GAP_US=8000 DEVOURER_LOG_LEVEL=warn \ + ./build/txdemo >"$OUT/tx2.jsonl" 2>"$OUT/tx2.err" & +sleep 6 # TX bring-up + +echo "== probe RX ($RX_VID:$RX_PID, ${SECS}s)" +sudo env DEVOURER_VID=$RX_VID DEVOURER_PID=$RX_PID DEVOURER_CHANNEL=$CH \ + DEVOURER_LOG_LEVEL=warn \ + ./build/ue_rx_probe "$SECS" >"$OUT/probe.jsonl" 2>"$OUT/probe.err" +cleanup; sleep 1 + +echo +python3 - "$OUT/probe.jsonl" "$TA1" "$TA2" <<'PYEOF' +import sys, json, collections +path, ta1, ta2 = sys.argv[1], sys.argv[2].lower(), sys.argv[3].lower() +frames = collections.Counter(); windows = collections.Counter() +rssi = collections.defaultdict(list) +for line in open(path): + try: ev = json.loads(line) + except ValueError: continue + if ev.get("ev") != "ue.rx": continue + ta = ev["ta"].lower() + frames[ta] += ev["frames"]; windows[ta] += 1 + rssi[ta].append(ev["rssi_dbm"]) +print(f"{'TA':<20}{'frames':>8}{'windows':>9}{'rssi_dbm':>10}") +for ta, n in frames.most_common(): + mean = sum(rssi[ta]) / len(rssi[ta]) + tag = " <- TX1" if ta == ta1 else (" <- TX2" if ta == ta2 else "") + print(f"{ta:<20}{n:>8}{windows[ta]:>9}{mean:>10.1f}{tag}") +f1, f2 = frames.get(ta1, 0), frames.get(ta2, 0) +ok = True +if f1 < 100 or f2 < 25: + print(f"FAIL: expected both TAs attributed (TX1={f1}, TX2={f2})"); ok = False +elif not (1.5 <= f1 / f2 <= 12.0): + # 2 ms vs 8 ms gaps -> ~4x ratio; wide tolerance for airtime contention. + print(f"FAIL: cadence ratio implausible (TX1/TX2 = {f1/f2:.1f}, expected ~4)"); ok = False +else: + print(f"PASS: two UEs attributed separately (TX1={f1}, TX2={f2}, ratio {f1/f2:.1f})") +sys.exit(0 if ok else 1) +PYEOF diff --git a/tests/ue_rx_attribution_selftest.cpp b/tests/ue_rx_attribution_selftest.cpp new file mode 100644 index 0000000..19f2765 --- /dev/null +++ b/tests/ue_rx_attribution_selftest.cpp @@ -0,0 +1,149 @@ +// ue_rx_attribution_selftest.cpp — headless unit tests for the per-UE RX +// attribution seed (src/cell/UeRxAttribution.h): TA extraction over the frame +// types that do / don't carry addr2, multi-TA accumulation with the +// RxQualityAccumulator folding conventions, drain-and-reset semantics, and the +// bounded-table eviction accounting. No hardware — runs in ctest; the on-air +// counterpart is tests/ue_rx_attribution_check.sh. +#include +#include +#include +#include +#include + +#include "cell/UeRxAttribution.h" + +static int g_fail = 0; +#define CHECK(cond, msg) \ + do { \ + if (!(cond)) { std::printf("FAIL: %s\n", (msg)); ++g_fail; } \ + } while (0) + +// Minimal 24-byte MPDU header with the given FC byte 0 and addr2. +static std::vector mk_mpdu(uint8_t fc0, const uint8_t ta[6], + size_t len = 24) { + std::vector f(len, 0); + f[0] = fc0; + for (int i = 0; i < 6; ++i) + f[4 + i] = 0xff; // addr1 broadcast + if (len >= 16) + std::memcpy(f.data() + 10, ta, 6); + return f; +} + +int main() { + using devourer::cell::UeRxAttribution; + using devourer::cell::extract_ta; + + const uint8_t taA[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0x01}; + const uint8_t taB[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0x02}; + + // --- 1. extract_ta over frame types --------------------------------------- + { + uint8_t out[6]; + auto data = mk_mpdu(0x08, taA); // data + CHECK(extract_ta(data.data(), data.size(), out) && + std::memcmp(out, taA, 6) == 0, + "data frame yields addr2"); + auto qos = mk_mpdu(0x88, taB, 26); // QoS data + CHECK(extract_ta(qos.data(), qos.size(), out) && + std::memcmp(out, taB, 6) == 0, + "QoS data frame yields addr2"); + auto beacon = mk_mpdu(0x80, taA); + CHECK(extract_ta(beacon.data(), beacon.size(), out), "beacon yields addr2"); + auto rts = mk_mpdu(0xb4, taA, 16); // RTS: control WITH a TA + CHECK(extract_ta(rts.data(), rts.size(), out) && + std::memcmp(out, taA, 6) == 0, + "RTS yields addr2"); + auto ba = mk_mpdu(0x94, taB, 16); // BlockAck: control WITH a TA + CHECK(extract_ta(ba.data(), ba.size(), out), "BlockAck yields addr2"); + auto cts = mk_mpdu(0xc4, taA, 16); // CTS ends at addr1 + CHECK(!extract_ta(cts.data(), cts.size(), out), "CTS rejected (no addr2)"); + auto ack = mk_mpdu(0xd4, taA, 16); // ACK ends at addr1 + CHECK(!extract_ta(ack.data(), ack.size(), out), "ACK rejected (no addr2)"); + auto shortf = mk_mpdu(0x08, taA, 12); + CHECK(!extract_ta(shortf.data(), shortf.size(), out), "short frame rejected"); + const uint8_t zero[6] = {0, 0, 0, 0, 0, 0}; + auto zta = mk_mpdu(0x08, zero); + CHECK(!extract_ta(zta.data(), zta.size(), out), "zero TA rejected"); + CHECK(!extract_ta(nullptr, 24, out), "null buffer rejected"); + } + + // --- 2. multi-TA accumulation + folding conventions ----------------------- + { + UeRxAttribution attr; + // UE A: two frames, rssi 60/70 raw, snr 20/40 raw, evm only on the second. + attr.add(taA, 60, 20, 0, 1000); + attr.add(taA, 70, 40, -30, 2000); + // UE B: one CCK-ish frame (no snr/evm). + attr.add(taB, 50, 0, 0, 1500); + // Non-samples: rssi<=0 must not create or touch an entry. + attr.add(taB, 0, 10, -10, 9999); + auto s = attr.snapshot(); + CHECK(s.ues.size() == 2, "two UEs attributed"); + CHECK(s.evicted_frames == 0, "no evictions"); + const devourer::cell::UeRxWindow *a = nullptr, *b = nullptr; + for (const auto &w : s.ues) { + if (std::memcmp(w.ta.data(), taA, 6) == 0) a = &w; + if (std::memcmp(w.ta.data(), taB, 6) == 0) b = &w; + } + CHECK(a && b, "both TAs present"); + if (a) { + CHECK(a->frames == 2, "UE A frame count"); + CHECK(a->rssi_mean_dbm == 65 - 110, "UE A rssi mean (raw-110)"); + CHECK(a->rssi_max_dbm == 70 - 110, "UE A rssi max"); + CHECK(std::fabs(a->snr_mean_db - 15.0) < 1e-9, "UE A snr mean (raw/2)"); + CHECK(std::fabs(a->snr_min_db - 10.0) < 1e-9, "UE A snr min"); + CHECK(a->evm_valid && std::fabs(a->evm_mean_db + 15.0) < 1e-9, + "UE A evm folded only from the frame that carried one"); + // nf: frame1 (60-110)-10 = -60; frame2 (70-110)-20 = -60 → mean -60. + CHECK(a->nf_valid && std::fabs(a->noise_floor_dbm + 60.0) < 1e-9, + "UE A passive noise floor"); + CHECK(a->last_tsfl == 2000, "UE A last_tsfl is the newest frame"); + } + if (b) { + CHECK(b->frames == 1, "UE B frame count (rssi<=0 frame skipped)"); + CHECK(!b->evm_valid && !b->nf_valid, "UE B no snr/evm → flags false"); + CHECK(b->last_tsfl == 1500, "UE B last_tsfl untouched by the non-sample"); + } + } + + // --- 3. drain-and-reset (delta semantics) --------------------------------- + { + UeRxAttribution attr; + attr.add(taA, 60, 20, 0, 1); + auto s1 = attr.snapshot(); + CHECK(s1.ues.size() == 1, "first window has the UE"); + auto s2 = attr.snapshot(); + CHECK(s2.ues.empty() && s2.evicted_frames == 0, + "second window empty after drain"); + attr.add(taA, 80, 30, 0, 2); + auto s3 = attr.snapshot(); + CHECK(s3.ues.size() == 1 && s3.ues[0].frames == 1 && + s3.ues[0].rssi_mean_dbm == 80 - 110, + "UE reappears fresh after drain"); + } + + // --- 4. bounded table + eviction accounting ------------------------------- + { + UeRxAttribution attr(2); // cap 2 + attr.add(taA, 60, 0, 0, 1); + attr.add(taB, 60, 0, 0, 2); + const uint8_t taC[6] = {0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0x03}; + attr.add(taC, 60, 0, 0, 3); // table full → evicted + attr.add(taC, 60, 0, 0, 4); + attr.add(taA, 70, 0, 0, 5); // known TA still accumulates + auto s = attr.snapshot(); + CHECK(s.ues.size() == 2, "cap holds"); + CHECK(s.evicted_frames == 2, "evicted frames counted, not silently lost"); + // After the drain the table is empty again — taC now fits. + attr.add(taC, 60, 0, 0, 6); + auto s2 = attr.snapshot(); + CHECK(s2.ues.size() == 1 && std::memcmp(s2.ues[0].ta.data(), taC, 6) == 0, + "evicted TA admitted in the next window"); + } + + std::printf(g_fail ? "ue_rx_attribution_selftest: %d FAILURE(S)\n" + : "ue_rx_attribution_selftest: all passed\n", + g_fail); + return g_fail ? 1 : 0; +} diff --git a/tests/ue_rx_probe.cpp b/tests/ue_rx_probe.cpp new file mode 100644 index 0000000..2646d5f --- /dev/null +++ b/tests/ue_rx_probe.cpp @@ -0,0 +1,89 @@ +// ue_rx_probe.cpp — on-air probe for the per-UE RX attribution seed +// (src/cell/UeRxAttribution.h): feeds every decoded frame's TA + path-A +// RSSI/SNR/EVM into a UeRxAttribution and drains it once a second, emitting one +// `ue.rx` JSONL event per UE per window — the per-transmitter view that the +// device-wide GetRxQuality (a single draining accumulator) cannot give. +// +// Validation harness: tests/ue_rx_attribution_check.sh runs two transmitters +// with distinct unicast SAs against this probe and asserts both TAs show up +// with sane, separate frame counts. +// +// Build: g++ -std=c++20 -O2 -Isrc -Iexamples/common \ +// tests/ue_rx_probe.cpp examples/common/env_config.cpp build/libdevourer.a \ +// $(pkg-config --cflags --libs libusb-1.0) -lpthread -o build/ue_rx_probe +// Run: sudo DEVOURER_PID=0x012d DEVOURER_CHANNEL=6 build/ue_rx_probe [seconds] +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "RxPacket.h" +#include "SelectedChannel.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "cell/UeRxAttribution.h" +#include "env_config.h" +#include "logger.h" + +static devourer::cell::UeRxAttribution g_attr; +static std::atomic g_frames{0}; + +int main(int argc, char** argv) { + int secs = argc > 1 ? atoi(argv[1]) : 15; + auto logger = std::make_shared(); + libusb_context* ctx = nullptr; + libusb_init(&ctx); + libusb_set_option(ctx, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_WARNING); + + uint16_t vid = 0x0bda, pid = 0x012d; + if (const char* p = std::getenv("DEVOURER_PID")) pid = (uint16_t)strtoul(p, 0, 0); + if (const char* v = std::getenv("DEVOURER_VID")) vid = (uint16_t)strtoul(v, 0, 0); + uint8_t ch = 6; + if (const char* c = std::getenv("DEVOURER_CHANNEL")) ch = atoi(c); + + auto* h = libusb_open_device_with_vid_pid(ctx, vid, pid); + if (!h) { fprintf(stderr, "open fail %04x:%04x\n", vid, pid); return 1; } + std::shared_ptr lk; + if (devourer::claim_interface_then_reset(h, 0, logger, true, lk) != 0) return 1; + WiFiDriver wifi(logger); + auto dev = wifi.CreateRtlDevice(h, ctx, lk, devourer_config_from_env()); + if (!dev) return 1; + + auto cb = [](const Packet& p) { + if (p.RxAtrib.crc_err || p.RxAtrib.icv_err) return; + if (g_attr.add_mpdu(p.Data.data(), p.Data.size(), p.RxAtrib.rssi[0], + p.RxAtrib.snr[0], p.RxAtrib.evm[0], p.RxAtrib.tsfl)) + ++g_frames; + }; + + std::thread rx([&] { dev->Init(cb, SelectedChannel{ch, 0, CHANNEL_WIDTH_20}); }); + + for (int t = 0; t < secs; ++t) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + auto s = g_attr.snapshot(); + for (const auto& w : s.ues) { + printf("{\"ev\":\"ue.rx\",\"ta\":\"%02x:%02x:%02x:%02x:%02x:%02x\"," + "\"frames\":%u,\"rssi_dbm\":%d,\"rssi_max_dbm\":%d," + "\"snr_db\":%.1f,\"snr_min_db\":%.1f,\"evm_db\":%.1f," + "\"evm_valid\":%s,\"nf_dbm\":%.1f,\"nf_valid\":%s," + "\"last_tsfl\":%u}\n", + w.ta[0], w.ta[1], w.ta[2], w.ta[3], w.ta[4], w.ta[5], w.frames, + w.rssi_mean_dbm, w.rssi_max_dbm, w.snr_mean_db, w.snr_min_db, + w.evm_mean_db, w.evm_valid ? "true" : "false", + w.noise_floor_dbm, w.nf_valid ? "true" : "false", w.last_tsfl); + } + if (s.evicted_frames) + printf("{\"ev\":\"ue.rx.evicted\",\"frames\":%u}\n", s.evicted_frames); + fflush(stdout); + } + fprintf(stderr, "ue_rx_probe: %llu attributed frames in %d s\n", + (unsigned long long)g_frames.load(), secs); + _exit(0); +}