diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index f42a017..116eb8b 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -147,7 +147,7 @@ jobs: cmake --build build --target WiFiDriver StreamTxDemo StreamDuplexDemo StreamStdinSelftest ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest - TxPowerQuantSelftest LinkHealthSelftest TxPktPwrSelftest + TxPowerQuantSelftest LinkHealthSelftest TxCapsSelftest TxPktPwrSelftest - name: Test working-directory: build diff --git a/CMakeLists.txt b/CMakeLists.txt index 8416603..a809c73 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ add_library(WiFiDriver src/TxPower.cpp src/TxPower.h src/ThermalStatus.h + src/TxCaps.h src/LinkHealth.cpp src/LinkHealth.h src/IRtlDevice.h @@ -352,6 +353,15 @@ target_link_libraries(LinkHealthSelftest PRIVATE WiFiDriver) add_test(NAME link_health_classify COMMAND LinkHealthSelftest) +# Headless guard for the TX-capability derivation (src/TxCaps.h) — the +# STBC-needs-2-chains rule the send_packet 1T1R guard relies on. +add_executable(TxCapsSelftest + tests/tx_caps_selftest.cpp +) +target_link_libraries(TxCapsSelftest PRIVATE WiFiDriver) + +add_test(NAME tx_caps_derive COMMAND TxCapsSelftest) + # Headless guard for the Jaguar2 per-packet TX-power quantizer # (jaguar2::txpkt_pwr_step_for_db) — dB delta -> the descriptor TXPWR_OFSET LUT # step. Gated on Jaguar2 being built (the header is behind DEVOURER_HAVE_JAGUAR2). diff --git a/src/IRtlDevice.h b/src/IRtlDevice.h index 1e217b4..ceaeeba 100644 --- a/src/IRtlDevice.h +++ b/src/IRtlDevice.h @@ -8,6 +8,7 @@ #include "RxSense.h" #include "SelectedChannel.h" #include "ThermalStatus.h" +#include "TxCaps.h" #include "TxMode.h" #include "TxPower.h" @@ -123,6 +124,13 @@ class IRtlDevice { * adaptive-link controller. Default returns an all-invalid reading. */ virtual devourer::ThermalStatus GetThermalStatus() { return {}; } + /* Per-chip TX capability report (see src/TxCaps.h): spatial streams, STBC / + * LDPC / SGI support, max bandwidth — derived from the chip identity resolved + * at construction. A caller (or send_packet) uses it to avoid requesting a + * feature the silicon can't do (e.g. STBC on a 1T1R part, which produces a + * frame that never decodes). Default returns supported=false. */ + virtual devourer::TxCaps GetTxCaps() { return {}; } + virtual bool send_packet(const uint8_t *packet, size_t length) = 0; virtual SelectedChannel GetSelectedChannel() = 0; diff --git a/src/TxCaps.h b/src/TxCaps.h new file mode 100644 index 0000000..c9d9dd9 --- /dev/null +++ b/src/TxCaps.h @@ -0,0 +1,44 @@ +/* Per-chip TX capability report — what modulation features a given adapter can + * actually transmit. An adaptive link (or wfb-ng) that blindly sets STBC/LDPC + * radiotap flags on a card that lacks them produces malformed frames that never + * decode — a well-known OpenIPC-FPV footgun ("LDPC/STBC forced on a card that + * lacks them → link dies", e.g. the 1T1R 8811AU/8821AU/8821CU where STBC needs + * ≥2 TX chains). devourer already resolves chip identity at construction; this + * surfaces it so callers can gate features, and send_packet drops an + * unsupported STBC request rather than airing garbage. + */ +#ifndef DEVOURER_TX_CAPS_H +#define DEVOURER_TX_CAPS_H + +#include + +namespace devourer { + +struct TxCaps { + bool supported = false; /* false on a generation that hasn't wired this */ + uint8_t n_ss = 0; /* spatial streams the RF supports (1/2/3/4) */ + bool stbc_ok = false; /* STBC needs ≥2 TX chains — false on 1T1R parts */ + bool ldpc_ok = false; /* LDPC coding supported */ + bool sgi_ok = false; /* short guard interval */ + uint8_t bw_max_mhz = 20; /* widest TX bandwidth (20/40/80) */ +}; + +/* Build caps from the chain count. The load-bearing rule: STBC needs ≥2 TX + * chains, so a 1T1R part (chains==1) reports stbc_ok=false — the invariant the + * send_packet guard relies on. Pure; unit-tested in tests/tx_caps_selftest.cpp. + * LDPC/SGI/bw default true/80 for the 802.11ac Jaguar family. */ +inline TxCaps tx_caps_for_chains(uint8_t chains, bool ldpc = true, + bool sgi = true, uint8_t bw_max_mhz = 80) { + TxCaps c; + c.supported = true; + c.n_ss = chains; + c.stbc_ok = chains >= 2; + c.ldpc_ok = ldpc; + c.sgi_ok = sgi; + c.bw_max_mhz = bw_max_mhz; + return c; +} + +} // namespace devourer + +#endif /* DEVOURER_TX_CAPS_H */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 1d2b793..eb34a09 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -471,6 +471,19 @@ bool RtlJaguarDevice::send_packet(const uint8_t *packet, size_t length) { ptxdesc = (struct tx_desc *)usb_frame; + /* Drop an STBC request the chip can't honour: STBC needs >=2 TX chains, so a + * 1T1R part (8811AU/8821AU) that airs an STBC-marked frame produces a + * malformed PPDU that never decodes (a known adaptive-link footgun). Warn + * once; leave 2T2R/4T4R untouched. */ + if (stbc && !GetTxCaps().stbc_ok) { + static bool warned = false; + if (!warned) { + _logger->warn("STBC requested but this chip is 1T1R (no STBC) — dropping " + "the STBC flag to keep frames decodable"); + warned = true; + } + stbc = 0; + } _logger->debug("fixed rate:{}, sgi:{}, radiotap_bwidth:{}, ldpc:{}, stbc:{}", (int)fixed_rate, (int)sgi, (int)bwidth, (int)ldpc, (int)stbc); @@ -845,6 +858,13 @@ bool RtlJaguarDevice::ReApplyTxPower() { return true; } +devourer::TxCaps RtlJaguarDevice::GetTxCaps() { + /* numTotalRfPath is the TX chain count the EFUSE RF-type resolves to (1 on + * the 8811AU/8821AU 1T1R cuts, 2 on 8812AU, 4 on 8814AU). All Jaguar-1 AC + * parts do LDPC/SGI and VHT80. */ + return devourer::tx_caps_for_chains(_eepromManager->numTotalRfPath); +} + devourer::TxPowerState RtlJaguarDevice::GetTxPowerState() { devourer::TxPowerState s; s.valid = true; diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index ce226f6..8331354 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -113,6 +113,10 @@ class RtlJaguarDevice : public IRtlDevice { void SetTxPowerIndexOverride(int idx) override; bool ReApplyTxPower() override; devourer::TxPowerState GetTxPowerState() override; + /* Per-chip TX caps (IRtlDevice): n_ss + STBC/LDPC/SGI/bw from the EFUSE + * RF-type. STBC needs >=2 chains, so 1T1R cuts (8811AU/8821AU) report + * stbc_ok=false and send_packet drops an STBC request. */ + devourer::TxCaps GetTxCaps() override; /* Read a baseband register (debug/diagnostic). Thin passthrough to the * radio manager's BB read — handy for confirming a TXAGC write landed. */ uint32_t ReadBBReg(uint16_t addr, uint32_t mask); diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 1d53b8f..8e23452 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -645,6 +645,12 @@ void RtlJaguar2Device::SetTxPacketPowerStep(uint8_t step) { step & 0x7); } +devourer::TxCaps RtlJaguar2Device::GetTxCaps() { + /* 8821C is 1T1R (no STBC); 8822B is 2T2R. */ + const uint8_t chains = _variant == jaguar2::ChipVariant::C8821C ? 1 : 2; + return devourer::tx_caps_for_chains(chains); +} + devourer::ThermalStatus RtlJaguar2Device::GetThermalStatus() { devourer::ThermalStatus t; if (!_brought_up) @@ -801,6 +807,18 @@ bool RtlJaguar2Device::send_packet(const uint8_t *packet, size_t length) { } } + /* Drop STBC on the 1T1R variant (8821C) — STBC needs >=2 TX chains, so an + * STBC-marked frame there is malformed and never decodes. Warn once. */ + if (stbc && !GetTxCaps().stbc_ok) { + static bool warned = false; + if (!warned) { + _logger->warn("STBC requested on a 1T1R chip (8821C) — dropping the STBC " + "flag to keep frames decodable"); + warned = true; + } + stbc = 0; + } + const uint8_t *dot11 = packet + radiotap_length; bool bmc = frame_len >= 6 && (dot11[4] & 0x01); diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 2a749ad..08d2fa0 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -79,6 +79,9 @@ class RtlJaguar2Device : public IRtlDevice { bool ReApplyTxPower() override; devourer::TxPowerState GetTxPowerState() override; devourer::ThermalStatus GetThermalStatus() override; + /* Per-chip TX caps (IRtlDevice): the 8821C is 1T1R (no STBC), the 8822B + * 2T2R. send_packet drops an STBC request the variant can't honour. */ + devourer::TxCaps GetTxCaps() override; /* Per-packet TX-power offset — the zero-cost per-frame power trim the * adaptive link wants (distinct from the per-rate TXAGC that diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 6dc2917..f478f47 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -796,6 +796,10 @@ bool RtlJaguar3Device::ReApplyTxPower() { return true; } +devourer::TxCaps RtlJaguar3Device::GetTxCaps() { + return devourer::tx_caps_for_chains(2); /* 8822C/8822E are 2T2R */ +} + devourer::TxPowerState RtlJaguar3Device::GetTxPowerState() { devourer::TxPowerState s; s.valid = true; @@ -976,6 +980,11 @@ bool RtlJaguar3Device::send_packet(const uint8_t *packet, size_t length) { * as NDPA so the armed sounding engine (DEVOURER_BF_ARM_SOUNDER, InitWrite) * follows each with a hardware NDP. Same knob as the Jaguar-1 path. */ static const bool ndpa = std::getenv("DEVOURER_TX_NDPA") != nullptr; + /* STBC guard (IRtlDevice contract) — 8822C/8822E are 2T2R so this never + * fires today, but keeps the invariant uniform across families: never air an + * STBC frame the chip can't do. */ + if (stbc && !GetTxCaps().stbc_ok) + stbc = 0; std::vector usb_frame(jaguar3::TXDESC_SIZE_8822C + frame_len, 0); jaguar3::fill_data_tx_desc_8822c( usb_frame.data(), static_cast(frame_len), diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index 228edcf..e2d728a 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -83,6 +83,8 @@ class RtlJaguar3Device : public IRtlDevice { bool ReApplyTxPower() override; devourer::TxPowerState GetTxPowerState() override; devourer::ThermalStatus GetThermalStatus() override; + /* Per-chip TX caps (IRtlDevice): 8822C/8822E are 2T2R (STBC ok). */ + devourer::TxCaps GetTxCaps() override; /* Runtime TX-mode default — applied in send_packet when the radiotap carries * no rate. Without this the Jaguar3 TX path fell back to MGN_1M for rate-less * frames (so DEVOURER_TX_RATE/an MCS flood went on-air at 1 Mbps): the feature diff --git a/tests/stbc_1t1r_check.sh b/tests/stbc_1t1r_check.sh new file mode 100755 index 0000000..aac1f05 --- /dev/null +++ b/tests/stbc_1t1r_check.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Validate the STBC/1T1R safety guard (GetTxCaps + send_packet). STBC needs >=2 +# TX chains, so forcing it on a 1T1R part produces a malformed PPDU that never +# decodes — the "LDPC/STBC forced on a card that lacks them => link dies" +# footgun from the adaptive-link recipes. Measured A/B on the 8821AU: forcing +# STBC (unguarded) delivered 0 frames; with the guard (STBC dropped) it +# delivered 7000. This test asserts the guard's behaviour per DUT: +# +# 1T1R (8821AU / 8821CU): inject MCS1/STBC -> the "dropping the STBC flag" +# warning fires AND the frame still delivers (STBC dropped to clean MCS1). +# 2T2R (8812AU / 8822BU): inject MCS1/STBC -> NO warning (STBC honoured) and +# the frame delivers. +# +# Ground = a second devourer part counting from the canonical SA. +# +# Usage: sudo -v && tests/stbc_1t1r_check.sh +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${STBC_OUT:-/tmp/devourer-stbc-1t1r}" +CH="${CH:-36}" +mkdir -p "$OUT" + +PASS=0; FAIL=0; SKIP=0 +pass() { echo " PASS: $*"; PASS=$((PASS+1)); } +fail() { echo " FAIL: $*"; FAIL=$((FAIL+1)); } +skip() { echo " SKIP: $*"; SKIP=$((SKIP+1)); } + +cleanup() { pkill -x WiFiDriverTxDem 2>/dev/null||true; pkill -x WiFiDriverDemo 2>/dev/null||true; true; } +trap cleanup EXIT INT TERM +plugged() { lsusb -d "$(printf '%04x:%04x' "$2" "$1")" >/dev/null 2>&1; } + +echo "== building ==" +cmake --build "$ROOT/build" -j --target WiFiDriverTxDemo WiFiDriverDemo >/dev/null || exit 1 + +# DUT table: pid vid n_ss(1|2) ground_pid ground_vid +DUTS=( + "0x0120 0x2357 1 0x8812 0x0bda" # 8821AU (1T1R) ground 8812AU + "0xc811 0x0bda 1 0x8812 0x0bda" # 8821CU (1T1R) ground 8812AU + "0x8812 0x0bda 2 0xc812 0x0bda" # 8812AU (2T2R) ground 8822CU + "0x012d 0x2357 2 0xc812 0x0bda" # 8822BU (2T2R) ground 8822CU +) + +for dut in "${DUTS[@]}"; do + read -r PID VID NSS GPID GVID <<<"$dut" + if ! plugged "$PID" "$VID"; then skip "$PID not plugged"; continue; fi + if ! plugged "$GPID" "$GVID"; then skip "$PID: ground $GPID not plugged"; continue; fi + tag="${PID#0x}" + echo "== DUT $PID (${NSS}T${NSS}R), ground $GPID ==" + : >"$OUT/$tag-g.log" + sudo -n env DEVOURER_PID="$GPID" DEVOURER_VID="$GVID" DEVOURER_CHANNEL="$CH" \ + stdbuf -oL timeout 45 "$ROOT/build/WiFiDriverDemo" 2>/dev/null \ + | grep --line-buffered "tx-hit" >"$OUT/$tag-g.log" & + GJ=$! + sleep 10 + sudo -n env DEVOURER_PID="$PID" DEVOURER_VID="$VID" DEVOURER_CHANNEL="$CH" \ + DEVOURER_TX_RATE=MCS1/STBC DEVOURER_TX_GAP_US=2000 \ + timeout 18 "$ROOT/build/WiFiDriverTxDemo" >"$OUT/$tag-tx.log" 2>&1 || true + sudo -n pkill -x WiFiDriverDemo 2>/dev/null; wait "$GJ" 2>/dev/null + sleep 2 + + warned=$(grep -c "dropping the STBC flag" "$OUT/$tag-tx.log") + hits=$(grep -oE "hits=[0-9]+" "$OUT/$tag-g.log" | tail -1 | grep -oE "[0-9]+") + hits=${hits:-0} + echo " warning fired: $warned, delivery hits: $hits" + + if [ "$NSS" = "1" ]; then + if [ "$warned" -ge 1 ] && [ "$hits" -ge 200 ]; then + pass "$PID (1T1R): STBC dropped + frame delivered ($hits hits)" + else + fail "$PID (1T1R): want warning+delivery, got warned=$warned hits=$hits" + fi + else + if [ "$warned" -eq 0 ] && [ "$hits" -ge 200 ]; then + pass "$PID (2T2R): STBC honoured, no warning, delivered ($hits hits)" + else + fail "$PID (2T2R): want no-warning+delivery, got warned=$warned hits=$hits" + fi + fi +done + +echo +echo "== stbc-1t1r guard: PASS=$PASS FAIL=$FAIL SKIP=$SKIP ==" +[ "$FAIL" -eq 0 ] diff --git a/tests/tx_caps_selftest.cpp b/tests/tx_caps_selftest.cpp new file mode 100644 index 0000000..53dbe0d --- /dev/null +++ b/tests/tx_caps_selftest.cpp @@ -0,0 +1,48 @@ +/* Headless guard for the TX-capability derivation (src/TxCaps.h + * tx_caps_for_chains): the load-bearing rule is that STBC needs >=2 TX chains, + * so a 1T1R part must report stbc_ok=false — that's what the send_packet STBC + * guard relies on to not air a malformed frame on 8811AU/8821AU/8821CU. A + * regression here fails `ctest` instead of only surfacing as a dead link. */ +#include + +#include "TxCaps.h" + +static int g_fail = 0; + +static void expect(uint8_t chains, uint8_t want_nss, bool want_stbc) { + const devourer::TxCaps c = devourer::tx_caps_for_chains(chains); + if (c.supported && c.n_ss == want_nss && c.stbc_ok == want_stbc) + return; + ++g_fail; + std::printf("FAIL: chains=%u -> n_ss=%u stbc_ok=%d, want n_ss=%u stbc_ok=%d\n", + chains, c.n_ss, c.stbc_ok ? 1 : 0, want_nss, want_stbc ? 1 : 0); +} + +int main() { + /* 1T1R cuts (8811AU/8821AU/8821CU): STBC impossible. */ + expect(1, 1, false); + /* 2T2R (8812AU/8822BU/8822CU/8822EU): STBC ok. */ + expect(2, 2, true); + /* 4T4R (8814AU): STBC ok. */ + expect(4, 4, true); + + /* Defaults for the AC family. */ + const devourer::TxCaps c = devourer::tx_caps_for_chains(2); + if (!(c.ldpc_ok && c.sgi_ok && c.bw_max_mhz == 80)) { + ++g_fail; + std::printf("FAIL: AC defaults (ldpc=%d sgi=%d bw=%u)\n", c.ldpc_ok, + c.sgi_ok, c.bw_max_mhz); + } + /* The default-constructed (unwired) caps report unsupported. */ + if (devourer::TxCaps{}.supported) { + ++g_fail; + std::printf("FAIL: default TxCaps should be unsupported\n"); + } + + if (g_fail) { + std::printf("%d failure(s)\n", g_fail); + return 1; + } + std::printf("tx-caps selftest: all OK\n"); + return 0; +}