diff --git a/CLAUDE.md b/CLAUDE.md index 4acfad8..6cb6e03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `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 + `` 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 diff --git a/src/RadioManagementModule.cpp b/src/RadioManagementModule.cpp index 9e55ff8..000f6a4 100644 --- a/src/RadioManagementModule.cpp +++ b/src/RadioManagementModule.cpp @@ -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 @@ -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(txpwr_override_ > 63 ? 63 + : txpwr_override_); + } else if (_eepromManager->TxPowerInfoLoaded) { powerIndex = _eepromManager->GetTxPowerIndexBase( static_cast(rfPath), static_cast(rate), rate_ntx(static_cast(rate)), bw, _currentChannel); diff --git a/src/RadioManagementModule.h b/src/RadioManagementModule.h index bce3529..3c47cbe 100644 --- a/src/RadioManagementModule.h +++ b/src/RadioManagementModule.h @@ -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; @@ -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); diff --git a/src/RtlJaguarDevice.cpp b/src/RtlJaguarDevice.cpp index f2bb803..92c46cb 100644 --- a/src/RtlJaguarDevice.cpp +++ b/src/RtlJaguarDevice.cpp @@ -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) { diff --git a/src/RtlJaguarDevice.h b/src/RtlJaguarDevice.h index 0295f84..9b49409 100644 --- a/src/RtlJaguarDevice.h +++ b/src/RtlJaguarDevice.h @@ -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; diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000..d9d8403 --- /dev/null +++ b/tests/pyproject.toml @@ -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.) diff --git a/tests/run_thermal_gain_sweep.sh b/tests/run_thermal_gain_sweep.sh new file mode 100755 index 0000000..ba30499 --- /dev/null +++ b/tests/run_thermal_gain_sweep.sh @@ -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" "$@" diff --git a/tests/sdr_power_probe.py b/tests/sdr_power_probe.py new file mode 100755 index 0000000..ea08886 --- /dev/null +++ b/tests/sdr_power_probe.py @@ -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= rms= n= + +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()) diff --git a/tests/thermal_gain_sweep.py b/tests/thermal_gain_sweep.py new file mode 100755 index 0000000..cbbb2df --- /dev/null +++ b/tests/thermal_gain_sweep.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Thermal-vs-TX-gain experiment orchestrator. + +Drives one continuous WiFiDriverTxDemo TX session on an RTL88xxAU adapter while +ramping the absolute TXAGC index up over time (DEVOURER_TX_PWR_* knobs), and +captures three interleaved streams: + + * `index=N` — the gain step the demo just applied + * `raw=.. delta=..` — the chip's own thermal meter + * `sdr-power dbfs=..` — an independent USRP receive-power probe + +Every line from both subprocesses is stamped with a host-side monotonic +timestamp (ms since orchestrator start), so the three streams share one clock +with no cross-process alignment needed. Output: a merged CSV plus a printed +per-gain-index summary that answers: + + - does the thermal `delta` climb over time under sustained TX? (degrades?) + - does `delta` track the TX gain index? (chip-meter response) + - does the SDR power track the gain index? (is the override real on-air?) + +Safety: if the chip reports thermal status=critical the TX session is stopped +immediately to protect the PA. + +Run (needs root for USB claim; SDR needs no root): + sudo python3 thermal_gain_sweep.py --channel 6 --start 8 --stop 40 \ + --step 4 --step-ms 30000 +""" +from __future__ import annotations + +import argparse +import csv +import os +import re +import subprocess +import sys +import threading +import time +from pathlib import Path + +import regress # reuse DUT discovery + process-hygiene helpers + +TXPWR_RE = re.compile(r"index=(\d+)") +THERMAL_RE = re.compile( + r"raw=(\d+) baseline=(\d+|none)(?: delta=([+-]?\d+))? status=(\w+)" +) +SDR_RE = re.compile(r"sdr-power dbfs=([+-]?[\d.]+)") + +# UHD channel-center frequencies (MHz) for the channels we use. +CH_FREQ_MHZ = { + 1: 2412, 6: 2437, 11: 2462, + 36: 5180, 44: 5220, 100: 5500, 149: 5745, 161: 5805, +} + + +class Sample: + __slots__ = ("t_ms", "source", "index", "raw", "baseline", "delta", + "status", "dbfs") + + def __init__(self, t_ms, source): + self.t_ms = t_ms + self.source = source + self.index = None + self.raw = None + self.baseline = None + self.delta = None + self.status = None + self.dbfs = None + + +def _devourer_env(dut: regress.Dut, channel: int, ramp: dict) -> dict: + env = os.environ.copy() + env["DEVOURER_VID"] = f"0x{dut.vid}" + env["DEVOURER_PID"] = f"0x{dut.pid}" + env["DEVOURER_CHANNEL"] = str(channel) + env["DEVOURER_TX_PWR_START"] = str(ramp["start"]) + env["DEVOURER_TX_PWR_STOP"] = str(ramp["stop"]) + env["DEVOURER_TX_PWR_STEP"] = str(ramp["step"]) + env["DEVOURER_TX_PWR_STEP_MS"] = str(ramp["step_ms"]) + env["DEVOURER_THERMAL_POLL_MS"] = str(ramp["thermal_ms"]) + env["DEVOURER_TX_GAP_US"] = str(ramp["gap_us"]) + if ramp["ht"]: + # Transmit HT/OFDM instead of the 1M-CCK default — CCK output power is + # decoupled from the per-rate TXAGC base, so the gain knob only shows + # up on-air for OFDM/HT/VHT rates. + env["DEVOURER_TX_HT_MCS"] = "1" + # Keep a high warn threshold so the demo's own back-off message doesn't + # spam; we judge from the data, and abort on `critical` ourselves. + env["DEVOURER_THERMAL_WARN_DELTA"] = "100" + return env + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--pid", default="8812", help="DUT PID (hex, no 0x)") + ap.add_argument("--channel", type=int, default=6) + ap.add_argument("--start", type=int, default=8, help="start TXAGC index") + ap.add_argument("--stop", type=int, default=40, help="stop TXAGC index") + ap.add_argument("--step", type=int, default=4) + ap.add_argument("--step-ms", type=int, default=30000, help="dwell per level") + ap.add_argument("--thermal-ms", type=int, default=1000) + ap.add_argument("--gap-us", type=int, default=2000, + help="inter-frame gap (us); lower = higher duty/heat " + "(default 2000 ~= 500fps)") + ap.add_argument("--ht", action="store_true", + help="transmit HT/OFDM (gain knob only affects on-air power " + "for OFDM/HT — CCK is decoupled)") + ap.add_argument("--duration", type=float, default=0.0, + help="total seconds (0 = derive from the ramp + 10s tail)") + ap.add_argument("--sdr-freq", type=float, default=0.0, + help="SDR center freq Hz (0 = derive from --channel)") + ap.add_argument("--sdr-rate", type=float, default=4e6) + ap.add_argument("--sdr-gain", type=float, default=40.0) + ap.add_argument("--no-sdr", action="store_true", help="thermal meter only") + ap.add_argument("--outdir", default="/tmp/devourer-thermal-sweep") + args = ap.parse_args() + + devourer_root = Path(__file__).resolve().parent.parent + txdemo = devourer_root / "build" / "WiFiDriverTxDemo" + if not txdemo.exists(): + sys.exit(f"{txdemo} not built — run `cmake --build build` first") + + duts = [d for d in regress.discover_duts() if d.pid == args.pid] + if not duts: + sys.exit(f"no DUT with PID {args.pid} found (plugged in?)") + dut = duts[0] + drv = regress.host_kernel_driver_for_dut(dut) + if drv: + sys.exit(f"{dut.chipset} is bound to kernel driver {drv!r}; " + f"unbind it first (see tests/regress.py detach helpers)") + + if args.duration > 0: + total_s = args.duration + else: + n_steps = max(1, (args.stop - args.start + args.step - 1) // args.step + 1) + total_s = n_steps * (args.step_ms / 1000.0) + 10.0 + + sdr_freq = args.sdr_freq or CH_FREQ_MHZ.get(args.channel, 0) * 1e6 + if not args.no_sdr and sdr_freq <= 0: + sys.exit(f"unknown center freq for channel {args.channel}; pass --sdr-freq") + + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + csv_path = outdir / f"sweep-{args.pid}-ch{args.channel}-{stamp}.csv" + + regress._install_cleanup_handlers() + + print(f"# DUT: {dut.chipset} ({dut.vidpid}) ch{args.channel}") + print(f"# ramp: index {args.start}..{args.stop} step {args.step} " + f"every {args.step_ms}ms | total ~{total_s:.0f}s") + print(f"# SDR: {'off' if args.no_sdr else f'{sdr_freq/1e6:.1f}MHz gain {args.sdr_gain}dB'}") + print(f"# CSV: {csv_path}\n") + + samples: list[Sample] = [] + samples_lock = threading.Lock() + t0 = time.monotonic() + abort = threading.Event() + + def now_ms() -> float: + return (time.monotonic() - t0) * 1000.0 + + def reader(proc: subprocess.Popen, source: str): + assert proc.stdout is not None + for raw_line in proc.stdout: + line = raw_line.rstrip("\n") + t = now_ms() + s = None + m = TXPWR_RE.search(line) + if m: + s = Sample(t, "txpwr") + s.index = int(m.group(1)) + if s is None: + m = THERMAL_RE.search(line) + if m: + s = Sample(t, "thermal") + s.raw = int(m.group(1)) + s.baseline = None if m.group(2) == "none" else int(m.group(2)) + s.delta = int(m.group(3)) if m.group(3) is not None else None + s.status = m.group(4) + if s.status == "critical": + print(f"\n!! thermal critical at t={t/1000:.1f}s — aborting TX") + abort.set() + if s is None: + m = SDR_RE.search(line) + if m: + s = Sample(t, "sdr") + s.dbfs = float(m.group(1)) + if s is not None: + with samples_lock: + samples.append(s) + + env = _devourer_env(dut, args.channel, { + "start": args.start, "stop": args.stop, "step": args.step, + "step_ms": args.step_ms, "thermal_ms": args.thermal_ms, + "gap_us": args.gap_us, "ht": args.ht, + }) + tx_log = open(outdir / f"txdemo-{stamp}.log", "w") + tx_proc = regress._register_local_proc(subprocess.Popen( + [str(txdemo)], env=env, + stdout=subprocess.PIPE, stderr=tx_log, text=True, + preexec_fn=regress._child_preexec, + )) + threads = [threading.Thread(target=reader, args=(tx_proc, "tx"), daemon=True)] + + sdr_proc = None + if not args.no_sdr: + sdr_log = open(outdir / f"sdr-{stamp}.log", "w") + sdr_proc = regress._register_local_proc(subprocess.Popen( + [sys.executable, str(devourer_root / "tests" / "sdr_power_probe.py"), + "--freq", str(sdr_freq), "--rate", str(args.sdr_rate), + "--gain", str(args.sdr_gain)], + stdout=subprocess.PIPE, stderr=sdr_log, text=True, + preexec_fn=regress._child_preexec, + )) + threads.append(threading.Thread(target=reader, args=(sdr_proc, "sdr"), + daemon=True)) + for t in threads: + t.start() + + deadline = t0 + total_s + try: + while time.monotonic() < deadline and not abort.is_set(): + if tx_proc.poll() is not None: + print("!! txdemo exited early — check txdemo log") + break + time.sleep(0.2) + except KeyboardInterrupt: + print("\ninterrupted") + finally: + for p in (tx_proc, sdr_proc): + if p is not None: + regress._terminate(p) + regress._unregister_local_proc(p) + time.sleep(0.3) + + with samples_lock: + rows = sorted(samples, key=lambda s: s.t_ms) + _write_csv(csv_path, rows) + _summarize(rows) + print(f"\nCSV written: {csv_path}") + return 0 + + +def _write_csv(path: Path, rows: list[Sample]) -> None: + cur_index = None + with open(path, "w", newline="") as f: + w = csv.writer(f) + w.writerow(["t_ms", "source", "gain_index", "raw", "baseline", + "delta", "status", "dbfs"]) + for s in rows: + if s.source == "txpwr": + cur_index = s.index + w.writerow([f"{s.t_ms:.1f}", s.source, + cur_index if cur_index is not None else "", + s.raw if s.raw is not None else "", + s.baseline if s.baseline is not None else "", + s.delta if s.delta is not None else "", + s.status or "", + f"{s.dbfs:.2f}" if s.dbfs is not None else ""]) + + +def _summarize(rows: list[Sample]) -> None: + # forward-fill the current gain index onto every thermal/sdr sample + cur = None + per_index: dict[int, dict] = {} + for s in rows: + if s.source == "txpwr": + cur = s.index + per_index.setdefault(cur, {"deltas": [], "dbfs": [], "t0": s.t_ms, + "t1": s.t_ms}) + continue + if cur is None: + continue + b = per_index.setdefault(cur, {"deltas": [], "dbfs": [], "t0": s.t_ms, + "t1": s.t_ms}) + b["t1"] = s.t_ms + if s.source == "thermal" and s.delta is not None: + b["deltas"].append((s.t_ms, s.delta, s.raw)) + elif s.source == "sdr" and s.dbfs is not None: + b["dbfs"].append(s.dbfs) + + if not per_index: + print("no gain-index transitions captured (did the demo emit " + "?)") + return + + print("\n=== per gain-index summary ===") + print(f"{'idx':>4} {'dwell_s':>7} {'raw_first':>9} {'raw_last':>8} " + f"{'d_first':>7} {'d_last':>6} {'d_min':>5} {'d_max':>5} " + f"{'sdr_dbfs_mean':>13} {'sdr_n':>5}") + index_means = [] + for idx in sorted(per_index): + b = per_index[idx] + ds = b["deltas"] + dwell = (b["t1"] - b["t0"]) / 1000.0 + if ds: + d_first, d_last = ds[0][1], ds[-1][1] + raw_first, raw_last = ds[0][2], ds[-1][2] + d_min = min(d for _, d, _ in ds) + d_max = max(d for _, d, _ in ds) + d_mean = sum(d for _, d, _ in ds) / len(ds) + else: + d_first = d_last = raw_first = raw_last = d_min = d_max = d_mean = None + sdr_mean = (sum(b["dbfs"]) / len(b["dbfs"])) if b["dbfs"] else None + index_means.append((idx, d_mean, sdr_mean)) + + def fmt(v, p=""): + return f"{v}{p}" if v is not None else "-" + + print(f"{idx:>4} {dwell:>7.1f} {fmt(raw_first):>9} {fmt(raw_last):>8} " + f"{fmt(d_first):>7} {fmt(d_last):>6} {fmt(d_min):>5} {fmt(d_max):>5} " + f"{(f'{sdr_mean:.2f}' if sdr_mean is not None else '-'):>13} " + f"{len(b['dbfs']):>5}") + + # Verdicts ---------------------------------------------------------------- + print("\n=== verdicts ===") + deltas_all = [(s.t_ms, s.delta) for s in rows + if s.source == "thermal" and s.delta is not None] + if deltas_all: + first_d = deltas_all[0][1] + last_d = deltas_all[-1][1] + max_d = max(d for _, d in deltas_all) + span_s = (deltas_all[-1][0] - deltas_all[0][0]) / 1000.0 + print(f"thermal delta over whole run: first={first_d:+d} last={last_d:+d} " + f"max={max_d:+d} over {span_s:.0f}s") + if last_d > first_d: + print(f" -> DEGRADES OVER TIME: delta rose {last_d - first_d} units " + f"under sustained TX") + else: + print(f" -> stable/no rise: delta did not increase over the run") + + valid_means = [(i, dm) for i, dm, _ in index_means if dm is not None] + if len(valid_means) >= 2: + rising = all(valid_means[k][1] <= valid_means[k + 1][1] + 1e-9 + for k in range(len(valid_means) - 1)) + lo, hi = valid_means[0][1], valid_means[-1][1] + print(f"thermal delta vs gain index: {lo:.1f} (idx {valid_means[0][0]}) " + f"-> {hi:.1f} (idx {valid_means[-1][0]}); " + f"{'monotonic rise' if rising else 'non-monotonic'} " + f"(net {hi - lo:+.1f})") + + sdr_means = [(i, sm) for i, _, sm in index_means if sm is not None] + if len(sdr_means) >= 2: + lo, hi = sdr_means[0][1], sdr_means[-1][1] + rising = all(sdr_means[k][1] <= sdr_means[k + 1][1] + 0.5 + for k in range(len(sdr_means) - 1)) + print(f"SDR power vs gain index: {lo:.1f} (idx {sdr_means[0][0]}) -> " + f"{hi:.1f}dBFS (idx {sdr_means[-1][0]}); net {hi - lo:+.1f}dB " + f"-> {'override IS reaching the PA' if hi - lo > 1.0 else 'NO clear on-air change'}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/txdemo/main.cpp b/txdemo/main.cpp index 53d152f..ffbbd49 100644 --- a/txdemo/main.cpp +++ b/txdemo/main.cpp @@ -450,11 +450,72 @@ int main(int argc, char **argv) { } bool thermal_warned = false; + /* Inter-frame gap. Default 2 ms (~500 fps, gentle on the USB bulk EP). Lower + * it (e.g. DEVOURER_TX_GAP_US=0) to raise the TX duty cycle for thermal / + * heating experiments — at the default gap the PA barely warms. */ + long tx_gap_us = 2000; + if (const char *e = std::getenv("DEVOURER_TX_GAP_US")) { + tx_gap_us = std::strtol(e, nullptr, 0); + if (tx_gap_us < 0) tx_gap_us = 0; + } + + /* TX-gain ramp (thermal-vs-gain experiment). When DEVOURER_TX_PWR_START is + * set, force the per-rate TXAGC index to an absolute value and step it up by + * STEP every STEP_MS, in one continuous TX session (chip never stops, so we + * observe cumulative heating). Each (re-)apply re-runs the channel-set so the + * new index reaches the TXAGC registers. A marker is emitted + * on every change so the harness can correlate gain index with the thermal / + * SDR streams. Without START, behaviour is unchanged (EFUSE per-rate power). */ + bool pwr_ramp = false; + int pwr_cur = 0, pwr_stop = 0, pwr_step = 4; + long pwr_step_ms = 30000; + long pwr_next_step_ms = 0; + bool txpwr_readback = std::getenv("DEVOURER_TX_PWR_READBACK") != nullptr; + auto apply_txpwr = [&](int idx) { + rtlDevice->SetTxPowerOverride(idx); + rtlDevice->ApplyTxPower(); /* SetMonitorChannel early-returns on same ch */ + printf("index=%d t_ms=%lld\n", idx, + static_cast(ms_since_start())); + if (txpwr_readback) { + /* Confirm the per-rate TXAGC writes landed: path-A 1M-CCK is byte0 of + * 0xc20, 6M-OFDM is byte0 of 0xc24. If these read back == idx but on-air + * power doesn't follow (CCK), the chip floors CCK elsewhere; if they read + * back != idx, something clobbered the write. */ + uint32_t cck1m = rtlDevice->ReadBBReg(0xc20, 0x000000ff); + uint32_t ofdm6m = rtlDevice->ReadBBReg(0xc24, 0x000000ff); + printf("index=%d cck1m=%u ofdm6m=%u\n", idx, cck1m, + ofdm6m); + } + fflush(stdout); + }; + if (const char *e = std::getenv("DEVOURER_TX_PWR_START")) { + pwr_ramp = true; + pwr_cur = std::atoi(e); + pwr_stop = pwr_cur; + if (const char *s = std::getenv("DEVOURER_TX_PWR_STOP")) pwr_stop = std::atoi(s); + if (const char *s = std::getenv("DEVOURER_TX_PWR_STEP")) pwr_step = std::atoi(s); + if (const char *s = std::getenv("DEVOURER_TX_PWR_STEP_MS")) + pwr_step_ms = std::strtol(s, nullptr, 0); + if (pwr_step < 1) pwr_step = 1; + logger->info("DEVOURER_TX_PWR ramp — index {}..{} step {} every {} ms", + pwr_cur, pwr_stop, pwr_step, pwr_step_ms); + } + long tx_count = 0; while (true) { if (tx_count == 0) { logger->info("init-timing: txdemo.first_tx_submit = {} ms", ms_since_start()); + if (pwr_ramp) { + apply_txpwr(pwr_cur); + pwr_next_step_ms = ms_since_start() + pwr_step_ms; + } + } + if (pwr_ramp && pwr_cur < pwr_stop && ms_since_start() >= pwr_next_step_ms) { + pwr_cur += pwr_step; + if (pwr_cur > pwr_stop) pwr_cur = pwr_stop; + apply_txpwr(pwr_cur); + pwr_next_step_ms = ms_since_start() + pwr_step_ms; } rc = rtlDevice->send_packet(tx_buf.data(), tx_buf.size()); ++tx_count; @@ -482,7 +543,8 @@ int main(int argc, char **argv) { } fflush(stdout); } - std::this_thread::sleep_for(std::chrono::milliseconds(2)); /* ~500 fps, gentle on USB bulk EP */ + if (tx_gap_us > 0) + std::this_thread::sleep_for(std::chrono::microseconds(tx_gap_us)); } rc = libusb_release_interface(handle, 0); assert(rc == 0);