From fc7eb72b5ffca661819fc4816fdaac584f266adc Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 1 Oct 2026 17:27:51 +0200 Subject: [PATCH] jaguar1/2/3: the Realtek station arm for IRadio::SetStationIdentity SetStationIdentity / ClearStationIdentity on Jaguar1, Jaguar2 and Jaguar3 (#461, first library item), over a station recipe in AckResponder.h and the per-device state in src/StationArm.h: gate closed, MACID = own, BSSID = the AP, net_type = Infra, all read back; Clear restores the exact pre-arm MACID/BSSID/net_type and reads it back. How it differs from the MT7612U arm, stated at StationArm.h and each backend's declaration: - it configures rather than checks: Jaguar2/3 bring-up never programs MACID, and net_type comes up NoLink; - later port-0 claimants are refused rather than dropping the arm. SetAckResponder and StartBeacon return false while a station is armed, and on Jaguar2/3 ClearAckResponder leaves the port alone; - Stop() and the destructor clear the arm, because Jaguar2's teardown does not power the chip down, and a re-Init of an armed object clears it before the bring-up, keeping the record for a retry if that clear does not verify. The station is also refused while a failed beacon start's enables are still set, and the beacon disable leaves net_type alone while a station holds it. Clear's gate close is a precondition: if it fails, the identity is not touched and the arm stays recorded for a retry. The arm waits for each backend's _station_ready, which is cleared at the top of Init/InitWrite and committed only after that bring-up's last port-0 write (the BF beamformee identity, the configured ACK responder). It is not _brought_up, which goes true mid-bring-up for the tail's own setters. An all-zero own or BSSID is refused: is_unicast alone passes it, and it would program MACID = 0. The arm also prints the MT7612U's arm-time retry-limit WARN, and the 8814A variant says that die airs every unicast once. Jaguar3's CFO tracker and BF apply took _reg_mu blocking on the RX thread, the libusb event thread in the Async ring. A _reg_mu holder doing sync USB I/O (the coex tick, now the arm) would deadlock against them. Both now try_lock. The CFO tracker takes the lock before it steps, so a skipped tick cannot feed its polarity detection. Found by reading the code; not reproduced on hardware. rxdemo / txdemo (with DEVOURER_TX_WITH_RX=thread) gain DEVOURER_STA_IDENTITY=, and DEVOURER_STA_CLEAR_AFTER_MS. They emit sta.arm / sta.clear events. An arm is refused while the demo's RX worker has failed or ended; txdemo checks the knob before any of its threads start, waits for its RX loop's first frame, and sends nothing after a refused arm. tests/realtek_station_onair.sh measures both halves of station_mode_ok's bar from the transmitter's own CCX reports: - DOWN: a Realtek peer injects to the armed DUT, with controls for a destination nobody holds, the DUT absent, the DUT unarmed and the DUT armed then cleared, plus the DUT's rx.seq reception; - UP: the armed DUT sends to a hostapd AP, against a destination nobody holds, plus an unarmed uplink arm (H) that is reported and not scored. An arm is scored only when its transmitter kept airing through the window (MAX_GAP_MS between CCX reports; the final tx.stats now carries t), it submitted MIN_SUBMITTED frames, and its receiver was still running at the end. Reception is judged against the peer's reported frames. station_mode_ok is TRUE on the 8822C and 8822B dies only, scoped by variant. On one RTL8812CU and one RTL8812BU (each the other's peer, an MT7612U/hostapd AP, ch6, near field, two records on this rig, the current one on this head matching the first), the claim arms read as follows: - A (armed, to own): 100.0% ACKed, 0.03 / 0.33 mean retries; - F (uplink to the AP): 100.0%, 0.09 / 0.20. Every control (nobody, DUT absent, unarmed, cleared, uplink to nobody) read 0.0% at the 12-retry limit. Arm H (uplink, NOT armed) was ACKed 100% (0.08 / 0.20): the AP acknowledges by address, so the uplink half holds without the arm. The arm is what the DOWN half needs (D and E at 0%). The flag rests on both halves met while armed. Limits, in docs/realtek-station-arm.md: - one unit per die, two runs on one rig, near field, one AP type; - the station receives in the controls too, because it is promiscuous; the arm changes the ACK; - submitted exceeds reports on every arm. The 8822E, the 8821C and every Jaguar1 die stay false, unmeasured. Kestrel and the RTL8733B keep the not-ported default. ctest station_arm, gated on the Jaguar options. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- CLAUDE.md | 5 + CMakeLists.txt | 13 + docs/logging.md | 4 +- docs/realtek-station-arm.md | 96 +++++ examples/common/station_arm_env.h | 183 +++++++++ examples/rx/main.cpp | 46 +++ examples/tx/main.cpp | 79 +++- src/AckResponder.h | 143 +++++++ src/AdapterCaps.h | 32 +- src/StationArm.h | 210 ++++++++++ src/jaguar1/RtlJaguarDevice.cpp | 83 ++++ src/jaguar1/RtlJaguarDevice.h | 23 ++ src/jaguar2/RtlJaguar2Device.cpp | 107 ++++- src/jaguar2/RtlJaguar2Device.h | 20 + src/jaguar3/RtlJaguar3Device.cpp | 151 ++++++- src/jaguar3/RtlJaguar3Device.h | 20 + tests/mt7612u_sta_lib.sh | 5 +- tests/realtek_station_onair.sh | 657 ++++++++++++++++++++++++++++++ tests/station_arm_selftest.cpp | 491 ++++++++++++++++++++++ 19 files changed, 2344 insertions(+), 24 deletions(-) create mode 100644 docs/realtek-station-arm.md create mode 100644 examples/common/station_arm_env.h create mode 100644 src/StationArm.h create mode 100755 tests/realtek_station_onair.sh create mode 100644 tests/station_arm_selftest.cpp diff --git a/CLAUDE.md b/CLAUDE.md index f3f42f8c..17cf51c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,11 @@ those are the ones listed below. degradation predictor — `docs/warm-tx-degradation.md` has delivery scattered 63–83% with no relation to the meter, and inside one uninterrupted session the meter stays pinned while delivery drifts. +- `DEVOURER_STA_IDENTITY=,` (rxdemo; txdemo with + `DEVOURER_TX_WITH_RX=thread`) — call `IRadio::SetStationIdentity` once the + RX loop is up, `DEVOURER_STA_CLEAR_AFTER_MS=N` to clear it later; `sta.arm` + / `sta.clear` events (`examples/common/station_arm_env.h`); txdemo sends + nothing after a refused arm. The Realtek cell is `tests/realtek_station_onair.sh`. - `DEVOURER_RX_BUSY_MS=N` (rxdemo) — the vendor-neutral busy-airtime window at a fixed cadence: arm, wait N ms, read, one `rx.busy` event per window (`IRadio::ArmChannelBusy`/`GetChannelBusy`, so it runs on the MT7612U where diff --git a/CMakeLists.txt b/CMakeLists.txt index ae7b302f..37821ba3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1139,6 +1139,19 @@ target_link_libraries(AckResponderSelftest PRIVATE devourer PkgConfig::libusb) add_test(NAME ack_responder_recipe COMMAND AckResponderSelftest) +# Headless guard for the Realtek station arm (src/StationArm.h over the +# AckResponder.h station recipe) - the Jaguar1/2/3 half of +# IRadio::SetStationIdentity: the exact registers armed and restored, every +# refusal writing nothing, and readback catching a write that did not land. +# Built only when a backend that uses it is. Silicon behaviour is on-air only: +# tests/realtek_station_onair.sh. +if(DEVOURER_JAGUAR1 OR DEVOURER_JAGUAR2_8822B OR DEVOURER_JAGUAR2_8821C OR + DEVOURER_JAGUAR3_8822C OR DEVOURER_JAGUAR3_8822E) + add_executable(StationArmSelftest tests/station_arm_selftest.cpp) + target_link_libraries(StationArmSelftest PRIVATE devourer PkgConfig::libusb) + add_test(NAME station_arm COMMAND StationArmSelftest) +endif() + # Headless guard for the windowed RX-receipt primitives (src/cell/RxReceipt.h): # TLV round-trip, ring eviction, late accounting, ledger merge idempotence, # strict-parse rejections. The on-air halves are tests/receipt_verify.py over diff --git a/docs/logging.md b/docs/logging.md index ba6ae369..cfc43f83 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -78,6 +78,8 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; |---|---|---| | `init.timing` | L (`src/InitTimer.h`) + demos | stage ("scope.stage", e.g. "demo.first_rx_frame", "txdemo.first_tx_submit"), ms, [xfers] (register transfers the stage spent on that adapter's transport, USB only — present on the Jaguar3 `j3hal.*` / `j3init.*` stages) | | `adapter.caps` | RX, TX, doctor, txpower (`examples/common/caps_event.h`) | supported, chip, names, chip_id "0x..", gen, variant, transport, tx_chains, rx_chains, n_ss, stbc, ldpc, sgi, bw_max, bw[] (MHz), txpwr_max, txpwr_step_qdb, txpwr_step_measured, txpwr_min_qdb, txpwr_max_qdb, txpwr_rate_diffs, txpwr_rate_diffs_hw, txpwr_rate_diffs_measured, tune_2g4[]\|null, tune_5g[]\|null, char_2g4[]\|null, char_5g[]\|null, ldpc_rx_ht, ldpc_rx_vht, ldpc_rx_flag, vht_2g4, per_pkt_txpwr, per_pkt_txpwr_steps, per_pkt_txpwr_step_qdb, per_pkt_txpwr_min_qdb, per_pkt_txpwr_max_qdb, per_pkt_txpwr_measured, narrowband, fastretune, ack_responder, station_mode, tx_retry_limit, tx_no_agg, he_er_su, per_chain_rssi, hw_rx_tsf, hw_beacon_txtsf, tsf_write, xtal_cap_max, xtal_cap_default | +| `sta.arm` | RX, TX (`DEVOURER_STA_IDENTITY`, `examples/common/station_arm_env.h`) | ok (0/1, `IRadio::SetStationIdentity`'s return), own "aa:..", bssid, attempts (a refused arm is retried; this is the last outcome), [why] on ok 0: "refused", "no_mac", or "rx_not_running" (the RX worker failed or ended; an arm that landed is cleared again) | +| `sta.clear` | RX, TX (`DEVOURER_STA_CLEAR_AFTER_MS`) | ok (0/1, `IRadio::ClearStationIdentity`'s return: rollback restored and verified) | | `debug.wreg` | L (`DEVOURER_LOG_WRITES`) | addr "0x0nnn", width, val "0x…" | | `hop.prof` | L (`DEVOURER_HOP_PROF`) | gen, ch, `_us`…, total_us | | `tx.fail` | L (send failure; regress.py keys on it) | {status, actual_len, timeout} or {rc, timeout} | @@ -109,7 +111,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; | ev | emitter | fields | |---|---|---| | `tx.frame` | TX | n, rc — precoder demo variant: n, ok | -| `tx.stats` | TX | submitted, failed, was_timeout, last_rc; periodic events (not the `final:1` one) also carry `txdma_status` (the raw `REG_TXDMA_STATUS` latch; which bits mean a stopped transmitter: `IRtlRadio::GetTxDmaStatus`) where `IRtlRadio::HasTxDmaStatus()` (which backends: its declaration), or `txdma_read_failed:1` when that sample's register read failed | +| `tx.stats` | TX | submitted, failed, was_timeout, last_rc; the `final:1` event also carries t (the `tx.report` timebase, so a harness can tell how long before the end a transmitter last reported); periodic events (not the `final:1` one) also carry `txdma_status` (the raw `REG_TXDMA_STATUS` latch; which bits mean a stopped transmitter: `IRtlRadio::GetTxDmaStatus`) where `IRtlRadio::HasTxDmaStatus()` (which backends: its declaration), or `txdma_read_failed:1` when that sample's register read failed | | `tx.agg` | L (`DEVOURER_TX_USB_AGG`, send_packets) | frames, bytes, shim, ok — one per multi-frame bulk-OUT URB. The sync-TX generations (Jaguar2/Jaguar3/RTL8733B) also emit `sent` — bytes actually transferred, OR the negative libusb rc on a transport error (deliberately raw: this event is the only machine-readable carrier of the aggregated-path error code) — and set `ok` only on a FULL write, so `ok=false` splits as `sent < 0` transport error vs `0 <= sent < bytes` short write. Jaguar1 TX is async: its `ok` means URB accepted by the transport and there is no `sent` field (bytes resolve at completion reaping) | | `tx.report` | L (`DEVOURER_TX_REPORT`, CCX decode) | t, state (0=delivered, 1=retry-drop), ok, retries, final_rate, queue_time_raw, bmc, macid, fmt ("8812"\|"halmac"); halmac adds tag (SW_DEFINE echo), rts_retries, missed (fw-stuffed constant on Jaguar3 — tag gaps are the drop signal; `tests/txrpt_coverage_attrib.py`) — t is the achieved-report-rate timebase (the CCX emission ceiling is reports/s) | | `tx.status` | RX, duplex (C2H TX_RPT decode) | hoff, queue, retry, airtime_us, rate | diff --git a/docs/realtek-station-arm.md b/docs/realtek-station-arm.md new file mode 100644 index 00000000..9df9040c --- /dev/null +++ b/docs/realtek-station-arm.md @@ -0,0 +1,96 @@ +# The Realtek station arm: bench record + +`IRadio::SetStationIdentity` on Jaguar1/2/3. The contract is on the +declaration in `src/IRadio.h`. How the Realtek arm differs from the MT7612U's +is in `src/StationArm.h`. The flag is `AdapterCaps::station_mode_ok`. This +page holds the run behind that flag and its limits. + +## The cell + +`tests/realtek_station_onair.sh`. Its header defines the arms (A–H), their +controls and the verdicts. Both halves are read off the transmitter's own CCX +reports (`tx.report`), at retry limit 12, MCS3, with 200-byte frames, a 5 ms +gap and 10 s per arm. + +## Runs + +The rig: +- ch6, near field, one run per arm per record; +- an RTL8812CU (8822C) and an RTL8812BU (8822B), each the other's peer; +- the AP is an MT7612U on mt76x2u running hostapd; +- rtw88 was not blacklisted: the demos detached it, and the harness handed + every adapter back. + +There are two records on this rig: the first on the arm's first version, and +the current one on the reviewed code, which adds arm H. Every run exited 0 +with 5 verdicts passed. The second record matches the first arm for arm, +within a few frames and a few hundredths of a retry. Both were scored before +the harness gained its transmitter-liveness gate and per-arm submission +floor, and before it judged reception against reported rather than +submitted frames; a run on the current harness is pending. + +Each cell is reports / submitted, ok, mean retries, then rx_distinct where +the arm counts reception. + +| arm | 8812CU station | 8812BU station | +|---|---|---| +| A | 1616 / 1658, 100.0%, 0.03, 1616 | 860 / 910, 100.0%, 0.33, 860 | +| B | 861 / 1694, 0.0%, 12.00, 870 | 85 / 902, 0.0%, 12.00, 86 | +| C | 858 / 1684, 0.0%, 12.00 | 88 / 911, 0.0%, 12.00 | +| D | 861 / 1698, 0.0%, 12.00, 1101 | 87 / 918, 0.0%, 12.00, 90 | +| E | 859 / 1694, 0.0%, 12.00, 1098 | 88 / 918, 0.0%, 12.00, 91 | +| F | 2291 / 2341, 100.0%, 0.09 | 3095 / 3137, 100.0%, 0.20 | +| G | 222 / 1369, 0.0%, 12.00 | 1615 / 2547, 0.0%, 12.00 | +| H | 2399 / 2449, 100.0%, 0.08 | 3202 / 3244, 100.0%, 0.20 | + +The first record has no arm H; the other arms read: + +| arm | 8812CU station | 8812BU station | +|---|---|---| +| A | 1616 / 1658, 100.0%, 0.02, 1616 | 847 / 897, 100.0%, 0.28, 847 | +| B | 862 / 1689, 0.0%, 12.00, 870 | 88 / 917, 0.0%, 12.00, 91 | +| C | 862 / 1688, 0.0%, 12.00 | 87 / 917, 0.0%, 12.00 | +| D | 858 / 1690, 0.0%, 12.00, 1100 | 89 / 926, 0.0%, 12.00, 93 | +| E | 866 / 1691, 0.0%, 12.00, 1096 | 87 / 918, 0.0%, 12.00, 90 | +| F | 2317 / 2367, 100.0%, 0.11 | 3095 / 3137, 100.0%, 0.20 | +| G | 229 / 1377, 0.0%, 12.00 | 1605 / 2538, 0.0%, 12.00 | + +## What the arm is for + +Arm H is F's uplink with the DUT NOT armed. It was ACKed 100% on both dies, +at the same retries as armed F. So on Jaguar2/3 the UP half of the bar does +not depend on the arm: the AP acknowledges by address, and the transmitter +counts that ACK whether or not MACID holds the station's address. What +needs the arm is the DOWN half. With the arm absent (D) or cleared (E), the +DUT ACKs nothing addressed to it. + +`station_mode_ok` rests on both halves being met while armed, which is how +a station runs. H adds that the uplink half holds without the arm too. It +is reported and never scored. + +## Limits + +- **The run's scope.** One unit per die, two runs per arm on one rig (one + for H), near field, one channel, one AP type, and unassociated + throughout. Power save, TIM, cross-BSS duplicate detection, hardware key + lookup and the managed receive filter are all untested. +- **What "received" means.** `rx_distinct` is the DUT's count of distinct + frames from the peer, taken from rxdemo's `rx.seq` stream. The station runs + the promiscuous monitor filter, so it also received in the controls where + the frames were not addressed to it, or where it was unarmed: B, D and E + received about 870–1100 frames on the CU and about 86–93 on the BU. In + arm A, "received" means only that the frames arrived. The arm changes the ACK, + which is read off the peer's reports, not reception. +- **Submitted exceeds reports on every arm.** By arm group: + - acknowledged arms (A, F, H): 40–50 frames; + - unacknowledged downlink arms (B to E): about half the submissions on the + CU (about 860 of 1690) and about nine in ten on the BU (about 87 of 910); + - the G arms: 222–229 of about 1370 reported on the CU (83–84% missing), + and 1605–1615 of about 2540 on the BU (36–37% missing). + + That fits frames still queued in the chip when the window closes, because + an unacknowledged frame airs 13 times before its report. It is not proven. + In arm A, `rx_distinct` equals the report count on both units. +- **Dies not measured by this cell:** the 8822E, the 8821C, and every + Jaguar1 die. On the 8812, arm D is predicted to answer, because bring-up + programs the EFUSE MAC into MACID; run it with `EXPECT_UNARMED_SILENT=0`. diff --git a/examples/common/station_arm_env.h b/examples/common/station_arm_env.h new file mode 100644 index 00000000..d833771b --- /dev/null +++ b/examples/common/station_arm_env.h @@ -0,0 +1,183 @@ +/* DEVOURER_STA_IDENTITY / DEVOURER_STA_CLEAR_AFTER_MS - drive + * IRadio::SetStationIdentity / ClearStationIdentity from rxdemo and txdemo. + * + * Demo-local (no DeviceConfig field): the seam is a runtime call that IRadio + * orders after the RX loop is running, which a construction-time config + * cannot express. The knob means the same on every backend - it calls the + * seam and reports the return value; a backend that has not ported it returns + * false, and the event says so. + * + * DEVOURER_STA_IDENTITY=, + * own the station's address, or `self` for the adapter's permanent + * (EFUSE) MAC via IRadio::GetPermanentMacAddress. + * bssid the AP's address. + * DEVOURER_STA_CLEAR_AFTER_MS=N + * N ms after a successful arm, call ClearStationIdentity - the on-air + * form of "Clear returns to the pre-arm state". + * + * Events (docs/logging.md): `sta.arm` {ok, own, bssid, attempts, [why]} once + * the arm has been tried; `sta.clear` {ok} after a scheduled clear. A refused arm is + * retried (a backend refuses before bring-up), ten times 500 ms apart; the + * event reports the last outcome. */ +#ifndef DEVOURER_STATION_ARM_ENV_H +#define DEVOURER_STATION_ARM_ENV_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DeviceConfig.h" +#include "Event.h" +#include "IRadio.h" +#include "logger.h" + +namespace devourer { + +struct StationArmRequest { + bool own_self = false; + MacAddr own{}; + MacAddr bssid{}; + uint32_t clear_after_ms = 0; /* 0 = never */ +}; + +/* nullopt when DEVOURER_STA_IDENTITY is unset. `bad` is set when it is set + * but malformed - the demo refuses to run rather than measure an unarmed + * station as an armed one. */ +inline std::optional +station_arm_request_from_env(const Logger_t &log, bool &bad) { + bad = false; + const char *e = std::getenv("DEVOURER_STA_IDENTITY"); + if (e == nullptr || *e == '\0') + return std::nullopt; + const std::string v(e); + const size_t comma = v.find(','); + StationArmRequest r; + std::optional bssid; + if (comma != std::string::npos) { + const std::string own = v.substr(0, comma); + bssid = parse_mac(v.substr(comma + 1)); + if (own == "self") { + r.own_self = true; + } else if (auto m = parse_mac(own)) { + r.own = *m; + } else { + bssid.reset(); + } + } + if (!bssid) { + log->error("DEVOURER_STA_IDENTITY='{}' is not ,", e); + bad = true; + return std::nullopt; + } + r.bssid = *bssid; + if (const char *c = std::getenv("DEVOURER_STA_CLEAR_AFTER_MS")) { + char *end = nullptr; + const unsigned long ms = std::strtoul(c, &end, 10); + if (end == c || *end != '\0' || c[0] == '-' || ms > 3600000ul) { + log->error("DEVOURER_STA_CLEAR_AFTER_MS='{}' is not 0..3600000", c); + bad = true; + return std::nullopt; + } + r.clear_after_ms = static_cast(ms); + } + return r; +} + +inline std::string station_mac_str(const MacAddr &m) { + char b[18]; + std::snprintf(b, sizeof(b), "%02x:%02x:%02x:%02x:%02x:%02x", m.bytes[0], + m.bytes[1], m.bytes[2], m.bytes[3], m.bytes[4], m.bytes[5]); + return b; +} + +/* Arm (with the bounded retry), emit `sta.arm`, and return the outcome. Call + * after the RX loop has started (IRadio's ORDERING clause). `stop` aborts the + * retry wait. `rx_ended`, when given, is set by the caller once its RX worker + * has returned or thrown: an arm is refused while it is set (a station with no + * receive loop is not one), and an arm that lands just as the worker ends is + * cleared again and reported refused. Never holds a lock the RX callback + * takes. */ +inline bool station_arm_run(IRadio *dev, const StationArmRequest &req, + EventSink &ev, const Logger_t &log, + const std::atomic &stop, + const std::atomic *rx_ended = nullptr) { + auto rx_gone = [&] { return rx_ended != nullptr && rx_ended->load(); }; + MacAddr own = req.own; + if (req.own_self) { + uint8_t m[6]; + if (!dev->GetPermanentMacAddress(m)) { + log->error("DEVOURER_STA_IDENTITY: own=self, but this backend reports " + "no permanent MAC"); + Ev(ev, "sta.arm").f("ok", 0).f("own", nullptr).f("bssid", + station_mac_str(req.bssid)).f("attempts", 0).f("why", "no_mac"); + return false; + } + for (int i = 0; i < 6; ++i) + own.bytes[i] = m[i]; + } + bool ok = false; + int attempts = 0; + while (attempts < 10 && !stop.load() && !rx_gone()) { + ++attempts; + ok = dev->SetStationIdentity(own, req.bssid); + if (ok) + break; + for (int s = 0; s < 500 && !stop.load(); s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + const char *why = ok ? nullptr : "refused"; + if (rx_gone()) { + if (ok) + (void)dev->ClearStationIdentity(); + ok = false; + why = "rx_not_running"; + } + Ev e(ev, "sta.arm"); + e.f("ok", ok ? 1 : 0) + .f("own", station_mac_str(own)) + .f("bssid", station_mac_str(req.bssid)) + .f("attempts", attempts); + if (why) + e.f("why", why); + if (ok) + log->info("DEVOURER_STA_IDENTITY: station identity armed (own {}, " + "BSSID {}, attempt {})", + station_mac_str(own), station_mac_str(req.bssid), attempts); + else if (rx_gone()) + log->error("DEVOURER_STA_IDENTITY: not armed - the RX loop is not " + "running (it failed or ended)"); + else + log->error("DEVOURER_STA_IDENTITY: SetStationIdentity refused after {} " + "attempt(s)", + attempts); + return ok; +} + +/* The scheduled clear, after a successful arm. Emits `sta.clear`. */ +inline void station_clear_after(IRadio *dev, const StationArmRequest &req, + EventSink &ev, const Logger_t &log, + const std::atomic &stop) { + if (req.clear_after_ms == 0) + return; + for (uint32_t s = 0; s < req.clear_after_ms && !stop.load(); s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (stop.load()) + return; + const bool ok = dev->ClearStationIdentity(); + Ev(ev, "sta.clear").f("ok", ok ? 1 : 0); + if (ok) + log->info("DEVOURER_STA_CLEAR_AFTER_MS: station identity cleared and " + "verified"); + else + log->error("DEVOURER_STA_CLEAR_AFTER_MS: ClearStationIdentity could not " + "verify its rollback"); +} + +} /* namespace devourer */ + +#endif /* DEVOURER_STATION_ARM_ENV_H */ diff --git a/examples/rx/main.cpp b/examples/rx/main.cpp index 9c0fa5b3..74952cf5 100644 --- a/examples/rx/main.cpp +++ b/examples/rx/main.cpp @@ -30,6 +30,7 @@ #include "SweepSpec.h" #include "TriggerParse.h" #include "caps_event.h" +#include "station_arm_env.h" #if defined(DEVOURER_HAVE_JAGUAR1) #include "jaguar1/RtlJaguarDevice.h" #endif @@ -108,6 +109,9 @@ static constexpr uint16_t kRealtekProductIds[] = { /* Written on the RX callback thread, read by the main thread and the pollers * as the "bring-up produced a frame" signal, so atomic. */ static std::atomic g_rx_count{0}; +/* Set when a worker-thread Init (the hop and sweep paths) has returned or + * thrown: RX is no longer running, so DEVOURER_STA_IDENTITY must not arm. */ +static std::atomic g_rx_ended{false}; #if defined(DEVOURER_HAVE_JAGUAR1) static RtlJaguarDevice *g_rtl_device = nullptr; #endif @@ -1347,6 +1351,12 @@ int main(int argc, char **argv) { .f("stage", "demo.create_device") .f("ms", ms_since_start()); devourer::emit_adapter_caps(*g_ev, dev); + if (const char *sta = std::getenv("DEVOURER_STA_IDENTITY"); + sta != nullptr && *sta != '\0') { + logger->error("DEVOURER_STA_IDENTITY is wired on the USB path only - " + "refusing rather than running an unarmed station"); + return 1; + } int pch = 36; if (const char *ch_env = std::getenv("DEVOURER_CHANNEL")) pch = std::atoi(ch_env); @@ -1504,6 +1514,13 @@ int main(int argc, char **argv) { .f("stage", "demo.create_device") .f("ms", ms_since_start()); devourer::emit_adapter_caps(*g_ev, rtlDevice); + /* DEVOURER_STA_IDENTITY (examples/common/station_arm_env.h). Parsed here so + * a malformed value stops the run before bring-up: a station measured + * unarmed must never be reported as armed. */ + bool sta_bad = false; + const auto sta_req = devourer::station_arm_request_from_env(logger, sta_bad); + if (sta_bad) + return 1; /* Backend-scoped measurement hook. Scheduling belongs to each measured * concrete backend so the delay starts after its arm/bring-up rather than * racing Init from a generic side thread. Refuse unmeasured paths: a green @@ -1875,6 +1892,30 @@ int main(int argc, char **argv) { }); } + /* The station arm: IRadio orders it after the RX loop is running, so it + * waits for the first frame (10 s cap - a silent channel still arms; a + * backend refuses before bring-up and the helper retries that), then arms, + * then runs the optional scheduled clear. A worker-thread Init that has + * failed or ended (g_rx_ended) refuses the arm; the main-path Init runs on + * this thread's caller, and its end stops this thread through sta_stop. */ + std::atomic sta_stop{false}; + std::thread sta_thread; + if (sta_req) { + IRadio *dev = rtlDevice; + const devourer::StationArmRequest req = *sta_req; + sta_thread = std::thread([&sta_stop, dev, req, logger]() { + for (uint32_t s = 0; s < 10000 && !sta_stop.load() && + !g_rx_ended.load() && g_rx_count.load() == 0; + s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (sta_stop.load()) + return; + if (devourer::station_arm_run(dev, req, *g_ev, logger, sta_stop, + &g_rx_ended)) + devourer::station_clear_after(dev, req, *g_ev, logger, sta_stop); + }); + } + /* Every path below may have one or more background register readers alive. * Stop them before DeviceSession tears down the device/USB transport, and do * the same on exceptions: a joinable std::thread destructor terminates the @@ -1886,6 +1927,9 @@ int main(int argc, char **argv) { #endif energy_emitter_stop = true; busy_emitter_stop = true; + sta_stop = true; + if (sta_thread.joinable()) + sta_thread.join(); if (busy_emitter.joinable()) busy_emitter.join(); if (therm_emitter.joinable()) @@ -2043,6 +2087,7 @@ int main(int argc, char **argv) { } catch (const std::exception &e) { logger->error("lockstep RX failed: {}", e.what()); } + g_rx_ended = true; }); /* Do not race FastRetune against firmware/calibration bring-up. A frame on * the parked channel proves RX is live; a silent link uses the same 10 s @@ -2215,6 +2260,7 @@ int main(int argc, char **argv) { } catch (const std::exception &e) { logger->error("RX-sweep bring-up failed: {}", e.what()); } + g_rx_ended = true; }); /* Let bring-up complete before the first retune: a retune racing the * worker thread's init (FW download, DACK/IQK on Jaguar3) interleaves diff --git a/examples/tx/main.cpp b/examples/tx/main.cpp index b8c3ad04..33112639 100644 --- a/examples/tx/main.cpp +++ b/examples/tx/main.cpp @@ -48,6 +48,7 @@ #include "SweepSpec.h" #include "TxPower.h" /* txpkt_pwr_db_for_step — DEVOURER_TX_PKT_OFSET fan-out */ #include "caps_event.h" +#include "station_arm_env.h" #if defined(DEVOURER_HAVE_JAGUAR1) #include "jaguar1/RtlJaguarDevice.h" #endif @@ -311,7 +312,9 @@ static bool hopset_sense_window(IRtlRadio *dev, uint32_t settle_us, return true; } -static int g_rx_count = 0; +/* Atomic: the station arm reads it from the main thread as proof the RX loop + * is running. */ +static std::atomic g_rx_count{0}; /* Windowed RX receipts (src/cell/RxReceipt.h): DEVOURER_TX_RECEIPTS=1 arms * the transmitter-side ledger. Receipts name our TA — DEVOURER_TX_SA when * set, else the canonical SA — and anything else refuses to absorb. */ @@ -325,7 +328,7 @@ static void packetProcessor(const Packet &packet) { * Jaguar3), not 802.11 frames — skip before counting/parsing. */ if (packet.RxAtrib.pkt_rpt_type == RX_PACKET_TYPE::C2H_PACKET) return; - ++g_rx_count; + const int rx_n = ++g_rx_count; /* Windowed RX receipts (DEVOURER_TX_RECEIPTS=1, needs TX_WITH_RX=thread): * a plain data frame whose body parses as a receipt TLV naming OUR TA is * the receiver's delivered-set update (src/cell/RxReceipt.h). Every @@ -352,9 +355,9 @@ static void packetProcessor(const Packet &packet) { } /* RX liveness marker for the TX+RX=thread mode: first frame + every 500th. * Without it a deaf RX loop is indistinguishable from a quiet channel. */ - if (g_rx_count == 1 || g_rx_count % 500 == 0) { + if (rx_n == 1 || rx_n % 500 == 0) { devourer::Ev(*g_ev, "rx.count") - .f("total", g_rx_count) + .f("total", rx_n) .f("len", packet.Data.size()); } /* BF self-sounding report detector (DEVOURER_BF_DETECT_REPORT modes 1-4, @@ -372,7 +375,7 @@ static void packetProcessor(const Packet &packet) { /* rx.txhit fields are parsed by tests/regress.py — keep names stable. */ devourer::Ev(*g_ev, "rx.txhit") .f("hits", hits) - .f("total_rx", g_rx_count) + .f("total_rx", rx_n) .f("len", packet.Data.size()); } } @@ -691,6 +694,22 @@ int main(int argc, char **argv) { * nothing reaches the air" (issue #36). This thread polls EP 0x85 until * the process is killed; failures other than -ETIMEDOUT are logged once * per N. */ + /* DEVOURER_STA_IDENTITY (examples/common/station_arm_env.h). Here it needs + * DEVOURER_TX_WITH_RX=thread: IRadio orders the arm after the RX loop, and + * a station that cannot hear its ACKs is not one. Checked before any + * thread below is started, so a refusal returns with nothing to join. */ + bool sta_bad = false; + const auto sta_req = devourer::station_arm_request_from_env(logger, sta_bad); + if (sta_bad) + return 1; + if (sta_req) { + const char *twr = std::getenv("DEVOURER_TX_WITH_RX"); + if (twr == nullptr || std::string(twr) != "thread") { + logger->error("DEVOURER_STA_IDENTITY needs DEVOURER_TX_WITH_RX=thread " + "on txdemo"); + return 1; + } + } /* Optional bulk-IN drainer on EP 0x81 — gated by DEVOURER_DRAIN_BULK_IN=1. * The kernel `88XXau` driver pre-arms 8 bulk-IN URBs of 32 KB each on * EP 0x81 at the end of init, *before* the first TX. The RTL8814AU @@ -1037,8 +1056,11 @@ int main(int argc, char **argv) { * StartRxLoop assumes the chip is up and takes over bulk-IN; send_packet's * bulk-OUT is safe alongside it. packetProcessor runs on this thread. */ std::thread rx_thread; + /* Set once StartRxLoop has returned or thrown - the RX loop is gone. The + * station arm below refuses on it. */ + std::atomic rx_ended{false}; if (rx_thread_mode) { - rx_thread = std::thread([&rtlDevice, logger] { + rx_thread = std::thread([&rtlDevice, &rx_ended, logger] { /* An uncaught exception in a std::thread is std::terminate — a transient * USB read failure in the RX loop must not tear down the TX process. */ try { @@ -1046,10 +1068,47 @@ int main(int argc, char **argv) { } catch (const std::exception &e) { logger->error("RX loop died: {} (TX continues)", e.what()); } + rx_ended = true; }); logger->info("DEVOURER_TX_WITH_RX=thread: RX loop started alongside TX"); } + /* DEVOURER_STA_IDENTITY: arm before the first frame, once the RX loop is + * shown running - its first received frame (3 s cap: a silent channel still + * arms) - and refuse if it has failed or ended (rx_ended). A refused arm + * sends nothing and exits 1 - frames from an unarmed station must not be + * scored as a station's. */ + std::atomic sta_stop{false}; + std::thread sta_clear_thread; + /* Joins the scheduled clear on every exit path, the early returns below + * included - a joinable std::thread destructor terminates the process. */ + struct StaClearJoin { + std::atomic &stop; + std::thread &t; + ~StaClearJoin() { + stop = true; + if (t.joinable()) + t.join(); + } + } sta_clear_join{sta_stop, sta_clear_thread}; + bool sta_failed = false; + if (sta_req) { + for (int s = 0; s < 3000 && !rx_ended.load() && !g_devourer_should_stop && + g_rx_count.load() == 0; + s += 50) + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + if (!devourer::station_arm_run(rtlDevice, *sta_req, *g_ev, logger, + sta_stop, &rx_ended)) { + sta_failed = true; + g_devourer_should_stop = true; + } else if (sta_req->clear_after_ms > 0) { + const devourer::StationArmRequest req = *sta_req; + sta_clear_thread = std::thread([&sta_stop, rtlDevice, req, logger]() { + devourer::station_clear_after(rtlDevice, req, *g_ev, logger, sta_stop); + }); + } + } + uint8_t beacon_frame[] = { 0x00, 0x00, 0x0a, 0x00, 0x00, 0x80, 0x00, 0x00, 0x08, 0x00, // radiotap: TX_FLAGS only (rate-less) — rate comes from SetTxMode @@ -2543,7 +2602,8 @@ int main(int argc, char **argv) { .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); + .f("final", 1) + .t(); } /* Shard accounting: what the producer handed us versus what the chip was @@ -2594,6 +2654,9 @@ int main(int argc, char **argv) { /* Join the RX loop BEFORE Stop(): the chip de-init must not race in-flight * RX URBs (and StartRxLoop's event pump must exit before libusb_exit). */ + sta_stop = true; + if (sta_clear_thread.joinable()) + sta_clear_thread.join(); rtlDevice->StopRxLoop(); if (rx_thread.joinable()) rx_thread.join(); @@ -2613,5 +2676,7 @@ int main(int argc, char **argv) { session.close(); /* A truncated caller stream is a producer fault, and a harness that scored * the run as if it had ended cleanly would be scoring a short measurement. */ + if (sta_failed) + return 1; return stdin_truncated ? 2 : 0; } diff --git a/src/AckResponder.h b/src/AckResponder.h index b5dcbeed..cdc489e7 100644 --- a/src/AckResponder.h +++ b/src/AckResponder.h @@ -285,5 +285,148 @@ inline bool verify(RtlAdapter &dev, const uint8_t mac[6]) noexcept { } } +/* --- the STATION half of port 0 (IRadio::SetStationIdentity) -------------- + * + * The same three registers as the responder recipe above, in the vendor + * drivers' station arrangement (hw_var_set_opmode STATION / + * Set_MSR(_HW_STATE_STATION_)): MACID = the station's OWN address, BSSID = + * the AP's, net_type = Infra (2). The ACK engine matches address 1 against + * MACID, so this is what makes the adapter answer the AP's unicast; net_type + * is the gate the AP-mode work found on the Jaguar generations (the RTL8733B + * has no gate - see retarget()). + * + * WHAT IT DELIBERATELY LEAVES ALONE: the receive filter. The vendor station + * path also rewrites RCR to a managed value (CBSSID_DATA/BCN and friends) + * because its host stack trusts the MAC to filter. A station built on src/sta + * filters in software (its address checks and BssTable), as the MT7612U + * station does, so a managed RCR would buy nothing the host does not already + * do and would change what every other consumer of the RX loop sees. + * + * The rollback target is the EXACT pre-arm port state - MACID, BSSID and the + * net_type bits - because on Jaguar1/CHIP_8812 a gate-only clear was measured + * to leave the old MACID answering (see the ACK-responder section above and + * AdapterCaps.h). Restoring all three is correct on every die, so every + * backend uses it rather than a per-die subset. */ +constexpr uint8_t kNetTypeMask = 0x03u; +constexpr uint8_t kNetTypeInfra = 0x02u; + +struct StationRestore { + PortIdentity identity; + uint8_t net_type = 0; /* 0x0102[1:0] before the arm */ +}; + +inline bool snapshot_station_restore(RtlAdapter &dev, + StationRestore &out) noexcept { + try { + if (!snapshot_port_identity(dev, out.identity)) + return false; + out.net_type = static_cast(dev.rtw_read8(0x0102) & kNetTypeMask); + return true; + } catch (...) { + return false; + } +} + +/* Gate closed first, as enable() does, so a failed identity write leaves the + * port passive rather than answering for half an address. The gate close is a + * PRECONDITION: if its transfer fails nothing else is written. Past it, every + * identity write is attempted (no short-circuit), and the gate opens only if + * all four reported success. The return is transfer status, NOT the verdict: + * a write can report failure and land, or report success and not - which is + * why StationArm ignores it and decides on station_is() alone. */ +inline bool arm_station(RtlAdapter &dev, const uint8_t own[6], + const uint8_t bssid[6]) noexcept { + try { + const uint8_t nt = dev.rtw_read8(0x0102); + const uint8_t closed = static_cast(nt & ~kNetTypeMask); + if (!dev.rtw_write8(0x0102, closed)) + return false; + const bool ml = dev.rtw_write(0x0610, macid_lo(own)); + const bool mh = dev.rtw_write16(0x0614, macid_hi(own)); + const bool bl = dev.rtw_write(0x0618, macid_lo(bssid)); + const bool bh = dev.rtw_write16(0x061c, macid_hi(bssid)); + if (!(ml && mh && bl && bh)) + return false; + return dev.rtw_write8(0x0102, + static_cast(closed | kNetTypeInfra)); + } catch (...) { + return false; + } +} + +/* Did the station arm land? net_type, MACID and BSSID all read back. Unlike + * verify() above, BSSID IS checked here: it is what the caller asked for, and + * on the Infra path the MAC uses it (beacon TSF sync, CBSSID matching) even + * though the ACK decision rides on MACID. */ +inline bool station_is(RtlAdapter &dev, const uint8_t own[6], + const uint8_t bssid[6]) noexcept { + try { + return (dev.rtw_read8(0x0102) & kNetTypeMask) == kNetTypeInfra && + dev.rtw_read(0x0610) == macid_lo(own) && + dev.rtw_read16(0x0614) == macid_hi(own) && + dev.rtw_read(0x0618) == macid_lo(bssid) && + dev.rtw_read16(0x061c) == macid_hi(bssid); + } catch (...) { + return false; + } +} + +/* Back to the snapshot: gate closed, identity restored, then the pre-arm + * net_type bits. Returns the READBACK verdict, not the transfer status. + * + * The gate close is a PRECONDITION, as in arm_station: when it fails (a + * refused write or a throw) the identity is NOT touched - moving it under a + * live Infra port would answer for a half-restored address - and the result + * is whether the port already reads as the snapshot. Anything else is false, + * which keeps the arm recorded so ClearStationIdentity can retry. */ +inline bool station_matches(RtlAdapter &dev, + const StationRestore &saved) noexcept { + /* Its OWN guard: a write that threw may or may not have landed, and only + * the readback knows (tests/station_arm_selftest.cpp, the throwing + * transport). */ + try { + return port_identity_is(dev, saved.identity) && + (dev.rtw_read8(0x0102) & kNetTypeMask) == + (saved.net_type & kNetTypeMask); + } catch (...) { + return false; + } +} + +inline bool restore_station(RtlAdapter &dev, + const StationRestore &saved) noexcept { + bool closed = false; + try { + const uint8_t nt = dev.rtw_read8(0x0102); + closed = dev.rtw_write8(0x0102, static_cast(nt & ~kNetTypeMask)); + } catch (...) { + } + if (!closed) + return station_matches(dev, saved); + (void)restore_port_identity(dev, saved.identity); + try { + const uint8_t nt = dev.rtw_read8(0x0102); + (void)dev.rtw_write8(0x0102, + static_cast((nt & ~kNetTypeMask) | + (saved.net_type & kNetTypeMask))); + } catch (...) { + } + return station_matches(dev, saved); +} + +/* The seam's argument rule (IRadio.h): both unicast, and different - and + * neither all-zero. */ +inline bool station_args_ok(const uint8_t own[6], + const uint8_t bssid[6]) noexcept { + /* is_unicast() alone passes 00:00:00:00:00:00, which would program + * MACID = 0 - unsafe for the reason has_safe_restore_mac() gives - and is + * no AP's BSSID either: a zero address on either side is refused. */ + static const uint8_t kZero[6] = {0, 0, 0, 0, 0, 0}; + return is_unicast(own) && is_unicast(bssid) && + std::memcmp(own, kZero, 6) != 0 && + std::memcmp(bssid, kZero, 6) != 0 && + std::memcmp(own, bssid, 6) != 0; +} + } /* namespace ack */ } /* namespace devourer */ diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index bbc82c7b..d0d5a00c 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -282,7 +282,37 @@ struct AdapterCaps { * uplink at 1.9 mean retries against the first unit's 0.0), not the * BSSID receive table. * - * FALSE on every other backend: not ported. */ + * TRUE on the Jaguar3 8822C and the Jaguar2 8822B dies, through the + * Realtek arm (src/StationArm.h has how it differs from the MT7612U: it + * configures the port rather than checking it, and refuses the other + * port-0 claimants rather than being dropped by them). Both halves were + * measured by tests/realtek_station_onair.sh, which arms through the seam + * itself and reads the transmitter's own CCX reports; one RTL8812CU and + * one RTL8812BU, each the other's peer. Read docs/realtek-station-arm.md + * - its limits section above all (one unit, two runs on one rig, near + * field, one AP type; what "received" means; the report gap) - before + * quoting: + * + * 8812CU station 8812BU station + * A armed 100.0% ok, 0.03 retries 100.0% ok, 0.33 retries + * B nobody 0.0%, 12.00 0.0%, 12.00 + * C DUT absent 0.0%, 12.00 0.0%, 12.00 + * D unarmed 0.0%, 12.00 0.0%, 12.00 + * E cleared 0.0%, 12.00 0.0%, 12.00 + * F uplink->AP 100.0%, 0.09 100.0%, 0.20 + * G uplink->none 0.0%, 12.00 0.0%, 12.00 + * H unarmed F 100.0%, 0.08 100.0%, 0.20 + * + * (Current record; an earlier record on the same rig matches it.) The + * flag rests on both halves met WHILE ARMED. H shows the uplink half holds + * without the arm too - the AP acknowledges by address - so on these dies + * the arm is what the DOWN half needs (D and E at 0%). + * + * FALSE on the other Realtek dies, where SetStationIdentity is ported and + * unmeasured: the 8822E (not measured by this cell), the 8821C, and every + * Jaguar1 die (8812, 8814A, 8821A, the 8811AU cut). + * + * FALSE on Kestrel and the RTL8733B: not ported. */ bool station_mode_ok = false; /* TxMode::no_agg is honoured: a frame carrying the radiotap TX_FLAGS diff --git a/src/StationArm.h b/src/StationArm.h new file mode 100644 index 00000000..49bf0fbc --- /dev/null +++ b/src/StationArm.h @@ -0,0 +1,210 @@ +#pragma once + +/* StationArm - the per-device state behind IRadio::SetStationIdentity on the + * Realtek generations that share the port-0 register map (Jaguar1/2/3). + * + * The register recipe lives in AckResponder.h (arm_station / station_is / + * restore_station); this holds the one piece of state the seam's contract + * needs on top of it - the exact pre-arm port snapshot - and the refusal + * rules, so the three backends carry a call rather than three copies of the + * logic. The caller supplies the lock (each backend serializes register + * access its own way) and its own port-0 ownership checks. + * + * CONFIGURE, NOT CHECK. The MT7612U arm writes nothing: its bring-up already + * leaves the port identity on the station's own address, so its seam only + * verifies that. These dies cannot take that shape. Jaguar2/3 bring-up never + * programs MACID, and net_type comes up NoLink on all three, so a port left + * as bring-up made it does not answer for `own`. The arm therefore writes + * MACID = own, BSSID = the AP, net_type = Infra, and reads all three back. + * (On the Jaguar1 8812 die bring-up does program the EFUSE MAC into MACID; + * the arm writes it anyway, so every die ends in the same verified state.) + * + * REFUSALS, all logged, none throwing: + * - the seam's argument rule (both unicast, different), plus neither + * address all-zero (MACID = 0 is not a safe identity). Like the MT7612U, + * this refusal writes nothing and leaves an existing arm in place - a + * false return is not proof of a passive port (IRadio); + * - port 0 already has a net_type set by someone else on the FIRST arm: + * a beacon or an ACK responder owns it, and arming over it would take + * the port away from them. A re-arm (a new BSSID while armed) is not + * refused - it replaces our own arm and keeps the original snapshot, so + * Clear still returns to the state before the FIRST arm; + * - any readback that does not match. A FAILED arm - first or re-arm - + * rolls back to the pre-FIRST-arm snapshot and, once that verifies, is + * no longer armed: a re-arm that fails tears down the arm before it + * rather than guessing that the old identity still holds, and returns + * false so the caller knows the port is passive. When the rollback does + * not verify either, the arm stays recorded so ClearStationIdentity can + * retry it. + * + * LATER PORT-0 CLAIMANTS are REFUSED by the backends while a station is + * armed (IRadio leaves the rule backend-specific): SetAckResponder and + * StartBeacon return false, and ClearAckResponder leaves the port alone on + * Jaguar2/3, where its gate-only clear would otherwise close the station's + * net_type. Unlike the MT7612U, a station arm is never dropped from under + * the caller. + * + * ORDERING (IRadio's clause): on Jaguar1/2/3 the port-0 writers are + * bring-up, the tail of Init/InitWrite (the BF beamformee identity into + * MACID, the configured ACK responder), the beacon and the ACK responder - + * no RX-loop start writes 0x0102/0x0610/0x0618. So the backends refuse the + * arm until Init/InitWrite has made its last port-0 write (each backend's + * _station_ready, committed at the end of a bring-up that did not throw) and + * need nothing else; calling before or after StartRxLoop is the same. + * + * LIFETIME: the arm ends with the session. Stop() and the destructor clear + * it (best effort, logged), because not every teardown powers the chip down + * (Jaguar2's does not; Jaguar1's is optional) and a port left on MACID = own + * / Infra goes on acknowledging for a station whose process has gone. A + * (re-)bring-up clears a held arm first (retire()); a clear that does not + * verify keeps the record for ClearStationIdentity to retry. */ + +#include +#include +#include + +#include "AckResponder.h" +#include "IRadio.h" +#include "logger.h" + +namespace devourer { + +class StationArm { +public: + bool armed() const { return _restore.has_value(); } + + /* The (re-)bring-up entry: a re-Init of an object that still holds an arm + * clears it first. A clear that does not verify KEEPS the record, so a + * later ClearStationIdentity retries it and armed() keeps the other port-0 + * claimants refused: bring-up does not reprogram MACID or net_type on + * Jaguar2/3, so the port can still be answering for the station, and + * dropping the record would make the next Clear a trivially-true no-op. + * Keeping it is safe on every die - the snapshot is the station-free port + * state an earlier bring-up left (MACID as bring-up programs it, or not; + * net_type NoLink), which is what restoring it writes back. Caller holds + * the backend's station lock. */ + void retire(RtlAdapter &dev, const Logger_t &log, const char *tag) { + if (!armed()) + return; + bool cleared = false; + try { + cleared = clear(dev, log, tag); + } catch (...) { + } + if (!cleared) { + try { + log->error("{}: station identity kept across the bring-up: its clear " + "did not verify, so ClearStationIdentity will retry it", + tag); + } catch (...) { + } + } + } + + /* `retry_limit` is the session's DeviceConfig::tx.retry_limit as this die + * applies it, or nullopt where the die ignores the knob and airs every + * unicast once (the Jaguar1 8814A carve-out). It is not part of the arm; + * it decides the WARN a successful arm prints when the station's own + * unicast would never be retransmitted. */ + bool arm(RtlAdapter &dev, const MacAddr &own, const MacAddr &bssid, + std::optional retry_limit, const Logger_t &log, + const char *tag) { + if (!ack::station_args_ok(own.data(), bssid.data())) { + log->error("{}: station identity refused: own and BSSID must both be " + "unicast, non-zero and different", + tag); + return false; + } + if (!_restore) { + ack::StationRestore snap; + if (!ack::snapshot_station_restore(dev, snap)) { + log->error("{}: station identity refused: the port-0 state could " + "not be read, so no rollback target exists", + tag); + return false; + } + if (snap.net_type != 0) { + log->error("{}: station identity refused: port 0 already has " + "net_type {} (a beacon or an ACK responder owns it, or " + "an earlier session left it set)", + tag, snap.net_type); + return false; + } + _restore = snap; + } + /* The transfer status is not the verdict - a write can report failure + * and land, or report success and not. station_is() decides. */ + (void)ack::arm_station(dev, own.data(), bssid.data()); + if (ack::station_is(dev, own.data(), bssid.data())) { + log->info("{}: station identity armed: own " + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x} BSSID " + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x} (net_type=Infra)", + tag, own.bytes[0], own.bytes[1], own.bytes[2], own.bytes[3], + own.bytes[4], own.bytes[5], bssid.bytes[0], bssid.bytes[1], + bssid.bytes[2], bssid.bytes[3], bssid.bytes[4], + bssid.bytes[5]); + warn_if_unicast_never_retried(retry_limit, log, tag); + return true; + } + if (ack::restore_station(dev, *_restore)) { + log->error("{}: station identity arm did not read back; pre-arm port " + "state restored and verified", + tag); + _restore.reset(); + } else { + log->error("{}: station identity arm did not read back AND the " + "rollback did not verify; port-0 state is unknown (Clear " + "will retry it)", + tag); + } + return false; + } + + /* IRadio::ClearStationIdentity: true when nothing was armed (nothing to + * undo) or the pre-arm state was restored AND read back. */ + bool clear(RtlAdapter &dev, const Logger_t &log, const char *tag) { + if (!_restore) + return true; + if (!ack::restore_station(dev, *_restore)) { + log->error("{}: station identity clear did not verify; the port may " + "still answer for the station address", + tag); + return false; + } + _restore.reset(); + log->info("{}: station identity cleared (pre-arm MACID/BSSID/net_type " + "restored)", + tag); + return true; + } + + /* The arm covers RECEIVE and auto-ACK only. What the station transmits is + * the caller's: its unicast must request an ACK + * (build_stream_radiotap(mode, false)), and the descriptor retries an + * unacknowledged frame only tx.retry_limit times - default 0. Not refused + * (an RX-only or test session may want exactly that), but said, because + * nothing else would say it. Same condition and wording as the MT7612U + * arm. */ + static void warn_if_unicast_never_retried(std::optional retry_limit, + const Logger_t &log, + const char *tag) { + if (!retry_limit) { + log->warn("{}: station identity armed on a die that ignores " + "tx.retry_limit and sends every unicast frame once - the MAC " + "will not retransmit this station's unacknowledged unicast", + tag); + return; + } + if (std::clamp(*retry_limit, 0, 63) == 0) + log->warn("{}: station identity armed with tx.retry_limit=0 - the MAC " + "will not retransmit this station's unacknowledged unicast. " + "Set DEVOURER_TX_RETRY_LIMIT / tx.retry_limit (nonzero) and " + "send unicast with an ACK-requesting radiotap", + tag); + } + +private: + std::optional _restore; +}; + +} /* namespace devourer */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index f5935bc7..399b4be0 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -104,6 +104,14 @@ void RtlJaguarDevice::InitWrite(SelectedChannel channel) { std::lock_guard ccx(busy_window_mutex()); busy_window_reset(); } + /* Likewise a station arm: a re-Init of a live session clears an arm this + * object holds before the bring-up below; one whose clear does not verify + * stays recorded for ClearStationIdentity (StationArm::retire). */ + { + std::lock_guard lock(_port0_mu); + _station_ready = false; + _station.retire(_device, _logger, "Jaguar1"); + } std::optional configured_arm_generation; JaguarScopeExit rollback([&] { if (!configured_arm_generation) @@ -162,6 +170,10 @@ void RtlJaguarDevice::InitWrite(SelectedChannel channel) { if (_cfg.tx.ampdu) SetAmpduMode(*_cfg.tx.ampdu); /* DEVOURER_TX_AMPDU_MODE */ + { + std::lock_guard lock(_port0_mu); + _station_ready = true; /* every port-0 write of this bring-up is done */ + } rollback.release(); } @@ -565,6 +577,11 @@ bool RtlJaguarDevice::StartBeacon(const uint8_t *beacon, size_t len, "responder is armed; clear the responder first"); return false; } + if (_station.armed()) { + _logger->error("beacon(J1): cannot claim port 0 while a station " + "identity is armed; ClearStationIdentity first"); + return false; + } /* Mirrors RtlJaguar2Device::StartBeacon on the pre-HalMAC registers, in the * VENDOR ORDER: port/beacon configuration first, reserved-page download * LAST. A download issued before the port is configured latches BCN_VALID @@ -862,6 +879,11 @@ bool RtlJaguarDevice::SetAckResponder(const devourer::MacAddr &mac) { "beacon owns MACID/BSSID/net_type"); return false; } + if (_station.armed()) { + _logger->error("Jaguar1: ACK responder cannot be armed while a station " + "identity owns port 0; ClearStationIdentity first"); + return false; + } if (_eepromManager->version_id.ICType == CHIP_8812) { const bool had_restore_identity = _ack_restore_identity.has_value(); if (!_ack_restore_identity) { @@ -997,6 +1019,34 @@ bool RtlJaguarDevice::disarm_ack_responder() { return true; } +bool RtlJaguarDevice::SetStationIdentity(const devourer::MacAddr &own, + const devourer::MacAddr &bssid) { + std::lock_guard lock(_port0_mu); + if (!_station_ready) { + _logger->error("Jaguar1: station identity refused until bring-up " + "(Init/InitWrite) has finished"); + return false; + } + if (_port0_beacon_claimed || _port0_ack_claimed) { + _logger->error("Jaguar1: station identity refused: port 0 is claimed " + "by the {}", + _port0_beacon_claimed ? "beacon" : "ACK responder"); + return false; + } + /* The 8814A descriptor keeps the vendor DATA_RETRY_LIMIT=0 whatever + * tx.retry_limit says (tx_retry_limit_ok), so a station there sends every + * unicast once - nullopt makes the arm say that. */ + std::optional retry; + if (_eepromManager->version_id.ICType != CHIP_8814A) + retry = _cfg.tx.retry_limit; + return _station.arm(_device, own, bssid, retry, _logger, "Jaguar1"); +} + +bool RtlJaguarDevice::ClearStationIdentity() { + std::lock_guard lock(_port0_mu); + return _station.clear(_device, _logger, "Jaguar1"); +} + void RtlJaguarDevice::ClearAckResponder() { (void)disarm_ack_responder(); } @@ -1624,6 +1674,14 @@ void RtlJaguarDevice::Init(Action_ParsedRadioPacket packetProcessor, std::lock_guard ccx(busy_window_mutex()); busy_window_reset(); } + /* Likewise a station arm: a re-Init of a live session clears an arm this + * object holds before the bring-up below; one whose clear does not verify + * stays recorded for ClearStationIdentity (StationArm::retire). */ + { + std::lock_guard lock(_port0_mu); + _station_ready = false; + _station.retire(_device, _logger, "Jaguar1"); + } std::optional configured_arm_generation; JaguarScopeExit rollback([&] { if (!configured_arm_generation) @@ -1736,6 +1794,10 @@ void RtlJaguarDevice::Init(Action_ParsedRadioPacket packetProcessor, } } + { + std::lock_guard lock(_port0_mu); + _station_ready = true; /* every port-0 write of this bring-up is done */ + } StartRxLoop(std::move(packetProcessor)); rollback.release(); } @@ -2229,6 +2291,10 @@ devourer::AdapterCaps RtlJaguarDevice::GetAdapterCaps() { * the vendor retry carve-out (knob inert). */ c.ack_responder_ok = true; c.tx_retry_limit_ok = _eepromManager->version_id.ICType != CHIP_8814A; + /* station_mode_ok: false on every Jaguar1 die - ported (StationArm.h), + * not yet measured by tests/realtek_station_onair.sh; the AdapterCaps + * declaration says what is and is not measured. */ + c.station_mode_ok = false; /* Per-packet TX power: 8814A only — its dword5 [30:28] descriptor LUT (the * 8822B TXPWR_OFSET position; vendor-defined, vendor-unused). measured * stays false until tests/txpkt_pwr_ofset_onair.sh proves it moves on-air @@ -2418,6 +2484,15 @@ void RtlJaguarDevice::Stop() { busy_window_reset(); } _device.quiesce_tx(); + /* A station arm ends with the session: restored before the optional + * power-down (best effort; a failure is logged by the clear), so a chip + * left powered (tuning.teardown_power_down=0) does not keep answering for + * the station. */ + { + std::lock_guard lock(_port0_mu); + if (_station.armed()) + (void)_station.clear(_device, _logger, "Jaguar1"); + } if (!_cfg.tuning.teardown_power_down) { _logger->info("Jaguar1: Stop() leaving the chip powered " "(tuning.teardown_power_down=0)"); @@ -2451,6 +2526,14 @@ RtlJaguarDevice::~RtlJaguarDevice() { if (_rxmask_thread.joinable()) { _rxmask_thread.join(); } + /* Safety net: a station arm the caller did not clear ends with the + * device, not with whatever state the chip is left in. */ + try { + std::lock_guard lk(_port0_mu); + if (_station.armed()) + (void)_station.clear(_device, _logger, "Jaguar1"); + } catch (...) { + } /* Backstop for a caller that destroys without Stop(): power the chip down so * it is not left in ACT indefinitely. After the thread joins, so nothing is * still touching the chip. Harmless if Stop() already ran. */ diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 66a9687f..d650346b 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -13,6 +13,7 @@ #include #include "AckResponder.h" +#include "StationArm.h" #include "logger.h" #include "BbDbgportReader.h" #include "LaCapture.h" @@ -91,6 +92,16 @@ class RtlJaguarDevice : public IRtlRadio { * back into PinBeaconTbtt, so serialize the complete ownership checks and * multi-register transactions with a recursive mutex. */ std::recursive_mutex _port0_mu; + /* The third port-0 claimant, next to the beacon and the ACK responder; + * each refuses while another holds the port. Under _port0_mu. */ + devourer::StationArm _station; + /* The station arm's readiness gate, committed late: false from the top of + * Init/InitWrite until that bring-up has made its last port-0 write (the + * BF beamformee identity into MACID, the configured ACK responder), and + * left false by a bring-up that throws. Not _brought_up: that flag goes + * true mid-bring-up because the tail's own setters apply live only once it + * is set. Under the station lock. */ + bool _station_ready = false; /* Shared by ClearAckResponder and SetAckResponder rollback. On 8812 * silicon a gate-only clear was measured to leave the old MACID answering, @@ -288,6 +299,18 @@ class RtlJaguarDevice : public IRtlRadio { /* Hardware ACK responder (IRadio contract; src/AckResponder.h). */ bool SetAckResponder(const devourer::MacAddr &mac) override; void ClearAckResponder() override; + /* Station identity (IRadio contract; src/StationArm.h): MACID = own, + * BSSID = the AP, net_type = Infra, read back, and restored and read back + * exactly on clear - the CHIP_8812 gate-only-clear finding is why every + * die restores MACID. Refused until Init/InitWrite has made its last + * port-0 write (_station_ready) and while the beacon or the ACK responder + * owns port 0; while armed, those two are refused in turn + * (ClearAckResponder has nothing of its own to clear and leaves the port + * alone). Order-independent of the RX loop: StartRxLoop writes no port-0 + * register here. */ + bool SetStationIdentity(const devourer::MacAddr &own, + const devourer::MacAddr &bssid) override; + bool ClearStationIdentity() override; /* Schedule rxdemo's hardware-only live-disarm cell. False leaves the * request unset. Enabled for the shared CHIP_8812 implementation; measured * on a reference RTL8812AU, not separately on its RTL8811AU cut. */ diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 8bb2e643..ac80f318 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -51,6 +51,14 @@ RtlJaguar2Device::~RtlJaguar2Device() { StopCwTone(); stop_pwrtrack(); stop_dig(); + /* Safety net: a station arm the caller did not clear ends with the + * device, not with whatever state the chip is left in. */ + try { + std::lock_guard lk(_reg_mu); + if (_station.armed()) + (void)_station.clear(_device, _logger, "Jaguar2"); + } catch (...) { + } } void RtlJaguar2Device::bring_up(SelectedChannel channel) { @@ -385,6 +393,16 @@ bool RtlJaguar2Device::SetAckResponder(const devourer::MacAddr &mac) { "Jaguar2", mac.bytes[0]); return false; } + /* Under _reg_mu end to end: the station check and the port write must be + * one step, or a SetStationIdentity between them has its identity + * overwritten while it still reads as armed. No caller (the Init/InitWrite + * config arms included) holds _reg_mu here. */ + std::lock_guard lk(_reg_mu); + if (_station.armed()) { + _logger->error("Jaguar2: ACK responder refused: a station identity owns " + "port 0 (ClearStationIdentity first)"); + return false; + } /* Hardware ACK responder (src/AckResponder.h): port identity + net_type so * the MAC auto-ACKs unicast frames to `mac`. Same registers the proven * StartBeacon/AP path programs, minus the beacon machinery. */ @@ -404,7 +422,47 @@ bool RtlJaguar2Device::SetAckResponder(const devourer::MacAddr &mac) { return true; } +bool RtlJaguar2Device::SetStationIdentity(const devourer::MacAddr &own, + const devourer::MacAddr &bssid) { + std::lock_guard lk(_reg_mu); + if (!_station_ready) { + _logger->error("Jaguar2: station identity refused until bring-up " + "(Init/InitWrite) has finished"); + return false; + } + /* The beacon's own record, not net_type: a ClearAckResponder can close the + * gate under a live beacon, and net_type alone would then read "free". */ + if (!_bcn_mpdu.empty()) { + _logger->error("Jaguar2: station identity refused: the beacon owns port " + "0 (StopBeacon first)"); + return false; + } + /* A StartBeacon whose rollback did not land leaves the beacon enables set + * with no beacon recorded; the StopBeacon that retires them writes + * net_type. */ + if (_bcn_hw_touched) { + _logger->error("Jaguar2: station identity refused: a beacon's hardware " + "state is still set (StopBeacon first)"); + return false; + } + return _station.arm(_device, own, bssid, _cfg.tx.retry_limit, _logger, + "Jaguar2"); +} + +bool RtlJaguar2Device::ClearStationIdentity() { + std::lock_guard lk(_reg_mu); + return _station.clear(_device, _logger, "Jaguar2"); +} + void RtlJaguar2Device::ClearAckResponder() { + std::lock_guard lk(_reg_mu); + /* The gate this closes is the station's too: a clear here would leave the + * station deaf to its AP while it still reads as armed. */ + if (_station.armed()) { + _logger->error("Jaguar2: ACK responder clear refused: port 0 belongs to " + "a station identity (ClearStationIdentity instead)"); + return; + } if (!devourer::ack::disable_verified(_device)) { _logger->error("Jaguar2: ACK responder disarm did not latch"); return; @@ -459,6 +517,14 @@ void RtlJaguar2Device::Init(Action_ParsedRadioPacket packetProcessor, std::lock_guard ccx(busy_window_mutex()); busy_window_reset(); } + /* Likewise a station arm: a re-Init of a live session clears an arm this + * object holds before the bring-up below; one whose clear does not verify + * stays recorded for ClearStationIdentity (StationArm::retire). */ + { + std::lock_guard lk(_reg_mu); + _station_ready = false; + _station.retire(_device, _logger, "Jaguar2"); + } _channel = channel; bring_up(channel); _rx_bw_code.store(channel_width_to_bw_code(channel.ChannelWidth), @@ -520,6 +586,10 @@ void RtlJaguar2Device::Init(Action_ParsedRadioPacket packetProcessor, _hal.dbg_rf_read(0, r), _hal.dbg_rf_read(1, r)); } + { + std::lock_guard lk(_reg_mu); + _station_ready = true; /* every port-0 write of this bring-up is done */ + } StartRxLoop(std::move(packetProcessor)); } @@ -712,6 +782,14 @@ void RtlJaguar2Device::InitWrite(SelectedChannel channel) { std::lock_guard ccx(busy_window_mutex()); busy_window_reset(); } + /* Likewise a station arm: a re-Init of a live session clears an arm this + * object holds before the bring-up below; one whose clear does not verify + * stays recorded for ClearStationIdentity (StationArm::retire). */ + { + std::lock_guard lk(_reg_mu); + _station_ready = false; + _station.retire(_device, _logger, "Jaguar2"); + } _channel = channel; /* TX shares the full cold bring-up (config_trx_mode enables the TX antenna * paths, enable_rx sets CR MACTXEN). The chip transmits at its @@ -762,6 +840,10 @@ void RtlJaguar2Device::InitWrite(SelectedChannel channel) { if (_cfg.tx.ampdu) SetAmpduMode(*_cfg.tx.ampdu); /* DEVOURER_TX_AMPDU_MODE */ apply_replay_wseq(); + { + std::lock_guard lk(_reg_mu); + _station_ready = true; /* every port-0 write of this bring-up is done */ + } _logger->info("Jaguar2: ready for TX (monitor inject, ch={})", channel.Channel); } @@ -1268,6 +1350,10 @@ devourer::AdapterCaps RtlJaguar2Device::GetAdapterCaps() { * bench cell yet, so its flags stay false-as-unmeasured. */ c.ack_responder_ok = _variant == jaguar2::ChipVariant::C8822B; c.tx_retry_limit_ok = _variant == jaguar2::ChipVariant::C8822B; + /* station_mode_ok: the 8822B die only - both halves measured on one + * RTL8812BU by tests/realtek_station_onair.sh (numbers and limits at the + * AdapterCaps declaration). The 8821C runs the same code with no cell. */ + c.station_mode_ok = _variant == jaguar2::ChipVariant::C8822B; c.hw_rx_timestamp = true; /* FrameParserJaguar2 fills RxAtrib.tsfl */ c.hw_beacon_txtsf = true; /* StartBeacon: MAC inserts the egress TSF into beacons */ c.tsf_write_ok = true; /* WriteTsf: REG_TSFTR (8822B readback) */ @@ -1769,6 +1855,11 @@ bool RtlJaguar2Device::GetPermanentMacAddress(uint8_t out[6]) { bool RtlJaguar2Device::StartBeacon(const uint8_t *beacon, size_t len, int interval_tu) { std::lock_guard lk(_reg_mu); + if (_station.armed()) { + _logger->error("beacon-tbtt(J2): refused: a station identity owns port 0 " + "(ClearStationIdentity first)"); + return false; + } /* Mirrors the working Jaguar3 path (RtlJaguar3Device::StartBeacon) — the same * two bugs (beacon at page 0, radiotap-in-rsvd-page) applied here. Validated on * hardware: RTL8812BU (2357:012d, Jaguar2) auto-transmits the beacon at TBTT, @@ -1953,8 +2044,11 @@ bool RtlJaguar2Device::disable_beacon_locked() { ok = _device.rtw_write(0x0420, txq & ~(1u << 22) /* BIT_EN_BCNQ_DL */) && ok; - uint8_t nt = _device.rtw_read8(0x0102); - ok = _device.rtw_write8(0x0102, static_cast(nt & ~0x03u)) && ok; + /* net_type belongs to a station arm while one is held: leave it. */ + if (!_station.armed()) { + uint8_t nt = _device.rtw_read8(0x0102); + ok = _device.rtw_write8(0x0102, static_cast(nt & ~0x03u)) && ok; + } return ok; } @@ -2229,6 +2323,15 @@ void RtlJaguar2Device::Stop() { } stop_pwrtrack(); stop_dig(); + /* This Stop leaves the chip powered, so a station arm would outlive the + * session: port 0 on MACID = own / Infra keeps acknowledging for the + * station after the process has gone. Clear it here (best effort; a + * failure is logged by the clear). */ + { + std::lock_guard lk(_reg_mu); + if (_station.armed()) + (void)_station.clear(_device, _logger, "Jaguar2"); + } } void RtlJaguar2Device::SetTxMode(const devourer::TxMode &mode) { diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 0d23405a..f47ef04d 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -12,6 +12,7 @@ #include "IRtlRadio.h" #include "TxMode.h" #include "RtlAdapter.h" +#include "StationArm.h" #include "SelectedChannel.h" #include "CfoTracker.h" #include "HalJaguar2.h" @@ -75,6 +76,17 @@ class RtlJaguar2Device : public IRtlRadio { /* Hardware ACK responder (IRadio contract; src/AckResponder.h). */ bool SetAckResponder(const devourer::MacAddr &mac) override; void ClearAckResponder() override; + /* Station identity (IRadio contract; src/StationArm.h): MACID = own, + * BSSID = the AP, net_type = Infra, under _reg_mu, read back; Clear + * restores and reads back the pre-arm MACID/BSSID/net_type. Refused until + * Init/InitWrite has made its last port-0 write (_station_ready) and while + * a beacon or an ACK responder owns port 0; while + * armed, SetAckResponder, ClearAckResponder and StartBeacon are refused in + * turn. Order-independent of the RX loop: StartRxLoop writes no port-0 + * register here. */ + bool SetStationIdentity(const devourer::MacAddr &own, + const devourer::MacAddr &bssid) override; + bool ClearStationIdentity() override; /* A-MPDU TX mode (IRadio contract; src/AmpduMode.h). Programs the * 8822B pacing regs (0x455 max-time, 0x4BC burst-mode) under _reg_mu and * records the descriptor state the TX path reads. */ @@ -362,6 +374,14 @@ class RtlJaguar2Device : public IRtlRadio { * FastRetune / the TX-power setters / GetThermalStatus by _reg_mu (the RF * read window is a multi-transfer sequence that must not tear). */ std::mutex _reg_mu; + devourer::StationArm _station; /* under _reg_mu */ + /* The station arm's readiness gate, committed late: false from the top of + * Init/InitWrite until that bring-up has made its last port-0 write (the + * BF beamformee identity into MACID, the configured ACK responder), and + * left false by a bring-up that throws. Not _brought_up: that flag goes + * true mid-bring-up because the tail's own setters apply live only once it + * is set. Under the station lock. */ + bool _station_ready = false; std::thread _pwrtrack_thread; std::atomic _pwrtrack_stop{false}; void start_pwrtrack(); diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index af7c9393..1d86df2b 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -90,6 +90,14 @@ void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, std::lock_guard ccx(busy_window_mutex()); busy_window_reset(); } + /* Likewise a station arm: a re-Init of a live session clears an arm this + * object holds before the bring-up below; one whose clear does not verify + * stays recorded for ClearStationIdentity (StationArm::retire). */ + { + std::lock_guard lk(_reg_mu); + _station_ready = false; + _station.retire(_device, _logger, "Jaguar3"); + } _channel = channel; _rx_bw_code.store(channel_width_to_bw_code(channel.ChannelWidth), std::memory_order_relaxed); @@ -187,6 +195,10 @@ void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, _device.rtw_read32(a), _device.rtw_read32(a + 4), _device.rtw_read32(a + 8), _device.rtw_read32(a + 12)); } + { + std::lock_guard lk(_reg_mu); + _station_ready = true; /* every port-0 write of this bring-up is done */ + } StartRxLoop(std::move(packetProcessor)); } @@ -295,6 +307,17 @@ void RtlJaguar3Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { return; if (std::chrono::steady_clock::now() < cfo_next) return; + /* NON-BLOCKING, and taken BEFORE the tracker steps, because this runs on + * the RX thread - the thread that drives libusb's event handling. A + * _reg_mu holder doing synchronous USB I/O (the coex tick, + * SetStationIdentity, any setter) waits for this thread, so blocking here + * would deadlock both (IRadio's StartRxLoop lock rule). On a busy lock + * the tracker is not stepped - its samples keep accumulating and the next + * completion tries again - so a skipped tick cannot feed its polarity + * detection a step that was never written. */ + std::unique_lock lk(_reg_mu, std::try_to_lock); + if (!lk.owns_lock()) + return; cfo_next += std::chrono::seconds(2); double avg_khz = 0; const int cur = _xtal_cap < 0 ? 0x20 : _xtal_cap; @@ -305,7 +328,6 @@ void RtlJaguar3Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { const uint32_t field = static_cast(nc) | (static_cast(nc) << 7); /* 14-bit */ try { - std::lock_guard lk(_reg_mu); _device.rtw_write(0x1040, reg1040_base | ((field << 10) & 0x00FFFC00u)); _xtal_cap = nc; @@ -313,6 +335,7 @@ void RtlJaguar3Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { return; } } + lk.unlock(); if (nc >= 0) /* log only on an actual step, not every idle tick */ _logger->info("Jaguar3 cfo.track: cfo~{} (raw*2.5) xtal_cap=0x{:02x}", static_cast(avg_khz), _xtal_cap); @@ -403,13 +426,33 @@ void RtlJaguar3Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { if (fb[24] == 0x15 && fb[25] == 0x00 && std::memcmp(fb + 10, _bf_peer, 6) == 0) { _bf_cbr_count.fetch_add(1, std::memory_order_relaxed); - if (!_bf_apply_on.load(std::memory_order_relaxed)) { - std::lock_guard lk(_reg_mu); - devourer::bf::apply_vmatrix( - _device, true, static_cast(_channel.ChannelWidth)); - _bf_apply_on.store(true, std::memory_order_relaxed); - _logger->info("Jaguar3 BF: CBR from peer ingested — TXBF apply " - "ENABLED (steering subsequent TX)"); + /* try_lock for the reason cfo_tick gives: this is the RX + * thread. Busy -> _bf_apply_on stays false, and the next CBR + * from the peer retries the apply. */ + std::unique_lock lk(_reg_mu, std::defer_lock); + const bool want = !_bf_apply_on.load(std::memory_order_relaxed); + if (want && !lk.try_lock()) { + DVR_DEBUG(_logger, "Jaguar3 BF: CBR ingested but _reg_mu busy " + "- apply skipped, the next CBR retries"); + } + if (want && lk.owns_lock()) { + /* Caught for the reason cfo_tick is: apply_vmatrix is a + * register read + write, and a failed transfer throws - which + * must not unwind through the extern "C" libusb callback. A + * throw leaves _bf_apply_on false, so the next CBR retries. */ + try { + devourer::bf::apply_vmatrix( + _device, true, static_cast(_channel.ChannelWidth)); + _bf_apply_on.store(true, std::memory_order_relaxed); + _logger->info("Jaguar3 BF: CBR from peer ingested — TXBF " + "apply ENABLED (steering subsequent TX)"); + } catch (const std::exception &e) { + DVR_DEBUG(_logger, "Jaguar3 BF: TXBF apply failed ({}) - " + "the next CBR retries", e.what()); + } catch (...) { + DVR_DEBUG(_logger, "Jaguar3 BF: TXBF apply failed - the next " + "CBR retries"); + } } } } @@ -460,6 +503,14 @@ RtlJaguar3Device::~RtlJaguar3Device() { _coex_stop = true; if (_coex_thread.joinable()) _coex_thread.join(); + /* Safety net: a station arm the caller did not clear ends with the + * device, not with whatever state the chip is left in. */ + try { + std::lock_guard lk(_reg_mu); + if (_station.armed()) + (void)_station.clear(_device, _logger, "Jaguar3"); + } catch (...) { + } } void RtlJaguar3Device::coex_runtime_loop() { @@ -804,6 +855,15 @@ void RtlJaguar3Device::Stop() { _coex_stop = true; if (_coex_thread.joinable()) _coex_thread.join(); + /* A station arm ends with the session, not with whatever the de-init + * below leaves: restored first (best effort; a failure is logged by the + * clear), so the port stops answering for the station even where the + * power-down does not complete. */ + { + std::lock_guard lk(_reg_mu); + if (_station.armed()) + (void)_station.clear(_device, _logger, "Jaguar3"); + } try { _hal.rtw_hal_deinit(); } catch (...) { @@ -819,6 +879,14 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { std::lock_guard ccx(busy_window_mutex()); busy_window_reset(); } + /* Likewise a station arm: a re-Init of a live session clears an arm this + * object holds before the bring-up below; one whose clear does not verify + * stays recorded for ClearStationIdentity (StationArm::retire). */ + { + std::lock_guard lk(_reg_mu); + _station_ready = false; + _station.retire(_device, _logger, "Jaguar3"); + } _channel = channel; _rx_bw_code.store(channel_width_to_bw_code(channel.ChannelWidth), std::memory_order_relaxed); @@ -1099,6 +1167,10 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { "Jaguar3: configured ACK responder could not be armed"); if (_cfg.tx.ampdu) SetAmpduMode(*_cfg.tx.ampdu); /* DEVOURER_TX_AMPDU_MODE */ + { + std::lock_guard lk(_reg_mu); + _station_ready = true; /* every port-0 write of this bring-up is done */ + } _logger->info("Jaguar3: ready for TX (monitor inject)"); } @@ -1827,6 +1899,10 @@ devourer::AdapterCaps RtlJaguar3Device::GetAdapterCaps() { * measured — responder matrix + retry-knob A/B + the arq_e2e ledgers. */ c.ack_responder_ok = true; c.tx_retry_limit_ok = true; + /* station_mode_ok: the 8822C die only - both halves measured on one + * RTL8812CU by tests/realtek_station_onair.sh (numbers and limits at the + * AdapterCaps declaration). The 8822E runs the same code with no cell. */ + c.station_mode_ok = _variant == jaguar3::ChipVariant::C8822C; /* TxMode::no_agg: AGG_EN=0 + BK=1 in build_tx_block. */ c.tx_no_agg_ok = true; /* Per-packet TX power: the TXPWR_OFSET_TYPE bank selector + programmable @@ -2804,8 +2880,14 @@ bool RtlJaguar3Device::SetAckResponder(const devourer::MacAddr &mac) { /* Hardware ACK responder (src/AckResponder.h): port identity + net_type so * the MAC auto-ACKs unicast frames to `mac`. Same registers the proven * StartBeacon/AP path programs, minus the beacon machinery. Serialized on - * _reg_mu like every other register-touching control call. */ + * _reg_mu like every other register-touching control call, the station + * check included, so no SetStationIdentity can land between the two. */ std::lock_guard lk(_reg_mu); + if (_station.armed()) { + _logger->error("Jaguar3: ACK responder refused: a station identity owns " + "port 0 (ClearStationIdentity first)"); + return false; + } if (!devourer::ack::enable(_device, mac.data())) { if (!devourer::ack::disable_verified(_device)) { _logger->error("Jaguar3: ACK responder arm failed and rollback did " @@ -2822,8 +2904,47 @@ bool RtlJaguar3Device::SetAckResponder(const devourer::MacAddr &mac) { return true; } +bool RtlJaguar3Device::SetStationIdentity(const devourer::MacAddr &own, + const devourer::MacAddr &bssid) { + std::lock_guard lk(_reg_mu); + if (!_station_ready) { + _logger->error("Jaguar3: station identity refused until bring-up " + "(Init/InitWrite) has finished"); + return false; + } + /* The beacon's own record, not net_type: a ClearAckResponder can close the + * gate under a live beacon, and net_type alone would then read "free". */ + if (_bcn_interval_tu > 0) { + _logger->error("Jaguar3: station identity refused: the beacon owns port " + "0 (StopBeacon first)"); + return false; + } + /* A StartBeacon whose rollback did not land leaves the beacon enables set + * with no beacon recorded; the StopBeacon that retires them writes + * net_type. */ + if (_bcn_hw_touched) { + _logger->error("Jaguar3: station identity refused: a beacon's hardware " + "state is still set (StopBeacon first)"); + return false; + } + return _station.arm(_device, own, bssid, _cfg.tx.retry_limit, _logger, + "Jaguar3"); +} + +bool RtlJaguar3Device::ClearStationIdentity() { + std::lock_guard lk(_reg_mu); + return _station.clear(_device, _logger, "Jaguar3"); +} + void RtlJaguar3Device::ClearAckResponder() { std::lock_guard lk(_reg_mu); + /* The gate this closes is the station's too: a clear here would leave the + * station deaf to its AP while it still reads as armed. */ + if (_station.armed()) { + _logger->error("Jaguar3: ACK responder clear refused: port 0 belongs to " + "a station identity (ClearStationIdentity instead)"); + return; + } if (!devourer::ack::disable_verified(_device)) { _logger->error("Jaguar3: ACK responder disarm did not latch"); return; @@ -2866,6 +2987,11 @@ void RtlJaguar3Device::ClearAmpduMode() { SetAmpduMode(devourer::AmpduMode{}); } bool RtlJaguar3Device::StartBeacon(const uint8_t *beacon, size_t len, int interval_tu) { std::lock_guard lk(_reg_mu); + if (_station.armed()) { + _logger->error("beacon-tbtt(J3): refused: a station identity owns port 0 " + "(ClearStationIdentity first)"); + return false; + } /* The caller may pass [radiotap][802.11 MPDU]; the rsvd-page beacon must be the * RAW 802.11 MPDU (the TX descriptor carries the PHY, not a radiotap header). * radiotap it_len is bytes [2:3] LE. Strip it. */ @@ -2982,8 +3108,11 @@ bool RtlJaguar3Device::disable_beacon_locked() { ok = _device.rtw_write(0x0420, txq & ~(1u << 22) /* BIT_EN_BCNQ_DL */) && ok; - uint8_t nt = _device.rtw_read8(0x0102); - ok = _device.rtw_write8(0x0102, static_cast(nt & ~0x03u)) && ok; + /* net_type belongs to a station arm while one is held: leave it. */ + if (!_station.armed()) { + uint8_t nt = _device.rtw_read8(0x0102); + ok = _device.rtw_write8(0x0102, static_cast(nt & ~0x03u)) && ok; + } return ok; } diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index 00683f28..12d143ec 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -11,6 +11,7 @@ #include "IRtlRadio.h" #include "TxMode.h" #include "RtlAdapter.h" +#include "StationArm.h" #include "SelectedChannel.h" #include "ChipVariant.h" #include "HalJaguar3.h" @@ -88,6 +89,17 @@ class RtlJaguar3Device : public IRtlRadio { bool ReadPacketBuffer(int sel, uint32_t offset, uint8_t *out, size_t n) override; void ClearAckResponder() override; + /* Station identity (IRadio contract; src/StationArm.h): MACID = own, + * BSSID = the AP, net_type = Infra, under _reg_mu, read back; Clear + * restores and reads back the pre-arm MACID/BSSID/net_type. Refused until + * Init/InitWrite has made its last port-0 write (_station_ready) and while + * a beacon or an ACK responder owns port 0; while + * armed, SetAckResponder, ClearAckResponder and StartBeacon are refused in + * turn. Order-independent of the RX loop: StartRxLoop rewrites the RX + * filters (0x06A0..0x06A4) and the BB RX path, no port-0 register. */ + bool SetStationIdentity(const devourer::MacAddr &own, + const devourer::MacAddr &bssid) override; + bool ClearStationIdentity() override; /* A-MPDU TX mode (IRadio contract; src/AmpduMode.h). Programs the 8822C * aggregate-fill timer (0x455) under _reg_mu (serialized against the coex * thread) and records the descriptor state the TX path reads. */ @@ -509,6 +521,14 @@ class RtlJaguar3Device : public IRtlRadio { /* Serializes the coex housekeeping tick against StartRxLoop's register * restore (the only two register writers during an active TX session). */ std::mutex _reg_mu; + devourer::StationArm _station; /* under _reg_mu */ + /* The station arm's readiness gate, committed late: false from the top of + * Init/InitWrite until that bring-up has made its last port-0 write (the + * BF beamformee identity into MACID, the configured ACK responder), and + * left false by a bring-up that throws. Not _brought_up: that flag goes + * true mid-bring-up because the tail's own setters apply live only once it + * is set. Under the station lock. */ + bool _station_ready = false; }; #endif /* RTL_JAGUAR3_DEVICE_H */ diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 071671f6..4e2dcb49 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -1,6 +1,7 @@ # shellcheck shell=sh -# mt7612u_sta_lib.sh - shared plumbing for the MT7612U station harnesses -# (tests/mt7612u_sta_identity.sh, _autoack.sh, _uplink.sh). Sourced, not run. +# mt7612u_sta_lib.sh - shared plumbing for the station harnesses +# (tests/mt7612u_sta_identity.sh, _autoack.sh, _uplink.sh; the generic +# helpers also serve tests/realtek_station_onair.sh). Sourced, not run. # # Four rules these scripts run as root under: # diff --git a/tests/realtek_station_onair.sh b/tests/realtek_station_onair.sh new file mode 100755 index 00000000..1bb8208f --- /dev/null +++ b/tests/realtek_station_onair.sh @@ -0,0 +1,657 @@ +#!/usr/bin/env bash +# realtek_station_onair.sh - both halves of AdapterCaps::station_mode_ok's bar +# for a Realtek station armed through IRadio::SetStationIdentity: +# +# DOWN unicast addressed to the station is received AND acknowledged; +# UP the station's own unicast is acknowledged. +# +# The transmitter is the only party that knows whether its frame was +# acknowledged, so each half asks the transmitter: a devourer Realtek adapter +# reading its own per-frame CCX reports (tx.report), retries ~0 when the +# frame was answered and pinned at RETRY_LIMIT when it was not - the +# instrument tests/ack_txreport_matrix.sh and tests/mt7612u_sta_autoack.sh +# use. Unlike the MT7612U cells, the DUT here is armed through the library +# seam itself (rxdemo / txdemo DEVOURER_STA_IDENTITY, which emits `sta.arm`). +# +# DOWN - the DUT runs rxdemo, armed as own=self, BSSID; the PEER (a second +# Realtek adapter) injects ACK-requesting QoS-Data with TA = BSSID, as the AP +# would: +# A DUT armed, RA = DUT's own address -> the claim: ACKed, and the +# DUT's rx.seq shows it arrived +# B DUT armed, RA = NOBODY -> control: the instrument fails +# C DUT absent, RA = DUT's own address -> control: the DUT is the ACKer +# (the previous arm's Stop() +# cleared its station arm) +# D DUT unarmed, RA = DUT's own address -> the arm is what answers +# E DUT armed then cleared (DEVOURER_STA_CLEAR_AFTER_MS), RA = own +# -> Clear silences the port +# The bar is A against B and C, plus A's reception. D and E are verdicts on +# the arm itself and count toward the exit status; on a die whose bring-up +# already answers for its own address (the Jaguar1 8812 programs the EFUSE +# MAC into MACID) D is expected to fail - set EXPECT_UNARMED_SILENT=0 to +# report it without scoring it. +# +# UP - hostapd on AP_SYSFS holds BSSID; the DUT runs txdemo with +# DEVOURER_TX_WITH_RX=thread (the CCX reports ride the RX path), armed as +# own, BSSID, sending ACK-requesting QoS-Data with TA = own and a nonzero +# retry limit, and reads its own reports: +# F RA = BSSID -> the claim: the AP acknowledges the station's unicast +# G RA = NOBODY -> control: same transmitter, nobody answers +# H RA = BSSID, the DUT NOT armed -> reported, never scored: whether the +# TX side's ACK matching needs the arm at all (Jaguar2/3 +# bring-up never programs MACID) +# hostapd's MAC acknowledges by address, so F holds for an unassociated +# station (the AP then deauths the "class 3" sender - expected, harmless). +# +# An arm is scored only when its transmitter kept airing through the window +# (no silence over MAX_GAP_MS between its reports, or after the last one - +# see summarize) and carried MIN_REPORTS reports and MIN_SUBMITTED +# submissions. Reception in arm A is +# judged against the peer's REPORTED frames, which aired; submitted frames +# left unreported at window close are printed separately. +# +# Exit status: 0 every verdict passed; 1 a verdict failed; 2 INCONCLUSIVE (an +# arm aborted, produced no reports, or the rig was refused); 3 interrupted. +# +# sudo DUT_PID=0xc812 DUT_SYSFS=5-1 PEER_PID=0xb812 PEER_SYSFS=6-1 \ +# AP_SYSFS=1-1 tests/realtek_station_onair.sh +# +# Rig: DUT and PEER are devourer Realtek adapters (Jaguar1/2/3); AP_SYSFS is +# any adapter whose kernel driver supports AP mode. Temp-blacklist rtw88 +# (CLAUDE.md, "Hardware testing"): it auto-probes every Realtek dongle at +# each enumeration, and the hand-back below re-enumerates both. Read +# AP_SYSFS from `lsusb -t` after its driver has loaded - it can move. +# +# Env: DUT_VID, DUT_PID, DUT_SYSFS, PEER_VID, PEER_PID, PEER_SYSFS, AP_SYSFS, +# CH, BSSID, NOBODY, SECS, RETRY_LIMIT, RATE, GAP_US, HALF (both|down|up), +# MIN_REPORTS, MIN_RX_PCT, MAX_GAP_MS, MIN_SUBMITTED, +# EXPECT_UNARMED_SILENT, READY_TIMEOUT, OUT. + +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="${BUILD:-$ROOT/build}" +cd "$ROOT" || exit 2 +DUT_VID="${DUT_VID:-0x0bda}" +DUT_PID="${DUT_PID:-0xc812}" +DUT_SYSFS="${DUT_SYSFS:-}" +PEER_VID="${PEER_VID:-0x0bda}" +PEER_PID="${PEER_PID:-0xb812}" +PEER_SYSFS="${PEER_SYSFS:-}" +AP_SYSFS="${AP_SYSFS:-}" +CH="${CH:-6}" +BSSID="${BSSID:-02:42:75:05:d6:ab}" +NOBODY="${NOBODY:-02:00:00:de:ad:07}" +SECS="${SECS:-10}" +RETRY_LIMIT="${RETRY_LIMIT:-12}" +RATE="${RATE:-MCS3}" +GAP_US="${GAP_US:-5000}" +HALF="${HALF:-both}" +MIN_REPORTS="${MIN_REPORTS:-50}" +MIN_RX_PCT="${MIN_RX_PCT:-80}" +# Transmitter liveness: the longest silence allowed between two of an arm's +# CCX reports, and between its last report and its final tx.stats. +MAX_GAP_MS="${MAX_GAP_MS:-2000}" +# Per-arm floor on frames the transmitter submitted. Default: a quarter of +# the nominal SECS / GAP_US rate (500 at the defaults; the slowest arm on +# record, an unacknowledged one at 12 retries, submitted ~900). +MIN_SUBMITTED="${MIN_SUBMITTED:-}" +EXPECT_UNARMED_SILENT="${EXPECT_UNARMED_SILENT:-1}" +READY_TIMEOUT="${READY_TIMEOUT:-30}" +CLEAR_AFTER_MS="${CLEAR_AFTER_MS:-2000}" +# Unset: a fresh private directory (sta_out_prepare in the lib). +OUT="${OUT:-}" + +[ "$(id -u)" = 0 ] || { echo "must run as root"; exit 2; } +case "$HALF" in both|down|up) ;; *) echo "HALF must be both, down or up"; exit 2 ;; esac +for v in SECS RETRY_LIMIT GAP_US MIN_REPORTS MIN_RX_PCT MAX_GAP_MS READY_TIMEOUT CLEAR_AFTER_MS CH; do + case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac +done +# GAP_US=0 (max duty) has no nominal rate, so no default floor. +if [ -z "$MIN_SUBMITTED" ]; then + if [ "$GAP_US" -gt 0 ]; then + MIN_SUBMITTED=$(( SECS * 1000000 / GAP_US / 4 )) + else + MIN_SUBMITTED=0 + fi +fi +case "$MIN_SUBMITTED" in *[!0-9]*) echo "MIN_SUBMITTED must be a non-negative integer"; exit 2 ;; esac +# RETRY_LIMIT 0 would make every control indistinguishable from a one-shot +# send: the retry count is the instrument. +if [ "$RETRY_LIMIT" -lt 1 ] || [ "$RETRY_LIMIT" -gt 63 ]; then + echo "RETRY_LIMIT must be 1..63"; exit 2 +fi +[ -n "$DUT_SYSFS" ] || { echo "DUT_SYSFS is required (lsusb -t)"; exit 2; } +if [ "$HALF" != up ] && [ -z "$PEER_SYSFS" ]; then + echo "PEER_SYSFS is required for the DOWN half"; exit 2 +fi +if [ "$HALF" != down ]; then + [ -n "$AP_SYSFS" ] || { echo "AP_SYSFS is required for the UP half"; exit 2; } + command -v hostapd >/dev/null || { echo "hostapd is required for the UP half"; exit 2; } +fi +for b in rxdemo txdemo; do + [ -x "$BUILD/$b" ] || { echo "$BUILD/$b is not built"; exit 2; } +done + +# shellcheck source=tests/mt7612u_sta_lib.sh +. "$ROOT/tests/mt7612u_sta_lib.sh" +sta_out_prepare || exit 2 +sta_lock_take || exit 2 +sta_pid_init dut peer hostapd + +# --- adapter identity and hand-back ----------------------------------------- +# The DUT and the PEER are opened by rxdemo / txdemo, whose libusb open +# detaches the kernel driver and never re-attaches it. Each is recorded +# (idVendor:idProduct:serial) before anything runs, marked touched just +# before a process opens it, and handed back with an `authorized` 0/1 toggle +# only when this run touched it AND the path still names the recorded device. +declare -A RT_ID=() +rt_record() { # $1 name, $2 sysfs, $3 vid, $4 pid + local d="/sys/bus/usb/devices/$2" want have + if [ "$(cat "$d/bDeviceClass" 2>/dev/null)" = "09" ]; then + echo "refusing $1 at $2 - a hub"; return 1 + fi + want=$(printf '%04x:%04x' "$(($3))" "$(($4))" 2>/dev/null) + have="$(cat "$d/idVendor" 2>/dev/null):$(cat "$d/idProduct" 2>/dev/null)" + if [ "$have" != "$want" ]; then + echo "refusing $1 at $2 - it reports $have, not $want"; return 1 + fi + RT_ID[$1]=$(sta_usb_id "$2") + rm -f "$OUT/.opened_$1" +} +rt_opened() { : > "$OUT/.opened_$1"; } +rt_handback() { # $1 name, $2 sysfs + [ -n "${RT_ID[$1]:-}" ] || return 0 + local id=${RT_ID[$1]} + RT_ID[$1]="" + [ -e "$OUT/.opened_$1" ] || return 0 + rm -f "$OUT/.opened_$1" + if [ "$(sta_usb_id "$2")" != "$id" ]; then + echo "$1 path $2 no longer names the recorded device ($id) - not re-enumerating it" + return 0 + fi + echo 0 > "/sys/bus/usb/devices/$2/authorized" 2>/dev/null + sleep 2 + echo 1 > "/sys/bus/usb/devices/$2/authorized" 2>/dev/null +} + +if [ "$DUT_SYSFS" = "${PEER_SYSFS:-x}" ] || [ "$DUT_SYSFS" = "${AP_SYSFS:-x}" ] || + { [ -n "$PEER_SYSFS" ] && [ "$PEER_SYSFS" = "${AP_SYSFS:-x}" ]; }; then + echo "DUT_SYSFS, PEER_SYSFS and AP_SYSFS must be three different adapters" + sta_lock_release; exit 2 +fi +rt_record dut "$DUT_SYSFS" "$DUT_VID" "$DUT_PID" || { sta_lock_release; exit 2; } +if [ "$HALF" != up ]; then + rt_record peer "$PEER_SYSFS" "$PEER_VID" "$PEER_PID" || { sta_lock_release; exit 2; } +fi + +AP_IF="" +AP_ID="" +AP_REENUM=no +CLEANED=no +# shellcheck disable=SC2317 # reached through the traps below +cleanup() { + [ "$CLEANED" = yes ] && return 0 + CLEANED=yes + local dut_gone=0 peer_gone=0 + sta_pid_kill peer INT || peer_gone=1 + sta_pid_kill dut INT || dut_gone=1 + sta_pid_kill hostapd + # Only once a process has really exited: re-enumerating an adapter still + # inside its de-init is what the hand-back must not do. + if [ "$dut_gone" = 0 ]; then rt_handback dut "$DUT_SYSFS" + else echo "DUT still running - not re-enumerating $DUT_SYSFS"; fi + if [ "$peer_gone" = 0 ]; then rt_handback peer "${PEER_SYSFS:-}" + else echo "peer still running - not re-enumerating $PEER_SYSFS"; fi + if [ "$AP_REENUM" = yes ]; then + # hostapd's `bssid=` leaves the interface carrying that address after it + # exits; re-enumerate rather than bounce the link + # (tests/mt7612u_sta_identity.sh has the history). + if [ -n "$AP_ID" ] && [ "$(sta_usb_id "$AP_SYSFS")" = "$AP_ID" ]; then + echo 0 > "/sys/bus/usb/devices/$AP_SYSFS/authorized" 2>/dev/null + sleep 3 + echo 1 > "/sys/bus/usb/devices/$AP_SYSFS/authorized" 2>/dev/null + sleep 8 + AP_IF=$(sta_first_netdev "$AP_SYSFS") + [ -n "$AP_IF" ] && { + rfkill unblock wlan 2>/dev/null + ip link set "$AP_IF" up 2>/dev/null + nmcli device set "$AP_IF" managed yes >/dev/null 2>&1 + } + else + echo "AP_SYSFS=$AP_SYSFS no longer names the accepted AP ($AP_ID) - not re-enumerating it" + fi + fi + sta_lock_release +} +trap cleanup EXIT +# AND IT MUST STOP: with INT/TERM on the EXIT trap the shell would run +# cleanup and then carry on into the next arm. +trap 'cleanup; exit 3' INT TERM + +sel_env() { # $1 sysfs -> DEVOURER_USB_BUS / _PORT assignments + printf 'DEVOURER_USB_BUS=%s DEVOURER_USB_PORT=%s' "${1%%-*}" "${1#*-}" +} + +# Is PID running? `kill -0` is not enough: a background child that has exited +# but is not yet reaped is a zombie, and kill -0 still succeeds on it. The +# state field of /proc/PID/stat (after the parenthesised command name) is Z +# for a zombie. +proc_running() { # $1 pid + local st + st=$(sed 's/^.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f1) + [ -n "$st" ] && [ "$st" != Z ] && [ "$st" != X ] +} + +# Wait for a regex in a file while the process lives. 0 found, 1 not. +wait_for() { # $1 pid, $2 file, $3 regex, $4 timeout s + local t=0 + until grep -qE "$3" "$2" 2>/dev/null; do + proc_running "$1" || return 1 + [ "$t" -ge "$4" ] && return 1 + sleep 1; t=$((t + 1)) + done + return 0 +} + +# One summary line from a transmitter's JSONL (and, optionally, the DUT's +# rx.seq stream for frames from TA): reports, submitted, unreported, +# ok_pct, retries_mean, max_gap_ms, tail_ms, live, rx_distinct. +# +# LIVENESS. A report is a frame that aired, so the reports' own timestamps +# (t, the host-monotonic tx.report timebase) show whether the transmitter +# kept airing through the window: max_gap_ms is the longest silence between +# two reports, tail_ms the silence from the last report to the final +# tx.stats, which carries t in the same timebase. live=0 when either exceeds +# MAX_GAP_MS or the final tx.stats has no t - an arm that aired a burst and +# stalled, which MIN_REPORTS alone would accept. +summarize() { # $1 tx jsonl, $2 tag, $3 dut jsonl or "" + python3 - "$1" "$2" "${3:-}" "$MAX_GAP_MS" <<'PYEOF' +import json, sys +tx, tag, rx, max_gap = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]) +n = okc = retries = 0 +submitted = 0 +ts = [] +final_t = None +for line in open(tx, errors='replace'): + if not line.startswith('{'): + continue + try: + e = json.loads(line) + except ValueError: + continue + if e.get('ev') == 'tx.report': + n += 1 + okc += 1 if e.get('ok') else 0 + retries += int(e.get('retries', 0) or 0) + if 't' in e: + ts.append(int(e['t'])) + elif e.get('ev') == 'tx.stats': + submitted = int(e.get('submitted', submitted) or submitted) + if e.get('final') and 't' in e: + final_t = int(e['t']) +gap = max((b - a for a, b in zip(ts, ts[1:])), default=0) +tail = (final_t - ts[-1]) if (final_t is not None and ts) else None +live = int(bool(ts) and tail is not None and gap <= max_gap + and tail <= max_gap) +out = (f"{tag} reports={n} submitted={submitted} " + f"unreported={max(submitted - n, 0)}") +if n: + out += f" ok_pct={100.0*okc/n:.1f} retries_mean={retries/n:.2f}" +out += f" max_gap_ms={gap} tail_ms={'none' if tail is None else tail} live={live}" +if rx: + seen = set() + try: + for line in open(rx, errors='replace'): + if '"ev":"rx.seq"' not in line: + continue + try: + e = json.loads(line) + except ValueError: + continue + if not e.get('crc'): + seen.add(e.get('pctr')) + except OSError: + pass + out += f" rx_distinct={len(seen)}" +print(out) +PYEOF +} + +# --- DOWN: one arm ---------------------------------------------------------- +# $1 tag, $2 RA, $3 DUT mode: armed | unarmed | cleared | absent. +# Writes the summary line (or " ABORTED ") to $OUT/res_. +down_arm() { + local tag="$1" ra="$2" mode="$3" dut="" peer rc=0 t0 t1 + local res="$OUT/res_$tag" + : > "$res" + if [ "$mode" != absent ]; then + local sta_env="" + case "$mode" in + armed) sta_env="DEVOURER_STA_IDENTITY=self,$BSSID" ;; + cleared) sta_env="DEVOURER_STA_IDENTITY=self,$BSSID DEVOURER_STA_CLEAR_AFTER_MS=$CLEAR_AFTER_MS" ;; + esac + rt_opened dut + # shellcheck disable=SC2046,SC2086 # word-split assignments on purpose + env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" $(sel_env "$DUT_SYSFS") \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info \ + DEVOURER_RX_PCTR=1 DEVOURER_RX_AGG_SA="$BSSID" $sta_env \ + "$BUILD/rxdemo" >"$OUT/dut_$tag.jsonl" 2>"$OUT/dut_$tag.err" & + dut=$! + sta_pid_record dut "$dut" + local ready='ring of .* URBs submitted' file="$OUT/dut_$tag.err" + case "$mode" in + armed) ready='"ev":"sta.arm"'; file="$OUT/dut_$tag.jsonl" ;; + cleared) ready='"ev":"sta.clear"'; file="$OUT/dut_$tag.jsonl" ;; + esac + if ! wait_for "$dut" "$file" "$ready" "$READY_TIMEOUT"; then + echo "$tag ABORTED the DUT never reached '$ready': $(tail -1 "$OUT/dut_$tag.err" 2>/dev/null)" > "$res" + sta_pid_kill dut INT; return 0 + fi + case "$mode" in + armed|cleared) + if ! grep -q '"ev":"sta.arm","ok":1' "$OUT/dut_$tag.jsonl"; then + echo "$tag ABORTED SetStationIdentity was refused: $(grep -m1 'station identity' "$OUT/dut_$tag.err")" > "$res" + sta_pid_kill dut INT; return 0 + fi ;; + esac + if [ "$mode" = cleared ] && + ! grep -q '"ev":"sta.clear","ok":1' "$OUT/dut_$tag.jsonl"; then + echo "$tag FAILCLEAR ClearStationIdentity did not verify its rollback" > "$res" + sta_pid_kill dut INT; return 0 + fi + # The arm sits after the first RX frame; give the ring a moment either way. + sleep 2 + fi + + t0=$(date +%s) + rt_opened peer + # shellcheck disable=SC2046 # word-split assignments on purpose + env DEVOURER_VID="$PEER_VID" DEVOURER_PID="$PEER_PID" $(sel_env "$PEER_SYSFS") \ + DEVOURER_CHANNEL="$CH" \ + DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_RA="$ra" DEVOURER_TX_SA="$BSSID" \ + DEVOURER_TX_RATE="$RATE" DEVOURER_TX_PAYLOAD_BYTES=200 \ + DEVOURER_TX_GAP_US="$GAP_US" DEVOURER_TX_REPORT=1 \ + DEVOURER_TX_RETRY_LIMIT="$RETRY_LIMIT" \ + DEVOURER_TX_WITH_RX=thread DEVOURER_LOG_LEVEL=warn \ + timeout -s INT -k 3 "$SECS" "$BUILD/txdemo" \ + >"$OUT/peer_$tag.jsonl" 2>"$OUT/peer_$tag.err" & + peer=$! + sta_pid_record peer "$peer" + wait "$peer"; rc=$? + rm -f "$OUT/.pid_peer" + t1=$(date +%s) + # timeout(1) returns 124 when it delivered the INT: the normal end. + if [ "$rc" -ne 124 ]; then + echo "$tag ABORTED the peer exited early (status $rc): $(tail -1 "$OUT/peer_$tag.err" 2>/dev/null)" > "$res" + [ -n "$dut" ] && sta_pid_kill dut INT + return 0 + fi + if [ $((t1 - t0)) -gt $((SECS + 5)) ]; then + echo "$tag ABORTED the peer overran its ${SECS}s window" > "$res" + [ -n "$dut" ] && sta_pid_kill dut INT + return 0 + fi + # LIVENESS AFTER THE WINDOW: a DUT that died mid-window reads ~0% ACKed, + # which is a control's PASSING value. + if [ -n "$dut" ] && ! proc_running "$dut"; then + echo "$tag ABORTED the DUT died during the window: $(tail -1 "$OUT/dut_$tag.err" 2>/dev/null)" > "$res" + rm -f "$OUT/.pid_dut"; return 0 + fi + if [ -n "$dut" ]; then + sta_pid_kill dut INT || { + echo "$tag ABORTED the DUT did not stop" > "$res"; return 0; } + summarize "$OUT/peer_$tag.jsonl" "$tag" "$OUT/dut_$tag.jsonl" > "$res" + else + summarize "$OUT/peer_$tag.jsonl" "$tag" "" > "$res" + fi + return 0 +} + +# --- UP: one arm ------------------------------------------------------------ +up_arm() { # $1 tag, $2 RA, $3 armed | unarmed (default armed) + local tag="$1" ra="$2" mode="${3:-armed}" dut rc=0 t0 t1 sta_env="" + local res="$OUT/res_$tag" + : > "$res" + [ "$mode" = armed ] && sta_env="DEVOURER_STA_IDENTITY=$OWN,$BSSID" + t0=$(date +%s) + rt_opened dut + # shellcheck disable=SC2046,SC2086 # word-split assignments on purpose + env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" $(sel_env "$DUT_SYSFS") \ + DEVOURER_CHANNEL="$CH" $sta_env \ + DEVOURER_TX_QOS_DATA=1 DEVOURER_TX_RA="$ra" DEVOURER_TX_SA="$OWN" \ + DEVOURER_TX_RATE="$RATE" DEVOURER_TX_PAYLOAD_BYTES=200 \ + DEVOURER_TX_GAP_US="$GAP_US" DEVOURER_TX_REPORT=1 \ + DEVOURER_TX_RETRY_LIMIT="$RETRY_LIMIT" \ + DEVOURER_TX_WITH_RX=thread DEVOURER_LOG_LEVEL=info \ + timeout -s INT -k 3 "$((SECS + 8))" "$BUILD/txdemo" \ + >"$OUT/dut_$tag.jsonl" 2>"$OUT/dut_$tag.err" & + dut=$! + sta_pid_record dut "$dut" + wait "$dut"; rc=$? + rm -f "$OUT/.pid_dut" + t1=$(date +%s) + if [ "$mode" = armed ] && + ! grep -q '"ev":"sta.arm","ok":1' "$OUT/dut_$tag.jsonl"; then + echo "$tag ABORTED the DUT's SetStationIdentity was refused or never ran: $(grep -m1 -i 'station identity\|error' "$OUT/dut_$tag.err")" > "$res" + return 0 + fi + if [ "$mode" = unarmed ] && grep -q '"ev":"sta.arm"' "$OUT/dut_$tag.jsonl"; then + echo "$tag ABORTED the unarmed DUT ran an arm" > "$res" + return 0 + fi + if [ "$rc" -ne 124 ]; then + echo "$tag ABORTED the DUT exited early (status $rc): $(tail -1 "$OUT/dut_$tag.err" 2>/dev/null)" > "$res" + return 0 + fi + if [ $((t1 - t0)) -gt $((SECS + 13)) ]; then + echo "$tag ABORTED the DUT overran its window" > "$res"; return 0 + fi + # The AP must still be up at the end, or G's "nobody answered" and F's + # failure would both be the AP's absence. + if ! iw dev "$AP_IF" info 2>/dev/null | grep -q 'type AP'; then + echo "$tag ABORTED the AP left AP mode during the window" > "$res"; return 0 + fi + summarize "$OUT/dut_$tag.jsonl" "$tag" "" > "$res" + return 0 +} + +pass=0; fail=0; inconclusive=0 +ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } +bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$*"; } +inc() { inconclusive=$((inconclusive+1)); printf ' INCONCLUSIVE %s\n' "$*"; } +field() { sed -n "s/.* $2=\\([0-9.]*\\).*/\\1/p" "$OUT/res_$1"; } +# A usable arm: not aborted, carrying at least MIN_REPORTS reports and +# MIN_SUBMITTED submissions, and with a transmitter that kept airing through +# the window (live=1, see summarize). Anything else leaves its verdicts +# INCONCLUSIVE. +usable() { + local r n sub + r=$(cat "$OUT/res_$1" 2>/dev/null) + case "$r" in + *ABORTED*|*FAILCLEAR*|'') return 1 ;; + esac + case "$r" in *" live=1"*) ;; *) return 1 ;; esac + n=$(field "$1" reports) + sub=$(field "$1" submitted) + [ "${n:-0}" -ge "$MIN_REPORTS" ] && [ "${sub:-0}" -ge "$MIN_SUBMITTED" ] +} +show() { echo " $(cat "$OUT/res_$1" 2>/dev/null)"; } + +OWN="${DUT_MAC:-}" +# The DUT's own address: the one `self` resolves to (GetPermanentMacAddress), +# learned from a short armed rxdemo run's sta.arm event - which also proves, +# before any arm is scored, that the seam arms on this DUT at all. +if [ -z "$OWN" ]; then + rt_opened dut + # shellcheck disable=SC2046 # word-split assignments on purpose + env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" $(sel_env "$DUT_SYSFS") \ + DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info \ + DEVOURER_STA_IDENTITY="self,$BSSID" \ + "$BUILD/rxdemo" >"$OUT/dut_own.jsonl" 2>"$OUT/dut_own.err" & + own_pid=$! + sta_pid_record dut "$own_pid" + wait_for "$own_pid" "$OUT/dut_own.jsonl" '"ev":"sta.arm"' "$READY_TIMEOUT" + sta_pid_kill dut INT + OWN=$(sed -n 's/.*"ev":"sta.arm","ok":1,"own":"\([0-9a-f:]\{17\}\)".*/\1/p' \ + "$OUT/dut_own.jsonl" | head -1) + if [ -z "$OWN" ]; then + echo "the DUT did not arm (or reported no own address) - see $OUT/dut_own.err" + grep -m3 -i 'station identity' "$OUT/dut_own.err" + echo "VERDICT: INCONCLUSIVE"; exit 2 + fi +fi +case "$OWN" in + [0-9a-f][02468ace]:*) ;; + *) echo "DUT_MAC=$OWN is not a lowercase unicast address"; exit 2 ;; +esac +echo "DUT $DUT_VID:$DUT_PID at $DUT_SYSFS BSSID $BSSID ch$CH rate $RATE retry limit $RETRY_LIMIT" +echo "DUT own address $OWN" +[ "$HALF" != up ] && echo "PEER $PEER_VID:$PEER_PID at $PEER_SYSFS" +echo "logs: $OUT" + +# ============================== DOWN ======================================== +if [ "$HALF" != up ]; then + echo + echo "== DOWN A: DUT armed, peer -> its own address $OWN ==" + down_arm A "$OWN" armed; show A + echo "== DOWN B: DUT armed, peer -> NOBODY ($NOBODY) (control) ==" + down_arm B "$NOBODY" armed; show B + echo "== DOWN C: DUT absent, peer -> $OWN (control) ==" + down_arm C "$OWN" absent; show C + echo "== DOWN D: DUT running UNARMED, peer -> $OWN ==" + down_arm D "$OWN" unarmed; show D + echo "== DOWN E: DUT armed then CLEARED, peer -> $OWN ==" + down_arm E "$OWN" cleared; show E + echo + if usable A && usable B && usable C; then + a=$(field A ok_pct); b=$(field B ok_pct); c=$(field C ok_pct) + if awk -v a="$a" -v b="$b" -v c="$c" 'BEGIN{exit !(a > b + 40 && a > c + 40)}'; then + ok "DOWN ack: A ${a}% ACKed against B ${b}% (nobody) and C ${c}% (DUT absent)" + else + bad "DOWN ack: A ${a}% is not clearly above B ${b}% and C ${c}%" + fi + # The denominator is the peer's REPORT count: a reported frame aired, + # while a submitted one may still have been queued when the window + # closed (reported separately as unreported). + rep=$(field A reports); rxd=$(field A rx_distinct); unr=$(field A unreported) + if [ "${rep:-0}" -gt 0 ] && + awk -v r="${rxd:-0}" -v s="$rep" -v m="$MIN_RX_PCT" 'BEGIN{exit !(100*r/s >= m)}'; then + ok "DOWN receive: the DUT delivered ${rxd} distinct frames of the peer's ${rep} reported (>= ${MIN_RX_PCT}%; ${unr:-0} submitted unreported)" + else + bad "DOWN receive: the DUT delivered ${rxd:-0} distinct frames of the peer's ${rep:-0} reported (< ${MIN_RX_PCT}%; ${unr:-0} submitted unreported)" + fi + else + inc "DOWN: arm A, B or C aborted, carried under $MIN_REPORTS reports or $MIN_SUBMITTED submissions, or its transmitter stalled (live=0) - not a measurement" + fi + if usable A && usable D; then + a=$(field A ok_pct); d=$(field D ok_pct) + if awk -v a="$a" -v d="$d" 'BEGIN{exit !(d < a - 40)}'; then + ok "ARM is the cause: unarmed D ${d}% against armed A ${a}%" + elif [ "$EXPECT_UNARMED_SILENT" = 0 ]; then + echo " NOTE unarmed D ${d}% against armed A ${a}% - this die answers unarmed (not scored)" + else + bad "ARM is the cause: unarmed D ${d}% is not clearly below armed A ${a}%" + fi + else + inc "ARM is the cause: arm D aborted or carried under $MIN_REPORTS reports" + fi + if grep -q FAILCLEAR "$OUT/res_E" 2>/dev/null; then + bad "CLEAR: ClearStationIdentity returned false (rollback not verified)" + elif usable A && usable E && usable D; then + a=$(field A ok_pct); e=$(field E ok_pct); d=$(field D ok_pct) + # Clear returns to the pre-arm port, so E must read like D (unarmed), + # not like A. + if awk -v a="$a" -v e="$e" -v d="$d" 'BEGIN{exit !(e < a - 40 && e < d + 20)}'; then + ok "CLEAR silences: cleared E ${e}% against armed A ${a}% and unarmed D ${d}%" + elif [ "$EXPECT_UNARMED_SILENT" = 0 ]; then + echo " NOTE cleared E ${e}% (A ${a}%, D ${d}%) - this die answers unarmed (not scored)" + else + bad "CLEAR silences: cleared E ${e}% (A ${a}%, D ${d}%)" + fi + else + inc "CLEAR: arm E (or D) aborted or carried under $MIN_REPORTS reports" + fi + # The DOWN half no longer needs the peer: hand it back now. + rt_handback peer "$PEER_SYSFS" +fi + +# =============================== UP ========================================= +if [ "$HALF" != down ]; then + # --- the AP: the guard tests/mt7612u_sta_identity.sh uses --- + ap_refuse() { echo "refusing AP_SYSFS=$AP_SYSFS: $* - cleanup would re-enumerate it."; exit 2; } + [ -n "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idVendor" 2>/dev/null)" ] || + ap_refuse "not a USB device - if its driver just loaded it may have moved; re-read lsusb -t" + [ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/bDeviceClass" 2>/dev/null)" != "09" ] || ap_refuse "a hub" + AP_IF=$(sta_first_netdev "$AP_SYSFS") + [ -n "$AP_IF" ] || ap_refuse "no network interface on it" + [ -e "/sys/class/net/$AP_IF/phy80211" ] || ap_refuse "$AP_IF is not wireless" + for fam in -4 -6; do + ip "$fam" route show default 2>/dev/null | grep -qw "dev $AP_IF" && + ap_refuse "$AP_IF carries a default route" + done + PHY=$(basename "$(readlink -f "/sys/class/net/$AP_IF/phy80211")") + iw phy "$PHY" info 2>/dev/null | grep -q '\* AP$' || ap_refuse "$AP_IF ($PHY) does not support AP mode" + AP_ID=$(sta_usb_id "$AP_SYSFS") + + nmcli device set "$AP_IF" managed no >/dev/null 2>&1 + sleep 1 + ip link set "$AP_IF" down 2>/dev/null + iw dev "$AP_IF" set type managed 2>/dev/null + ip link set "$AP_IF" up 2>/dev/null + if [ "$CH" -le 14 ]; then hw_mode=g; else hw_mode=a; fi + cat > "$OUT/hostapd.conf" </dev/null 2>&1 + ap_up=no + for _ in 1 2 3 4 5 6 7 8 9 10; do + if iw dev "$AP_IF" info 2>/dev/null | grep -q 'type AP'; then ap_up=yes; break; fi + sleep 1 + done + if [ "$ap_up" != yes ]; then + echo "hostapd did not bring $AP_IF up in AP mode (a 5 GHz channel may be no-IR here):" + tail -12 "$OUT/hostapd.log" 2>/dev/null || echo "(no hostapd log written)" + echo "VERDICT UP: INCONCLUSIVE"; exit 2 + fi + echo + echo "AP $AP_IF ($PHY) at $AP_SYSFS holds $BSSID; station $OWN" + echo "== UP F: station -> BSSID (the AP) ==" + up_arm F "$BSSID"; show F + echo "== UP G: station -> NOBODY ($NOBODY) (control) ==" + up_arm G "$NOBODY"; show G + echo "== UP H: station UNARMED -> BSSID (reported, not scored) ==" + up_arm H "$BSSID" unarmed; show H + echo + if usable F && usable G; then + f=$(field F ok_pct); g=$(field G ok_pct) + fr=$(field F retries_mean); gr=$(field G retries_mean) + if awk -v f="$f" -v g="$g" 'BEGIN{exit !(f > g + 40)}'; then + ok "UP ack: F ${f}% ACKed (retries ${fr}) against G ${g}% (retries ${gr}, limit $RETRY_LIMIT)" + else + bad "UP ack: F ${f}% is not clearly above the control G ${g}%" + fi + else + inc "UP: arm F or G aborted, carried under $MIN_REPORTS reports or $MIN_SUBMITTED submissions, or its transmitter stalled (live=0) - not a measurement" + fi + # H is information, not a verdict: whether TX ACK matching needs the arm + # at all on this die. Never counted into pass/fail/inconclusive. + if usable H; then + echo " INFO unarmed uplink H $(field H ok_pct)% ACKed (retries $(field H retries_mean)) - against armed F $(field F ok_pct)%" + else + echo " INFO unarmed uplink H: no usable result ($(cat "$OUT/res_H" 2>/dev/null))" + fi +fi + +echo +echo "=== $pass passed, $fail failed, $inconclusive inconclusive (logs: $OUT) ===" +[ "$fail" -gt 0 ] && exit 1 +[ "$inconclusive" -gt 0 ] && exit 2 +exit 0 diff --git a/tests/station_arm_selftest.cpp b/tests/station_arm_selftest.cpp new file mode 100644 index 00000000..0ac456ab --- /dev/null +++ b/tests/station_arm_selftest.cpp @@ -0,0 +1,491 @@ +/* Headless guard for the Realtek station arm (src/StationArm.h over the + * AckResponder.h recipe): what IRadio::SetStationIdentity / Clear do to the + * port-0 registers on Jaguar1/2/3. + * + * Pinned here: the arm writes MACID = own, BSSID = the AP, net_type = Infra + * and nothing else in 0x0102; every refusal writes NOTHING; a port another + * claimant already owns (net_type set) is refused; a re-arm keeps the FIRST + * snapshot so Clear returns to the state before any arm; a write that does not + * land is caught by readback - the BSSID included - and rolled back; and a + * rollback that cannot verify leaves the arm recorded so Clear can retry it. + * + * Also pinned: the arm-time retry-limit WARN (StationArm:: + * warn_if_unicast_never_retried) fires for a zero limit and for a die that + * ignores the knob, and not otherwise. + * + * NOT covered: silicon behaviour. That the arm makes the MAC ACK the AP's + * unicast is an on-air result (tests/realtek_station_onair.sh). */ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "StationArm.h" +#include "RtlAdapter.h" + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +namespace { + +/* A byte-addressed register file (the ack_responder_selftest shape), plus + * `deaf`: addresses whose writes report success and store nothing - the + * failure only a readback can see; `latch`: addresses that keep their FIRST + * write and ignore the rest - an arm that lands and a rollback that does not; + * and `open_identity_writes`: identity bytes written while 0x0102 had a + * net_type set, i.e. a responder live for a half-written address. */ +class FakeRegs final : public devourer::ITransport { +public: + std::map mem; + std::set deaf; + std::set latch, latched; + std::set drop_once; /* the next write here is lost, then normal */ + std::set refuse; /* writes here report FAILURE and store nothing */ + std::set> open_writes; /* (addr, value) */ + int open_identity_writes = 0; + bool fail_writes = false; + bool throw_writes = false; + int write_calls = 0; + + bool is_usb() const override { return true; } + uint8_t read8(uint16_t a) override { return mem.count(a) ? mem[a] : 0; } + uint16_t read16(uint16_t a) override { + return static_cast(read8(a) | (read8(a + 1) << 8)); + } + uint32_t read32(uint16_t a) override { + return static_cast(read16(a)) | + (static_cast(read16(a + 2)) << 16); + } + bool write8(uint16_t a, uint8_t v) override { + ++write_calls; + if (throw_writes) throw std::runtime_error("injected write failure"); + if (fail_writes) return false; + if (refuse.count(a)) return false; + if (a >= 0x0610 && a <= 0x061d && (read8(0x0102) & 0x03u) != 0) { + ++open_identity_writes; + open_writes.insert({a, v}); + } + if (deaf.count(a)) return true; + if (drop_once.erase(a)) return true; + if (latch.count(a)) { + if (latched.count(a)) return true; + latched.insert(a); + } + mem[a] = v; + return true; + } + bool write16(uint16_t a, uint16_t v) override { + return write8(a, static_cast(v)) && + write8(a + 1, static_cast(v >> 8)); + } + bool write32(uint16_t a, uint32_t v) override { + return write16(a, static_cast(v)) && + write16(a + 2, static_cast(v >> 16)); + } + bool write_bytes(uint16_t, const uint8_t *, size_t) override { return true; } + bool tx_async(uint8_t, uint8_t *, size_t, unsigned) override { return true; } + int tx_sync(uint8_t, uint8_t *, size_t len, int) override { + return static_cast(len); + } + void rx_loop(int, int, const std::function &, + const std::function &) override {} + + void put_mac(uint16_t at, const uint8_t m[6]) { + for (int i = 0; i < 6; ++i) mem[at + i] = m[i]; + } + bool mac_is(uint16_t at, const uint8_t m[6]) { + for (int i = 0; i < 6; ++i) + if (read8(at + i) != m[i]) return false; + return true; + } +}; + +constexpr uint16_t kNetType = 0x0102; +constexpr uint16_t kMacId = 0x0610; +constexpr uint16_t kBssid = 0x0618; + +const uint8_t kOwn[6] = {0x40, 0xa5, 0xef, 0x2f, 0x22, 0x9b}; +const uint8_t kAp[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x00}; +const uint8_t kAp2[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0x01}; +/* The pre-arm port: what bring-up left (a nonzero MACID, a stale BSSID) and + * 0x0102 with upper bits set and net_type NoLink - upper bits the arm must + * carry through untouched. */ +const uint8_t kPreMac[6] = {0x00, 0xe0, 0x4c, 0x88, 0x22, 0xbb}; +const uint8_t kPreBss[6] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; +constexpr uint8_t kPreNetReg = 0xa4; +/* A nonzero session retry limit, so the arms below print no WARN. */ +const std::optional kRetry = 5; + +devourer::MacAddr mac(const uint8_t m[6]) { + return devourer::MacAddr{{m[0], m[1], m[2], m[3], m[4], m[5]}}; +} + +std::shared_ptr fresh_port() { + auto r = std::make_shared(); + r->put_mac(kMacId, kPreMac); + r->put_mac(kBssid, kPreBss); + r->mem[kNetType] = kPreNetReg; + return r; +} + +bool at_pre_arm(FakeRegs &r) { + return r.mac_is(kMacId, kPreMac) && r.mac_is(kBssid, kPreBss) && + r.read8(kNetType) == kPreNetReg; +} + +} // namespace + +int main() { + auto log = std::make_shared(); + log->set_level(Logger::Level::Silent); + + { /* arm, then clear: the exact registers, both ways */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + CHECK(s.armed()); + CHECK(r->mac_is(kMacId, kOwn)); + CHECK(r->mac_is(kBssid, kAp)); + CHECK(r->read8(kNetType) == ((kPreNetReg & ~0x03u) | 0x02u)); + CHECK(s.clear(dev, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* every argument refusal writes nothing: a group own or BSSID, own == + * BSSID, and an all-zero own or BSSID (I/G clear, so is_unicast alone + * would pass it and program MACID = 0) */ + const uint8_t grp[6] = {0x01, 0x00, 0x5e, 0x00, 0x00, 0x01}; + const uint8_t zero[6] = {0, 0, 0, 0, 0, 0}; + for (int c = 0; c < 5; ++c) { + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + const uint8_t *o = c == 0 ? grp : (c == 3 ? zero : kOwn); + const uint8_t *b = c == 1 ? grp + : c == 2 ? kOwn + : c == 4 ? zero + : kAp; + CHECK(!s.arm(dev, mac(o), mac(b), kRetry, log, "t")); + CHECK(!s.armed()); + CHECK(r->write_calls == 0); + CHECK(at_pre_arm(*r)); + } + } + { /* a port someone else owns (AP net_type) is refused, untouched */ + auto r = fresh_port(); + r->mem[kNetType] = 0xa7; + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(!s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + CHECK(!s.armed()); + CHECK(r->write_calls == 0); + CHECK(r->read8(kNetType) == 0xa7); + } + { /* a re-arm on a new BSSID keeps the FIRST snapshot, and closes the gate + * before it moves the identity - the port is live (Infra) at that point */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + CHECK(r->open_identity_writes == 0); + CHECK(s.arm(dev, mac(kOwn), mac(kAp2), kRetry, log, "t")); + CHECK(r->open_identity_writes == 0); + CHECK(r->mac_is(kBssid, kAp2)); + CHECK(s.clear(dev, log, "t")); + CHECK(at_pre_arm(*r)); + } + { /* a BSSID write that reports success and stores nothing: caught by + * readback, rolled back, not armed */ + auto r = fresh_port(); + for (int i = 0; i < 6; ++i) r->deaf.insert(kBssid + i); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(!s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* a MACID write that does not land: caught by readback (the half the + * ACK engine matches), rolled back, not armed */ + auto r = fresh_port(); + r->deaf.insert(kMacId + 5); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(!s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* every write failing outright: the gate close fails, nothing else is + * attempted, readback shows the untouched pre-arm port, not armed */ + auto r = fresh_port(); + r->fail_writes = true; + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(!s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* a FAILED re-arm tears the earlier arm down (documented): passive, + * verified, not armed - not a stale "armed" over half a new identity */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + r->drop_once.insert(kBssid + 5); /* the re-arm's write is lost; the + * rollback's lands */ + CHECK(!s.arm(dev, mac(kOwn), mac(kAp2), kRetry, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* a re-arm whose gate close FAILS writes no identity at all: the port is + * live (Infra), so neither the new BSSID nor the rollback's pre-arm + * identity may be written with the gate open. The rollback's own gate + * close fails too, so it reports false and the arm stays recorded - and + * a Clear once the bus is back restores it. */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + r->refuse.insert(kNetType); + r->open_writes.clear(); + CHECK(!s.arm(dev, mac(kOwn), mac(kAp2), kRetry, log, "t")); + CHECK(r->open_writes.empty()); + CHECK(s.armed()); + CHECK(r->mac_is(kMacId, kOwn)); + CHECK(r->mac_is(kBssid, kAp)); + r->refuse.clear(); + CHECK(s.clear(dev, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* a Clear whose gate close is REFUSED leaves the identity alone and the + * arm recorded; the retry restores it */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + r->refuse.insert(kNetType); + r->open_writes.clear(); + CHECK(!s.clear(dev, log, "t")); + CHECK(s.armed()); + CHECK(r->open_writes.empty()); + CHECK(r->mac_is(kMacId, kOwn)); + r->refuse.clear(); + CHECK(s.clear(dev, log, "t")); + CHECK(at_pre_arm(*r)); + } + { /* the same with a THROWING gate close: no identity write, arm kept, + * no exception out of Clear */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + r->throw_writes = true; + const int before = r->write_calls; + bool threw = false; + try { + CHECK(!s.clear(dev, log, "t")); + } catch (...) { + threw = true; + } + CHECK(!threw); + CHECK(r->write_calls == before + 1); /* the gate close, and nothing else */ + CHECK(s.armed()); + CHECK(r->mac_is(kMacId, kOwn)); + r->throw_writes = false; + CHECK(s.clear(dev, log, "t")); + CHECK(at_pre_arm(*r)); + } + { /* Clear closes the gate BEFORE it moves the identity back - otherwise the + * live Infra port briefly answers for a half-restored address */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + r->open_identity_writes = 0; + CHECK(s.clear(dev, log, "t")); + CHECK(r->open_identity_writes == 0); + } + { /* a net_type write that does not land: not armed, rolled back */ + auto r = fresh_port(); + r->deaf.insert(kNetType); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(!s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* writes failing outright: false, and since the rollback cannot land + * either the arm stays recorded - Clear retries it once the bus is back */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + r->deaf.insert(kMacId); /* the rollback's MACID restore will not land */ + CHECK(!s.clear(dev, log, "t")); + CHECK(s.armed()); + r->deaf.clear(); + CHECK(s.clear(dev, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* the arm fails readback AND its rollback cannot land: the arm must + * stay recorded, or Clear has nothing to retry and the port is left + * answering for `own` with nobody holding the restore target */ + auto r = fresh_port(); + for (int i = 0; i < 6; ++i) r->deaf.insert(kBssid + i); /* arm fails */ + r->latch.insert(kMacId); /* MACID takes `own`, refuses the restore */ + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(!s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + CHECK(s.armed()); + CHECK(!s.clear(dev, log, "t")); + CHECK(s.armed()); + r->latch.clear(); + CHECK(s.clear(dev, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* a throwing transport from the start: no exception escapes, and since + * nothing landed the port still reads as the snapshot - the rollback's + * failed gate close reports that readback, so the arm is not kept */ + auto r = fresh_port(); + r->throw_writes = true; + RtlAdapter dev(r, log); + devourer::StationArm s; + bool threw = false; + try { + CHECK(!s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + } catch (...) { + threw = true; + } + CHECK(!threw); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* ack::restore_station on its own: it restores the snapshot's net_type + * bits, not merely "closed". Through StationArm the snapshot is always + * NoLink (the ownership refusal), which makes that half unobservable + * there - so the helper's own contract is pinned here directly. */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::ack::StationRestore saved; + CHECK(devourer::ack::snapshot_station_restore(dev, saved)); + saved.net_type = 0x01; /* Ad-hoc, as a snapshot might have recorded */ + CHECK(devourer::ack::arm_station(dev, kOwn, kAp)); + CHECK(devourer::ack::restore_station(dev, saved)); + CHECK(r->read8(kNetType) == ((kPreNetReg & ~0x03u) | 0x01u)); + CHECK(r->mac_is(kMacId, kPreMac)); + } + { /* retire (the re-Init path) with a clear that cannot verify KEEPS the + * arm and its snapshot: a later Clear restores the pre-arm port */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + r->refuse.insert(kNetType); + s.retire(dev, log, "t"); + CHECK(s.armed()); + CHECK(r->mac_is(kMacId, kOwn)); + r->refuse.clear(); + CHECK(s.clear(dev, log, "t")); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + } + { /* retire with a clear that verifies: restored, record dropped; retire + * with nothing armed touches nothing */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + s.retire(dev, log, "t"); + CHECK(!s.armed()); + CHECK(at_pre_arm(*r)); + const int before = r->write_calls; + s.retire(dev, log, "t"); + CHECK(r->write_calls == before); + } + { /* a zero own on a live arm is refused like any argument error: no + * write, and the existing arm stays in place */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), kRetry, log, "t")); + const uint8_t zero[6] = {0, 0, 0, 0, 0, 0}; + const int before = r->write_calls; + CHECK(!s.arm(dev, mac(zero), mac(kAp2), kRetry, log, "t")); + CHECK(r->write_calls == before); + CHECK(s.armed()); + CHECK(r->mac_is(kMacId, kOwn)); + CHECK(r->mac_is(kBssid, kAp)); + } + { /* clear with nothing armed: true, and touches nothing */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.clear(dev, log, "t")); + CHECK(r->write_calls == 0); + } + + { /* the arm-time retry-limit WARN: zero and a die that ignores the knob + * warn; a nonzero limit (and its clamp) does not. Counted off the + * diagnostic stream, so a WARN compiled out by DEVOURER_LOG_MAX_LEVEL + * skips the case rather than failing it. */ + std::FILE *f = std::tmpfile(); + if (f == nullptr) { + std::printf("station_arm_selftest: no tmpfile - WARN case skipped\n"); + } else { + auto wl = std::make_shared(); + wl->set_level(Logger::Level::Warn); + wl->set_diag_stream(f); + auto warned = [&](std::optional limit) { + std::fflush(f); + const long before = std::ftell(f); + devourer::StationArm::warn_if_unicast_never_retried(limit, wl, "t"); + std::fflush(f); + return std::ftell(f) > before; + }; + const bool probe = warned(0); + if (!probe) { + std::printf("station_arm_selftest: WARN compiled out - case " + "skipped\n"); + } else { + CHECK(warned(std::nullopt)); + CHECK(warned(-3)); /* clamps to 0 */ + CHECK(!warned(1)); + CHECK(!warned(63)); + CHECK(!warned(100)); /* clamps to 63 */ + } + std::fclose(f); + } + } + { /* a successful arm with a zero limit still arms - the WARN is advice, + * not a refusal */ + auto r = fresh_port(); + RtlAdapter dev(r, log); + devourer::StationArm s; + CHECK(s.arm(dev, mac(kOwn), mac(kAp), 0, log, "t")); + CHECK(s.armed()); + CHECK(s.arm(dev, mac(kOwn), mac(kAp2), std::nullopt, log, "t")); + CHECK(s.armed()); + } + + if (failures) { + std::fprintf(stderr, "station_arm_selftest: %d failure(s)\n", failures); + return 1; + } + std::printf("station_arm_selftest: ok\n"); + return 0; +}