Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cmake-multi-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ jobs:
cmake --build build --target
WiFiDriver StreamTxDemo StreamDuplexDemo StreamStdinSelftest
ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest
TxPowerQuantSelftest LinkHealthSelftest
TxPowerQuantSelftest LinkHealthSelftest TxPktPwrSelftest

- name: Test
working-directory: build
Expand Down
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ the same stream-count bases as HT (vendor parity) so the offset covers VHT
there; the Jaguar2 TXAGC block and the 8814A's packed port are write-only, so
their `GetTxPowerState` reports the software shadow (`hw_readback=false`).

**Per-packet TX power (Jaguar2 only).** Distinct from the per-rate TXAGC that
`SetTxPowerOffsetQdb` shifts, the 8822B/8821C TX descriptor has a `TXPWR_OFSET`
field (`+0x14[30:28]`) — a hardware LUT applied per-frame *on top of* the
rate's power at **zero USB cost** (a bitfield in the descriptor `send_packet`
already builds): `0`=none, `1`=-3, `2`=-7, `3`=-11, `4`=+3, `5`=+6 dB
(vendor `PHYDM_OFFSET_*`). `send_packet` honours a per-frame radiotap
`DBM_TX_POWER` field as a dB delta quantized to that LUT
(`jaguar2::txpkt_pwr_step_for_db`, selftest `tests/txpkt_pwr_selftest.cpp`) —
the adaptive-link per-packet power knob (wfb-style injection sets it per
frame); `RtlJaguar2Device::SetTxPacketPowerStep` sets a session default for
rate-less frames. On-air-confirmed both paths (`tests/txpkt_pwr_ofset_onair.sh`:
the LUT tracks on-air power ±dB). The classic 8812AU has no such descriptor
field — its per-packet power *is* per-rate selection (radiotap rate → the
TXAGC table), which the library already does; Jaguar3's `TXPWR_OFSET_TYPE` is
a 2-bit variant left unwired.

**RTL8821AU is Jaguar wave 1** (CHIP_8821 = 7, shares the enum with CHIP_8812),
not Jaguar2 — distinct from the Jaguar2 **RTL8821C** (8811CU/8821CU) despite the
similar name. The Jaguar2 **8822BU/8812BU** and **8811CU/8821CU** USB parts are
Expand Down
12 changes: 12 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,18 @@ target_link_libraries(LinkHealthSelftest PRIVATE WiFiDriver)

add_test(NAME link_health_classify COMMAND LinkHealthSelftest)

# 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).
if(DEVOURER_JAGUAR2_8822B OR DEVOURER_JAGUAR2_8821C)
add_executable(TxPktPwrSelftest
tests/txpkt_pwr_selftest.cpp
)
target_link_libraries(TxPktPwrSelftest PRIVATE WiFiDriver)
target_include_directories(TxPktPwrSelftest PRIVATE src)
add_test(NAME txpkt_pwr_quant COMMAND TxPktPwrSelftest)
endif()

# Headless guard for the beamforming-report decoder (src/BfReportDecode.h) — the
# LSB-first Givens-angle unpacking + per-tone variance behind WiFiSenseDemo. It
# decodes a real captured VHT MU report and checks the angles against an offline
Expand Down
38 changes: 37 additions & 1 deletion src/jaguar2/FrameParserJaguar2.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ constexpr size_t RXDESC_SIZE_8822B = 24; /* RX_DESC_SIZE_88XX */
#define SET_TX_DESC_DATA_BW_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 5, 2, v)
#define SET_TX_DESC_DATA_LDPC_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 7, 1, v)
#define SET_TX_DESC_DATA_STBC_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 8, 2, v)
/* Per-packet TX-power offset (halmac TXPWR_OFSET, txdesc+0x14[30:28]). A 3-bit
* hardware LUT applied per-frame on top of the rate's TXAGC — the vendor's
* dynamic-TX-power field (phydm dpt_lv): 0=none, 1=-3dB, 2=-7dB, 3=-11dB,
* 4=+3dB, 5=+6dB (PHYDM_OFFSET_* in phydm_dynamictxpower.h). Zero-cost
* per-packet power trim — a bitfield in the descriptor devourer already builds. */
#define SET_TX_DESC_TXPWR_OFSET_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x14, 28, 3, v)
#define SET_TX_DESC_NAVUSEHDR_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x0C, 15, 1, v)
#define SET_TX_DESC_NDPA_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x0C, 22, 2, v)
#define SET_TX_DESC_DISQSELSEQ_8822B(d, v) SET_BITS_TO_LE_4BYTE((d) + 0x00, 31, 1, v)
Expand Down Expand Up @@ -87,11 +93,37 @@ inline void cal_txdesc_chksum_8822b(uint8_t *txdesc) {

/* Fill an 8822B data/monitor-inject TX descriptor (48 bytes, zeroed by caller)
* and finalise its checksum. Mirrors the Jaguar3 inject field choices. */
/* Quantize a requested per-packet power delta (dB) to the nearest hardware
* TXPWR_OFSET LUT step. The field is discrete — {0, -3, -7, -11, +3, +6} dB —
* so an adaptive link's continuous request lands on the closest rung. Pure;
* unit-tested in tests/txpkt_pwr_selftest.cpp. */
inline uint8_t txpkt_pwr_step_for_db(int db) {
static const struct { int db; uint8_t step; } lut[] = {
{0, 0}, {-3, 1}, {-7, 2}, {-11, 3}, {3, 4}, {6, 5}};
uint8_t best = 0;
int best_err = 1 << 30;
for (const auto &e : lut) {
int err = db > e.db ? db - e.db : e.db - db;
if (err < best_err) {
best_err = err;
best = e.step;
}
}
return best;
}

/* Inverse: the nominal dB of a LUT step (for readback / logging). */
inline int txpkt_pwr_db_for_step(uint8_t step) {
static const int db[] = {0, -3, -7, -11, 3, 6};
return step < 6 ? db[step] : 0;
}

inline void fill_data_tx_desc_8822b(uint8_t *d, uint16_t pkt_size,
uint8_t rate_hw, uint8_t rate_id, uint8_t bw,
bool short_gi, bool ldpc, uint8_t stbc,
bool bmc = false, uint8_t wheader_len = 12,
bool ndpa = false, uint8_t data_sc = 0) {
bool ndpa = false, uint8_t data_sc = 0,
uint8_t pwr_ofset = 0) {
SET_TX_DESC_TXPKTSIZE_8822B(d, pkt_size);
SET_TX_DESC_OFFSET_8822B(d, static_cast<uint32_t>(TXDESC_SIZE_8822B));
SET_TX_DESC_LS_8822B(d, 1);
Expand All @@ -113,6 +145,10 @@ inline void fill_data_tx_desc_8822b(uint8_t *d, uint16_t pkt_size,
* BW, the common case). Non-zero places e.g. a 40-in-80 frame on the primary
* lower-40 instead of the block centre. */
SET_TX_DESC_DATA_SC_8822B(d, data_sc);
/* Per-packet TX-power offset (0 = none, byte-identical to the prior desc).
* Inside the 32-byte checksummed region (0x14), so set before the checksum. */
if (pwr_ofset)
SET_TX_DESC_TXPWR_OFSET_8822B(d, pwr_ofset & 0x7);
SET_TX_DESC_DATA_SHORT_8822B(d, short_gi ? 1 : 0);
SET_TX_DESC_DATA_LDPC_8822B(d, ldpc ? 1 : 0);
SET_TX_DESC_DATA_STBC_8822B(d, stbc & 0x3);
Expand Down
25 changes: 24 additions & 1 deletion src/jaguar2/RtlJaguar2Device.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,13 @@ devourer::TxPowerState RtlJaguar2Device::GetTxPowerState() {
return s;
}

void RtlJaguar2Device::SetTxPacketPowerStep(uint8_t step) {
_tx_pkt_pwr_step = step & 0x7;
_logger->info("Jaguar2: per-packet TXPWR_OFSET default step = {} "
"(0=none 1=-3 2=-7 3=-11 4=+3 5=+6 dB)",
step & 0x7);
}

devourer::ThermalStatus RtlJaguar2Device::GetThermalStatus() {
devourer::ThermalStatus t;
if (!_brought_up)
Expand Down Expand Up @@ -670,6 +677,11 @@ bool RtlJaguar2Device::send_packet(const uint8_t *packet, size_t length) {
bool rate_from_radiotap = false;
/* Per-packet hop target from a radiotap CHANNEL field (0 = none present). */
int radiotap_channel = 0;
/* Per-packet TX-power delta (dB) from a radiotap DBM_TX_POWER field; on an
* INJECTED frame we treat it as a delta to the rate's calibrated power and
* quantize to the descriptor TXPWR_OFSET LUT (the vendor skips this field, so
* there is no absolute-dBm convention to violate). INT_MIN = not present. */
int radiotap_pkt_pwr_db = INT_MIN;

auto *rtap_hdr = reinterpret_cast<struct ieee80211_radiotap_header *>(
const_cast<uint8_t *>(packet));
Expand All @@ -692,6 +704,10 @@ bool RtlJaguar2Device::send_packet(const uint8_t *packet, size_t length) {
radiotap_channel =
devourer::freq_to_chan(get_unaligned_le16(it.this_arg));
break;
case IEEE80211_RADIOTAP_DBM_TX_POWER:
/* Signed 1-byte dB — the per-packet power delta (see the field decl). */
radiotap_pkt_pwr_db = static_cast<int8_t>(*it.this_arg);
break;
case IEEE80211_RADIOTAP_MCS: {
uint8_t mcs_flags = it.this_arg[1];
if ((mcs_flags & IEEE80211_RADIOTAP_MCS_BW_MASK) ==
Expand Down Expand Up @@ -803,11 +819,18 @@ bool RtlJaguar2Device::send_packet(const uint8_t *packet, size_t length) {
* NDPA so the armed sounding engine (DEVOURER_BF_ARM_SOUNDER, InitWrite)
* follows each with a hardware-generated NDP. Same knob as Jaguar-1/-3. */
static const bool ndpa = std::getenv("DEVOURER_TX_NDPA") != nullptr;
/* Per-packet TX power: a radiotap DBM_TX_POWER on this frame (quantized to
* the descriptor LUT) wins per-packet; otherwise the SetTxPacketPowerStep
* default. Zero cost — a descriptor bitfield, no register write. */
const uint8_t pkt_pwr_step =
radiotap_pkt_pwr_db != INT_MIN
? jaguar2::txpkt_pwr_step_for_db(radiotap_pkt_pwr_db)
: _tx_pkt_pwr_step.load();
std::vector<uint8_t> usb_frame(jaguar2::TXDESC_SIZE_8822B + frame_len, 0);
jaguar2::fill_data_tx_desc_8822b(
usb_frame.data(), static_cast<uint16_t>(frame_len),
MRateToHwRate(fixed_rate), rate_id, bw_desc, sgi != 0, ldpc != 0, stbc,
bmc, static_cast<uint8_t>(hdrlen >> 1), ndpa, data_sc);
bmc, static_cast<uint8_t>(hdrlen >> 1), ndpa, data_sc, pkt_pwr_step);
std::memcpy(usb_frame.data() + jaguar2::TXDESC_SIZE_8822B, dot11, frame_len);

int rc = _device.bulk_send_sync_ep(_device.first_bulk_out_ep(),
Expand Down
14 changes: 14 additions & 0 deletions src/jaguar2/RtlJaguar2Device.h
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ class RtlJaguar2Device : public IRtlDevice {
devourer::TxPowerState GetTxPowerState() override;
devourer::ThermalStatus GetThermalStatus() override;

/* Per-packet TX-power offset — the zero-cost per-frame power trim the
* adaptive link wants (distinct from the per-rate TXAGC that
* SetTxPowerOffsetQdb shifts). Sets the 8822B/8821C TX-descriptor TXPWR_OFSET
* field (a hardware LUT applied on top of the rate's power); no register
* write, no channel-set cost. `step` is the raw 3-bit LUT index: 0=none,
* 1=-3dB, 2=-7dB, 3=-11dB, 4=+3dB, 5=+6dB. Applied as the default to every
* frame lacking a per-packet radiotap DBM_TX_POWER field (which, on an
* injected frame, send_packet honours as a per-packet dB delta quantized to
* this LUT — the true per-packet path). On-air-confirmed: the LUT tracks
* on-air power (tests/txpkt_pwr_ofset_onair.sh). */
void SetTxPacketPowerStep(uint8_t step);

/* Realtek MP single-tone (CW carrier) — radiate a bare RF local-oscillator
* carrier at the tuned channel center. Path A; both Jaguar2 variants, per the
* vendor hal_mpt_SetSingleToneTx() branches: OFDM/CCK modulators off, RF path A
Expand Down Expand Up @@ -119,6 +131,8 @@ class RtlJaguar2Device : public IRtlDevice {
* override -1 = efuse per-rate baseline; offset in 0.5 dB index steps. */
std::atomic<int> _tx_pwr_override{-1};
std::atomic<int> _tx_pwr_offset_steps{0};
/* Default per-packet TXPWR_OFSET LUT step (0 = none) — see SetTxPacketPowerStep. */
std::atomic<uint8_t> _tx_pkt_pwr_step{0};
/* Bring-up completion: gates the live apply in the TX-power setters. */
bool _brought_up = false;
/* Re-program TXAGC from the current knob state at the current channel:
Expand Down
154 changes: 154 additions & 0 deletions tests/txpkt_pwr_ofset_onair.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# MEASURE the Jaguar2 per-packet TX-descriptor power lever (TXPWR_OFSET). Does
# setting the 3-bit descriptor field actually move on-air power standalone (no
# phydm dynamic-txpwr enable), and by the vendor LUT (0=none, 1=-3, 2=-7, 3=-11,
# 4=+3, 5=+6 dB)? Measure-first, before wiring the radiotap per-packet path.
#
# TX = 8822BU (the descriptor field is Jaguar2) at a FIXED rate/power, stepping
# DEVOURER_TX_PKT_OFSET; ground = a second devourer part reporting per-frame
# RSSI (the same chip-RSSI sensor the TX-power slope work uses, since the B210
# front end limits on near-field frames). If the field works, ground RSSI
# tracks the LUT: step 4 (+3) up ~3 dB, step 5 (+6) up ~6 dB, step 1/2/3
# (-3/-7/-11) down, all vs step 0.
#
# Usage: sudo -v && tests/txpkt_pwr_ofset_onair.sh [ground_pid]
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="${TXPKT_OUT:-/tmp/devourer-txpkt-ofset}"
CH="${CH:-36}"
TX_PID=0x012d TX_VID=0x2357 # 8822BU (T3U) — has TXPWR_OFSET
GROUND_PID="${1:-0xc812}" GROUND_VID="${GROUND_VID:-0x0bda}"
# LUT steps to sweep, paired with their nominal dB (for the assertion).
STEPS="0 4 5 1 2 3"
declare -A DB=( [0]=0 [4]=3 [5]=6 [1]=-3 [2]=-7 [3]=-11 )
mkdir -p "$OUT"

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; }
plugged "$TX_PID" "$TX_VID" || { echo "SKIP: 8822BU (TX) not plugged"; exit 0; }
plugged "$GROUND_PID" "$GROUND_VID" || { echo "SKIP: ground $GROUND_PID not plugged"; exit 0; }

echo "== building =="
cmake --build "$ROOT/build" -j --target WiFiDriverTxDemo WiFiDriverDemo >/dev/null || exit 1

echo "== ground RX ($GROUND_PID) on ch$CH =="
: >"$OUT/ground.log"
sudo -n env DEVOURER_PID="$GROUND_PID" DEVOURER_VID="$GROUND_VID" \
DEVOURER_CHANNEL="$CH" DEVOURER_STREAM_OUT=1 \
stdbuf -oL timeout 300 "$ROOT/build/WiFiDriverDemo" 2>"$OUT/ground.err" \
| while IFS= read -r l; do printf '%s %s\n' "$(date +%s.%N)" "$l"; done \
>>"$OUT/ground.log" &
GJ=$!
sleep 12

: >"$OUT/cells.txt"
for step in $STEPS; do
t0="$(date +%s.%N)"
# Fixed rate MCS3, fixed base power, only the per-packet offset varies.
sudo -n env DEVOURER_PID="$TX_PID" DEVOURER_VID="$TX_VID" DEVOURER_CHANNEL="$CH" \
DEVOURER_TX_RATE=MCS3 DEVOURER_TX_PWR=40 DEVOURER_TX_PKT_OFSET="$step" \
DEVOURER_TX_GAP_US=1500 \
timeout 12 "$ROOT/build/WiFiDriverTxDemo" >/dev/null 2>&1 || true
t1="$(date +%s.%N)"
echo "$step ${DB[$step]} $t0 $t1" >>"$OUT/cells.txt"
sleep 2
done
sudo -n pkill -x WiFiDriverDemo 2>/dev/null; wait "$GJ" 2>/dev/null

# --- radiotap path: same effect via a per-packet DBM_TX_POWER field (not the
# device default) — proves send_packet honours per-frame power. Two cells
# at the LUT extremes; append to cells.txt with a 'r' tag column.
echo "== ground still up: radiotap DBM_TX_POWER cells ==" >&2
sudo -n env DEVOURER_PID="$GROUND_PID" DEVOURER_VID="$GROUND_VID" \
DEVOURER_CHANNEL="$CH" DEVOURER_STREAM_OUT=1 \
stdbuf -oL timeout 90 "$ROOT/build/WiFiDriverDemo" 2>/dev/null \
| while IFS= read -r l; do printf '%s %s\n' "$(date +%s.%N)" "$l"; done \
>>"$OUT/ground.log" &
GJ2=$!
sleep 10
for pair in "0:0" "6:6" "-11:-11"; do
db="${pair%%:*}"
t0="$(date +%s.%N)"
sudo -n env DEVOURER_PID="$TX_PID" DEVOURER_VID="$TX_VID" DEVOURER_CHANNEL="$CH" \
DEVOURER_TX_RATE=MCS3 DEVOURER_TX_PWR=40 DEVOURER_TX_PKT_PWR_DB="$db" \
DEVOURER_TX_GAP_US=1500 \
timeout 12 "$ROOT/build/WiFiDriverTxDemo" >/dev/null 2>&1 || true
t1="$(date +%s.%N)"
echo "r$db $db $t0 $t1" >>"$OUT/cells.txt"
sleep 2
done
sudo -n pkill -x WiFiDriverDemo 2>/dev/null; wait "$GJ2" 2>/dev/null

python3 - "$OUT/ground.log" "$OUT/cells.txt" <<'PYEOF'
import re, statistics, sys
frames = []
rx = re.compile(r"^([0-9.]+) .*<devourer-stream>.*\brssi=(-?\d+),")
for line in open(sys.argv[1], errors="replace"):
m = rx.match(line)
if m:
frames.append((float(m.group(1)), int(m.group(2))))
def med(t0, t1):
win = [r for (t, r) in frames if t0 + 6 <= t <= t1 - 1]
return (int(statistics.median(win)), len(win)) if len(win) >= 15 else (None, len(win))

desc = [] # (step:int, db, rssi) — device-default path
rtap = [] # (db, rssi) — radiotap path
for line in open(sys.argv[2]):
label, db, t0, t1 = line.split(); db, t0, t1 = int(db), float(t0), float(t1)
rssi, n = med(t0, t1)
if rssi is None:
print(f"{label} ({db:+d} dB): too few frames ({n})"); continue
if label.startswith("r"):
rtap.append((db, rssi))
print(f"<txpkt-radiotap> db={db:+d} rssi_med={rssi} frames={n}")
else:
desc.append((int(label), db, rssi))
print(f"<txpkt> step={label} nominal_db={db:+d} rssi_med={rssi} frames={n}")

fail = 0
# --- device-default (descriptor LUT) path ---
base = next((r[2] for r in desc if r[0] == 0), None)
if base is None or len(desc) < 4:
print("RESULT-DESC inconclusive (no step-0 baseline)"); fail = 1
else:
print(f"\ndescriptor-default baseline (step 0) rssi = {base}")
moved = correct = total = 0
for step, db, rssi in desc:
if step == 0:
continue
total += 1
d = rssi - base
moved += abs(d) >= 1
correct += (db > 0 and d > 0) or (db < 0 and d < 0)
print(f" step {step} ({db:+3d} dB): rssi {base}->{rssi} = {d:+d} raw")
if moved == 0:
print("RESULT-DESC INERT — descriptor TXPWR_OFSET did not move on-air power")
fail = 1
elif correct >= total - 1:
print(f"RESULT-DESC WORKS — {correct}/{total} steps correct direction")
else:
print(f"RESULT-DESC PARTIAL — {correct}/{total} correct sign"); fail = 1

# --- radiotap per-packet path ---
rbase = next((r[1] for r in rtap if r[0] == 0), None)
if rbase is None or len(rtap) < 3:
print("RESULT-RADIOTAP skipped (no cells)")
else:
print(f"\nradiotap DBM_TX_POWER baseline (0 dB) rssi = {rbase}")
rcorrect = rtotal = 0
for db, rssi in rtap:
if db == 0:
continue
rtotal += 1
d = rssi - rbase
rcorrect += (db > 0 and d > 0) or (db < 0 and d < 0)
print(f" radiotap {db:+3d} dB: rssi {rbase}->{rssi} = {d:+d} raw")
if rcorrect == rtotal and rtotal >= 2:
print(f"RESULT-RADIOTAP WORKS — {rcorrect}/{rtotal} per-packet power via "
f"radiotap DBM_TX_POWER moved on-air power correctly")
else:
print(f"RESULT-RADIOTAP FAIL — {rcorrect}/{rtotal} correct"); fail = 1

sys.exit(1 if fail else 0)
PYEOF
Loading
Loading