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
22 changes: 22 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,28 @@ VHT info field) before the bulk-OUT loop:

`_LDPC` / `_STBC` / `_BW` apply to whichever (HT/VHT) mode is active.

`WiFiDriverTxDemo` also honours a TX-gain ramp + duty knob for thermal /
TX-power characterisation (drives `RtlJaguarDevice::SetTxPowerOverride` +
`ApplyTxPower`):

- `DEVOURER_TX_PWR_START=N` — force an absolute per-rate TXAGC index (0..63),
bypassing the EFUSE per-rate table. Unset = normal EFUSE-driven power.
- `DEVOURER_TX_PWR_STOP=N` / `DEVOURER_TX_PWR_STEP=N` / `DEVOURER_TX_PWR_STEP_MS=N`
— step the override from START up to STOP by STEP every STEP_MS, in one
uninterrupted TX session, emitting a `<devourer-txpwr>index=N` marker per step.
The override only moves on-air power for OFDM/HT/VHT rates — drive HT with
`DEVOURER_TX_HT_MCS=1` (CCK at the 1M default tracks the index in-register but
the SDR-measured swing is dominated by the CCK path).
- `DEVOURER_TX_GAP_US=N` — inter-frame gap in microseconds (default 2000,
~500 fps). `0` = back-to-back for maximum TX duty (heating experiments).
- `DEVOURER_TX_PWR_READBACK=1` — after each override apply, print
`<devourer-txpwr-rb>` with the read-back TXAGC registers (0xc20 CCK 1M /
0xc24 OFDM 6M) to confirm the write landed.

The reusable experiment harness lives in `tests/thermal_gain_sweep.py`
(orchestrator), `tests/sdr_power_probe.py` (USRP receive-power ground truth),
and `tests/run_thermal_gain_sweep.sh` (build + uv venv + sudo run).

## Architecture

**The caller owns libusb.** `WiFiDriver::CreateRtlDevice` is intentionally
Expand Down
16 changes: 15 additions & 1 deletion src/RadioManagementModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,15 @@ bool RadioManagementModule::phy_SwBand8812(uint8_t channelToSW) {
BandToSW = BandType::BAND_ON_2_4G;
}

/* The shadow band-type must track the channel's band even when no HW
* band-switch is needed (the chip is already on that band). It used to be
* updated only inside PHY_SwitchWirelessBand8812, so after init_hw_mlme_ext
* reset it to BAND_MAX and the following same-band channel-set skipped the
* switch, current_band_type stayed BAND_MAX. That made bIsIn24G false and
* silently skipped CCK TX-power programming on every later TX-power apply
* (and fed BAND_MAX to the thermal tracker + IQK). */
current_band_type = BandToSW;

if (BandToSW != Band) {
/* Per-chip band-switch. 8814 has a completely separate sequence
* (path-C/D RFE pinmux, 8814 AGC table register, CCK clock-gate
Expand Down Expand Up @@ -1963,7 +1972,12 @@ void RadioManagementModule::PHY_SetTxPowerIndexByRateArray(
for (int i = 0; i < rates.size(); ++i) {
MGN_RATE rate = rates[i];
uint32_t powerIndex;
if (_eepromManager->TxPowerInfoLoaded) {
if (txpwr_override_ >= 0) {
/* Experiment override: force every rate to the same TXAGC index,
* bypassing the EFUSE per-rate table. Clamp to the 6-bit field. */
powerIndex = static_cast<uint32_t>(txpwr_override_ > 63 ? 63
: txpwr_override_);
} else if (_eepromManager->TxPowerInfoLoaded) {
powerIndex = _eepromManager->GetTxPowerIndexBase(
static_cast<uint8_t>(rfPath), static_cast<uint8_t>(rate),
rate_ntx(static_cast<uint8_t>(rate)), bw, _currentChannel);
Expand Down
16 changes: 16 additions & 0 deletions src/RadioManagementModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ class RadioManagementModule {
uint8_t _cur80MhzPrimeSc;
uint8_t _currentCenterFrequencyIndex;
uint8_t power = 16;
/* Experiment knob: when >= 0, every per-rate TXAGC index is forced to this
* value instead of the EFUSE-derived per-rate table (or the `power`
* fallback). -1 = disabled (normal behaviour). Set via SetTxPowerOverride
* and re-applied on the next channel-set; used by the thermal-vs-gain
* ramp in WiFiDriverTxDemo. */
int txpwr_override_ = -1;
PowerTracking8812a _pwrTrk;
Iqk8812a _iqk;
Iqk8814a _iqk8814;
Expand Down Expand Up @@ -235,6 +241,16 @@ class RadioManagementModule {
* TX (submits OK, 0 on-air) even though RX works. */
void InitRFEGpio8814A();
void SetTxPower(uint8_t p);
/* Force the per-rate TXAGC index (0..63) on the next and subsequent
* channel-sets, overriding the EFUSE per-rate table. -1 restores normal
* (EFUSE-driven) behaviour. Takes effect when set_channel_bwmode runs, or
* immediately via ApplyTxPower(). */
void SetTxPowerOverride(int idx) { txpwr_override_ = idx; }
/* Re-run the per-rate TXAGC writes for the current channel WITHOUT a channel
* switch. set_channel_bwmode early-returns when the channel/bw is unchanged,
* so this is the only way to push a freshly-set SetTxPowerOverride() value to
* the TXAGC registers mid-session (used by the thermal-vs-gain ramp). */
void ApplyTxPower() { PHY_SetTxPowerLevel8812(_currentChannel); }

private:
void rtw_hal_set_msr(uint8_t net_type);
Expand Down
10 changes: 10 additions & 0 deletions src/RtlJaguarDevice.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,16 @@ void RtlJaguarDevice::SetTxPower(uint8_t power) {
_radioManagement->SetTxPower(power);
}

void RtlJaguarDevice::SetTxPowerOverride(int idx) {
_radioManagement->SetTxPowerOverride(idx);
}

void RtlJaguarDevice::ApplyTxPower() { _radioManagement->ApplyTxPower(); }

uint32_t RtlJaguarDevice::ReadBBReg(uint16_t addr, uint32_t mask) {
return _radioManagement->phy_query_bb_reg_public(addr, mask);
}

bool RtlJaguarDevice::NetDevOpen(SelectedChannel selectedChannel) {
auto status = _halModule.rtw_hal_init(selectedChannel);
if (status == false) {
Expand Down
11 changes: 11 additions & 0 deletions src/RtlJaguarDevice.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ class RtlJaguarDevice {
void SetMonitorChannel(SelectedChannel channel);
void InitWrite(SelectedChannel channel);
void SetTxPower(uint8_t power);
/* Force the per-rate TXAGC index (0..63), bypassing the EFUSE per-rate
* table; -1 restores normal behaviour. Re-applied on the next
* SetMonitorChannel. Used by the thermal-vs-gain ramp in WiFiDriverTxDemo. */
void SetTxPowerOverride(int idx);
/* Re-apply the per-rate TX power for the current channel immediately (no
* channel switch). Needed because SetMonitorChannel early-returns when the
* channel is unchanged. Call after SetTxPowerOverride to make it take. */
void ApplyTxPower();
/* 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);
bool send_packet(const uint8_t* packet, size_t length);
SelectedChannel GetSelectedChannel();
bool should_stop = false;
Expand Down
18 changes: 18 additions & 0 deletions tests/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[project]
name = "devourer-tests"
version = "0.0.0"
description = "Devourer hardware test/experiment harness (regress, bench, thermal sweep)"
requires-python = ">=3.10"
# NOTE: `uhd` (UHD's Python bindings) is a SYSTEM package, not on PyPI. Create
# the venv so it can see system site-packages:
# uv venv --system-site-packages
# uv pip install -e .
# `run_thermal_gain_sweep.sh` does this automatically.
dependencies = [
"numpy>=1.23",
"scapy>=2.5",
]

[tool.uv]
# Allow the experiment scripts to import the system-installed `uhd` module.
# (uv still manages numpy/scapy in the venv.)
54 changes: 54 additions & 0 deletions tests/run_thermal_gain_sweep.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Thermal-vs-TX-gain experiment runner.
#
# Builds devourer, sets up a uv venv (with system site-packages so UHD's
# `uhd` module is importable), then runs the orchestrator under sudo (USB claim
# needs root). All extra args are forwarded to thermal_gain_sweep.py, e.g.:
#
# ./tests/run_thermal_gain_sweep.sh --channel 6 --start 8 --stop 40 \
# --step 4 --step-ms 30000
#
# Cleanup: on any exit (including Ctrl-C) leftover demo / SDR-probe processes
# are killed by exact comm. The orchestrator already installs PDEATHSIG +
# killpg handlers; this is belt-and-braces.
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"

cleanup() {
# Exact-comm kills only — never a broad pkill pattern.
for comm in WiFiDriverTxDemo sdr_power_probe.py; do
pkill -x "$comm" 2>/dev/null || true
done
# sdr_power_probe runs as `python3 .../sdr_power_probe.py`; comm is python3,
# so match on the full arg instead.
pkill -f "tests/sdr_power_probe.py" 2>/dev/null || true
}
trap cleanup EXIT INT TERM

echo "== building devourer =="
cmake --build "$ROOT/build" -j >/dev/null

echo "== preparing uv venv (system site-packages for uhd) =="
cd "$HERE"
if ! command -v uv >/dev/null 2>&1; then
echo "uv not found — install it (https://docs.astral.sh/uv/) or run the" \
"orchestrator with system python3 directly." >&2
exit 1
fi
if [ ! -d "$HERE/.venv" ]; then
uv venv --system-site-packages "$HERE/.venv"
fi
# shellcheck disable=SC1091
uv pip install --python "$HERE/.venv/bin/python" -q -e "$HERE" >/dev/null

PY="$HERE/.venv/bin/python"
# Verify uhd is reachable through the venv before we bother claiming USB.
if ! "$PY" -c "import uhd, numpy" 2>/dev/null; then
echo "WARNING: 'import uhd' failed in the venv; falling back to system python3." >&2
PY="$(command -v python3)"
fi

echo "== running experiment (sudo) =="
exec sudo --preserve-env=UHD_IMAGES_DIR "$PY" "$HERE/thermal_gain_sweep.py" "$@"
120 changes: 120 additions & 0 deletions tests/sdr_power_probe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""USRP (UHD) receive-power probe for the thermal-vs-TX-gain experiment.

Tunes a UHD device (B200/B210/LibreSDR) to a fixed frequency and streams RX
samples continuously, emitting one windowed power reading per `--window`
samples as:

sdr-power dbfs=<float> rms=<float> n=<int>

Power is **uncalibrated, relative dBFS** (10*log10(mean(|x|^2)) of the
normalised complex samples) — UHD's `recv` returns fc32 in roughly [-1, 1], so
this tracks *changes* in received power (e.g. as the 8812AU's TX gain ramps),
not an absolute dBm. Pair it with a fixed RX gain and a fixed geometry so the
trend is meaningful.

Stdout is line-buffered and each line is flushed; the orchestrator
(thermal_gain_sweep.py) stamps every line with a host-side arrival time, so no
cross-process clock alignment is needed here.

Run standalone to sanity-check the SDR:
python3 sdr_power_probe.py --freq 2437e6 --rate 4e6 --gain 40 --duration 10
"""
from __future__ import annotations

import argparse
import math
import sys
import time

import numpy as np

try:
import uhd
except ImportError:
sys.stderr.write(
"sdr_power_probe: `import uhd` failed. UHD's Python module is a system "
"package, not pip — create the venv with `uv venv --system-site-packages` "
"(or run this script with the system python3).\n"
)
raise


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--freq", type=float, default=2437e6, help="center freq (Hz)")
ap.add_argument("--rate", type=float, default=4e6, help="sample rate (Hz)")
ap.add_argument("--gain", type=float, default=40.0, help="RX gain (dB)")
ap.add_argument("--antenna", default="RX2", help="RX antenna port")
ap.add_argument("--args", default="", help="UHD device args (e.g. type=b200)")
ap.add_argument("--window", type=int, default=0,
help="samples per power reading (0 = ~rate/20, ~50ms)")
ap.add_argument("--duration", type=float, default=0.0,
help="seconds to run (0 = until killed)")
args = ap.parse_args()

window = args.window if args.window > 0 else max(1024, int(args.rate / 20))

usrp = uhd.usrp.MultiUSRP(args.args)
usrp.set_rx_rate(args.rate)
usrp.set_rx_freq(uhd.types.TuneRequest(args.freq))
usrp.set_rx_gain(args.gain)
try:
usrp.set_rx_antenna(args.antenna)
except Exception:
pass # some clones expose only one port

sys.stderr.write(
f"sdr_power_probe: freq={args.freq/1e6:.3f}MHz rate={args.rate/1e6:.3f}Msps "
f"gain={args.gain}dB window={window} actual_rate={usrp.get_rx_rate()/1e6:.3f}Msps\n"
)
sys.stderr.flush()

st_args = uhd.usrp.StreamArgs("fc32", "sc16")
st_args.channels = [0]
rx = usrp.get_rx_stream(st_args)
buf = np.zeros((1, rx.get_max_num_samps()), dtype=np.complex64)

md = uhd.types.RXMetadata()
stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont)
stream_cmd.stream_now = True
rx.issue_stream_cmd(stream_cmd)

acc_sumsq = 0.0
acc_n = 0
t_end = time.monotonic() + args.duration if args.duration > 0 else None
try:
while True:
if t_end is not None and time.monotonic() >= t_end:
break
n = rx.recv(buf, md, 1.0)
if md.error_code != uhd.types.RXMetadataErrorCode.none:
# overflow ('O') is benign for a power probe; keep going
if md.error_code != uhd.types.RXMetadataErrorCode.overflow:
sys.stderr.write(f"sdr_power_probe: rx error {md.error_code}\n")
sys.stderr.flush()
continue
if n <= 0:
continue
samples = buf[0, :n]
acc_sumsq += float(np.sum((samples.real.astype(np.float64) ** 2)
+ (samples.imag.astype(np.float64) ** 2)))
acc_n += n
if acc_n >= window:
meansq = acc_sumsq / acc_n
rms = math.sqrt(meansq)
dbfs = 10.0 * math.log10(meansq) if meansq > 0 else -200.0
print(f"sdr-power dbfs={dbfs:.2f} rms={rms:.6f} n={acc_n}",
flush=True)
acc_sumsq = 0.0
acc_n = 0
except KeyboardInterrupt:
pass
finally:
stop = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont)
rx.issue_stream_cmd(stop)
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading