From 742b44f3f9b144441d3df7153fa296b92e1cf6fe Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:45:00 +0300 Subject: [PATCH 1/3] M0 contracts on Kestrel/PCIe: beacon update/stop, PPDU-status pairing fix, PCIe-capable probes Re-measuring the four M0 contracts (#261) on RTL8852CE over PCIe (epic #254 rig) found three Kestrel gaps and one RX bug: - UpdateBeaconPayload was not implemented: now the BCN_UPD_V1 H2C with new content, port and TBTT untouched. 30/30 updates aired, 0 skips, 0 torn, update->air 13/16 ms p50/p99. - StopBeacon was not implemented, so a beacon kept airing after its process exited: now BCNTX_EN off (vendor MAC_AX_PCFG_TX_SW, rtw_hal_beacon_stop). - StartBeacon did not strip a leading radiotap header (IRadio contract), so a radiotap-prefixed beacon aired as a garbage frame. - RX: each PPDU-status report was attached to the WIFI frames that FOLLOWED it, but the MAC delivers a PPDU's frames first and its status after (traced: same ppdu_cnt, next RX buffer, ~140 us later), so every frame carried the previous PPDU's RSSI/SNR/EVM. Frames are now held until their status arrives (vendor phl_rx.c pairing: ppdu_cnt + rate/bw/gi/type), delivered without one on the next PPDU, after 2 ms idle, or at loop exit. This fixes per-UE attribution, GetRxQuality and GetActiveRxPaths on Kestrel. Attenuating one UE by 10 dB nominal now moves only that UE. The M0 probes (dl_departure_tx, txegress_witness, beacon_update_probe, ue_rx_probe) open USB or PCIe through examples/common/radio_open.h (DEVOURER_PCIE_BDF) and are CMake targets; dl_departure_tx takes DL_CSMA=1. docs/scheduled-mac.md gets the Kestrel rows: DL guard floor 1.3-2.0 us, p99.9 0.46 ms on a clean channel / 2.7 ms next to a foreign AP; the 8852CE ACK responder closes 1036/1036 first-try (9 copies unarmed). Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 12 +++ docs/scheduled-mac.md | 51 ++++++++++- examples/common/radio_open.h | 75 ++++++++++++++++ src/kestrel/HalKestrel.cpp | 12 +++ src/kestrel/HalKestrel.h | 7 ++ src/kestrel/RtlKestrelDevice.cpp | 143 +++++++++++++++++++++++++------ src/kestrel/RtlKestrelDevice.h | 9 +- tests/beacon_update_probe.cpp | 18 +--- tests/dl_departure_tx.cpp | 36 +++----- tests/txegress_witness.cpp | 18 +--- tests/ue_rx_probe.cpp | 18 +--- 11 files changed, 298 insertions(+), 101 deletions(-) create mode 100644 examples/common/radio_open.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 5b0f0858..d3552fa4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -652,6 +652,18 @@ if(DEVOURER_PCIE) ) target_link_libraries(beacon_phase_observer PUBLIC devourer) endif() +# M0 contract probes (docs/scheduled-mac.md), USB or PCIe through +# examples/common/radio_open.h (DEVOURER_PCIE_BDF selects the vfio radio). +foreach(m0probe dl_departure_tx txegress_witness beacon_update_probe ue_rx_probe) + add_executable(${m0probe} + tests/${m0probe}.cpp + examples/common/env_config.cpp + ) + target_include_directories(${m0probe} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/examples/common + ${CMAKE_CURRENT_SOURCE_DIR}/examples/tdma) + target_link_libraries(${m0probe} PUBLIC devourer) +endforeach() # kestrelprobe — staged USB bring-up prober for the Kestrel (RTL8852B/C): # AX identity (die-id/cut) -> power-on + EFUSE -> firmware download, the diff --git a/docs/scheduled-mac.md b/docs/scheduled-mac.md index b5bde4a2..3f21b0c8 100644 --- a/docs/scheduled-mac.md +++ b/docs/scheduled-mac.md @@ -47,6 +47,19 @@ the PCIe cell ships the probe to the remote rig, vfio-binds, runs, restores). | 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 | +| Kestrel 8852CE (PCIe, vfio), ch36 | 2.0 µs | 4.1 µs | 0.25 ms | 2.7 ms | 5.1 ms | +| Kestrel 8852CE (PCIe, vfio), ch149 | 1.3 µs | 2.3 µs | 34 µs | 0.46 ms | 4.8 ms | + +The Kestrel rows are the epic-254 rig (TX and an 8852CE witness in two +J6412 boxes, ~2400 frames per cell, `DEVOURER_PCIE_BDF` through +`examples/common/radio_open.h`). ch36 carries a foreign AP (two BSSIDs at +~−83 dBm); ch149 had no decodable traffic in a channel survey. On ch36 about +60% of the frames later than 50 µs fell in two 10 ms bins of a 102.4 ms +period, i.e. phase-locked to that AP's beacons — even with carrier sense off +(`SetCcaMode(true)`). Keeping carrier sense on (`DL_CSMA=1`) did not change the +picture on either channel (ch149: p99 41 µs, p99.9 1.6 ms, max 4.3 ms); the +few ms-class frames remain on the clean channel and their cause is not +established. 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 @@ -109,6 +122,13 @@ separating update-caused missing slots from background witness loss. | 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 | +| Kestrel 8852CE (PCIe), ch149 | 30/30 | 0.0 | 0 | 0 | 13 ms | 16 ms | + +Kestrel row: TX and witness are the two 8852CEs of one J6412 (shared host +clock). The update is the same `BCN_UPD_V1` H2C `StartBeacon` sends, with no +port or TBTT reprogramming. The probe's updates come at a fixed spacing of 10 +intervals, so their phase against the TBTT barely moves and the latency here +samples one point of the grid: every update aired on the next beacon. ### The contract @@ -294,9 +314,14 @@ nonzero limit for absolute numbers; 8821AU row re-measured ch6): | 8812EU | 98% | 0% | works | | 8812CU | 69% | 0% | works | | 8733B | unmeasured | 0% | works (closed-loop 1725/1725 at retry 12; single-shot cell never run) | -| 8852CU (Kestrel) | 0% | 0% | not implemented on the AX generation | - -Unmeasured for lack of plugged hardware: 8821CU / PCIe 8821CE (recipe-shared +| 8852CU (Kestrel) | 0% | 0% | measured before the AX responder existed; see [kestrel-ack-responder.md](kestrel-ack-responder.md) | +| 8852CE (Kestrel, PCIe) | 100% (closed loop, retry 8) | 0% (9 copies) | works | + +8852CE row (epic-254 rig, ch149, MCS3, 200-byte QoS-Data at a 20 ms gap): Kestrel has +no CCX report, so a third 8852CE counted on-air copies per payload counter +(the `tests/kestrel_retry_witness.sh` method). Responder armed: 1036/1036 +frames aired once. Not armed: 184 of 185 aired 9 times (limit 8 = 9 +attempts). Unmeasured for lack of plugged hardware: 8821CU / PCIe 8821CE (recipe-shared with the 8822B; their `AdapterCaps.ack_responder_ok` stays false-as-unmeasured until a cell runs). The `ack_responder_ok` / `tx_retry_limit_ok` caps flags carry this table per die. @@ -401,6 +426,26 @@ 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. +**Kestrel (8852CE PCIe DU, two 8852CE UEs, ch149, 10 ms / 40 ms gaps):** the +counts separate (ratio 3.90 against 4), but the first runs showed the signal +statistics *did* bleed. With UE2 attenuated by `DEVOURER_TX_PWR_OFFSET_QDB=-40`, +UE1's window read −58.6 dBm / 32.7 dB SNR and UE2's −55.0 dBm / 38.2 dB, the +opposite of the change made. Cause: the Kestrel RX loop attached each PPDU-status +report to the WIFI frames that followed it, but the MAC delivers a PPDU's +frames *first* and its status after them (traced on the 8852CE: the status +with the same `ppdu_cnt` arrives in the next RX buffer, ~140 µs later). So +every frame carried the previous PPDU's RSSI/SNR/EVM. The RX loop now pairs them +the vendor way (`phl_rx.c` `sts_ent` queues): it holds the PPDU's frames until the status +with the same `ppdu_cnt`, rate, bandwidth, GI/LTF and PPDU type arrives. Frames whose +status never comes are delivered without one (RSSI 0 = no sample) on the next +PPDU, after 2 ms idle, or at loop exit. After the fix, the same sweep reads +UE1 −55.0 dBm / 38 dB in every arm and UE2 −55.0 → −71.5 dBm (SNR 38.9 → 13.4 +dB) at −40 qdB. At −80 qdB UE2 was not decoded at all. Two observations are not +explained: the drop is 16.5 dB for a nominal 10 dB offset, and both full-power UEs read +exactly −55 dBm at desk range (possibly a reporting ceiling). The same fix +corrects `GetRxQuality` and `GetActiveRxPaths` on Kestrel, which folded the +same misattributed values. + ### Tooling - `tests/ue_rx_probe.cpp` — on-air probe: feeds every decoded frame into a diff --git a/examples/common/radio_open.h b/examples/common/radio_open.h new file mode 100644 index 00000000..33aff3e7 --- /dev/null +++ b/examples/common/radio_open.h @@ -0,0 +1,75 @@ +// radio_open.h — open the radio a bench tool names in its environment, over +// USB or PCIe, so one probe binary serves both transports: +// DEVOURER_PCIE_BDF=0000:05:00.0 -> vfio PCIe (DEVOURER_PCIE=ON builds; the +// device bound by tests/pcie_vfio_bind.sh) +// otherwise -> USB by DEVOURER_VID / DEVOURER_PID, with +// the tool's own defaults. +// The radio gets devourer_config_from_env() either way. The USB context and +// handle are deliberately never released: these are one-shot bench tools that +// end in _exit/return from main, so the OS reclaims them. +#ifndef DEVOURER_RADIO_OPEN_H +#define DEVOURER_RADIO_OPEN_H + +#include +#include +#include +#include +#include + +#include + +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "env_config.h" +#include "logger.h" +#if defined(DEVOURER_HAVE_PCIE) +#include "PcieTransport.h" +#endif + +struct OpenedRadio { + std::unique_ptr dev; /* null on failure (the reason is on stderr) */ + std::string label; /* "pcie 0000:05:00.0" / "usb 0bda:8812" */ +}; + +inline OpenedRadio open_radio_from_env(const std::shared_ptr &logger, + uint16_t def_vid, uint16_t def_pid) { + OpenedRadio r; + if (const char *bdf = std::getenv("DEVOURER_PCIE_BDF")) { +#if defined(DEVOURER_HAVE_PCIE) + r.label = std::string("pcie ") + bdf; + auto t = devourer::PcieTransport::Open(bdf, logger); + if (!t) { + fprintf(stderr, "pcie open failed for %s (vfio-bound?)\n", bdf); + return r; + } + WiFiDriver wifi(logger); + r.dev = wifi.CreateRadioPcie(std::move(t), devourer_config_from_env()); + if (!r.dev) fprintf(stderr, "no driver for the PCIe chip at %s\n", bdf); +#else + fprintf(stderr, "DEVOURER_PCIE_BDF=%s but this build has no PCIe " + "(configure with -DDEVOURER_PCIE=ON)\n", bdf); +#endif + return r; + } + uint16_t vid = def_vid, pid = def_pid; + 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); + char buf[24]; + snprintf(buf, sizeof buf, "usb %04x:%04x", vid, pid); + r.label = buf; + libusb_context *ctx = nullptr; + if (libusb_init(&ctx) < 0) { fprintf(stderr, "libusb_init failed\n"); return r; } + 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 r; } + std::shared_ptr lk; + if (devourer::claim_interface_then_reset(h, devourer::find_wifi_interface(h), + logger, true, lk) != 0) + return r; + WiFiDriver wifi(logger); + r.dev = wifi.CreateRadio(h, ctx, lk, devourer_config_from_env()); + if (!r.dev) fprintf(stderr, "no driver for %04x:%04x\n", vid, pid); + return r; +} + +#endif /* DEVOURER_RADIO_OPEN_H */ diff --git a/src/kestrel/HalKestrel.cpp b/src/kestrel/HalKestrel.cpp index b66bfb5c..08d330b2 100644 --- a/src/kestrel/HalKestrel.cpp +++ b/src/kestrel/HalKestrel.cpp @@ -1749,6 +1749,18 @@ bool HalKestrel::start_beacon(const uint8_t *body, uint32_t len, return true; } +bool HalKestrel::update_beacon(const uint8_t *body, uint32_t len, + uint16_t rate_ax) { + return _fw.fw_send_beacon(body, len, /*macid=*/0, rate_ax, /*ntx_path_en=*/1, + /*path_map_a=*/0); +} + +bool HalKestrel::stop_beacon() { + namespace r = kestrel::reg; + const uint32_t p = _device.rtw_read32(r::R_AX_PORT_CFG_P0); + return _device.rtw_write32(r::R_AX_PORT_CFG_P0, p & ~r::B_AX_BCNTX_EN_P0); +} + void HalKestrel::mac_enable_imr() { /* mac_enable_imr(DMAC)+(CMAC0) + mac_err_imr_ctrl(EN): the System-Error- * Recovery interrupt masks (ser_imr_config_8852b auto-gen). Each entry does diff --git a/src/kestrel/HalKestrel.h b/src/kestrel/HalKestrel.h index 570f4026..4bcf865b 100644 --- a/src/kestrel/HalKestrel.h +++ b/src/kestrel/HalKestrel.h @@ -138,6 +138,13 @@ class HalKestrel { * datarate for the beacon (e.g. MAC_AX_OFDM6). */ bool start_beacon(const uint8_t *body, uint32_t len, uint16_t interval_tu, uint8_t bss_color, uint16_t rate_ax); + /* Replace the armed beacon's content: the same BCN_UPD_V1 H2C as + * start_beacon, without touching the port or the TBTT timing. */ + bool update_beacon(const uint8_t *body, uint32_t len, uint16_t rate_ax); + /* Stop the HW beacon: clear BCNTX_EN in PORT_CFG_P0 (mac_port_cfg + * MAC_AX_PCFG_TX_SW = 0, the vendor rtw_hal_beacon_stop path). The port, + * its NET_TYPE and the TSF keep running. */ + bool stop_beacon(); /* PHY bring-up: apply the halbb BB register + gain tables and the * halrf radio-A/B tables (via the vendored halbb/halrf loaders). `rfe_type` diff --git a/src/kestrel/RtlKestrelDevice.cpp b/src/kestrel/RtlKestrelDevice.cpp index 5bbca174..f95a562d 100644 --- a/src/kestrel/RtlKestrelDevice.cpp +++ b/src/kestrel/RtlKestrelDevice.cpp @@ -468,6 +468,55 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b const uint16_t drv_info_unit = _variant == kestrel::ChipVariant::C8852C ? 16 : 8; long long parse_aborts = 0; + + /* PPDU-status pairing (vendor phl_rx.c, the psts_info sts_ent queues): the + * MAC delivers a PPDU's WIFI frame(s) FIRST and its PPDU-status report + * after them, in a later RX buffer (measured on an RTL8852CE: ~140 us + * later). The frames of the PPDU in flight are held (copied — the DMA + * buffer is recycled) until the status with the same ppdu_cnt arrives, then + * delivered carrying it. The vendor's sanity match (rate/bw/gi_ltf/ppdu_type) + * guards against a lost status pairing with a later PPDU of the same 3-bit + * count. Held frames go out WITHOUT a status (rssi/snr/evm 0 = no + * measurement, the convention every consumer already skips) when a frame of + * another PPDU or a non-matching status arrives, after kHoldMax of RX idle, + * and when the loop exits — so no frame is ever dropped or reordered. */ + struct HeldRx { + std::vector bytes; + Packet p; + }; + std::vector held; + kestrel::KestrelRxFrame held_hdr; /* the held PPDU's descriptor fields */ + std::chrono::steady_clock::time_point held_since{}; + constexpr auto kHoldMax = std::chrono::milliseconds(2); + auto deliver_held = [&](const kestrel::KestrelPhySts *ps) { + for (HeldRx &h : held) { + Packet &p = h.p; + for (int i = 0; i < 4; i++) { + p.RxAtrib.rssi[i] = ps ? ps->rssi[i] : 0; + p.RxAtrib.snr[i] = ps ? ps->snr[i] : 0; + p.RxAtrib.evm[i] = ps ? ps->evm[i] : 0; + } + /* Window aggregates fold CRC-clean frames only (the Jaguar + * convention): a garbled frame's status would bias the means and the + * active-chain classification. + * + * _rxq: passive rssi-snr floor + LinkHealth, fed path-A RSSI + + * the IE01 average SNR (the all-paths quantity the passive floor + * was validated against; per-path SNR goes to _rxpaths instead). + * A frame with no IE_01 SNR (snr_avg=0) still counts toward RSSI. + * _rxpaths: per-antenna window means (GetActiveRxPaths). Both + * dies are 2 RX chains; C/D read 0 and are excluded by n_chains. */ + if (ps && !p.RxAtrib.crc_err) { + if (ps->rssi[0] > 0) + _rxq.add(ps->rssi[0], ps->snr_avg, 0); + _rxpaths.add(p.RxAtrib.rssi, p.RxAtrib.snr, p.RxAtrib.evm, 2); + } + p.Data = std::span(h.bytes.data(), h.bytes.size()); + if (packetProcessor) + packetProcessor(p); + } + held.clear(); + }; _rx_running = true; struct RunningGuard { std::atomic &f; @@ -498,10 +547,16 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b * kestrel_halbb_rx_bringup + the R_AX_PPDU_STAT no-APP-prepend * config in bb_reset_all) — same header, same IE walk. */ kestrel::KestrelPhySts ps; - if (kestrel::parse_physts_8852( - f.payload, f.payload_len, - _variant == kestrel::ChipVariant::C8852C, ps)) - _last_physts = ps; + const bool ps_ok = kestrel::parse_physts_8852( + f.payload, f.payload_len, + _variant == kestrel::ChipVariant::C8852C, ps); + if (!held.empty()) { + const bool same_ppdu = + f.ppdu_cnt == held_hdr.ppdu_cnt && f.rx_rate == held_hdr.rx_rate && + f.bw == held_hdr.bw && f.gi_ltf == held_hdr.gi_ltf && + f.ppdu_type == held_hdr.ppdu_type; + deliver_held(same_ppdu && ps_ok ? &ps : nullptr); + } /* Trace: raw physts blobs (first few) — the IE-page ground truth * when a per-path metric reads 0 (is the page absent, or zero?). */ static int physts_dumped = 0; @@ -518,7 +573,10 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b _logger->trace("Kestrel physts[{}B]: {}", f.payload_len, hex); } } else if (f.rpkt_type == kestrel::RPKT_TYPE_WIFI && packetProcessor) { - Packet p{}; + if (!held.empty() && f.ppdu_cnt != held_hdr.ppdu_cnt) + deliver_held(nullptr); /* that PPDU's status never came */ + HeldRx h; + Packet &p = h.p; p.RxAtrib.pkt_len = static_cast(f.payload_len); p.RxAtrib.crc_err = f.crc_err; p.RxAtrib.icv_err = f.icv_err; @@ -527,29 +585,12 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b p.RxAtrib.ppdu_type = f.ppdu_type; /* 7=HE_SU 8=HE_ERSU */ p.RxAtrib.ppdu_cnt = f.ppdu_cnt; p.RxAtrib.tsfl = f.freerun_cnt; - for (int i = 0; i < 4; i++) { - p.RxAtrib.rssi[i] = _last_physts.rssi[i]; - p.RxAtrib.snr[i] = _last_physts.snr[i]; - p.RxAtrib.evm[i] = _last_physts.evm[i]; - } - /* Window aggregates fold CRC-clean frames only (the Jaguar - * convention): a garbled frame's cached physts would bias the - * means and the active-chain classification. - * - * _rxq: passive rssi-snr floor + LinkHealth, fed path-A RSSI + - * the IE01 average SNR (the all-paths quantity the passive floor - * was validated against; per-path SNR goes to _rxpaths instead). - * A frame with no IE_01 SNR (snr_avg=0) still counts toward RSSI. - * _rxpaths: per-antenna window means (GetActiveRxPaths). Both - * dies are 2 RX chains; C/D read 0 and are excluded by n_chains. */ - if (!f.crc_err) { - if (_last_physts.rssi[0] > 0) - _rxq.add(_last_physts.rssi[0], _last_physts.snr_avg, 0); - _rxpaths.add(p.RxAtrib.rssi, p.RxAtrib.snr, p.RxAtrib.evm, 2); + h.bytes.assign(f.payload, f.payload + f.payload_len); + if (held.empty()) { + held_hdr = f; + held_since = std::chrono::steady_clock::now(); } - p.Data = std::span(const_cast(f.payload), - f.payload_len); - packetProcessor(p); + held.push_back(std::move(h)); } else if (f.rpkt_type == kestrel::RPKT_TYPE_C2H) { handle_c2h(f.payload, f.payload_len); } @@ -561,7 +602,16 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b /* Stop on StopRxLoop() or the demos' SIGINT/SIGTERM flag — the same * signal-flag pattern every generation's RX loop honours (without it the * harness's `timeout` SIGTERM never unblocks the loop). */ - [this]() -> bool { return _rx_stop || g_devourer_should_stop; }); + [&]() -> bool { + /* Polled every loop pass on the RX thread (PCIe: each idle poll; + * USB: each <=100 ms event wait) — bounds a held frame's wait when + * its status never arrives and nothing else is received. */ + if (!held.empty() && + std::chrono::steady_clock::now() - held_since > kHoldMax) + deliver_held(nullptr); + return _rx_stop || g_devourer_should_stop; + }); + deliver_held(nullptr); } void RtlKestrelDevice::SetMonitorChannel(SelectedChannel channel) { @@ -1071,6 +1121,16 @@ uint64_t RtlKestrelDevice::ReadTsf() { return (static_cast(hi) << 32) | lo; } +/* The IRadio beacon buffer contract: a leading radiotap header is stripped. + * A beacon MPDU starts with FC byte 0x80; a radiotap header with version 0 and + * its little-endian it_len at bytes 2..3. */ +static size_t kestrel_beacon_radiotap_len(const uint8_t *b, size_t len) { + if (len < 8 || b[0] != 0) + return 0; + const size_t rt = static_cast(b[2] | (b[3] << 8)); + return (rt >= 8 && rt < len) ? rt : 0; +} + bool RtlKestrelDevice::StartBeacon(const uint8_t *beacon, size_t len, int interval_tu) { if (!_tx_up) { @@ -1081,6 +1141,9 @@ bool RtlKestrelDevice::StartBeacon(const uint8_t *beacon, size_t len, _logger->error("Kestrel: StartBeacon empty beacon"); return false; } + const size_t rt = kestrel_beacon_radiotap_len(beacon, len); + beacon += rt; + len -= rt; const uint16_t iv = interval_tu > 0 ? static_cast(interval_tu) : 100; /* OFDM 6M beacon (MAC_AX_OFDM6). bss_color 0 (no HE-BSS coloring). */ const bool ok = _hal.start_beacon(beacon, static_cast(len), iv, @@ -1092,6 +1155,30 @@ bool RtlKestrelDevice::StartBeacon(const uint8_t *beacon, size_t len, return ok; } +bool RtlKestrelDevice::UpdateBeaconPayload(const uint8_t *beacon, size_t len) { + if (_bcn_interval_tu <= 0) { + _logger->error("Kestrel: UpdateBeaconPayload without an active beacon"); + return false; + } + if (beacon == nullptr || len == 0) + return false; + const size_t rt = kestrel_beacon_radiotap_len(beacon, len); + return _hal.update_beacon(beacon + rt, static_cast(len - rt), + kestrel::reg::MAC_AX_OFDM6); +} + +bool RtlKestrelDevice::StopBeacon() { + if (_bcn_interval_tu <= 0) + return false; + if (!_hal.stop_beacon()) { + _logger->error("Kestrel: StopBeacon register write failed"); + return false; + } + _bcn_interval_tu = 0; + _logger->info("Kestrel: HW beacon stopped (BCNTX_EN off)"); + return true; +} + bool RtlKestrelDevice::SetBeaconBackoff(bool random) { if (!_tx_up) { _logger->error("Kestrel: SetBeaconBackoff before InitWrite"); diff --git a/src/kestrel/RtlKestrelDevice.h b/src/kestrel/RtlKestrelDevice.h index c4f1b5c3..b72f8fcd 100644 --- a/src/kestrel/RtlKestrelDevice.h +++ b/src/kestrel/RtlKestrelDevice.h @@ -215,6 +215,10 @@ class RtlKestrelDevice : public IRtlRadio { * prior InitWrite. `beacon` is a full 802.11 beacon; the MAC airs it every * `interval_tu` TU with the live TSF inserted. */ bool StartBeacon(const uint8_t *beacon, size_t len, int interval_tu) override; + /* New content for the armed beacon (BCN_UPD H2C; port/TBTT untouched). */ + bool UpdateBeaconPayload(const uint8_t *beacon, size_t len) override; + /* BCNTX_EN off; returns false when no beacon is armed. */ + bool StopBeacon() override; int _bcn_interval_tu = 0; /* >0 while a HW beacon is armed (StartBeacon) */ /* 802.11ax scheduled UL: air an HE Basic Trigger (UL-OFDMA grant), program @@ -391,11 +395,6 @@ class RtlKestrelDevice : public IRtlRadio { * (a fixed-rate stream pays once and then nothing). */ std::atomic _rate_diffs_on{false}; std::atomic _rate_diff_qdb[10]{}; - /* Per-path RSSI/SNR/EVM (rx_pkt_attrib raw conventions) parsed from the last - * PPDU-status physts blob (kestrel::parse_physts_8852), attached to the - * following WIFI frame(s) in the aggregate. snr_avg (raw dB*2, IE01) feeds - * the passive noise floor (rssi_dbm - snr_db). 0 = not measured. */ - kestrel::KestrelPhySts _last_physts{}; /* Windowed RX link-quality accumulator (passive noise floor + LinkHealth), * fed per decoded frame from the RX loop; drained by GetRxQuality. */ devourer::RxQualityAccumulator _rxq; diff --git a/tests/beacon_update_probe.cpp b/tests/beacon_update_probe.cpp index e0b8a7c5..7500df77 100644 --- a/tests/beacon_update_probe.cpp +++ b/tests/beacon_update_probe.cpp @@ -39,6 +39,7 @@ #include "UsbOpen.h" #include "WiFiDriver.h" #include "env_config.h" +#include "radio_open.h" #include "logger.h" static const uint8_t kSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; @@ -128,20 +129,9 @@ int main(int argc, char** argv) { 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, devourer::find_wifi_interface(h), logger, true, lock) != 0) - return 1; - WiFiDriver wifi(logger); - auto dev = wifi.CreateRadio(h, ctx, lock, devourer_config_from_env()); - if (!dev) { fprintf(stderr, "no driver\n"); return 1; } + OpenedRadio radio = open_radio_from_env(logger, 0x0bda, 0); + if (!radio.dev) return 1; + IRadio* dev = radio.dev.get(); if (rx_mode) { int secs = argc > 1 ? atoi(argv[1]) : 60; diff --git a/tests/dl_departure_tx.cpp b/tests/dl_departure_tx.cpp index 8cb0df3a..039d9ded 100644 --- a/tests/dl_departure_tx.cpp +++ b/tests/dl_departure_tx.cpp @@ -1,5 +1,5 @@ // 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 — +// v2 data frames over ANY USB or PCIe adapter (radio_open.h), 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). @@ -13,6 +13,8 @@ // 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] +// sudo DEVOURER_PCIE_BDF=0000:05:00.0 DEVOURER_CHANNEL=36 build/dl_departure_tx +// DL_CSMA=1 keeps the MAC's carrier sense (default: SetCcaMode(true), off). #include #include #include @@ -21,14 +23,10 @@ #include #include -#include - #include "RadiotapBuilder.h" #include "SelectedChannel.h" -#include "UsbOpen.h" -#include "WiFiDriver.h" -#include "env_config.h" #include "logger.h" +#include "radio_open.h" #include "tdma.h" static uint64_t host_ns() { @@ -41,27 +39,19 @@ 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, devourer::find_wifi_interface(h), logger, true, lk) != 0) return 1; - WiFiDriver wifi(logger); - auto dev = wifi.CreateRadio(h, ctx, lk, devourer_config_from_env()); - if (!dev) return 1; + OpenedRadio radio = open_radio_from_env(logger, 0x0bda, 0x8812); + if (!radio.dev) return 1; + IRadio* dev = radio.dev.get(); + const bool csma = std::getenv("DL_CSMA") && std::strcmp(std::getenv("DL_CSMA"), "1") == 0; 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 + if (!csma) + 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( @@ -78,7 +68,7 @@ int main(int argc, char** argv) { 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); + fprintf(stderr, "dl_departure_tx: %u frames on ch%d (%s, csma %s)\n", seq, ch, + radio.label.c_str(), csma ? "on" : "off"); return 0; } diff --git a/tests/txegress_witness.cpp b/tests/txegress_witness.cpp index 0c5c27da..4bf39869 100644 --- a/tests/txegress_witness.cpp +++ b/tests/txegress_witness.cpp @@ -39,6 +39,7 @@ #include "UsbOpen.h" #include "WiFiDriver.h" #include "env_config.h" +#include "radio_open.h" #include "logger.h" #include "tdma.h" @@ -54,23 +55,12 @@ static int64_t host_ns() { int main(int argc, char** argv) { if (argc > 1) g_want = atoi(argv[1]); 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, devourer::find_wifi_interface(h), logger, true, lk) != 0) return 1; - WiFiDriver wifi(logger); - auto dev = wifi.CreateRadio(h, ctx, lk, devourer_config_from_env()); - if (!dev) return 1; + OpenedRadio radio = open_radio_from_env(logger, 0x0bda, 0x012d); + if (!radio.dev) return 1; + IRadio* dev = radio.dev.get(); auto cb = [](const Packet& p) { if (p.RxAtrib.crc_err || p.Data.size() < 32) return; diff --git a/tests/ue_rx_probe.cpp b/tests/ue_rx_probe.cpp index 1ab28818..093957a9 100644 --- a/tests/ue_rx_probe.cpp +++ b/tests/ue_rx_probe.cpp @@ -30,6 +30,7 @@ #include "WiFiDriver.h" #include "cell/UeRxAttribution.h" #include "env_config.h" +#include "radio_open.h" #include "logger.h" static devourer::cell::UeRxAttribution g_attr; @@ -38,23 +39,12 @@ 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, devourer::find_wifi_interface(h), logger, true, lk) != 0) return 1; - WiFiDriver wifi(logger); - auto dev = wifi.CreateRadio(h, ctx, lk, devourer_config_from_env()); - if (!dev) return 1; + OpenedRadio radio = open_radio_from_env(logger, 0x0bda, 0x012d); + if (!radio.dev) return 1; + IRadio* dev = radio.dev.get(); auto cb = [](const Packet& p) { if (p.RxAtrib.crc_err || p.RxAtrib.icv_err) return; From 2fe7abd6db04877e66e1879fffefdbb9af25e266 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:02:00 +0300 Subject: [PATCH 2/3] kestrel RX pairing + beacon stop: address Qodo review on #515 - HeldRx value-initializes its Packet (Packet p{}): HeldRx h; left rx_pkt_attrib's plain fields (pkt_rpt_type, seq_num, ...) indeterminate. - A WIFI frame joins the held batch only if same_ppdu() (ppdu_cnt + rate, bw, gi_ltf, ppdu_type), the same test the status pairing uses: the 3-bit count wraps. - held_mu: on USB's spsc RX mode on_data runs on a consumer thread while the stop predicate's idle flush runs on the event thread. - ITransport::set_rx_max_wait_us (USB: caps the 100 ms event wait / 200 ms sync read): the Kestrel RX loop sets 1 ms, so the 2 ms hold bound holds on USB too; reset on loop exit. - StopBeacon also returns NET_TYPE to NO_LINK (IRadio StopBeacon contract). - beacon_radiotap_len and same_ppdu move to FrameParserKestrel.h with kestrel_rxparse selftest cases. - The M0 probes are POSIX bench tools: not built on Windows (unistd.h). Re-verified on RTL8852CE: ctest 79/79 on all three rig boxes; attenuated UE reads -71.2 dBm vs -55.0 (no bleed); beacon update 30/30, 0 skips; 0 beacons after StopBeacon; beacon egress 275/276 at 22 us. Co-Authored-By: Claude Opus 5.5 (1M context) --- CMakeLists.txt | 3 ++ src/RtlAdapter.h | 1 + src/Transport.h | 5 +++ src/UsbTransport.cpp | 7 +++-- src/UsbTransport.h | 2 ++ src/kestrel/FrameParserKestrel.h | 20 ++++++++++++ src/kestrel/HalKestrel.cpp | 10 ++++-- src/kestrel/HalKestrel.h | 4 +-- src/kestrel/RtlKestrelDevice.cpp | 50 ++++++++++++++---------------- tests/kestrel_rxparse_selftest.cpp | 42 +++++++++++++++++++++++++ 10 files changed, 112 insertions(+), 32 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index d3552fa4..1b89399c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -654,6 +654,8 @@ if(DEVOURER_PCIE) endif() # M0 contract probes (docs/scheduled-mac.md), USB or PCIe through # examples/common/radio_open.h (DEVOURER_PCIE_BDF selects the vfio radio). +# POSIX bench tools (unistd.h / _exit), so not on Windows. +if(NOT WIN32) foreach(m0probe dl_departure_tx txegress_witness beacon_update_probe ue_rx_probe) add_executable(${m0probe} tests/${m0probe}.cpp @@ -664,6 +666,7 @@ foreach(m0probe dl_departure_tx txegress_witness beacon_update_probe ue_rx_probe ${CMAKE_CURRENT_SOURCE_DIR}/examples/tdma) target_link_libraries(${m0probe} PUBLIC devourer) endforeach() +endif() # kestrelprobe — staged USB bring-up prober for the Kestrel (RTL8852B/C): # AX identity (die-id/cut) -> power-on + EFUSE -> firmware download, the diff --git a/src/RtlAdapter.h b/src/RtlAdapter.h index 13e17b85..6f289a3a 100644 --- a/src/RtlAdapter.h +++ b/src/RtlAdapter.h @@ -90,6 +90,7 @@ class RtlAdapter { void hci_setup() { _transport->hci_setup(); } /* PCIe LTR permission (ITransport::ltr_allowed); true on USB. */ bool ltr_allowed() { return _transport->ltr_allowed(); } + void set_rx_max_wait_us(unsigned us) { _transport->set_rx_max_wait_us(us); } /* Pipelined register writes — see ITransport::write_batch_begin. */ void write_batch_begin() { _transport->write_batch_begin(); } bool write_batch_end() { return _transport->write_batch_end(); } diff --git a/src/Transport.h b/src/Transport.h index a6a7bd92..96d1a478 100644 --- a/src/Transport.h +++ b/src/Transport.h @@ -119,6 +119,11 @@ class ITransport { (void)buf; (void)len; (void)timeout_ms; return -1; } + /* Cap on how long rx_loop may wait for data between should_stop() polls + * (us; 0 = the transport default). A caller whose stop predicate does + * time-based work (Kestrel's held-frame flush) sets it; PCIe already polls + * every few hundred us, so only USB implements it. */ + virtual void set_rx_max_wait_us(unsigned us) { (void)us; } virtual void clear_halt(uint8_t ep) { (void)ep; } /* Stop the TX engine and return only once nothing is in flight: cancel every * outstanding transfer, reap the completions, and refuse further sends. The diff --git a/src/UsbTransport.cpp b/src/UsbTransport.cpp index dd4b9aff..aa71d06e 100644 --- a/src/UsbTransport.cpp +++ b/src/UsbTransport.cpp @@ -873,7 +873,8 @@ void UsbTransport::rx_loop( auto last_ring = std::chrono::steady_clock::now(); while (!should_stop() && sh.active > 0 && !sh.dead.load(std::memory_order_relaxed)) { - struct timeval tv {0, 100000}; + const unsigned cap = _rx_max_wait_us.load(std::memory_order_relaxed); + struct timeval tv {0, cap > 0 && cap < 100000 ? static_cast(cap) : 100000}; libusb_handle_events_timeout_completed(_ctx, &tv, nullptr); if (sh.telemetry) { const auto now = std::chrono::steady_clock::now(); @@ -974,7 +975,9 @@ void UsbTransport::rx_loop_sync( auto last_ring = std::chrono::steady_clock::now(); long long cb_max_us = 0; while (!should_stop()) { - int n = rx_raw(buf.data(), buf_size, 200); + const unsigned cap = _rx_max_wait_us.load(std::memory_order_relaxed); + const int wait_ms = cap > 0 && cap < 200000 ? static_cast(cap / 1000 > 0 ? cap / 1000 : 1) : 200; + int n = rx_raw(buf.data(), buf_size, wait_ms); if (n > 0) { if (telemetry) { const auto t0 = std::chrono::steady_clock::now(); diff --git a/src/UsbTransport.h b/src/UsbTransport.h index fdea5891..fd7b6c2b 100644 --- a/src/UsbTransport.h +++ b/src/UsbTransport.h @@ -113,6 +113,7 @@ class UsbTransport final : public ITransport { const std::function &on_data, const std::function &should_stop) override; int rx_raw(uint8_t *buf, int len, int timeout_ms) override; + void set_rx_max_wait_us(unsigned us) override { _rx_max_wait_us = us; } void clear_halt(uint8_t ep) override { libusb_clear_halt(_dev_handle, ep); } void quiesce_tx() override; @@ -272,6 +273,7 @@ class UsbTransport final : public ITransport { RxMode _rx_mode = RxMode::Async; int _pool_spare = 0; int _ring_ms = 0; + std::atomic _rx_max_wait_us{0}; /* set_rx_max_wait_us; 0 = default */ PoolExhaust _pool_exhaust = PoolExhaust::Backpressure; /* rx_loop helpers for the servicing strategies dispatched off _rx_mode. */ diff --git a/src/kestrel/FrameParserKestrel.h b/src/kestrel/FrameParserKestrel.h index bbf251d2..388d5947 100644 --- a/src/kestrel/FrameParserKestrel.h +++ b/src/kestrel/FrameParserKestrel.h @@ -115,6 +115,26 @@ inline bool parse_rx_8852b(const uint8_t *buf, size_t buflen, return true; } +/* Do two sub-packet descriptors describe the same PPDU? The vendor pairing + * check (phl_rx.c): the 3-bit ppdu_cnt wraps every 8 PPDUs, so rate, bw, + * GI/LTF and PPDU type must match too. Used both to grow a held batch of WIFI + * frames and to pair a PPDU-status report with it. */ +inline bool same_ppdu(const KestrelRxFrame &a, const KestrelRxFrame &b) { + return a.ppdu_cnt == b.ppdu_cnt && a.rx_rate == b.rx_rate && a.bw == b.bw && + a.gi_ltf == b.gi_ltf && a.ppdu_type == b.ppdu_type; +} + +/* Length of a leading radiotap header on a beacon buffer, or 0 (IRadio beacon + * contract: StartBeacon / UpdateBeaconPayload strip one). A beacon MPDU starts + * with FC byte 0x80; radiotap starts with version 0 and its little-endian + * it_len at bytes 2..3, which must leave at least one byte of MPDU. */ +inline size_t beacon_radiotap_len(const uint8_t *b, size_t len) { + if (b == nullptr || len < 8 || b[0] != 0) + return 0; + const size_t rt = static_cast(b[2] | (b[3] << 8)); + return (rt >= 8 && rt < len) ? rt : 0; +} + /* --- PPDU-status (halbb physts) parse: per-path RSSI / SNR / EVM --- * * A PPDU-status payload is the bare halbb physts blob (HalKestrel clears the diff --git a/src/kestrel/HalKestrel.cpp b/src/kestrel/HalKestrel.cpp index 08d330b2..4d78b0f2 100644 --- a/src/kestrel/HalKestrel.cpp +++ b/src/kestrel/HalKestrel.cpp @@ -1757,8 +1757,14 @@ bool HalKestrel::update_beacon(const uint8_t *body, uint32_t len, bool HalKestrel::stop_beacon() { namespace r = kestrel::reg; - const uint32_t p = _device.rtw_read32(r::R_AX_PORT_CFG_P0); - return _device.rtw_write32(r::R_AX_PORT_CFG_P0, p & ~r::B_AX_BCNTX_EN_P0); + /* Beacon TX off, and the port back to the NO_LINK injection identity + * start_beacon moved it from (IRadio StopBeacon contract). The port stays + * FUNC-enabled, as the injection setup leaves it. */ + uint32_t p = _device.rtw_read32(r::R_AX_PORT_CFG_P0); + p = r::set_clr_word(p, r::MAC_AX_NET_TYPE_NO_LINK, r::B_AX_NET_TYPE_P0_MSK, + r::B_AX_NET_TYPE_P0_SH); + p &= ~r::B_AX_BCNTX_EN_P0; + return _device.rtw_write32(r::R_AX_PORT_CFG_P0, p); } void HalKestrel::mac_enable_imr() { diff --git a/src/kestrel/HalKestrel.h b/src/kestrel/HalKestrel.h index 4bcf865b..04ac4a12 100644 --- a/src/kestrel/HalKestrel.h +++ b/src/kestrel/HalKestrel.h @@ -142,8 +142,8 @@ class HalKestrel { * start_beacon, without touching the port or the TBTT timing. */ bool update_beacon(const uint8_t *body, uint32_t len, uint16_t rate_ax); /* Stop the HW beacon: clear BCNTX_EN in PORT_CFG_P0 (mac_port_cfg - * MAC_AX_PCFG_TX_SW = 0, the vendor rtw_hal_beacon_stop path). The port, - * its NET_TYPE and the TSF keep running. */ + * MAC_AX_PCFG_TX_SW = 0, the vendor rtw_hal_beacon_stop path) and return + * NET_TYPE to NO_LINK. The port stays enabled and the TSF keeps running. */ bool stop_beacon(); /* PHY bring-up: apply the halbb BB register + gain tables and the diff --git a/src/kestrel/RtlKestrelDevice.cpp b/src/kestrel/RtlKestrelDevice.cpp index f95a562d..e659bda6 100644 --- a/src/kestrel/RtlKestrelDevice.cpp +++ b/src/kestrel/RtlKestrelDevice.cpp @@ -479,11 +479,14 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b * count. Held frames go out WITHOUT a status (rssi/snr/evm 0 = no * measurement, the convention every consumer already skips) when a frame of * another PPDU or a non-matching status arrives, after kHoldMax of RX idle, - * and when the loop exits — so no frame is ever dropped or reordered. */ + * and when the loop exits — so no frame is ever dropped or reordered. + * held_mu: on USB's spsc RX mode on_data runs on a consumer thread while the + * stop predicate (the idle flush) runs on the event thread. */ struct HeldRx { std::vector bytes; - Packet p; + Packet p{}; /* value-initialized: rx_pkt_attrib's plain fields read 0 */ }; + std::mutex held_mu; std::vector held; kestrel::KestrelRxFrame held_hdr; /* the held PPDU's descriptor fields */ std::chrono::steady_clock::time_point held_since{}; @@ -517,6 +520,9 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b } held.clear(); }; + /* The idle flush rides the stop predicate: have USB poll it at least every + * 1 ms (its default event wait is 100 ms), so kHoldMax holds on both buses. */ + _device.set_rx_max_wait_us(1000); _rx_running = true; struct RunningGuard { std::atomic &f; @@ -525,6 +531,7 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b _device.bulk_read_async_loop( 32768, 8, [&, drv_info_unit](const uint8_t *data, int n) { + std::lock_guard held_lk(held_mu); uint32_t off = 0; while (off + 16 <= static_cast(n)) { kestrel::KestrelRxFrame f; @@ -550,13 +557,9 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b const bool ps_ok = kestrel::parse_physts_8852( f.payload, f.payload_len, _variant == kestrel::ChipVariant::C8852C, ps); - if (!held.empty()) { - const bool same_ppdu = - f.ppdu_cnt == held_hdr.ppdu_cnt && f.rx_rate == held_hdr.rx_rate && - f.bw == held_hdr.bw && f.gi_ltf == held_hdr.gi_ltf && - f.ppdu_type == held_hdr.ppdu_type; - deliver_held(same_ppdu && ps_ok ? &ps : nullptr); - } + if (!held.empty()) + deliver_held(kestrel::same_ppdu(f, held_hdr) && ps_ok ? &ps + : nullptr); /* Trace: raw physts blobs (first few) — the IE-page ground truth * when a per-path metric reads 0 (is the page absent, or zero?). */ static int physts_dumped = 0; @@ -573,9 +576,9 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b _logger->trace("Kestrel physts[{}B]: {}", f.payload_len, hex); } } else if (f.rpkt_type == kestrel::RPKT_TYPE_WIFI && packetProcessor) { - if (!held.empty() && f.ppdu_cnt != held_hdr.ppdu_cnt) + if (!held.empty() && !kestrel::same_ppdu(f, held_hdr)) deliver_held(nullptr); /* that PPDU's status never came */ - HeldRx h; + HeldRx h{}; Packet &p = h.p; p.RxAtrib.pkt_len = static_cast(f.payload_len); p.RxAtrib.crc_err = f.crc_err; @@ -606,11 +609,16 @@ void RtlKestrelDevice::start_rx_loop(Action_ParsedRadioPacket packetProcessor, b /* Polled every loop pass on the RX thread (PCIe: each idle poll; * USB: each <=100 ms event wait) — bounds a held frame's wait when * its status never arrives and nothing else is received. */ - if (!held.empty() && - std::chrono::steady_clock::now() - held_since > kHoldMax) - deliver_held(nullptr); + { + std::lock_guard held_lk(held_mu); + if (!held.empty() && + std::chrono::steady_clock::now() - held_since > kHoldMax) + deliver_held(nullptr); + } return _rx_stop || g_devourer_should_stop; }); + _device.set_rx_max_wait_us(0); + std::lock_guard held_lk(held_mu); deliver_held(nullptr); } @@ -1121,16 +1129,6 @@ uint64_t RtlKestrelDevice::ReadTsf() { return (static_cast(hi) << 32) | lo; } -/* The IRadio beacon buffer contract: a leading radiotap header is stripped. - * A beacon MPDU starts with FC byte 0x80; a radiotap header with version 0 and - * its little-endian it_len at bytes 2..3. */ -static size_t kestrel_beacon_radiotap_len(const uint8_t *b, size_t len) { - if (len < 8 || b[0] != 0) - return 0; - const size_t rt = static_cast(b[2] | (b[3] << 8)); - return (rt >= 8 && rt < len) ? rt : 0; -} - bool RtlKestrelDevice::StartBeacon(const uint8_t *beacon, size_t len, int interval_tu) { if (!_tx_up) { @@ -1141,7 +1139,7 @@ bool RtlKestrelDevice::StartBeacon(const uint8_t *beacon, size_t len, _logger->error("Kestrel: StartBeacon empty beacon"); return false; } - const size_t rt = kestrel_beacon_radiotap_len(beacon, len); + const size_t rt = kestrel::beacon_radiotap_len(beacon, len); beacon += rt; len -= rt; const uint16_t iv = interval_tu > 0 ? static_cast(interval_tu) : 100; @@ -1162,7 +1160,7 @@ bool RtlKestrelDevice::UpdateBeaconPayload(const uint8_t *beacon, size_t len) { } if (beacon == nullptr || len == 0) return false; - const size_t rt = kestrel_beacon_radiotap_len(beacon, len); + const size_t rt = kestrel::beacon_radiotap_len(beacon, len); return _hal.update_beacon(beacon + rt, static_cast(len - rt), kestrel::reg::MAC_AX_OFDM6); } diff --git a/tests/kestrel_rxparse_selftest.cpp b/tests/kestrel_rxparse_selftest.cpp index e9f19de3..0b203aa5 100644 --- a/tests/kestrel_rxparse_selftest.cpp +++ b/tests/kestrel_rxparse_selftest.cpp @@ -252,6 +252,48 @@ int main() { CHECK(p2.rssi[0] == 0 && p2.snr_avg == 0); } + /* beacon_radiotap_len: the StartBeacon / UpdateBeaconPayload strip. */ + { + std::vector raw = {0x80, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff}; + CHECK(beacon_radiotap_len(raw.data(), raw.size()) == 0); /* bare MPDU */ + std::vector rt(10 + 24, 0); + rt[2] = 10; /* it_len 10, LE */ + rt[10] = 0x80; + CHECK(beacon_radiotap_len(rt.data(), rt.size()) == 10); + rt[2] = 0x0a; rt[3] = 0x01; /* it_len 0x010a: little-endian high byte */ + CHECK(beacon_radiotap_len(rt.data(), rt.size()) == 0); /* > len */ + rt[3] = 0; + CHECK(beacon_radiotap_len(rt.data(), 10) == 0); /* it_len == len: no MPDU */ + CHECK(beacon_radiotap_len(rt.data(), 11) == 10); + rt[2] = 4; /* it_len below the 8-byte radiotap minimum */ + CHECK(beacon_radiotap_len(rt.data(), rt.size()) == 0); + rt[2] = 10; rt[0] = 1; /* version != 0 */ + CHECK(beacon_radiotap_len(rt.data(), rt.size()) == 0); + CHECK(beacon_radiotap_len(rt.data(), 7) == 0); + CHECK(beacon_radiotap_len(nullptr, 34) == 0); + } + + /* same_ppdu: count AND descriptor fields (the 3-bit count wraps). */ + { + KestrelRxFrame a, b; + a.ppdu_cnt = b.ppdu_cnt = 3; + a.rx_rate = b.rx_rate = 0x4; + a.bw = b.bw = 0; + a.gi_ltf = b.gi_ltf = 0; + a.ppdu_type = b.ppdu_type = 2; + CHECK(same_ppdu(a, b)); + b.ppdu_cnt = 4; + CHECK(!same_ppdu(a, b)); + b.ppdu_cnt = 3; b.rx_rate = 0x83; /* same count, another PPDU */ + CHECK(!same_ppdu(a, b)); + b.rx_rate = 0x4; b.ppdu_type = 3; + CHECK(!same_ppdu(a, b)); + b.ppdu_type = 2; b.bw = 1; + CHECK(!same_ppdu(a, b)); + b.bw = 0; b.gi_ltf = 1; + CHECK(!same_ppdu(a, b)); + } + if (failures == 0) std::printf("kestrel_rxparse_selftest: all checks passed\n"); return failures == 0 ? 0 : 1; From 917e09092a487045c841703768943c2a925259b7 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:10:22 +0300 Subject: [PATCH 3/3] UsbTransport: set the capped RX wait by assignment (suseconds_t is int on macOS) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/UsbTransport.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/UsbTransport.cpp b/src/UsbTransport.cpp index aa71d06e..ac516b29 100644 --- a/src/UsbTransport.cpp +++ b/src/UsbTransport.cpp @@ -874,7 +874,9 @@ void UsbTransport::rx_loop( while (!should_stop() && sh.active > 0 && !sh.dead.load(std::memory_order_relaxed)) { const unsigned cap = _rx_max_wait_us.load(std::memory_order_relaxed); - struct timeval tv {0, cap > 0 && cap < 100000 ? static_cast(cap) : 100000}; + struct timeval tv {0, 100000}; + if (cap > 0 && cap < 100000) /* suseconds_t is int on macOS: no narrowing init */ + tv.tv_usec = static_cast(cap); libusb_handle_events_timeout_completed(_ctx, &tv, nullptr); if (sh.telemetry) { const auto now = std::chrono::steady_clock::now();