diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 2c21342..42ae18e 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -147,6 +147,7 @@ jobs: cmake --build build --target WiFiDriver StreamTxDemo StreamDuplexDemo StreamStdinSelftest ToneMaskSelftest BfReportDecodeSelftest SweepSpecSelftest + TxPowerQuantSelftest - name: Test working-directory: build diff --git a/CLAUDE.md b/CLAUDE.md index 259df71..64423df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,26 @@ per-channel level (the rtl8822e TXAGC references match the kernel driver's). `DEVOURER_TX_PWR=0xNN` forces a flat TXAGC reference — a debug/SDR-visibility knob, not for sustained use. +Runtime TX-power control (all three generations, the adaptive-link power +lever — see `src/TxPower.h`): `SetTxPowerOffsetQdb(qdb)` adjusts power in +quarter-dB relative to the efuse per-rate table (per-rate shape preserved +until rates saturate at the rails; saturation flags in `GetTxPowerState`), +`SetTxPowerIndexOverride(idx)` forces/clears the flat index (`SetTxPower` +forwards here), both live mid-stream and sticky across `SetMonitorChannel` +(re-folded against the new channel's table) and `FastRetune` (hop path never +rewrites TXAGC). `GetTxPowerCaps` reports the family step (0.5 dB Jaguar1/2, +0.25 dB Jaguar3 — on-air-measured via `tests/txpwr_offset_onair.sh`, which +uses a second chip's per-frame RSSI as the sensor: at bench range the B210 +front-end limits on the frames at any gain and cannot see TXAGC slopes). +`GetThermalStatus` reads the RF 0x42 meter with the family baseline on every +generation. `TxPowerStepDemo` (examples/txpower/, CLI-only) is the reference +consumer; register-level validation lives in `tests/txpwr_offset_regcheck.sh` +(canary parity vs master, exact-step moves, rails, stickiness, the 8822E +TX+RX 0x41e8 quirk cell). The 8822B's VHT TXAGC sections are programmed from +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`). + **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 diff --git a/CMakeLists.txt b/CMakeLists.txt index eaf63c2..9adf984 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,6 +77,9 @@ add_library(WiFiDriver src/BeamformingSounder.h src/TxMode.cpp src/TxMode.h + src/TxPower.cpp + src/TxPower.h + src/ThermalStatus.h src/IRtlDevice.h src/SignalStop.cpp src/SignalStop.h @@ -266,6 +269,17 @@ add_executable(StreamDuplexDemo target_link_libraries(StreamDuplexDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb) target_include_directories(StreamDuplexDemo PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/txdemo) +# TxPowerStepDemo — reference consumer of the runtime TX-power API +# (IRtlDevice::SetTxPowerOffsetQdb / SetTxPowerIndexOverride / GetTxPowerState / +# GetThermalStatus): opens one adapter, steps the knobs from CLI args (no env +# vars), and prints machine-readable state markers. The register-level +# validation driver for tests/txpwr_offset_regcheck.sh and the shape of an +# adaptive-link controller's power leg. +add_executable(TxPowerStepDemo + examples/txpower/main.cpp +) +target_link_libraries(TxPowerStepDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb) + # WiFiSenseDemo — Wi-Fi motion/presence sensor from beamforming reports. Drives # two adapters (a sounder + a beamformee) on one host, self-captures the reports, # decodes the per-tone Givens angles (src/BfReportDecode.h), and shows a live @@ -315,6 +329,16 @@ target_link_libraries(SweepSpecSelftest PRIVATE WiFiDriver) add_test(NAME sweep_spec_math COMMAND SweepSpecSelftest) +# Headless guard for the TX-power offset quantization (src/TxPower.h) — the +# qdB->index-step rounding/clamping behind SetTxPowerOffsetQdb, so a rounding +# regression fails `ctest` instead of only surfacing as a wrong on-air level. +add_executable(TxPowerQuantSelftest + tests/txpower_quant_selftest.cpp +) +target_link_libraries(TxPowerQuantSelftest PRIVATE WiFiDriver) + +add_test(NAME txpower_quant_math COMMAND TxPowerQuantSelftest) + # 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 diff --git a/docs/adaptive-link-building-blocks.md b/docs/adaptive-link-building-blocks.md index 80ae09e..73863b9 100644 --- a/docs/adaptive-link-building-blocks.md +++ b/docs/adaptive-link-building-blocks.md @@ -33,7 +33,7 @@ milliseconds, so a controller can retune between frames. | **Modulation / MCS** (time-on-air) | strong | **strong** — less airtime, fewer Joules/bit | per-packet radiotap rate, or the device TX-mode default (`DEVOURER_TX_RATE`); immediate | | **FEC strength** | strong | strong | application-layer — the outer code + the per-temporal-layer MCS/FEC ladder (`DEVOURER_SVC_LADDER`, [`fused-fec.md`](fused-fec.md)), not a PHY register | | **Channel / bandwidth** | strong | moderate | per-packet retune (~1–2 ms intra-band fast retune / `DEVOURER_HOP_*`, longer on a band change); 20/40/80 MHz plus a 5/10 MHz **narrowband re-clock** that trades throughput for link budget | -| **Transmit power** | **strongest** | **weak** — the always-on baseline draw dominates | runtime TXAGC override, re-applied without a channel switch | +| **Transmit power** | **strongest** | **weak** — the always-on baseline draw dominates | the runtime TX-power API on every generation: `SetTxPowerOffsetQdb` (quarter-dB offset relative to the efuse per-rate table — the closed-loop knob, sticky across retunes, with saturation flags), `SetTxPowerIndexOverride` (flat index), `GetTxPowerState`/`GetTxPowerCaps` readback, `GetThermalStatus` (the PA-heating budget input); applied live, no channel switch | | **Active receive chains** | conditional | **conditional** — pays only when the antennas decorrelate (motion) | the RX-path enable mask (`DEVOURER_RX_PATHS`); a fade-state lever, not a range lever | | **Duty cycle** | — | direct | inter-frame gap; back-to-back for maximum airtime, idle to save it | diff --git a/examples/txpower/main.cpp b/examples/txpower/main.cpp new file mode 100644 index 0000000..6c41f60 --- /dev/null +++ b/examples/txpower/main.cpp @@ -0,0 +1,274 @@ +/* TxPowerStepDemo — reference consumer of the runtime TX-power API. + * + * Opens one adapter, brings it up for TX, prints the family's TxPowerCaps, + * then walks the requested knob sequence — a quarter-dB offset ramp + * (SetTxPowerOffsetQdb), a flat index (SetTxPowerIndexOverride), or both — + * echoing the applied qdB and a TxPowerState snapshot after every step. This + * is the shape of an adaptive-link controller's power leg: set, read back, + * observe saturation, react. + * + * Pure CLI configuration (no environment variables — the API is the point): + * + * --vid 0xNNNN --pid 0xNNNN adapter select (default: first Realtek PID) + * --channel N monitor channel for bring-up (default 36) + * --bw 20|40|80 bandwidth (default 20) + * --flat N force flat TXAGC index before the ramp (-1 clears) + * --offset-start Q offset ramp start, qdB (default: no ramp) + * --offset-stop Q offset ramp stop, qdB (default = start) + * --step-qdb Q ramp increment, qdB (default 4 = 1 dB) + * --step-ms N dwell per step, ms (default 500) + * --switch-channel N after the ramp: SetMonitorChannel(N) and re-dump + * state — proves the offset is sticky across a + * full channel set (and re-folds against the new + * channel's per-rate table) + * --retune N after the ramp: FastRetune(N) and re-dump state + * — proves the hop path leaves TXAGC alone + * --thermal print a thermal snapshot with each state line + * + * Machine-readable output (one line per step, consumed by + * tests/txpwr_offset_regcheck.sh): + * + * max=63 step_qdb=2 min=-126 max_qdb=126 + * flat=-1 offset_qdb=-24 steps=-12 satlo=0 sathi=0 \ + * cck=28 ofdm=34 mcs7=30 rb=1 + */ +#ifdef _WIN32 +#define NOMINMAX +#endif + +#if defined(__ANDROID__) || defined(_MSC_VER) || defined(__APPLE__) +#include +#else +#include +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include "SignalStop.h" +#include "ThermalStatus.h" +#include "TxPower.h" +#include "UsbOpen.h" +#include "WiFiDriver.h" +#include "logger.h" + +namespace { + +/* The Realtek PIDs the demos' open loop iterates; --pid narrows to one. */ +const uint16_t kRealtekPids[] = {0x8812, 0x8813, 0x881a, 0x0811, 0xa811, + 0x0820, 0x0821, 0x8822, 0x0120, 0x012d, + 0xb82c, 0xc811, 0xc812, 0xa81a}; + +struct Args { + uint16_t vid = 0x0bda; + int pid = -1; /* -1 = iterate kRealtekPids */ + int channel = 36; + int bw = 20; + int flat = -2; /* -2 = untouched, -1 = clear, >=0 = force */ + int offset_start = 0; + int offset_stop = 0; + bool have_ramp = false; + int step_qdb = 4; + int step_ms = 500; + int switch_channel = -1; + int retune = -1; + bool thermal = false; +}; + +bool parse_int(const char *s, int &out) { + char *end = nullptr; + long v = std::strtol(s, &end, 0); + if (end == s || *end != '\0') + return false; + out = static_cast(v); + return true; +} + +bool parse_args(int argc, char **argv, Args &a) { + for (int i = 1; i < argc; ++i) { + const std::string k = argv[i]; + auto next = [&](int &out) { + return i + 1 < argc && parse_int(argv[++i], out); + }; + int v = 0; + if (k == "--vid" && next(v)) + a.vid = static_cast(v); + else if (k == "--pid" && next(v)) + a.pid = v; + else if (k == "--channel" && next(a.channel)) + ; + else if (k == "--bw" && next(a.bw)) + ; + else if (k == "--flat" && next(a.flat)) + ; + else if (k == "--offset-start" && next(a.offset_start)) + a.have_ramp = true; + else if (k == "--offset-stop" && next(a.offset_stop)) + a.have_ramp = true; + else if (k == "--step-qdb" && next(a.step_qdb)) + ; + else if (k == "--step-ms" && next(a.step_ms)) + ; + else if (k == "--switch-channel" && next(a.switch_channel)) + ; + else if (k == "--retune" && next(a.retune)) + ; + else if (k == "--thermal") + a.thermal = true; + else { + std::fprintf(stderr, "unknown/incomplete arg: %s\n", k.c_str()); + return false; + } + } + if (a.have_ramp && a.offset_stop == 0 && a.offset_start != 0) + a.offset_stop = a.offset_start; + return true; +} + +ChannelWidth_t bw_enum(int bw) { + switch (bw) { + case 40: + return CHANNEL_WIDTH_40; + case 80: + return CHANNEL_WIDTH_80; + default: + return CHANNEL_WIDTH_20; + } +} + +void print_state(IRtlDevice *dev, bool with_thermal) { + const devourer::TxPowerState s = dev->GetTxPowerState(); + std::printf("flat=%d offset_qdb=%d steps=%d satlo=%d " + "sathi=%d cck=%d ofdm=%d mcs7=%d rb=%d\n", + s.flat_index, s.offset_qdb, s.offset_steps, + s.saturated_low ? 1 : 0, s.saturated_high ? 1 : 0, s.cck_index, + s.ofdm_index, s.mcs7_index, s.hw_readback ? 1 : 0); + if (with_thermal) { + const devourer::ThermalStatus t = dev->GetThermalStatus(); + std::printf("raw=%u baseline=%u delta=%d status=%s\n", + t.raw, t.baseline, t.delta, devourer::ThermalBucket(t)); + } + std::fflush(stdout); +} + +} // namespace + +int main(int argc, char **argv) { + Args a; + if (!parse_args(argc, argv, a)) + return 2; + + auto logger = std::make_shared(); + install_devourer_signal_handlers(); + + libusb_context *ctx = nullptr; + if (libusb_init(&ctx) < 0) { + logger->error("libusb_init failed"); + return 1; + } + libusb_device_handle *handle = nullptr; + if (a.pid >= 0) { + handle = libusb_open_device_with_vid_pid(ctx, a.vid, + static_cast(a.pid)); + } else { + for (uint16_t pid : kRealtekPids) { + handle = libusb_open_device_with_vid_pid(ctx, a.vid, pid); + if (handle) + break; + } + } + if (!handle) { + logger->error("no adapter found ({:04x}:{})", a.vid, + a.pid >= 0 ? "requested pid" : "any Realtek pid"); + libusb_exit(ctx); + return 1; + } + + std::shared_ptr lock; + if (devourer::claim_interface_then_reset(handle, 0, logger, true, lock) != + 0) { + libusb_close(handle); + libusb_exit(ctx); + return 1; + } + + WiFiDriver driver(logger); + std::unique_ptr dev = driver.CreateRtlDevice(handle, ctx, lock); + if (!dev) { + logger->error("CreateRtlDevice failed (chip support not built?)"); + libusb_close(handle); + libusb_exit(ctx); + return 1; + } + + const devourer::TxPowerCaps caps = dev->GetTxPowerCaps(); + std::printf("supported=%d max=%u step_qdb=%u " + "step_measured=%d min_qdb=%d max_qdb=%d\n", + caps.supported ? 1 : 0, caps.index_max, caps.step_qdb, + caps.step_measured ? 1 : 0, caps.offset_min_qdb, + caps.offset_max_qdb); + std::fflush(stdout); + if (!caps.supported) { + logger->error("TX-power API not wired for this family yet"); + dev->Stop(); + libusb_close(handle); + libusb_exit(ctx); + return 3; + } + + dev->InitWrite(SelectedChannel{.Channel = static_cast(a.channel), + .ChannelOffset = 0, + .ChannelWidth = bw_enum(a.bw)}); + logger->info("brought up on ch{} bw{}", a.channel, a.bw); + + /* Baseline state before any knob moves (the offset=0 parity reference). */ + print_state(dev.get(), a.thermal); + + if (a.flat >= -1) { + dev->SetTxPowerIndexOverride(a.flat); + logger->info("flat index override -> {}", a.flat); + print_state(dev.get(), a.thermal); + } + + if (a.have_ramp) { + const int dir = (a.offset_stop >= a.offset_start) ? 1 : -1; + const int inc = (a.step_qdb > 0 ? a.step_qdb : 4) * dir; + for (int q = a.offset_start; + (dir > 0 ? q <= a.offset_stop : q >= a.offset_stop) && + !g_devourer_should_stop; + q += inc) { + const int applied = dev->SetTxPowerOffsetQdb(q); + std::printf("requested=%d applied=%d\n", q, + applied); + print_state(dev.get(), a.thermal); + std::this_thread::sleep_for(std::chrono::milliseconds(a.step_ms)); + } + } + + if (a.switch_channel > 0 && !g_devourer_should_stop) { + dev->SetMonitorChannel( + SelectedChannel{.Channel = static_cast(a.switch_channel), + .ChannelOffset = 0, + .ChannelWidth = bw_enum(a.bw)}); + logger->info("SetMonitorChannel -> ch{} (offset must re-fold)", + a.switch_channel); + print_state(dev.get(), a.thermal); + } + + if (a.retune > 0 && !g_devourer_should_stop) { + dev->FastRetune(static_cast(a.retune)); + logger->info("FastRetune -> ch{} (TXAGC registers must be untouched)", + a.retune); + print_state(dev.get(), a.thermal); + } + + dev->Stop(); + libusb_close(handle); + libusb_exit(ctx); + return 0; +} diff --git a/src/IRtlDevice.h b/src/IRtlDevice.h index ae1f458..1e217b4 100644 --- a/src/IRtlDevice.h +++ b/src/IRtlDevice.h @@ -7,7 +7,9 @@ #include "RxSense.h" #include "SelectedChannel.h" +#include "ThermalStatus.h" #include "TxMode.h" +#include "TxPower.h" /* Packet is the parsed-RX type handed to the RX callback. Forward-declared here * (a reference in the std::function signature needs only an incomplete type) so @@ -63,7 +65,64 @@ class IRtlDevice { SetMonitorChannel(c); } - virtual void SetTxPower(uint8_t power) = 0; + /* Force a flat absolute TXAGC index across all rates (the debug / + * SDR-visibility knob — same knob as SetTxPowerIndexOverride, kept for + * source compatibility; the override form has the explicit clear). NB this + * is a REAL flat override on every generation now — it previously had three + * divergent semantics (Jaguar1: pre-efuse fallback, silently ignored with + * loaded EFUSE; Jaguar2: dead store; Jaguar3: flat reference). */ + virtual void SetTxPower(uint8_t power) { + SetTxPowerIndexOverride(static_cast(power)); + } + + /* --- Runtime TX-power control (see src/TxPower.h for the full model) --- + * + * THREADING CONTRACT: like every other control-plane entry point + * (SetMonitorChannel, FastRetune, SetTxMode, Start/StopCwTone), these are + * single-control-thread calls — invoke them from the thread that owns the + * device's control plane, never concurrently with a channel set. Jaguar3 + * additionally serializes against its coex/thermal tick internally; on + * Jaguar1/2 the USB-touching apply itself is the caller's to sequence. + * GetTxPowerCaps and the cached (non-readback) part of GetTxPowerState are + * safe from any thread. */ + + /* Static capabilities of this family's TX-power knobs; supported=false on + * the default (a generation without the API wired). */ + virtual devourer::TxPowerCaps GetTxPowerCaps() { return {}; } + + /* Adjust TX power RELATIVE to the efuse-calibrated per-rate table (or the + * flat override when one is active) in quarter-dB. Quantized to the family + * step and clamped to the caps range; returns the APPLIED qdB (0 when + * unsupported), so a closed-loop controller knows exactly what moved. + * Sticky: survives SetMonitorChannel (re-folded against the new channel's + * table) and FastRetune (hop paths never rewrite TXAGC). Refused (returns + * 0, logs) while a CW tone holds the chip — TXAGC does not modulate a bare + * LO carrier. */ + virtual int SetTxPowerOffsetQdb(int qdb) { + (void)qdb; + return 0; + } + + /* Force / clear the flat absolute TXAGC index: idx >= 0 forces it for all + * rates (composes with the offset), idx < 0 reverts to the efuse per-rate + * baseline. The primary knob — SetTxPower forwards here; a generation + * without the API wired ignores it (caps.supported=false). */ + virtual void SetTxPowerIndexOverride(int idx) { (void)idx; } + + /* Re-program the TX-power registers from the current knob state at the + * CURRENT channel — the hook tests use to force a re-apply without moving + * any knob. Returns false when unsupported or the chip isn't brought up. */ + virtual bool ReApplyTxPower() { return false; } + + /* Snapshot of the knob state + representative effective indices (register + * readback where the family's TXAGC block is readable). */ + virtual devourer::TxPowerState GetTxPowerState() { return {}; } + + /* Chip thermal-meter snapshot (RF 0x42 family; efuse baseline where the + * family wires one — see src/ThermalStatus.h). The PA-heating input of the + * adaptive-link controller. Default returns an all-invalid reading. */ + virtual devourer::ThermalStatus GetThermalStatus() { return {}; } + virtual bool send_packet(const uint8_t *packet, size_t length) = 0; virtual SelectedChannel GetSelectedChannel() = 0; diff --git a/src/ThermalStatus.h b/src/ThermalStatus.h new file mode 100644 index 0000000..7ba89a2 --- /dev/null +++ b/src/ThermalStatus.h @@ -0,0 +1,47 @@ +/* Chip thermal-meter snapshot, generation-agnostic (moved out of the Jaguar1 + * HAL when GetThermalStatus was promoted to IRtlDevice — the meter register + * family (RF 0x42) and the efuse-baseline semantics are common to all three + * generations, only the field layout and baseline source differ per family). + */ +#ifndef DEVOURER_THERMAL_STATUS_H +#define DEVOURER_THERMAL_STATUS_H + +#include + +namespace devourer { + +/* Read-only snapshot of the chip's thermal meter. `raw` is the live + * RF[A][0x42] reading (0..63, Realtek "thermal units" — roughly + * 1.5-2 C each, NOT absolute degrees). `baseline` is the EFUSE + * factory-calibrated reading (0xFF = autoload failed / no baseline; on the + * 8822C, which wires no efuse baseline, it is the first-read cold reference, + * so `delta` there means "since first read"). `delta = raw - baseline` + * (signed) is the heat signal — positive means the chip is running hotter + * than calibration. `valid` is false when no baseline of either kind is + * available, in which case only `raw` is meaningful. */ +struct ThermalStatus { + uint8_t raw = 0; + uint8_t baseline = 0xFF; + int delta = 0; + bool valid = false; +}; + +/* Coarse, honest health label for a thermal reading. The meter is NOT a + * calibrated °C sensor (Realtek publishes no °C transfer function for the AU + * family; the value is an RF/PA-bias tracking index), so we deliberately bucket + * the delta-from-baseline rather than fake a precise temperature — the same + * stance the rtl88x2eu driver takes (cool/warm/hot/...). Thresholds are in + * thermal units above the EFUSE baseline; "hot" aligns with the default + * DEVOURER_THERMAL_WARN_DELTA of 15. Returns "unknown" when no EFUSE baseline + * is available (delta is meaningless without it). */ +inline const char *ThermalBucket(const ThermalStatus &s) { + if (!s.valid) return "unknown"; + if (s.delta < 8) return "cool"; + if (s.delta < 15) return "warm"; + if (s.delta < 25) return "hot"; + return "critical"; +} + +} // namespace devourer + +#endif /* DEVOURER_THERMAL_STATUS_H */ diff --git a/src/TxPower.cpp b/src/TxPower.cpp new file mode 100644 index 0000000..bd0e3b0 --- /dev/null +++ b/src/TxPower.cpp @@ -0,0 +1,29 @@ +#include "TxPower.h" + +namespace devourer { + +int quantize_offset_qdb(int qdb, const TxPowerCaps &caps, int *steps_out) { + if (steps_out) + *steps_out = 0; + if (!caps.supported || caps.step_qdb == 0) + return 0; + /* Clamp the request to the representable range first so the rounding below + * cannot step past a rail. */ + if (qdb > caps.offset_max_qdb) + qdb = caps.offset_max_qdb; + if (qdb < caps.offset_min_qdb) + qdb = caps.offset_min_qdb; + /* Round to the nearest whole step, ties away from zero: a controller asking + * for +1 qdB on a 0.5 dB-step family gets +1 step (0.5 dB), not silence. */ + const int step = caps.step_qdb; + int steps; + if (qdb >= 0) + steps = (qdb + step / 2) / step; + else + steps = -((-qdb + step / 2) / step); + if (steps_out) + *steps_out = steps; + return steps * step; +} + +} // namespace devourer diff --git a/src/TxPower.h b/src/TxPower.h new file mode 100644 index 0000000..aeee169 --- /dev/null +++ b/src/TxPower.h @@ -0,0 +1,75 @@ +/* Runtime TX-power control types for the adaptive link. + * + * The TX-power lever has two knobs on IRtlDevice, both live (applied + * immediately on a brought-up chip, recorded and applied at InitWrite + * otherwise) and both sticky across channel switches: + * + * - SetTxPowerOffsetQdb(qdb): adjust power RELATIVE to the efuse-calibrated + * per-rate table in quarter-dB units, preserving the calibrated per-rate + * shape until individual rates saturate at the hardware rails. This is + * the closed-loop controller's knob ("back off 2 dB, keep the shape"). + * - SetTxPowerIndexOverride(idx): force a flat absolute TXAGC index across + * all rates (the debug/SDR-visibility knob; -1 reverts to the table). + * + * They compose: effective = clamp(baseline + offset_steps, 0, index_max), + * where baseline is the flat index when set, else the per-rate table value. + * The offset folds AFTER the per-rate regulatory min() (worldwide-min + * txpwr_lmt tables where the family has them), clamped only at the hardware + * max — headroom above the generated table is deliberate; compliance is the + * operator's call, exactly as with the flat override. + * + * Units: one TXAGC index step is family-specific (0.5 dB on Jaguar1/Jaguar2's + * 6-bit index, 0.25 dB on Jaguar3's 7-bit reference), so the API speaks + * quarter-dB (qdB) and reports what was actually applied after quantization. + */ +#ifndef DEVOURER_TX_POWER_H +#define DEVOURER_TX_POWER_H + +#include + +namespace devourer { + +/* Static per-family capabilities of the TX-power knobs. `step_qdb` is the + * quarter-dB value of one hardware index step (2 = 0.5 dB Jaguar1/2, + * 1 = 0.25 dB Jaguar3); `step_measured` stays false until the family's + * dB-per-step slope has been confirmed on-air (SDR least-squares ramp) — + * until then the step is vendor-documentation truth, not measured truth. */ +struct TxPowerCaps { + bool supported = false; + uint8_t index_max = 0; /* 63 (Jaguar1/2), 127 (Jaguar3) */ + uint8_t step_qdb = 0; /* qdB per index step */ + bool step_measured = false; /* on-air slope validated for this family */ + int16_t offset_min_qdb = 0; /* most negative representable offset */ + int16_t offset_max_qdb = 0; /* most positive representable offset */ +}; + +/* Snapshot of the TX-power knobs plus representative effective indices. + * `flat_index` = -1 means the efuse per-rate baseline is active. The three + * representative indices are path-A values for CCK 1M, OFDM 6M and HT MCS7; + * `hw_readback` says whether they were read back from the TXAGC registers + * (8814A's packed TXAGC port is write-only, so it reports the software + * shadow with hw_readback=false). `saturated_low/high` are set when the + * last apply clamped at least one rate at a rail — the signal a closed-loop + * controller uses to know the knob has run out of travel. */ +struct TxPowerState { + bool valid = false; + int16_t flat_index = -1; + int16_t offset_qdb = 0; /* quantized offset currently folded, in qdB */ + int16_t offset_steps = 0; /* same, in hardware index steps */ + bool saturated_low = false; + bool saturated_high = false; + int16_t cck_index = -1; + int16_t ofdm_index = -1; + int16_t mcs7_index = -1; + bool hw_readback = false; +}; + +/* Quantize a quarter-dB offset request to a family's step size: round to + * nearest step, ties away from zero, then clamp to the caps' offset range. + * Returns the applied qdB (what SetTxPowerOffsetQdb reports); the step count + * lands in *steps_out when non-null. Returns 0 for unsupported caps. */ +int quantize_offset_qdb(int qdb, const TxPowerCaps &caps, int *steps_out); + +} // namespace devourer + +#endif /* DEVOURER_TX_POWER_H */ diff --git a/src/jaguar1/RadioManagementModule.cpp b/src/jaguar1/RadioManagementModule.cpp index 5a27e88..121d0d9 100644 --- a/src/jaguar1/RadioManagementModule.cpp +++ b/src/jaguar1/RadioManagementModule.cpp @@ -1998,6 +1998,10 @@ uint8_t RadioManagementModule::phy_GetSecondaryChnl_8812() { void RadioManagementModule::PHY_SetTxPowerLevel8812(uint8_t Channel) { const bool is_8814a = _eepromManager->version_id.ICType == CHIP_8814A; + /* Fresh saturation snapshot for this apply pass: ComputeTxPowerIndex sets + * the flags whenever the offset/override pushes any rate onto a 6-bit rail. */ + txpwr_sat_low_ = false; + txpwr_sat_high_ = false; for (uint8_t path = 0; (uint8_t)path < _eepromManager->numTotalRfPath; path++) { phy_set_tx_power_level_by_path(Channel, (RfPath)path); @@ -2137,26 +2141,46 @@ void RadioManagementModule::PHY_SetTxPowerIndexByRateArray( if (r >= 0xBE && r <= 0xC7) return 3; /* VHT4SS */ return 0; }; - const uint8_t bw = static_cast(_currentChannelBw); for (int i = 0; i < rates.size(); ++i) { MGN_RATE rate = rates[i]; - uint32_t powerIndex; - 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); - } else { - powerIndex = power; - } + const uint32_t powerIndex = ComputeTxPowerIndex( + static_cast(rfPath), static_cast(rate), + rate_ntx(static_cast(rate))); PHY_SetTxPowerIndex_8812A(powerIndex, rfPath, rate); } } +uint8_t RadioManagementModule::ComputeTxPowerIndex(uint8_t path, uint8_t rate, + uint8_t ntx_idx) { + int idx; + if (txpwr_override_ >= 0) { + /* Experiment override: force every rate to the same TXAGC index, + * bypassing the EFUSE per-rate table. */ + idx = txpwr_override_; + } else if (_eepromManager->TxPowerInfoLoaded) { + idx = _eepromManager->GetTxPowerIndexBase( + path, rate, ntx_idx, static_cast(_currentChannelBw), + _currentChannel); + } else { + idx = power; + } + /* Runtime offset folds AFTER the per-rate table (whose values arrive + * regulatory-bounded from GetTxPowerIndexBase), inside the hardware clamp: + * the calibrated per-rate shape is preserved until individual rates + * saturate at a rail, and positive offsets get the full 6-bit headroom — + * same permissiveness as the flat override. */ + idx += txpwr_offset_steps_; + if (idx < 0) { + idx = 0; + txpwr_sat_low_ = true; + } + if (idx > 63) { + idx = 63; + txpwr_sat_high_ = true; + } + return static_cast(idx); +} + void RadioManagementModule::PHY_SetTxPowerIndex_8812A(uint32_t powerIndex, RfPath rfPath, MGN_RATE rate) { @@ -2661,13 +2685,16 @@ void RadioManagementModule::PHY_TxPowerTrainingByPath_8812(RfPath rfPath) { * HT MCS7 (1-stream). devourer used to read the uniform `power` class * member instead, which produced 0xc54 = 0x10161E vs kernel's 0x171D25 * at ch6 (the T1 canary diff's last outstanding divergence in the - * TX-power cluster). MGN_MCS7 = 0x87, ntx_idx = 0 (1-stream rate). */ - uint32_t powerLevel = _eepromManager->TxPowerInfoLoaded - ? _eepromManager->GetTxPowerIndexBase( - static_cast(rfPath), /*rate MGN_MCS7=*/0x87, - /*ntx_idx=*/0, - static_cast(_currentChannelBw), _currentChannel) - : power; + * TX-power cluster). MGN_MCS7 = 0x87, ntx_idx = 0 (1-stream rate). + * + * ComputeTxPowerIndex is the same computation the per-rate apply loop + * uses, so the flat override AND the runtime offset now reach the + * training word too (previously the override was ignored here — the one + * TX-power register the ramp didn't move). Signed arithmetic: an offset + * can legitimately push the level below the -10/-8/-6 ladder, where the + * old uint32_t underflowed past the >2 floor. */ + int powerLevel = ComputeTxPowerIndex(static_cast(rfPath), + /*rate MGN_MCS7=*/0x87, /*ntx_idx=*/0); if (rfPath == RfPath::RF_PATH_A) { writeOffset = rA_TxPwrTraing_Jaguar; @@ -2684,7 +2711,8 @@ void RadioManagementModule::PHY_TxPowerTrainingByPath_8812(RfPath rfPath) { } else { powerLevel = powerLevel - 6; } - writeData |= (((powerLevel > 2) ? (powerLevel) : 2) << (i * 8)); + writeData |= static_cast((powerLevel > 2) ? powerLevel : 2) + << (i * 8); } _device.phy_set_bb_reg(writeOffset, 0xffffff, writeData); diff --git a/src/jaguar1/RadioManagementModule.h b/src/jaguar1/RadioManagementModule.h index 680cc88..2745382 100644 --- a/src/jaguar1/RadioManagementModule.h +++ b/src/jaguar1/RadioManagementModule.h @@ -1,6 +1,7 @@ #ifndef RADIOMANAGEMENTMODULE_H #define RADIOMANAGEMENTMODULE_H +#include #include #include @@ -52,35 +53,13 @@ enum RATE_SECTION { #include "RateDefinitions.h" -/* Read-only snapshot of the chip's thermal meter. `raw` is the live - * RF[A][0x42][15:10] reading (0..63, Realtek "thermal units" — roughly - * 1.5-2 C each, NOT absolute degrees). `baseline` is the EFUSE - * factory-calibrated reading (0xFF = autoload failed / no baseline). - * `delta = raw - baseline` (signed) is the heat signal — positive means - * the chip is running hotter than calibration. `valid` is false when no - * EFUSE baseline is available, in which case only `raw` is meaningful. */ -struct ThermalStatus { - uint8_t raw = 0; - uint8_t baseline = 0xFF; - int delta = 0; - bool valid = false; -}; - -/* Coarse, honest health label for a thermal reading. The meter is NOT a - * calibrated °C sensor (Realtek publishes no °C transfer function for the AU - * family; the value is an RF/PA-bias tracking index), so we deliberately bucket - * the delta-from-baseline rather than fake a precise temperature — the same - * stance the rtl88x2eu driver takes (cool/warm/hot/...). Thresholds are in - * thermal units above the EFUSE baseline; "hot" aligns with the default - * DEVOURER_THERMAL_WARN_DELTA of 15. Returns "unknown" when no EFUSE baseline - * is available (delta is meaningless without it). */ -inline const char *ThermalBucket(const ThermalStatus &s) { - if (!s.valid) return "unknown"; - if (s.delta < 8) return "cool"; - if (s.delta < 15) return "warm"; - if (s.delta < 25) return "hot"; - return "critical"; -} +/* ThermalStatus/ThermalBucket moved to the generation-agnostic + * src/ThermalStatus.h when GetThermalStatus was promoted to IRtlDevice; + * the alias keeps the many existing Jaguar1 + demo references compiling + * unchanged. */ +#include "../ThermalStatus.h" +using ThermalStatus = devourer::ThermalStatus; +using devourer::ThermalBucket; class RadioManagementModule { RtlUsbAdapter _device; @@ -123,8 +102,19 @@ class RadioManagementModule { * 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; + * ramp in WiFiDriverTxDemo. Atomic so GetTxPowerState's cached snapshot is + * readable from any thread (setters remain control-plane-thread calls). */ + std::atomic txpwr_override_{-1}; + /* Runtime TX-power offset in TXAGC index steps (0.5 dB each), folded onto + * the per-rate baseline (EFUSE table or flat override) AFTER the per-rate + * regulatory min and clamped only at the 6-bit rails — the relative knob + * behind IRtlDevice::SetTxPowerOffsetQdb. The saturation flags record + * whether the last apply hit a rail on any rate (reset per + * PHY_SetTxPowerLevel8812 pass) — the "knob out of travel" signal for a + * closed-loop controller. */ + std::atomic txpwr_offset_steps_{0}; + std::atomic txpwr_sat_low_{false}; + std::atomic txpwr_sat_high_{false}; PowerTracking8812a _pwrTrk; Iqk8812a _iqk; #if defined(DEVOURER_HAVE_8814) @@ -195,10 +185,26 @@ class RadioManagementModule { * (EFUSE-driven) behaviour. Takes effect when set_channel_bwmode runs, or * immediately via ApplyTxPower(). */ void SetTxPowerOverride(int idx) { txpwr_override_ = idx; } + /* Runtime TX-power offset in index steps (see txpwr_offset_steps_). Takes + * effect on the next channel-set, or immediately via ApplyTxPower(). */ + void SetTxPowerOffsetSteps(int steps) { txpwr_offset_steps_ = steps; } + int GetTxPowerOffsetSteps() const { return txpwr_offset_steps_; } + int GetTxPowerOverride() const { return txpwr_override_; } + bool TxPowerSaturatedLow() const { return txpwr_sat_low_; } + bool TxPowerSaturatedHigh() const { return txpwr_sat_high_; } + /* Effective per-rate TXAGC index for the current channel/BW: the flat + * override (when armed) or the EFUSE per-rate base (or the legacy `power` + * fallback), plus the runtime offset, clamped to the 6-bit field with the + * saturation flags recording rail hits. The single computation behind both + * the per-rate apply loop and the 0xc54 power-training word; public so the + * device layer can build the software-shadow TxPowerState on the 8814A, + * whose packed TXAGC port (0x1998) is write-only. */ + uint8_t ComputeTxPowerIndex(uint8_t path, uint8_t rate, uint8_t ntx_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). */ + * so this is the only way to push a freshly-set SetTxPowerOverride() / + * SetTxPowerOffsetSteps() value to the TXAGC registers mid-session (used by + * the thermal-vs-gain ramp and SetTxPowerOffsetQdb). */ void ApplyTxPower() { PHY_SetTxPowerLevel8812(_currentChannel); } private: diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index c42d4c7..f1d3165 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -773,17 +773,96 @@ void RtlJaguarDevice::StartWithMonitorMode(SelectedChannel selectedChannel) { } _radioManagement->SetMonitorMode(); + _brought_up = true; } -void RtlJaguarDevice::SetTxPower(uint8_t power) { - _radioManagement->SetTxPower(power); +devourer::TxPowerCaps RtlJaguarDevice::GetTxPowerCaps() { + devourer::TxPowerCaps caps; + caps.supported = true; + caps.index_max = 63; + caps.step_qdb = 2; /* 0.5 dB per TXAGC index step */ + /* On-air slope (tests/txpwr_offset_onair.sh, chip-RSSI ground station): + * 8812AU 0.496 dB/idx, 8814AU 0.425 dB/idx @ ch36 — nominal confirmed. + * 8821AU: 0.50 dB/idx exactly at 2.4 GHz but FLAT at 5 GHz (registers move + * — regcheck readback — while on-air power stays pinned: the 5 GHz chain + * on that part ignores BB TXAGC), so its measured flag stays false and the + * power lever should be treated as 2.4 GHz-only there. */ + caps.step_measured = + _eepromManager->version_id.ICType != CHIP_8821; + caps.offset_min_qdb = -126; + caps.offset_max_qdb = 126; + return caps; } -void RtlJaguarDevice::SetTxPowerOverride(int idx) { - _radioManagement->SetTxPowerOverride(idx); +int RtlJaguarDevice::SetTxPowerOffsetQdb(int qdb) { + if (_cw_active) { + _logger->warn("SetTxPowerOffsetQdb refused: CW tone active (TXAGC does " + "not modulate a bare LO carrier)"); + return 0; + } + int steps = 0; + const int applied = devourer::quantize_offset_qdb(qdb, GetTxPowerCaps(), + &steps); + _radioManagement->SetTxPowerOffsetSteps(steps); + if (_brought_up) + _radioManagement->ApplyTxPower(); + _logger->info("TX-power offset: {} qdB requested -> {} qdB applied " + "({} steps){}", + qdb, applied, steps, + _brought_up ? "" : " (recorded; applies at bring-up)"); + return applied; } -void RtlJaguarDevice::ApplyTxPower() { _radioManagement->ApplyTxPower(); } +void RtlJaguarDevice::SetTxPowerIndexOverride(int idx) { + if (_cw_active) { + _logger->warn("SetTxPowerIndexOverride refused: CW tone active"); + return; + } + _radioManagement->SetTxPowerOverride(idx < 0 ? -1 : (idx > 63 ? 63 : idx)); + if (_brought_up) + _radioManagement->ApplyTxPower(); +} + +bool RtlJaguarDevice::ReApplyTxPower() { + if (!_brought_up || _cw_active) + return false; + _radioManagement->ApplyTxPower(); + return true; +} + +devourer::TxPowerState RtlJaguarDevice::GetTxPowerState() { + devourer::TxPowerState s; + s.valid = true; + s.flat_index = static_cast(_radioManagement->GetTxPowerOverride()); + s.offset_steps = + static_cast(_radioManagement->GetTxPowerOffsetSteps()); + s.offset_qdb = static_cast(s.offset_steps * 2); + s.saturated_low = _radioManagement->TxPowerSaturatedLow(); + s.saturated_high = _radioManagement->TxPowerSaturatedHigh(); + const bool is_8814 = _eepromManager->version_id.ICType == CHIP_8814A; + if (_brought_up && !is_8814 && !_cw_active) { + /* Path-A representative indices straight from the TXAGC registers: + * 0xc20[7:0] = CCK 1M, 0xc24[7:0] = OFDM 6M, 0xc30[31:24] = HT MCS7. */ + s.cck_index = static_cast( + _radioManagement->phy_query_bb_reg_public(0xc20, 0x000000ff)); + s.ofdm_index = static_cast( + _radioManagement->phy_query_bb_reg_public(0xc24, 0x000000ff)); + s.mcs7_index = static_cast( + _radioManagement->phy_query_bb_reg_public(0xc30, 0xff000000)); + s.hw_readback = true; + } else { + /* 8814A (write-only packed TXAGC port) / pre-bring-up: software shadow — + * the same computation the apply loop writes. */ + s.cck_index = _radioManagement->ComputeTxPowerIndex( + 0, static_cast(MGN_1M), 0); + s.ofdm_index = _radioManagement->ComputeTxPowerIndex( + 0, static_cast(MGN_6M), 0); + s.mcs7_index = _radioManagement->ComputeTxPowerIndex( + 0, static_cast(MGN_MCS7), 0); + s.hw_readback = false; + } + return s; +} void RtlJaguarDevice::SetTxMode(const devourer::TxMode& mode) { _tx_mode_default = mode; diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index d25dd53..aca1925 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -60,6 +60,11 @@ class RtlJaguarDevice : public IRtlDevice { /* Modulated continuous TX (StartContinuousTx/StopContinuousTx) guard. */ bool _cont_active = false; + /* Bring-up completion (StartWithMonitorMode succeeded): gates the live + * apply in the runtime TX-power setters — before bring-up they only record + * state (an ApplyTxPower on a cold chip would write an unpowered BB). */ + bool _brought_up = false; + public: RtlJaguarDevice(RtlUsbAdapter device, Logger_t logger); ~RtlJaguarDevice() override; @@ -83,15 +88,31 @@ class RtlJaguarDevice : public IRtlDevice { * resolve statically, so overrides must not re-declare it.) */ void FastRetune(uint8_t channel, bool cache_rf) override; void InitWrite(SelectedChannel channel) override; - void SetTxPower(uint8_t power) override; - /* 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(); + /* Legacy per-rate TXAGC override pair — superseded by the IRtlDevice + * runtime TX-power API (SetTxPowerIndexOverride applies in one call). + * Inline forwards kept for one release cycle, Rtl8812aDevice-alias style. */ + [[deprecated("use SetTxPowerIndexOverride (applies live)")]] + void SetTxPowerOverride(int idx) { + SetTxPowerIndexOverride(idx); + } + [[deprecated("SetTxPowerIndexOverride / SetTxPowerOffsetQdb apply live; " + "use ReApplyTxPower to force a re-program")]] + void ApplyTxPower() { + ReApplyTxPower(); + } + + /* Runtime TX-power control (IRtlDevice contract; see src/TxPower.h). + * Jaguar1 caps: 6-bit TXAGC index, 0.5 dB (2 qdB) per step. The offset + * folds into ComputeTxPowerIndex, so it reaches the per-rate TXAGC fanout + * (0xc20..0xc4c / packed 0x1998 on 8814) AND the 0xc54 power-training + * word, and is re-folded by every SetMonitorChannel. Readback comes from + * 0xc20/0xc24/0xc30 on 8812/8811/8821; the 8814's packed TXAGC port is + * write-only, so it reports the software shadow (hw_readback=false). */ + devourer::TxPowerCaps GetTxPowerCaps() override; + int SetTxPowerOffsetQdb(int qdb) override; + void SetTxPowerIndexOverride(int idx) override; + bool ReApplyTxPower() override; + devourer::TxPowerState GetTxPowerState() 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); @@ -159,8 +180,10 @@ class RtlJaguarDevice : public IRtlDevice { * baseline. Read-only — leaves the TX-power-tracking BB-swing registers * untouched. Works on every Jaguar member. Safe to call from the thread * that owns the device (e.g. inline in a TX loop) — no USB contention. - * See ThermalStatus in RadioManagementModule.h for field semantics. */ - ThermalStatus GetThermalStatus(); + * See devourer::ThermalStatus (src/ThermalStatus.h) for field semantics. + * NB: on the 8814 the EFUSE baseline is read at the 8812 offset, so the + * absolute delta may be off there; the raw trend is still valid. */ + ThermalStatus GetThermalStatus() override; /* Spawn a background thread that samples the thermal meter every * interval_ms and stores a snapshot (queryable via get_thermal_snapshot). diff --git a/src/jaguar2/HalJaguar2.cpp b/src/jaguar2/HalJaguar2.cpp index b70095b..03bfea9 100644 --- a/src/jaguar2/HalJaguar2.cpp +++ b/src/jaguar2/HalJaguar2.cpp @@ -1278,10 +1278,23 @@ void HalJaguar2::set_tx_power_flat(uint8_t idx) { _device.rtw_write32(static_cast(0x1d00 + off), v); /* path A */ _device.rtw_write32(static_cast(0x1d80 + off), v); /* path B */ } + set_txagc_shadow_flat(idx); /* the block is write-only — see txagc_shadow */ _logger->info("Jaguar2: TXAGC flat index 0x{:02x} applied", idx); } -void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { +void HalJaguar2::read_thermal(uint8_t &raw, uint8_t &baseline) { + /* Vendor 8822B/8821C halrf reads the meter with a plain + * odm_get_rf_reg(RF_PATH_A, 0x42, 0xfc00) — no trigger needed. */ + raw = static_cast((rf_read(0, 0x42) >> 10) & 0x3f); + if (!_efuse_valid) { + read_efuse_logical_map(_efuse_map, sizeof(_efuse_map), /*dump=*/false); + _efuse_valid = true; + } + baseline = _efuse_map[0xBA]; /* EEPROM_THERMAL_METER_8822B/_8821C */ +} + +void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type, + int offset_steps) { /* Port of phy_get_pg_txpwr_idx (hal_com_phycfg.c) + the 4-byte TXAGC write * (config_phydm_write_txagc_8822b): TXAGC[rate] = pg-base(rate-section) + * per-Nss diff, clamped to the txpwr_lmt regulatory limit, written to 0x1d00 @@ -1292,6 +1305,11 @@ void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { if (channel == 0) return; + /* Fresh rail-hit snapshot for this apply (SetTxPowerOffsetQdb's "knob out + * of travel" signal). */ + _txpwr_sat_low = false; + _txpwr_sat_high = false; + /* 8821C: the vendor hal_com_get_txpwr_idx computes per-rate power * idx[rate] = clamp(base + (min(by_rate[rate], lmt) - rs_target), 0, 63) * where base = efuse per-channel section index (phy_get_pg_txpwr_idx), @@ -1344,7 +1362,10 @@ void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { #endif }; /* write a rate-section: per byte (rate) in each dword, - * idx = clamp(base + (min(by_rate, lmt) - rs), 0, 63). */ + * idx = clamp(base + (min(by_rate, lmt) - rs) + offset, 0, 63) — the + * runtime offset folds after the regulatory min() (headroom above the + * worldwide-min table up to the hw rail, same permissiveness as the + * flat override). */ auto wsec = [&](uint16_t off, int base, int rs, int lm, const uint32_t *dw, int ndw) { for (int w = 0; w < ndw; w++) { @@ -1352,15 +1373,25 @@ void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { for (int b = 0; b < 4; b++) { int byr = static_cast((dw[w] >> (b * 8)) & 0xff); int rt = byr < lm ? byr : lm; - int idx = base + (rt - rs); - if (idx < 0) idx = 0; - if (idx > 63) idx = 63; + int idx = base + (rt - rs) + offset_steps; + if (idx < 0) { idx = 0; _txpwr_sat_low = true; } + if (idx > 63) { idx = 63; _txpwr_sat_high = true; } out |= static_cast(idx & 0xff) << (b * 8); } _device.rtw_write32(static_cast(0x1d00 + off + w * 4), out); } }; - /* by_rate dwords (array_mp_8821c_phy_reg_pg) + rs = ref-rate by_rate. */ + /* by_rate dwords (array_mp_8821c_phy_reg_pg) + rs = ref-rate by_rate. + * Representative-rate shadow (the TXAGC block is write-only): the value + * wsec computes for hw_rate 0x00 (CCK 1M) / 0x04 (OFDM 6M) / 0x13 + * (MCS7) — byte 0 of the section's first dword resp. byte 3 of the HT + * section's second dword. */ + auto sec_idx = [&](int base, int rs, int lm, uint32_t dw, int b) -> int { + int byr = static_cast((dw >> (b * 8)) & 0xff); + int rt = byr < lm ? byr : lm; + int idx = base + (rt - rs) + offset_steps; + return idx < 0 ? 0 : (idx > 63 ? 63 : idx); + }; if (!g5c) { const uint32_t cck[] = {0x32343638}; const uint32_t ofdm[] = {0x36363636, 0x28303234}; @@ -1370,6 +1401,9 @@ void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { wsec(0x04, ofdm_base, 40, lmtc(1), ofdm, 2); wsec(0x0c, ht_base, 38, lmtc(2), ht, 2); wsec(0x2c, ht_base, 38, lmtc(2), vht, 3); /* VHT1SS: HT base/lmt */ + _txagc_shadow_cck = sec_idx(cck_base, 50, lmtc(0), cck[0], 0); + _txagc_shadow_ofdm = sec_idx(ofdm_base, 40, lmtc(1), ofdm[0], 0); + _txagc_shadow_mcs7 = sec_idx(ht_base, 38, lmtc(2), ht[1], 3); } else { const uint32_t ofdm[] = {0x34343434, 0x26283032}; const uint32_t ht[] = {0x32343434, 0x24262830}; @@ -1377,6 +1411,9 @@ void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { wsec(0x04, ofdm_base, 38, lmtc(1), ofdm, 2); wsec(0x0c, ht_base, 36, lmtc(2), ht, 2); wsec(0x2c, ht_base, 36, lmtc(2), vht, 3); + _txagc_shadow_cck = -1; /* no CCK at 5G */ + _txagc_shadow_ofdm = sec_idx(ofdm_base, 38, lmtc(1), ofdm[0], 0); + _txagc_shadow_mcs7 = sec_idx(ht_base, 36, lmtc(2), ht[1], 3); } _logger->info("Jaguar2/8821C: per-rate TXAGC (vendor formula) ch{} {} " "cck_base={} ofdm_base={} ht_base={} lmt cck/ofdm/ht={}/{}/{}", @@ -1445,11 +1482,17 @@ void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { const int lmt_cck = g5 ? 63 : lmt(0); const int lmt_ofdm = lmt(1); const int lmt_ht = lmt(2); - auto clamp63 = [](int v) -> uint8_t { - return static_cast(v < 0 ? 0 : (v > 63 ? 63 : v)); + auto clamp63 = [&](int v) -> uint8_t { + if (v < 0) { v = 0; _txpwr_sat_low = true; } + if (v > 63) { v = 63; _txpwr_sat_high = true; } + return static_cast(v); }; + /* min() against the regulatory table FIRST, then the runtime offset, then + * the hw rail — a positive offset may exceed the worldwide-min table + * (operator's call, same as the flat override), a negative one always + * takes effect. */ auto clamp_lmt = [&](int v, int lmt) -> uint8_t { - if (v > lmt) v = lmt; return clamp63(v); + if (v > lmt) v = lmt; return clamp63(v + offset_steps); }; /* 8821C is 1T1R — only path A has a valid EFUSE base + a real RF path (its @@ -1463,8 +1506,13 @@ void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { const uint8_t cck_base = g5 ? 0 : map[blk + cck_group]; const uint8_t bw40_base = g5 ? map[blk + group] : map[blk + 6 + group]; if (bw40_base == 0xFF || (!g5 && cck_base == 0xFF)) { - _logger->info("Jaguar2: apply_tx_power path {} ch{} EFUSE base unprogrammed " - "— leaving TXAGC default", path, channel); + if (offset_steps != 0) + _logger->warn("Jaguar2: TX-power offset has no EFUSE baseline to fold " + "into on path {} (unprogrammed) — TXAGC left at BB-table " + "default", path); + else + _logger->info("Jaguar2: apply_tx_power path {} ch{} EFUSE base unprogrammed " + "— leaving TXAGC default", path, channel); continue; } const uint8_t d0 = map[blk + (g5 ? 14 : 11)]; /* MSB BW20_Diff0 / LSB OFDM_Diff0 */ @@ -1496,15 +1544,47 @@ void HalJaguar2::apply_tx_power(uint8_t channel, uint8_t bw, uint8_t rfe_type) { wr(0x04, ofdm_idx); wr(0x08, ofdm_idx); wr(0x0c, ht1_idx); wr(0x10, ht1_idx); wr(0x14, ht2_idx); wr(0x18, ht2_idx); + if (path == 0) { /* representative shadow — the block is write-only */ + _txagc_shadow_cck = g5 ? -1 : cck_idx; + _txagc_shadow_ofdm = ofdm_idx; + _txagc_shadow_mcs7 = ht1_idx; /* MCS7 = hw_rate 0x13, HT1SS section */ + } /* VHT1SS — the 8821C's AC mode — uses the same 1SS BW base as HT1SS, and the * vendor efuse-calibrates it (config_phydm_write_txagc_8821c writes hw_rate * up to VHT; 0x2c-0x35 = VHT1SS MCS0-9 -> regs 0x1d2c/0x1d30/0x1d34). Without * this VHT TXAGC stays at the BB-table default — uncalibrated and - * inconsistent with the CCK/OFDM/HT rates above. 8821C-only so the 8822B's - * VHT2SS handling stays byte-identical. */ + * inconsistent with the CCK/OFDM/HT rates above. */ if (_variant == ChipVariant::C8821C) { wr(0x2c, ht1_idx); wr(0x30, ht1_idx); wr(0x34, ht1_idx); } + /* VHT (8822B, 2SS): vendor phy_get_pg_txpwr_idx computes the PG index by + * rate-section STREAM COUNT only — VHT1SS rides the same base+BW20-diff as + * HT1SS, VHT2SS the same as HT2SS — clamped to the VHT regulatory section + * (sec 3). hw_rate 0x2c-0x35 = VHT1SS MCS0-9, 0x36-0x3f = VHT2SS MCS0-9; + * dword 0x34 straddles the sections (bytes 0-1 = 1SS MCS8/9, 2-3 = 2SS + * MCS0/1). Before this, 8822B VHT rates rode the hot BB-table default — + * uncalibrated, regulatory-unclamped, and out of reach of the runtime + * TX-power offset. */ + if (_variant == ChipVariant::C8822B) { + /* The worldwide-min table has no 2.4G VHT rows (non-standard mode), so + * the sec-3 lookup falls back to 63 there — which would let 2.4G VHT + * ride hotter than the HT clamp. Bound it by the HT limit instead; + * 5G has real VHT rows and uses them. */ + int lmt_vht = lmt(3); + if (!g5 && lmt_vht > lmt_ht) + lmt_vht = lmt_ht; + const uint8_t v1 = clamp_lmt(bw40_base + ht_diff0, lmt_vht); + const uint8_t v2 = clamp_lmt(bw40_base + ht_diff0 + ht_diff1, lmt_vht); + wr(0x2c, v1); wr(0x30, v1); + _device.rtw_write32(static_cast(p + 0x34), + static_cast(v1) | + (static_cast(v1) << 8) | + (static_cast(v2) << 16) | + (static_cast(v2) << 24)); + wr(0x38, v2); wr(0x3c, v2); + _logger->info("Jaguar2/8822B: VHT TXAGC path {}: VHT1SS={:#x} VHT2SS={:#x} " + "(lmt_vht={})", path, v1, v2, lmt_vht); + } _logger->info("Jaguar2: TXAGC path {} ch{} {} (g{}): CCK={:#x} OFDM={:#x} " "HT1SS={:#x} HT2SS={:#x} (bw40_base={})", path, channel, diff --git a/src/jaguar2/HalJaguar2.h b/src/jaguar2/HalJaguar2.h index 293d2de..141d77c 100644 --- a/src/jaguar2/HalJaguar2.h +++ b/src/jaguar2/HalJaguar2.h @@ -1,6 +1,7 @@ #ifndef HAL_JAGUAR2_H #define HAL_JAGUAR2_H +#include #include #include @@ -55,8 +56,50 @@ class HalJaguar2 { * the efuse-calibrated level the kernel uses. Without it the TXAGC sits at the * hot BB-table default which overdrives high-order QAM (MCS5/7) into PA * compression. Ports phy_get_pg_txpwr_idx (base + per-BW/Nss diff) + - * config_phydm_write_txagc_8822b. Both bands. */ - void apply_tx_power(uint8_t channel, uint8_t bw = 0, uint8_t rfe_type = 0); + * config_phydm_write_txagc_8822b. Both bands. On the 8822B this covers the + * VHT sections too (vendor parity: phy_get_pg_txpwr_idx computes VHT by + * stream count only, so VHT1SS/VHT2SS ride the HT 1SS/2SS bases with the + * VHT regulatory clamp — hw_rate 0x2c-0x3f). + * + * `offset_steps` is the runtime TX-power offset (TXAGC index steps, 0.5 dB + * each) behind IRtlDevice::SetTxPowerOffsetQdb: folded AFTER the min() with + * the regulatory table, clamped only at the 6-bit rails (the saturation + * flags below record rail hits — reset per apply). */ + void apply_tx_power(uint8_t channel, uint8_t bw = 0, uint8_t rfe_type = 0, + int offset_steps = 0); + + /* Rail-hit flags from the last apply_tx_power/flat-compose (see + * apply_tx_power). Atomic so a state snapshot may read them cross-thread. */ + bool txpwr_saturated_low() const { return _txpwr_sat_low; } + bool txpwr_saturated_high() const { return _txpwr_sat_high; } + void set_txpwr_saturation(bool lo, bool hi) { + _txpwr_sat_low = lo; + _txpwr_sat_high = hi; + } + + /* Software shadow of the last path-A TXAGC write for the representative + * rates (CCK 1M / OFDM 6M / HT MCS7). The Jaguar2 TXAGC block + * (0x1d00/0x1d80) is WRITE-ONLY — reads return 0 (hardware-verified on + * 8822BU + 8821CU) — so GetTxPowerState reports these with + * hw_readback=false, like the 8814A's packed TXAGC port. -1 = never + * written (TXAGC still at the BB-table default). */ + void txagc_shadow(int &cck, int &ofdm, int &mcs7) const { + cck = _txagc_shadow_cck; + ofdm = _txagc_shadow_ofdm; + mcs7 = _txagc_shadow_mcs7; + } + void set_txagc_shadow_flat(int idx) { + _txagc_shadow_cck = idx; + _txagc_shadow_ofdm = idx; + _txagc_shadow_mcs7 = idx; + } + + /* Chip thermal meter: raw = RF[A] 0x42[15:10] (a plain direct-window read — + * the vendor 8822B/8821C halrf reads it with no trigger), baseline = the + * EFUSE thermal calibration at logical 0xBA (EEPROM_THERMAL_METER_8822B / + * _8821C — same offset both variants; 0xFF = unprogrammed). Reads the + * cached logical map (lazy-read on first use, like apply_tx_power). */ + void read_thermal(uint8_t &raw, uint8_t &baseline); /* Apply the 8822B BB (phy_reg), AGC (agc_tab) and RF (radioa/radiob) phydm * tables via the shared check_positive walker, bracketed by the OFDM/CCK @@ -244,6 +287,13 @@ class HalJaguar2 { * USB control transfers ≈ 0.5s). read_efuse_rfe populates it; apply_tx_power * reuses it — avoids a second walk that would slow every cold init. */ uint8_t _efuse_map[0x200]; + /* Rail-hit flags from the last TXAGC apply (see txpwr_saturated_low/high). */ + std::atomic _txpwr_sat_low{false}; + std::atomic _txpwr_sat_high{false}; + /* Path-A TXAGC software shadow (see txagc_shadow — the block is write-only). */ + std::atomic _txagc_shadow_cck{-1}; + std::atomic _txagc_shadow_ofdm{-1}; + std::atomic _txagc_shadow_mcs7{-1}; bool _efuse_valid = false; /* 8821C CCK TX-filter defaults (0xa24/0xa28/0xaac) snapshotted from the BB diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 4c00de1..9a4ec8c 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -131,16 +131,37 @@ void RtlJaguar2Device::bring_up(SelectedChannel channel) { } } /* Program per-rate TXAGC from the EFUSE power-by-rate calibration (the level - * the kernel uses). DEVOURER_TX_PWR (flat override) applied later in InitWrite - * still wins for debug. DEVOURER_SKIP_TXPWR keeps the BB-table default. */ + * the kernel uses), through the runtime-knob composer so a flat override / + * offset recorded before bring-up folds in here. DEVOURER_TX_PWR (flat + * override) applied later in InitWrite still wins for debug. + * DEVOURER_SKIP_TXPWR keeps the BB-table default. */ if (!getenv("DEVOURER_SKIP_TXPWR")) - _hal.apply_tx_power(static_cast(channel.Channel), bw, rfe); + apply_tx_power_current(); /* Grant the antenna to WLAN (combo chip) — must precede enable. */ if (!getenv("DEVOURER_SKIP_COEX")) _hal.coex_wlan_only(channel.Channel > 14); else _logger->info("Jaguar2: coex WL grant SKIPPED (DEVOURER_SKIP_COEX)"); _hal.enable_rx(); /* CR MACTX|MACRX + RCR + IGI — enables both TX and RX */ + _brought_up = true; +} + +void RtlJaguar2Device::apply_tx_power_current() { + const int off = _tx_pwr_offset_steps; + const int flat = _tx_pwr_override; + if (flat >= 0) { + /* Flat override + offset, composed BEFORE set_tx_power_flat (whose &0x3f + * would wrap an over-range compose instead of clamping). */ + int idx = flat + off; + bool lo = false, hi = false; + if (idx < 0) { idx = 0; lo = true; } + if (idx > 63) { idx = 63; hi = true; } + _hal.set_txpwr_saturation(lo, hi); + _hal.set_tx_power_flat(static_cast(idx)); + return; + } + _hal.apply_tx_power(static_cast(_channel.Channel), + static_cast(_channel.ChannelWidth), _rfe, off); } void RtlJaguar2Device::Init(Action_ParsedRadioPacket packetProcessor, @@ -296,11 +317,11 @@ void RtlJaguar2Device::InitWrite(SelectedChannel channel) { * (SDR-visibility debug knob). */ bring_up(channel); /* DEVOURER_TX_PWR=0xNN forces a flat per-rate TXAGC reference (SDR-visibility - * debug knob) over the efuse-calibrated level bring_up applied. */ - if (const char *e = getenv("DEVOURER_TX_PWR")) { - uint8_t idx = static_cast(strtol(e, nullptr, 0) & 0x3f); - _hal.set_tx_power_flat(idx); - } + * debug knob) over the efuse-calibrated level bring_up applied — routed + * through the runtime flat-override knob so it composes with the offset and + * shows up in GetTxPowerState. */ + if (const char *e = getenv("DEVOURER_TX_PWR")) + SetTxPowerIndexOverride(static_cast(strtol(e, nullptr, 0) & 0x3f)); /* DEVOURER_BF_ARM_SOUNDER — beamforming self-sounding (beamformer side): * arm the MAC's hardware sounding engine so a TX-descriptor-marked NDPA * (DEVOURER_TX_NDPA=1) is followed by a hardware-generated NDP. The MAC @@ -507,6 +528,13 @@ void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { _hal.set_channel_bw(static_cast(channel.Channel), static_cast(channel.ChannelWidth), _rfe, channel.ChannelOffset); + /* Runtime TX-power knobs in use: re-fold them against the NEW channel's + * efuse group so the offset stays relative to the calibrated table (TXAGC + * registers are not per-channel — a cross-group move would otherwise keep + * the old group's absolute level). Gated on a knob being active so the + * legacy no-knob path stays byte-identical to the pure tune above. */ + if (_brought_up && (_tx_pwr_offset_steps != 0 || _tx_pwr_override >= 0)) + apply_tx_power_current(); } void RtlJaguar2Device::FastRetune(uint8_t channel, bool cache_rf) { @@ -541,7 +569,87 @@ RxEnergy RtlJaguar2Device::GetRxEnergy() { return e; } -void RtlJaguar2Device::SetTxPower(uint8_t power) { _tx_pwr_override = power; } +devourer::TxPowerCaps RtlJaguar2Device::GetTxPowerCaps() { + devourer::TxPowerCaps caps; + caps.supported = true; + caps.index_max = 63; + caps.step_qdb = 2; /* 0.5 dB per TXAGC index step */ + /* On-air slope (tests/txpwr_offset_onair.sh): 8822BU 0.488, 8821CU + * 0.581 dB/idx @ ch36 — nominal confirmed. */ + caps.step_measured = true; + caps.offset_min_qdb = -126; + caps.offset_max_qdb = 126; + return caps; +} + +int RtlJaguar2Device::SetTxPowerOffsetQdb(int qdb) { + if (_cw_active) { + _logger->warn("SetTxPowerOffsetQdb refused: CW tone active (TXAGC does " + "not modulate a bare LO carrier)"); + return 0; + } + int steps = 0; + const int applied = devourer::quantize_offset_qdb(qdb, GetTxPowerCaps(), + &steps); + _tx_pwr_offset_steps = steps; + if (_brought_up) + apply_tx_power_current(); + _logger->info("TX-power offset: {} qdB requested -> {} qdB applied " + "({} steps){}", + qdb, applied, steps, + _brought_up ? "" : " (recorded; applies at bring-up)"); + return applied; +} + +void RtlJaguar2Device::SetTxPowerIndexOverride(int idx) { + if (_cw_active) { + _logger->warn("SetTxPowerIndexOverride refused: CW tone active"); + return; + } + _tx_pwr_override = idx < 0 ? -1 : (idx > 63 ? 63 : idx); + if (_brought_up) + apply_tx_power_current(); +} + +bool RtlJaguar2Device::ReApplyTxPower() { + if (!_brought_up || _cw_active) + return false; + apply_tx_power_current(); + return true; +} + +devourer::TxPowerState RtlJaguar2Device::GetTxPowerState() { + devourer::TxPowerState s; + s.valid = true; + s.flat_index = static_cast(_tx_pwr_override.load()); + s.offset_steps = static_cast(_tx_pwr_offset_steps.load()); + s.offset_qdb = static_cast(s.offset_steps * 2); + s.saturated_low = _hal.txpwr_saturated_low(); + s.saturated_high = _hal.txpwr_saturated_high(); + /* The Jaguar2 TXAGC block (0x1d00/0x1d80) is write-only — reads return 0 + * (hardware-verified on 8822BU + 8821CU) — so the representative indices + * come from the HAL's software shadow of the last apply, like the 8814A. */ + int cck = -1, ofdm = -1, mcs7 = -1; + _hal.txagc_shadow(cck, ofdm, mcs7); + s.cck_index = static_cast(cck); + s.ofdm_index = static_cast(ofdm); + s.mcs7_index = static_cast(mcs7); + s.hw_readback = false; + return s; +} + +devourer::ThermalStatus RtlJaguar2Device::GetThermalStatus() { + devourer::ThermalStatus t; + if (!_brought_up) + return t; /* RF reads need a powered chip */ + uint8_t raw = 0, baseline = 0xFF; + _hal.read_thermal(raw, baseline); + t.raw = raw; + t.baseline = baseline; + t.valid = baseline != 0xFF; + t.delta = t.valid ? static_cast(raw) - static_cast(baseline) : 0; + return t; +} bool RtlJaguar2Device::send_packet(const uint8_t *packet, size_t length) { /* Parse the radiotap header for rate/bw/coding, build an 8822B TX descriptor diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 1486d9d..26c0557 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -57,13 +57,29 @@ class RtlJaguar2Device : public IRtlDevice { * measurement). */ void FastRetune(uint8_t channel, bool cache_rf) override; void InitWrite(SelectedChannel channel) override; - void SetTxPower(uint8_t power) override; bool send_packet(const uint8_t *packet, size_t length) override; SelectedChannel GetSelectedChannel() override; void Stop() override; void SetTxMode(const devourer::TxMode &mode) override; void ClearTxMode() override; + /* Runtime TX-power control (IRtlDevice contract; see src/TxPower.h). + * Jaguar2 caps: 6-bit TXAGC index, 0.5 dB (2 qdB) per step. The offset + * folds into HalJaguar2::apply_tx_power after the regulatory min() (or onto + * the flat override), covering CCK/OFDM/HT and — 8822B included — the VHT + * sections. Live once brought up; recorded and folded at bring-up before. + * A full SetMonitorChannel re-applies the knobs against the new channel's + * efuse group (gated on a knob being active so the legacy no-knob path + * stays byte-identical); FastRetune never touches TXAGC. Readback from + * 0x1d00/0x1d04/0x1d10. GetThermalStatus reads RF[A] 0x42[15:10] + the + * efuse baseline at 0xBA. */ + devourer::TxPowerCaps GetTxPowerCaps() override; + int SetTxPowerOffsetQdb(int qdb) override; + void SetTxPowerIndexOverride(int idx) override; + bool ReApplyTxPower() override; + devourer::TxPowerState GetTxPowerState() override; + devourer::ThermalStatus GetThermalStatus() override; + /* 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 @@ -98,7 +114,18 @@ class RtlJaguar2Device : public IRtlDevice { jaguar2::HalmacJaguar2Fw _fw; SelectedChannel _channel{}; Action_ParsedRadioPacket _packetProcessor = nullptr; - int _tx_pwr_override = -1; + /* Runtime TX-power knobs (atomic so GetTxPowerState's cached snapshot is + * readable cross-thread; setters are control-plane-thread calls). Flat + * override -1 = efuse per-rate baseline; offset in 0.5 dB index steps. */ + std::atomic _tx_pwr_override{-1}; + std::atomic _tx_pwr_offset_steps{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: + * flat override (+offset) via set_tx_power_flat, else the efuse per-rate + * path with the offset folded. Called from bring_up and live from the + * setters/SetMonitorChannel. */ + void apply_tx_power_current(); /* rfe_type resolved during bring_up (efuse + DEVOURER_RFE), cached so * SetMonitorChannel can retune (set_channel_bw needs it). */ uint8_t _rfe = 0; diff --git a/src/jaguar3/HalJaguar3.h b/src/jaguar3/HalJaguar3.h index b12b9ef..eec0eda 100644 --- a/src/jaguar3/HalJaguar3.h +++ b/src/jaguar3/HalJaguar3.h @@ -135,6 +135,11 @@ class HalJaguar3 { void coex_keepalive() { _cal->coex_keepalive(); } void coex_run_5g() { _cal->coex_run_5g(); } void pwr_track() { _cal->pwr_track(); } + /* One-shot thermal-meter read for GetThermalStatus (see Jaguar3Calibration). + * Caller serializes against pwr_track (same RF 0x42 trigger RMW). */ + bool read_thermal(uint8_t &raw, uint8_t &baseline) { + return _cal ? _cal->read_thermal(raw, baseline) : false; + } /* Runtime H2C (host-to-firmware) over the HMEBOX mailboxes (port of * rtw_fw_send_h2c_command): 8-byte command, round-robin across 4 boxes. */ diff --git a/src/jaguar3/Halrf8822c.cpp b/src/jaguar3/Halrf8822c.cpp index 9858051..883dd6f 100644 --- a/src/jaguar3/Halrf8822c.cpp +++ b/src/jaguar3/Halrf8822c.cpp @@ -1258,6 +1258,22 @@ void Halrf8822c::pwr_track() { } } +bool Halrf8822c::read_thermal(uint8_t &raw, uint8_t &baseline) { + /* Trigger a fresh reading, same sequence as the pwr_track tick. Caller + * serializes against pwr_track (identical RF 0x42[19] RMW). */ + rf_write(PATH_A, 0x42, 1u << 19, 1); + rf_write(PATH_A, 0x42, 1u << 19, 0); + rf_write(PATH_A, 0x42, 1u << 19, 1); + delay_us(15); + raw = static_cast(rf_read(PATH_A, 0x42, 0x7e)); /* RF_T_METER[6:1] */ + if (raw == 0) + return false; /* meter not ready */ + if (_thermal_ref[0] < 0) + _thermal_ref[0] = raw; /* first read = the cold reference pwr_track uses */ + baseline = static_cast(_thermal_ref[0]); + return true; +} + void Halrf8822c::do_lck() { /* Port of rtw8822c_do_lck (RF_SYN_CTRL=0xbb, SYN_PFD=0xb0, AAC_CTRL=0xca, * SYN_AAC=0xc9, FAST_LCK=0xcc). Re-locks the LC tank at the current freq. */ diff --git a/src/jaguar3/Halrf8822c.h b/src/jaguar3/Halrf8822c.h index 8e67874..6161fcd 100644 --- a/src/jaguar3/Halrf8822c.h +++ b/src/jaguar3/Halrf8822c.h @@ -75,6 +75,13 @@ class Halrf8822c : public Jaguar3Calibration { * first call (chip is coldest at bring-up). */ void pwr_track() override; + /* One-shot path-A meter read for GetThermalStatus (see Jaguar3Calibration): + * trigger + read RF 0x42[6:1], like a pwr_track tick without the swing + * write. No efuse thermal baseline is wired on the CU, so the baseline is + * the first-read cold reference (delta = "since first read"); shares + * _thermal_ref[0] with pwr_track so both agree on the reference. */ + bool read_thermal(uint8_t &raw, uint8_t &baseline) override; + /* Light periodic re-assertion of the WiFi-only coex state (no RF/BB writes): * re-disable the LTE/BT arbitration and re-grant the antenna to WL, in case * the coex firmware re-enables it. Safe to call on the TX hot path. */ diff --git a/src/jaguar3/Halrf8822e.cpp b/src/jaguar3/Halrf8822e.cpp index eeacc1b..adb18f1 100644 --- a/src/jaguar3/Halrf8822e.cpp +++ b/src/jaguar3/Halrf8822e.cpp @@ -1101,6 +1101,23 @@ void Halrf8822e::set_pwr_track_ctx(uint8_t thermal_base_a, uint8_t thermal_base_ void Halrf8822e::pwr_track() { thermal_track_8822e(); } +bool Halrf8822e::read_thermal(uint8_t &raw, uint8_t &baseline) { + /* Same one-time meter enable the tracker uses (RF 0x42[19] 1->0->1 per + * path, then the meter free-runs). Caller serializes against pwr_track. */ + if (!_tm_triggered) { + for (uint8_t p = 0; p < 2; ++p) { + rf_write(p, 0x42, 1u << 19, 1); + rf_write(p, 0x42, 1u << 19, 0); + rf_write(p, 0x42, 1u << 19, 1); + } + _tm_triggered = true; + delay_us(300); + } + raw = static_cast(rf_read(0, 0x42, 0x7e)); /* RF 0x42[6:1] */ + baseline = _therm_base[0]; /* efuse 0xd0 */ + return raw != 0; /* 0 = meter not ready this cycle */ +} + void Halrf8822e::thermal_track_8822e() { /* 5 GHz only (2.4 G swing tables not wired); need a valid efuse baseline. */ const uint8_t ch = _track_channel; diff --git a/src/jaguar3/Halrf8822e.h b/src/jaguar3/Halrf8822e.h index 83ad92f..e72eca1 100644 --- a/src/jaguar3/Halrf8822e.h +++ b/src/jaguar3/Halrf8822e.h @@ -226,6 +226,13 @@ class Halrf8822e : public Jaguar3Calibration { * path, compare to the efuse baseline, look up a per-band swing index, and * write it to the BB TX-power-adjust register (0x18a0/0x41a0[7:0]). */ void thermal_track_8822e(); + +public: + /* One-shot path-A meter read for GetThermalStatus (see Jaguar3Calibration): + * shares the tracker's one-time RF 0x42[19] trigger; baseline = efuse 0xd0. */ + bool read_thermal(uint8_t &raw, uint8_t &baseline) override; + +private: uint8_t _therm_base[2] = {0xFF, 0xFF}; /* efuse 0xd0/0xd1 baseline per path */ uint8_t _track_channel = 0; bool _tm_triggered = false; diff --git a/src/jaguar3/Jaguar3Calibration.h b/src/jaguar3/Jaguar3Calibration.h index eb271eb..cba8524 100644 --- a/src/jaguar3/Jaguar3Calibration.h +++ b/src/jaguar3/Jaguar3Calibration.h @@ -37,6 +37,18 @@ class Jaguar3Calibration { uint8_t /*thermal_base_b*/, uint8_t /*channel*/) {} + /* One-shot thermal-meter read (path A) for IRtlDevice::GetThermalStatus: + * `raw` = the live RF 0x42[6:1] reading, `baseline` = the calibration + * reference — the efuse baseline on the 8822e (0xFF = unprogrammed), the + * first-read cold reference on the 8822c (no efuse baseline is wired on the + * CU, so its delta means "since first read"). Read-only apart from the + * meter's own one-time enable trigger (RF 0x42[19]) — does NOT write the + * pwr_track swing registers. Returns false when unimplemented / the meter + * is not ready. Caller must serialize against pwr_track (same trigger RMW). */ + virtual bool read_thermal(uint8_t & /*raw*/, uint8_t & /*baseline*/) { + return false; + } + /* Coex antenna control (combo chips). */ virtual void force_wl_antenna() = 0; virtual void coex_wlan_only_init() = 0; diff --git a/src/jaguar3/RadioManagementJaguar3.cpp b/src/jaguar3/RadioManagementJaguar3.cpp index cbe40d8..d7803ef 100644 --- a/src/jaguar3/RadioManagementJaguar3.cpp +++ b/src/jaguar3/RadioManagementJaguar3.cpp @@ -233,7 +233,12 @@ void RadioManagementJaguar3::DumpCanary() { static const uint16_t bb_canary[] = { 0x808, 0x810, 0x828, 0x88c, 0x9b0, 0x9b4, 0xc30, 0xcbc, 0x1a00, 0x1a14, 0x1a80, 0x1a9c, 0x1abc, 0x1ae8, 0x1aec, 0x1c80, - 0x18ac, 0x41ac, 0x1944, 0x4044}; + 0x18ac, 0x41ac, 0x1944, 0x4044, + /* TXAGC refs (runtime TX-power API): OFDM 0x18e8/0x41e8, CCK + * 0x18a0/0x41a0, + the first per-rate diff dword. The regcheck's + * TX+RX cell asserts 0x41e8 stays at its table default (the 8822E + * RX-desense quirk) while path A moves with the offset. */ + 0x18e8, 0x41e8, 0x18a0, 0x41a0, 0x3a00}; static const uint16_t mac_canary[] = {0x0, 0x24, 0x454, 0x483, 0x55c, 0x638, 0x668}; static const uint8_t rf_canary[] = {0x18, 0x1a, 0x3f, 0xdf}; @@ -583,7 +588,8 @@ void RadioManagementJaguar3::set_mac_bw_txsc(ChannelWidth_t bw, uint8_t pri) { _device.rtw_write8(0x483, sc); } -void RadioManagementJaguar3::set_tx_power_ref(uint8_t idx, bool zero_diffs) { +void RadioManagementJaguar3::set_tx_power_ref(uint8_t idx, bool zero_diffs, + bool skip_path_b_ofdm_ref) { /* Writes the 0x1c90 TXAGC gate below — the fast path's composed 0x1c90 * cache would go stale. */ invalidate_fast_caches(); @@ -602,15 +608,19 @@ void RadioManagementJaguar3::set_tx_power_ref(uint8_t idx, bool zero_diffs) { _device.phy_set_bb_reg(off, mask, v); }; wr(0x18e8, 0x1fc00, idx); - wr(0x41e8, 0x1fc00, idx); + if (!skip_path_b_ofdm_ref) + wr(0x41e8, 0x1fc00, idx); /* path B — 8822E TX+RX RX-desense hazard */ wr(0x18a0, 0x7f0000, idx); wr(0x41a0, 0x7f0000, idx); if (zero_diffs) for (uint16_t off = 0x3a00; off <= 0x3a7c; off += 4) wr(off, 0xffffffff, 0x0); - _logger->info("Jaguar3: TX power reference set to 0x{:02x} on both paths " + _logger->info("Jaguar3: TX power reference set to 0x{:02x} ({}) " "(per-rate diffs {})", - idx, zero_diffs ? "zeroed" : "kept"); + idx, + skip_path_b_ofdm_ref ? "path A + CCK B; 0x41e8 kept" + : "both paths", + zero_diffs ? "zeroed" : "kept"); } /* Map a phy_reg_pg pseudo-address to the four MGN_* rates it encodes (byte i of @@ -654,9 +664,33 @@ static bool pg_addr_to_rates(uint32_t addr, std::array &rates) { } #endif /* DEVOURER_HAVE_JAGUAR3_8822E */ +void RadioManagementJaguar3::apply_tx_power_refs_8822e( + uint8_t ref_a, uint8_t ref_b, bool skip_path_b_ofdm_ref) { + invalidate_fast_caches(); /* writes the 0x1c90 TXAGC gate below */ + /* Clamp to the 7-bit ref field — the masked BB write truncates mod 128. */ + if (ref_a > 0x7f) + ref_a = 0x7f; + if (ref_b > 0x7f) + ref_b = 0x7f; + auto wr = [this](uint16_t off, uint32_t mask, uint32_t v) { + _device.phy_set_bb_reg(0x1c90, 1u << 15, 0); /* txagc write enable */ + _device.phy_set_bb_reg(off, mask, v); + }; + wr(0x18e8, 0x1fc00, ref_a); /* path A OFDM/HT/VHT ref */ + if (!skip_path_b_ofdm_ref) + wr(0x41e8, 0x1fc00, ref_b); /* path B — RX-desense hazard, see header */ + wr(0x18a0, 0x7f0000, ref_a); /* CCK ref */ + wr(0x41a0, 0x7f0000, ref_b); +} + void RadioManagementJaguar3::apply_power_by_rate_8822e( uint8_t channel, uint8_t ref_a, uint8_t ref_b, bool skip_path_b_ofdm_ref) { invalidate_fast_caches(); /* writes the 0x1c90 TXAGC gate below */ + /* Clamp to the 7-bit ref fields (masked BB writes truncate mod 128). */ + if (ref_a > 0x7f) + ref_a = 0x7f; + if (ref_b > 0x7f) + ref_b = 0x7f; #if defined(DEVOURER_HAVE_JAGUAR3_8822E) /* Port of the phy_reg_pg (power-by-rate) apply that devourer's table walk * skips. The 8822e TXAGC is ref + per-rate diff: the OFDM/HT/VHT reference diff --git a/src/jaguar3/RadioManagementJaguar3.h b/src/jaguar3/RadioManagementJaguar3.h index 4f6fdd5..b1068f1 100644 --- a/src/jaguar3/RadioManagementJaguar3.h +++ b/src/jaguar3/RadioManagementJaguar3.h @@ -70,8 +70,12 @@ class RadioManagementJaguar3 { * (the DEVOURER_TX_PWR debug knob) the per-rate diff table is flattened so * every rate emits at `idx`. With zero_diffs=false (the default bring-up path) * the diff table applied by the BB tables is preserved, so per-rate spread is - * kept and only the reference base is programmed. */ - void set_tx_power_ref(uint8_t idx, bool zero_diffs = true); + * kept and only the reference base is programmed. skip_path_b_ofdm_ref leaves + * 0x41e8 at its table default — the 8822E TX+RX RX-desense quirk (see + * apply_power_by_rate_8822e); previously a flat override on a TX+RX session + * wrote it unconditionally and deafened the EU's RX. */ + void set_tx_power_ref(uint8_t idx, bool zero_diffs = true, + bool skip_path_b_ofdm_ref = false); /* Apply the 8822e phy_reg_pg power-by-rate table for `channel`'s band: sets * the OFDM/CCK reference PER PATH (ref_a -> 0x18e8/0x18a0, ref_b -> 0x41e8/ @@ -90,6 +94,18 @@ class RadioManagementJaguar3 { void apply_power_by_rate_8822e(uint8_t channel, uint8_t ref_a, uint8_t ref_b, bool skip_path_b_ofdm_ref = false); + /* Light TX-power step (8822e): just the gated reference writes of + * apply_power_by_rate_8822e — 0x18e8[16:10] (path-A OFDM), optionally + * 0x41e8[16:10] (path B, same RX-desense hazard/flag as above), + * 0x18a0/0x41a0[22:16] (CCK) — WITHOUT the 0x3a00 per-rate diff walk. The + * diff table is offset-invariant (an offset shifts the reference anchor; + * the calibrated per-rate shape rides on top), so a runtime offset step is + * ~8 register ops instead of the full by-rate apply. Refs are clamped to + * the 7-bit field here — the BB masked write truncates mod 128, so an + * unclamped over-range ref would wrap to near-zero TX silently. */ + void apply_tx_power_refs_8822e(uint8_t ref_a, uint8_t ref_b, + bool skip_path_b_ofdm_ref); + private: /* Jaguar3 baseband bandwidth/clock registers (from * config_phydm_switch_bandwidth_8822c). These do NOT exist on Jaguar1. */ diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 3cf3b59..6dc2917 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -41,6 +41,7 @@ RtlJaguar3Device::RtlJaguar3Device(RtlUsbAdapter device, Logger_t logger, void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, SelectedChannel channel) { _channel = channel; + _rx_wanted = true; /* RX-side bring-up: no TXAGC apply may touch 0x41e8 */ _hal.rtw_hal_init(channel); /* full vendor-source bring-up */ /* Tune the channel/bandwidth (5/10 MHz ChannelWidth re-clocks to narrowband), * then run IQK calibration (it reads RF18 for the tuned channel). */ @@ -51,6 +52,7 @@ void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, _hal.config_rfe(channel.Channel); /* 8822e RFE/PAPE antenna-switch pins */ _hal.config_channel_8822e(channel.Channel); /* 8822e band TX scaling/backoff + shaping */ _hal.coex_wlan_only_init(); /* lock antenna to WLAN (disable BT/LTE coex) */ + _brought_up = true; /* DEVOURER_BF_ARM_BFEE=aa:bb:cc:dd:ee:ff — beamforming self-sounding probe * (beamformee side), Jaguar-3 variant. Arms the hardware CSI responder to @@ -277,6 +279,7 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { * after the coex/FW steps leaves the RX path dead, and the register RMWs * race the running TX). */ const bool want_rx = std::getenv("DEVOURER_TX_WITH_RX") != nullptr; + _rx_wanted = want_rx; /* consumed by every TXAGC ref write (0x41e8 quirk) */ _hal.rtw_hal_init(channel); /* full vendor-source bring-up */ _radioManagement.set_channel_bwmode(channel.Channel, channel.ChannelOffset, channel.ChannelWidth); @@ -327,38 +330,11 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { return; } - if (_tx_pwr_override >= 0) { - _radioManagement.set_tx_power_ref(static_cast(_tx_pwr_override)); - } else if (_variant == jaguar3::ChipVariant::C8822E) { - /* Default TX power (8822e). The TXAGC reference base is the per-channel 5 GHz - * power index the kernel programs. It reads that from the efuse power-by-rate - * table (logical 0x22 path A / 0x4C path B, per channel-group); when the byte - * is valid (<= txgi_max 127) we use it verbatim for kernel-faithful - * per-channel power. On bare modules the byte is unprogrammed (0xFF); the - * kernel's own fallback there is a fixed default (0x33 for the plain path, - * reshaped by the TSSI codeword on 8822e), and devourer falls back to - * JAGUAR3_TXPWR_REF_BASE — an empirically on-air-matched default that is not - * below the kernel's. The per-rate diffs come from the BB phy_reg_pg table - * (apply_power_by_rate_8822e); full TSSI-offset power-by-rate is a follow-up. */ - uint8_t efuse_a = 0xFF, efuse_b = 0xFF; - _hal.read_efuse_txpwr_base_8822e(channel.Channel, efuse_a, efuse_b); - uint8_t ref_a = (efuse_a <= 127) ? efuse_a : JAGUAR3_TXPWR_REF_BASE; - uint8_t ref_b = (efuse_b <= 127) ? efuse_b : JAGUAR3_TXPWR_REF_BASE; - /* TX+RX mode: the path-B OFDM reference (0x41e8) desenses the EU's RX - * (hardware-bisected, value-independent) — skip that one write and keep - * the rest of the per-rate power intact. See apply_power_by_rate_8822e. */ - _radioManagement.apply_power_by_rate_8822e(channel.Channel, ref_a, ref_b, - /*skip_path_b_ofdm_ref=*/want_rx); - if (want_rx) - _logger->info("Jaguar3(8822e): TX+RX mode — path-B OFDM TXAGC ref left " - "at table default (keeps RX alive)"); - } else { - /* 8822c (CU): preserve the flat reference the demo used to impose (40) so - * the SDR-validated CU TX power is unchanged now that the demo no longer - * sets it. The CU's efuse-calibrated per-rate default is a follow-up. */ - _radioManagement.set_tx_power_ref(JAGUAR3_TXPWR_REF_BASE_8822C, - /*zero_diffs=*/true); - } + /* TXAGC from the current runtime-knob state (flat override / offset folded + * onto the efuse-calibrated refs) — see apply_tx_power_current. Pre-coex, + * so no _reg_mu needed here. */ + apply_tx_power_current(/*full=*/true); + _brought_up = true; /* WiFi-only coex bring-up: disable the BT/LTE antenna arbitration and lock the * antenna to WLAN so on-air TX is not killed by the coex firmware. */ _hal.coex_wlan_only_init(); @@ -655,9 +631,17 @@ void RtlJaguar3Device::SetMonitorChannel(SelectedChannel channel) { * radio-management core directly (no lock needed: the coex thread isn't * running yet), so locking here cannot self-deadlock. */ std::lock_guard lk(_reg_mu); + const bool ch_changed = channel.Channel != _channel.Channel; _channel = channel; _radioManagement.set_channel_bwmode(channel.Channel, channel.ChannelOffset, channel.ChannelWidth); + /* Runtime TX-power knobs in use: re-fold them against the NEW channel + * group's efuse refs (8822E bases are per-group). Gated on a knob being + * active so the legacy no-knob path stays byte-identical. */ + if (ch_changed) + _pwr_ref_valid = false; + if (_brought_up && (_tx_pwr_offset_steps != 0 || _tx_pwr_override >= 0)) + apply_tx_power_current(/*full=*/true); } void RtlJaguar3Device::FastRetune(uint8_t channel, bool cache_rf) { @@ -676,13 +660,183 @@ void RtlJaguar3Device::FastRetune(uint8_t channel, bool cache_rf) { _channel.ChannelWidth); } -void RtlJaguar3Device::SetTxPower(uint8_t power) { - /* Record a flat TXAGC reference override; applied during InitWrite (may be - * called before bring-up). Without it the chip uses its efuse-calibrated - * power. If the device is already brought up, apply immediately. */ - _tx_pwr_override = power; - if (_coex_thread.joinable()) - _radioManagement.set_tx_power_ref(power); +/* Re-program TXAGC from the current knob state (see header). The 0x41e8 + * TX+RX quirk is 8822E-specific, so the skip flag is derived here — once — + * from _rx_wanted AND the variant; the 8822C keeps its path-B ref writes. */ +void RtlJaguar3Device::apply_tx_power_current(bool full) { + const int off = _tx_pwr_offset_steps; + const int flat = _tx_pwr_override; + const bool skip_b = + _rx_wanted && _variant == jaguar3::ChipVariant::C8822E; + _txpwr_sat_low = false; + _txpwr_sat_high = false; + auto clamp127 = [&](int v) -> uint8_t { + if (v < 0) { + v = 0; + _txpwr_sat_low = true; + } + if (v > 0x7f) { + v = 0x7f; + _txpwr_sat_high = true; + } + return static_cast(v); + }; + + if (flat >= 0) { + /* Flat semantics: reference = flat + offset, per-rate diffs zeroed (once — + * offset-only steps skip the 32-dword re-zero). */ + _radioManagement.set_tx_power_ref(clamp127(flat + off), + /*zero_diffs=*/!_diffs_zeroed, skip_b); + _diffs_zeroed = true; + return; + } + + if (_variant == jaguar3::ChipVariant::C8822E) { + /* Default TX power (8822e). The TXAGC reference base is the per-channel + * power index the kernel programs, from the efuse power-by-rate table + * (logical 0x22 path A / 0x4C path B, per channel-group); when the byte is + * valid (<= txgi_max 127) it is used verbatim for kernel-faithful + * per-channel power. On bare modules the byte is unprogrammed (0xFF) and + * devourer falls back to JAGUAR3_TXPWR_REF_BASE — an empirically + * on-air-matched default that is not below the kernel's. The per-rate + * diffs come from the BB phy_reg_pg table (apply_power_by_rate_8822e); + * full TSSI-offset power-by-rate is a follow-up. The derived base refs + * are cached so an offset-only step is just the light ref writes. */ + if (full || !_pwr_ref_valid) { + uint8_t efuse_a = 0xFF, efuse_b = 0xFF; + _hal.read_efuse_txpwr_base_8822e(_channel.Channel, efuse_a, efuse_b); + _pwr_ref_a = (efuse_a <= 127) ? efuse_a : JAGUAR3_TXPWR_REF_BASE; + _pwr_ref_b = (efuse_b <= 127) ? efuse_b : JAGUAR3_TXPWR_REF_BASE; + _pwr_ref_valid = true; + } + const uint8_t ra = clamp127(static_cast(_pwr_ref_a) + off); + const uint8_t rb = clamp127(static_cast(_pwr_ref_b) + off); + if (full || _diffs_zeroed) { + /* Full apply: refs + the per-rate diff walk (also the path back from + * flat semantics, which zeroed the diffs). */ + _radioManagement.apply_power_by_rate_8822e(_channel.Channel, ra, rb, + skip_b); + _diffs_zeroed = false; + if (skip_b) + _logger->info("Jaguar3(8822e): TX+RX mode — path-B OFDM TXAGC ref left " + "at table default (keeps RX alive)"); + } else { + _radioManagement.apply_tx_power_refs_8822e(ra, rb, skip_b); + } + return; + } + + /* 8822c (CU): preserve the flat reference the demo used to impose (40) so + * the SDR-validated CU TX power is unchanged. The CU's efuse-calibrated + * per-rate default is a follow-up; the offset shifts this reference. */ + _radioManagement.set_tx_power_ref( + clamp127(static_cast(JAGUAR3_TXPWR_REF_BASE_8822C) + off), + /*zero_diffs=*/!_diffs_zeroed, skip_b); + _diffs_zeroed = true; +} + +devourer::TxPowerCaps RtlJaguar3Device::GetTxPowerCaps() { + devourer::TxPowerCaps caps; + caps.supported = true; + caps.index_max = 127; + caps.step_qdb = 1; /* 0.25 dB per reference step */ + /* On-air slope (tests/txpwr_offset_onair.sh): 8822CU 0.325 dB/idx @ ch36 + * (classifies at the 0.25 nominal, rms 0.60). The 8822EU measures ~0.55 + * dB/idx with an accelerating curve (0.3 low range -> 0.9 around ref 72) — + * its TSSI/kfree trims reshape the ref->power transfer, so one reference + * step is NOT a constant 0.25 dB there and the measured flag stays false: + * a controller should calibrate its own dB-per-step on the E (or lean on + * GetTxPowerState readback + the ground's RSSI). */ + caps.step_measured = _variant == jaguar3::ChipVariant::C8822C; + caps.offset_min_qdb = -127; + caps.offset_max_qdb = 127; + return caps; +} + +int RtlJaguar3Device::SetTxPowerOffsetQdb(int qdb) { + if (_cw_active) { + _logger->warn("SetTxPowerOffsetQdb refused: CW tone active (TXAGC does " + "not modulate a bare LO carrier)"); + return 0; + } + int steps = 0; + const int applied = devourer::quantize_offset_qdb(qdb, GetTxPowerCaps(), + &steps); + _tx_pwr_offset_steps = steps; + if (_brought_up) { + std::lock_guard lk(_reg_mu); + apply_tx_power_current(/*full=*/false); + } + _logger->info("TX-power offset: {} qdB requested -> {} qdB applied " + "({} steps){}", + qdb, applied, steps, + _brought_up ? "" : " (recorded; applies at bring-up)"); + return applied; +} + +void RtlJaguar3Device::SetTxPowerIndexOverride(int idx) { + if (_cw_active) { + _logger->warn("SetTxPowerIndexOverride refused: CW tone active"); + return; + } + _tx_pwr_override = idx < 0 ? -1 : (idx > 0x7f ? 0x7f : idx); + if (_brought_up) { + std::lock_guard lk(_reg_mu); + /* Clearing back to the efuse baseline needs the full apply (the flat + * override zeroed the 8822E per-rate diffs). */ + apply_tx_power_current(/*full=*/idx < 0); + } +} + +bool RtlJaguar3Device::ReApplyTxPower() { + if (!_brought_up || _cw_active) + return false; + std::lock_guard lk(_reg_mu); + apply_tx_power_current(/*full=*/true); + return true; +} + +devourer::TxPowerState RtlJaguar3Device::GetTxPowerState() { + devourer::TxPowerState s; + s.valid = true; + s.flat_index = static_cast(_tx_pwr_override.load()); + s.offset_steps = static_cast(_tx_pwr_offset_steps.load()); + s.offset_qdb = s.offset_steps; /* 1 qdB per step on Jaguar3 */ + s.saturated_low = _txpwr_sat_low; + s.saturated_high = _txpwr_sat_high; + if (_brought_up && !_cw_active) { + /* Reference readback (the Jaguar3 TXAGC refs ARE readable): OFDM ref + * 0x18e8[16:10]; MCS7 is the per-rate diff table's zero anchor, so it + * equals the OFDM ref; CCK ref 0x18a0[22:16]. Under _reg_mu for a + * coherent snapshot vs the coex tick. */ + std::lock_guard lk(_reg_mu); + const uint32_t ofdm = (_device.rtw_read32(0x18e8) >> 10) & 0x7f; + s.ofdm_index = static_cast(ofdm); + s.mcs7_index = static_cast(ofdm); + s.cck_index = + static_cast((_device.rtw_read32(0x18a0) >> 16) & 0x7f); + s.hw_readback = true; + } + return s; +} + +devourer::ThermalStatus RtlJaguar3Device::GetThermalStatus() { + devourer::ThermalStatus t; + if (!_brought_up || _cw_active) + return t; /* RF reads need a live, non-CW-held chip */ + uint8_t raw = 0, baseline = 0xFF; + bool ok; + { + /* The meter trigger is an RF 0x42 RMW — the same register the coex + * tick's pwr_track toggles. */ + std::lock_guard lk(_reg_mu); + ok = _hal.read_thermal(raw, baseline); + } + t.raw = raw; + t.baseline = baseline; + t.valid = ok && baseline != 0xFF; + t.delta = t.valid ? static_cast(raw) - static_cast(baseline) : 0; + return t; } bool RtlJaguar3Device::send_packet(const uint8_t *packet, size_t length) { diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index 5f32fde..228edcf 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -59,10 +59,30 @@ class RtlJaguar3Device : public IRtlDevice { * sanctioned in-session hop primitive (unlike a bare SetMonitorChannel). */ void FastRetune(uint8_t channel, bool cache_rf) override; void InitWrite(SelectedChannel channel) override; - void SetTxPower(uint8_t power) override; bool send_packet(const uint8_t *packet, size_t length) override; SelectedChannel GetSelectedChannel() override; void Stop() override; + + /* Runtime TX-power control (IRtlDevice contract; see src/TxPower.h). + * Jaguar3 caps: 7-bit TXAGC reference, 0.25 dB (1 qdB) per step. The offset + * shifts the per-path reference anchor (0x18e8/0x41e8 OFDM, 0x18a0/0x41a0 + * CCK) — the 0x3a00 per-rate diff table is offset-invariant, so a live step + * is ~8 gated register writes (apply_tx_power_refs_8822e / a diffs-kept + * set_tx_power_ref) under _reg_mu, serialized against the coex tick's + * pwr_track (which RMWs the [7:0] thermal field of the SAME 0x18a0/0x41a0 + * dwords — field-disjoint, so thermal compensation and the offset compose). + * The 8822E TX+RX 0x41e8 quirk is enforced structurally: every ref write + * takes skip_path_b_ofdm_ref from _rx_wanted. A full SetMonitorChannel + * re-folds the knobs against the new channel group's efuse refs (gated on a + * knob being active); FastRetune never touches TXAGC. GetThermalStatus + * reads RF 0x42[6:1] via the calibration impl (efuse baseline on the E, + * first-read cold reference on the C). */ + devourer::TxPowerCaps GetTxPowerCaps() override; + int SetTxPowerOffsetQdb(int qdb) override; + void SetTxPowerIndexOverride(int idx) override; + bool ReApplyTxPower() override; + devourer::TxPowerState GetTxPowerState() override; + devourer::ThermalStatus GetThermalStatus() 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 @@ -111,10 +131,35 @@ class RtlJaguar3Device : public IRtlDevice { jaguar3::RadioManagementJaguar3 _radioManagement; SelectedChannel _channel{}; Action_ParsedRadioPacket _packetProcessor = nullptr; - /* Optional flat TXAGC override from SetTxPower(); -1 = use the chip's - * efuse-calibrated power. Applied during InitWrite (so it may be set before - * the device is brought up). */ - int _tx_pwr_override = -1; + /* Runtime TX-power knobs (atomic so GetTxPowerState's cached snapshot is + * readable cross-thread). Flat override -1 = the chip's efuse-calibrated + * power; offset in 0.25 dB reference steps. Applied live under _reg_mu once + * brought up; recorded and folded at InitWrite before. */ + std::atomic _tx_pwr_override{-1}; + std::atomic _tx_pwr_offset_steps{0}; + /* Rail-hit flags from the last apply (references clamped at 0/0x7f). */ + std::atomic _txpwr_sat_low{false}; + std::atomic _txpwr_sat_high{false}; + /* Bring-up completion: gates the live apply (+ _reg_mu use) in the setters. */ + bool _brought_up = false; + /* TX+RX intent (DEVOURER_TX_WITH_RX at InitWrite / an RX-side Init): + * consumed as skip_path_b_ofdm_ref by EVERY TXAGC ref write, so no offset + * churn can ever touch 0x41e8 while RX is alive (the 8822E RX-desense + * quirk is enforced structurally, not by call-site discipline). */ + bool _rx_wanted = false; + /* Cached 8822E per-channel-group efuse base refs (the values InitWrite + * derived, incl. the 0x4b fallback) so an offset-only step recomputes + * effective refs without re-deriving; invalidated by a channel change. */ + uint8_t _pwr_ref_a = 0, _pwr_ref_b = 0; + bool _pwr_ref_valid = false; + /* True while the 0x3a00 per-rate diff table is zeroed (flat semantics / + * 8822C default) — repeated flat steps then skip the 32-dword re-zero. */ + bool _diffs_zeroed = false; + /* Re-program TXAGC from the current knob state. full=true re-derives the + * 8822E efuse refs + rewrites the per-rate diff table (bring-up / channel + * change / flat<->efuse transitions); full=false is the light offset step. + * Caller holds _reg_mu when the coex thread may be running. */ + void apply_tx_power_current(bool full); /* Runtime TX-mode default (SetTxMode/ClearTxMode). */ std::optional _tx_mode_default; diff --git a/tests/link_probe.sh b/tests/link_probe.sh index 63f1123..9f82013 100755 --- a/tests/link_probe.sh +++ b/tests/link_probe.sh @@ -5,8 +5,8 @@ # histogram at each step. tests/link_probe.py aligns the two by wall-clock and # reports the margin-vs-lever curve + the operating point that meets a target: # power axis — the cheapest power that clears the SNR floor (least power that -# holds the link); the emitter must be a Jaguar1 part (the TXAGC ramp -# DEVOURER_TX_PWR_START/STOP/STEP is Jaguar1-only). +# holds the link); works on any emitter generation (the TXAGC ramp +# DEVOURER_TX_PWR_START/STOP/STEP rides the runtime TX-power API). # mcs axis — the highest rate whose ground SNR clears the floor (ride the # fastest modulation the link holds); works on any emitter generation. # The ground can be any generation. Two adapters, no SDR. The emitter's PA thermal @@ -56,7 +56,7 @@ while [ $# -gt 0 ]; do done [ "$AXIS" = power ] || [ "$AXIS" = mcs ] || { echo "--axis power|mcs" >&2; exit 2; } [ -n "$EMIT_PID" ] && [ -n "$GROUND_PID" ] || { - echo "need --emit-pid (Jaguar1) and --ground-pid" >&2; exit 2; } + echo "need --emit-pid and --ground-pid" >&2; exit 2; } # Host-side line timestamper (no moreutils dependency). TS='import sys,time diff --git a/tests/sdr_power_probe.py b/tests/sdr_power_probe.py index ea08886..63c3f09 100755 --- a/tests/sdr_power_probe.py +++ b/tests/sdr_power_probe.py @@ -51,6 +51,14 @@ def main() -> int: 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)") + ap.add_argument("--gated", action="store_true", + help="also report gated_dbfs: mean power of the 256-sample " + "blocks above noise+margin — duty-independent (frames " + "gate on, idle gaps gate off), so a framed feed reads " + "the same as continuous TX") + ap.add_argument("--gate-margin-db", type=float, default=10.0, + help="gate threshold above the window's 10th-percentile " + "block power (default 10 dB)") args = ap.parse_args() window = args.window if args.window > 0 else max(1024, int(args.rate / 20)) @@ -82,6 +90,8 @@ def main() -> int: acc_sumsq = 0.0 acc_n = 0 + block_pows: list[float] = [] # per-256-sample block mean-squares (--gated) + GATE_BLK = 256 t_end = time.monotonic() + args.duration if args.duration > 0 else None try: while True: @@ -100,11 +110,43 @@ def main() -> int: acc_sumsq += float(np.sum((samples.real.astype(np.float64) ** 2) + (samples.imag.astype(np.float64) ** 2))) acc_n += n + if args.gated: + m = n - (n % GATE_BLK) + if m > 0: + blocks = samples[:m].reshape(-1, GATE_BLK).astype(np.complex128) + # Per-block DC removal: the B210's DC-offset residual sits + # at band center (where a center-tuned DUT also sits) and + # reads as a constant ~-42 dBFS pedestal that ignores RX + # gain — measured pinning the low cells of a TXAGC sweep. + # OFDM carries no energy at DC, so subtracting the block + # mean removes the spur without touching signal power. + blocks = blocks - blocks.mean(axis=1, keepdims=True) + p = (np.abs(blocks) ** 2).mean(axis=1) + block_pows.extend(p.tolist()) 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}", + extra = "" + if args.gated and block_pows: + arr = np.asarray(block_pows) + lo = float(np.percentile(arr, 10)) + hi = float(arr.max()) + if lo <= 0 or hi / max(lo, 1e-30) < 2.0: + # Uniform window (all-on continuous TX, or all-idle): + # nothing to gate — the plain mean IS the level. + g_meansq, frac = meansq, 1.0 + else: + thr = lo * (10.0 ** (args.gate_margin_db / 10.0)) + on = arr[arr > thr] + if on.size: + g_meansq, frac = float(on.mean()), on.size / arr.size + else: + g_meansq, frac = meansq, 1.0 + g_dbfs = 10.0 * math.log10(g_meansq) if g_meansq > 0 else -200.0 + extra = f" gated_dbfs={g_dbfs:.2f} gated_frac={frac:.3f}" + block_pows.clear() + print(f"sdr-power dbfs={dbfs:.2f} rms={rms:.6f} n={acc_n}{extra}", flush=True) acc_sumsq = 0.0 acc_n = 0 diff --git a/tests/txpower_quant_selftest.cpp b/tests/txpower_quant_selftest.cpp new file mode 100644 index 0000000..0dcc3cf --- /dev/null +++ b/tests/txpower_quant_selftest.cpp @@ -0,0 +1,76 @@ +/* Headless guard for the TX-power offset quantization (src/TxPower.h) — the + * qdB -> index-step rounding (round-nearest, ties away from zero) and range + * clamping behind SetTxPowerOffsetQdb, so a rounding regression fails `ctest` + * instead of only surfacing as a wrong on-air level. Prints the failing case + * and exits nonzero. */ +#include + +#include "TxPower.h" + +static int g_fail = 0; + +static void expect(const devourer::TxPowerCaps &caps, int qdb, int want_qdb, + int want_steps) { + int steps = -9999; + const int got = devourer::quantize_offset_qdb(qdb, caps, &steps); + if (got == want_qdb && steps == want_steps) + return; + ++g_fail; + std::printf("FAIL: step=%u range=[%d,%d] qdb=%d -> (%d qdb, %d steps) " + "want (%d qdb, %d steps)\n", + caps.step_qdb, caps.offset_min_qdb, caps.offset_max_qdb, qdb, + got, steps, want_qdb, want_steps); +} + +int main() { + /* Jaguar1/Jaguar2-shaped caps: 6-bit index, 0.5 dB (2 qdB) per step. */ + devourer::TxPowerCaps half{}; + half.supported = true; + half.index_max = 63; + half.step_qdb = 2; + half.offset_min_qdb = -126; + half.offset_max_qdb = 126; + + expect(half, 0, 0, 0); + expect(half, 2, 2, 1); /* exact step */ + expect(half, -2, -2, -1); + expect(half, 1, 2, 1); /* tie (0.25 dB from both) -> away from zero */ + expect(half, -1, -2, -1); + expect(half, 3, 4, 2); /* 0.75 dB tie -> 1.0 dB */ + expect(half, -3, -4, -2); + expect(half, 4, 4, 2); + expect(half, -24, -24, -12); /* the -6 dB validation cell */ + expect(half, 126, 126, 63); /* rails */ + expect(half, 127, 126, 63); + expect(half, 200, 126, 63); + expect(half, -200, -126, -63); + + /* Jaguar3-shaped caps: 7-bit reference, 0.25 dB (1 qdB) per step. */ + devourer::TxPowerCaps quarter{}; + quarter.supported = true; + quarter.index_max = 127; + quarter.step_qdb = 1; + quarter.offset_min_qdb = -127; + quarter.offset_max_qdb = 127; + + expect(quarter, 0, 0, 0); + expect(quarter, 1, 1, 1); /* native quarter-dB, no rounding */ + expect(quarter, -1, -1, -1); + expect(quarter, 24, 24, 24); + expect(quarter, -24, -24, -24); + expect(quarter, 127, 127, 127); + expect(quarter, 200, 127, 127); + expect(quarter, -200, -127, -127); + + /* Unsupported caps: everything is a 0-applied no-op. */ + devourer::TxPowerCaps none{}; + expect(none, 24, 0, 0); + expect(none, -24, 0, 0); + + if (g_fail) { + std::printf("%d failure(s)\n", g_fail); + return 1; + } + std::printf("txpower quantization selftest: all OK\n"); + return 0; +} diff --git a/tests/txpwr_offset_onair.sh b/tests/txpwr_offset_onair.sh new file mode 100755 index 0000000..a71c512 --- /dev/null +++ b/tests/txpwr_offset_onair.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# On-air dB-per-step slope of the TXAGC index, per family — the over-air half +# of the runtime TX-power API validation. tests/txpwr_offset_regcheck.sh +# proves an offset moves the registers by exactly N index steps; this measures +# what one index step is WORTH on air, classifying each family's step against +# the nominal 0.5 dB (Jaguar1/2, 6-bit) vs 0.25 dB (Jaguar3, 7-bit ref). +# Offset truth follows by composition: -24 qdB = the measured slope x the +# register-verified step count. +# +# SENSOR: a second devourer adapter (ground station) reporting per-frame RSSI +# (dB-linear on Realtek phystatus) for the DUT's canonical-SA beacons — NOT +# the B210. Measured on this bench: with the antennas inches apart the B210's +# front end hard-limits on the DUT's frames at ANY RX gain (idx 8 and idx 56 +# both read -56.9 dBFS at gain 0), so wideband SDR power cannot see the TXAGC +# slope at all; a WiFi chip's AGC is built for exactly this input range. +# +# Method: one FIXED-INDEX TxDemo process per point (DEVOURER_TX_PWR — the flat +# override riding the runtime API), 6 points spanning the family's range, the +# ground's median per-frame RSSI per cell, least-squares slope. Default cell +# channel is 5 GHz ch36, per-DUT overrides in the table (canonical-SA +# filtering keeps the ground blind to ambient frames either way). +# +# Usage: sudo -v && tests/txpwr_offset_onair.sh [PID ...] (default: plugged set) +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${TXPWR_ONAIR_OUT:-/tmp/devourer-txpwr-onair}" +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 + +echo "== building ==" +cmake --build "$ROOT/build" -j --target WiFiDriverTxDemo WiFiDriverDemo >/dev/null || exit 1 + +# DUT table: pid vid ramp_start ramp_stop nominal_db_per_step channel +# - 8821AU runs at ch6: its 5 GHz chain IGNORES BB TXAGC (measured flat at +# ch36 across two grounds while registers move; 0.50 dB/idx exactly at +# 2.4 GHz) — the power lever is 2.4 GHz-only on that part. +# - 8822EU nominal "tssi": its TSSI/kfree trims reshape the ref->power +# transfer (measured 0.3->0.9 dB/idx across the range, ~0.55 avg), so the +# cell asserts a working monotone lever (slope 0.15..1.0) instead of a +# fixed step classification. +DUTS=( + "0x8812 0x0bda 8 56 0.5 36" # RTL8812AU (Jaguar1) + "0x0120 0x2357 8 56 0.5 6" # RTL8821AU (Jaguar1, 2.4G-only lever) + "0x8813 0x0bda 8 56 0.5 36" # RTL8814AU (Jaguar1) + "0x012d 0x2357 8 56 0.5 36" # RTL8822BU (Jaguar2) + "0xc811 0x0bda 8 56 0.5 36" # RTL8821CU (Jaguar2) + "0xc812 0x0bda 24 104 0.25 36" # RTL8822CU (Jaguar3) + "0xa81a 0x0bda 24 104 tssi 36" # RTL8822EU (Jaguar3, TSSI-reshaped) +) +plugged() { lsusb -d "$(printf '%04x:%04x' "$2" "$1")" >/dev/null 2>&1; } + +# Ground-station preference: 8822CU (RX-proven J3), else 8812AU — first +# plugged one that is not the DUT. +pick_ground() { # $1=dut_pid -> "pid vid" or "" + for g in "0xc812 0x0bda" "0x8812 0x0bda" "0x012d 0x2357"; do + read -r gp gv <<<"$g" + [ "$gp" = "$1" ] && continue + plugged "$gp" "$gv" && { echo "$g"; return; } + done +} + +for dut in "${DUTS[@]}"; do + read -r PID VID START STOP NOMINAL DCH <<<"$dut" + if [ "$#" -gt 0 ]; then + want=0; for p in "$@"; do [ "$p" = "$PID" ] && want=1; done + [ "$want" = "1" ] || continue + fi + if ! plugged "$PID" "$VID"; then + skip "$PID@$VID not plugged" + continue + fi + ground="$(pick_ground "$PID")" + if [ -z "$ground" ]; then + skip "$PID: no ground-station adapter available" + continue + fi + read -r GPID GVID <<<"$ground" + tag="${PID#0x}" + CH="$DCH" + echo "== DUT $PID@$VID (ground $GPID): ch$CH cells $START..$STOP (nominal $NOMINAL dB/idx) ==" + + # Ground RX for the whole DUT session, stream lines epoch-stamped. + : >"$OUT/$tag-ground.log" + sudo -n env DEVOURER_PID="$GPID" DEVOURER_VID="$GVID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_STREAM_OUT=1 \ + stdbuf -oL timeout 300 "$ROOT/build/WiFiDriverDemo" 2>"$OUT/$tag-ground.err" \ + | while IFS= read -r line; do + printf '%s %s\n' "$(date +%s.%N)" "$line" + done >>"$OUT/$tag-ground.log" & + GROUND_JOB=$! + sleep 12 # ground bring-up + + span=$((STOP - START)) + idxs="" + for k in 0 1 2 3 4 5; do idxs="$idxs $((START + span * k / 5))"; done + : >"$OUT/$tag-cells.txt" + for idx in $idxs; do + t0="$(date +%s.%N)" + sudo -n env DEVOURER_PID="$PID" DEVOURER_VID="$VID" \ + DEVOURER_CHANNEL="$CH" DEVOURER_TX_PWR="$idx" \ + DEVOURER_TX_GAP_US=2000 \ + timeout 14 "$ROOT/build/WiFiDriverTxDemo" >"$OUT/$tag-cell$idx.log" 2>&1 || true + t1="$(date +%s.%N)" + echo "$idx $t0 $t1" >>"$OUT/$tag-cells.txt" + sleep 2 + done + sudo -n pkill -x WiFiDriverDemo 2>/dev/null + wait "$GROUND_JOB" 2>/dev/null + + # Slope fit: per cell, median ground RSSI (chain A) of the canonical-SA + # stream lines in [t0+6, t1-1] (bring-up transmits nothing at first). + python3 - "$OUT/$tag-ground.log" "$OUT/$tag-cells.txt" "$NOMINAL" >"$OUT/$tag-fit.txt" 2>&1 <<'PYEOF' +import re, statistics, sys +ground_log, cells_txt = sys.argv[1], sys.argv[2] +tssi = sys.argv[3] == "tssi" +nominal = None if tssi else float(sys.argv[3]) +frames = [] +rx = re.compile(r"^([0-9.]+) .*.*\brssi=(-?\d+),(-?\d+)") +for line in open(ground_log, errors="replace"): + m = rx.match(line) + if m: + frames.append((float(m.group(1)), int(m.group(2)))) +pts = [] +for line in open(cells_txt): + idx, t0, t1 = line.split() + idx, t0, t1 = int(idx), float(t0), float(t1) + vals = [r for (t, r) in frames if t0 + 6 <= t <= t1 - 1] + if len(vals) >= 20: + pts.append((idx, statistics.median(vals))) +if len(pts) < 5: + print(f"RESULT skip pts={len(pts)} (ground caught too few beacons)") + sys.exit(0) +n = len(pts) +sx = sum(p[0] for p in pts); sy = sum(p[1] for p in pts) +sxx = sum(p[0] ** 2 for p in pts); sxy = sum(p[0] * p[1] for p in pts) +slope = (n * sxy - sx * sy) / (n * sxx - sx * sx) +resid = [y - (sy / n + slope * (x - sx / n)) for x, y in pts] +rms = (sum(r * r for r in resid) / n) ** 0.5 +span_db = slope * (pts[-1][0] - pts[0][0]) +detail = " ".join(f"{x}:{y:.1f}" for x, y in pts) +if tssi: + ok = 0.15 <= slope <= 1.0 + print(f"RESULT slope={slope:.3f} dB/idx (TSSI-reshaped lever; monotone " + f"0.15..1.0) rms_resid={rms:.2f} dB span={span_db:.1f} dB pts={n} [{detail}]") + sys.exit(0 if ok else 1) +klass = 0.5 if abs(slope - 0.5) < abs(slope - 0.25) else 0.25 +print(f"RESULT slope={slope:.3f} dB/idx (nominal {nominal}, classified {klass}) " + f"rms_resid={rms:.2f} dB span={span_db:.1f} dB pts={n} [{detail}]") +sys.exit(0 if klass == nominal and rms < 2.5 else 1) +PYEOF + rc=$? + res="$(grep RESULT "$OUT/$tag-fit.txt" | tail -1)" + echo " $res" + case "$res" in + *skip*) skip "$PID slope ($res)" ;; + *) if [ $rc -eq 0 ]; then + pass "$PID $res" + else + fail "$PID $res" + fi ;; + esac +done + +echo +echo "== txpwr-offset on-air: PASS=$PASS FAIL=$FAIL SKIP=$SKIP ==" +[ "$FAIL" -eq 0 ] diff --git a/tests/txpwr_offset_regcheck.sh b/tests/txpwr_offset_regcheck.sh new file mode 100755 index 0000000..2618433 --- /dev/null +++ b/tests/txpwr_offset_regcheck.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# Register-level validation of the runtime TX-power API (IRtlDevice:: +# SetTxPowerOffsetQdb / SetTxPowerIndexOverride / GetTxPowerState). +# +# Cells per plugged DUT (skip-if-unplugged, PASS/FAIL/SKIP tally like +# tone_mask_regcheck.sh): +# +# parity offset untouched => the bring-up TXAGC canary is byte-identical +# to the master build's (the key no-regression invariant). Needs +# a master worktree build (cached at $MASTER_BUILD, built on first +# run). Skipped with SKIP_PARITY=1. +# move offset -24 qdB moves every representative per-rate index down by +# exactly 24/step_qdb steps (12 on Jaguar1/2, 24 on Jaguar3). +# rails offset -200/+200 qdB clamps to all-0 / all-max with the +# saturated_low/high flags set. +# sticky offset -24 qdB, then a full SetMonitorChannel to another channel +# group: the offset re-folds against the NEW channel's table +# (post-switch indices == that channel's no-offset baseline - steps); +# then a FastRetune: TXAGC registers untouched (indices unchanged). +# +# Usage: sudo -v && tests/txpwr_offset_regcheck.sh [PID ...] +# (default: every supported plugged PID) +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${TXPWR_REGCHECK_OUT:-/tmp/devourer-txpwr-regcheck}" +MASTER_BUILD="${MASTER_BUILD:-/tmp/devourer-master-build}" +STEP_DEMO="$ROOT/build/TxPowerStepDemo" +TX_DEMO="$ROOT/build/WiFiDriverTxDemo" +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 TxPowerStepDemo 2>/dev/null || true + pkill -x WiFiDriverTxDem 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +echo "== building ==" +cmake --build "$ROOT/build" -j --target TxPowerStepDemo WiFiDriverTxDemo >/dev/null || exit 1 + +# DUT table: pid vid ch_a ch_b family +# ch_a/ch_b are same-band channels in different efuse channel groups so the +# sticky cell proves the re-fold uses the new group's base. +DUTS=( + "0x8812 0x0bda 6 11 jaguar1" # RTL8812AU + "0x0120 0x2357 6 11 jaguar1" # Archer T2U Plus (RTL8821AU) + "0x8813 0x0bda 6 11 jaguar1" # RTL8814AU (write-only TXAGC: shadow rb=0) + "0x012d 0x2357 6 11 jaguar2" # Archer T3U (RTL8822BU) + "0xc811 0x0bda 6 11 jaguar2" # RTL8821CU + "0xc812 0x0bda 36 149 jaguar3" # RTL8812CU/8822CU + "0xa81a 0x0bda 36 149 jaguar3" # RTL8812EU/8822EU +) + +plugged() { lsusb -d "$(printf '%04x:%04x' "$2" "$1")" >/dev/null 2>&1; } + +# --- helpers --------------------------------------------------------------- +# Extract field F from the Nth line of a log. +state_field() { # $1=log $2=line-index(1-based) $3=field + awk -v n="$2" -v f="$3" '// { + c++; if (c==n) { sub(/^.*/,""); + for (i=1;i<=NF;i++) { split($i,kv,"="); + if (kv[1]==f) print kv[2]; } } }' "$1" | tr -d '\r' +} +caps_field() { # $1=log $2=field + awk -v f="$2" '// { sub(/^.*/,""); + for (i=1;i<=NF;i++) { split($i,kv,"="); + if (kv[1]==f) { print kv[2]; exit } } }' "$1" | tr -d '\r' +} + +run_step_demo() { # $1=outfile, rest = args + local out="$1"; shift + sudo -n timeout 90 "$STEP_DEMO" "$@" >"$out" 2>&1 +} + +last_canary() { # $1 = log file (same extractor as hop_parity_check.sh) + awk '/=== DEVOURER_DUMP_CANARY/{buf=""} {buf=buf $0 "\n"} + /=== END DEVOURER_DUMP_CANARY/{last=buf} END{printf "%s", last}' "$1" \ + | grep -oE "(BB|MAC|RF\[[AB]\]) 0x[0-9a-fA-F]+ = 0x[0-9A-F]+" \ + | sort -u +} + +# Run-variant registers excluded from the parity diff (same list the +# hop-parity canary uses): TSF, IQK/RxIQC measurement jitter, live AGC / +# thermal RF words (RF 0x00 / 0x42 — hop_parity_check.sh's LIVE list), and +# the 8822E RXBB word RF 0x1a — its bit 18 latches prior-session residue +# (the chip retains state across soft re-init; A/B/A/B standalone runs of +# both builds read identically, only the in-script cell ordering flips it). +PARITY_EXCLUDE='MAC 0x560|BB 0xc10|BB 0xc14|BB 0xe10|BB 0xe14|BB 0xc90|BB 0xc94|BB 0xe90|BB 0xe94|RF\[[AB]\] 0x00 |RF\[[AB]\] 0x42 |RF\[[AB]\] 0x1a ' + +ensure_master_build() { + [ -x "$MASTER_BUILD/WiFiDriverTxDemo" ] && return 0 + echo "== building master baseline (one-time, $MASTER_BUILD) ==" + local wt="/tmp/devourer-master-worktree" + git -C "$ROOT" worktree add --force "$wt" origin/master >/dev/null 2>&1 || return 1 + cmake -S "$wt" -B "$MASTER_BUILD" -DPKG_CONFIG_EXECUTABLE=/usr/bin/pkg-config >/dev/null 2>&1 || return 1 + cmake --build "$MASTER_BUILD" -j --target WiFiDriverTxDemo >/dev/null 2>&1 || return 1 +} + +run_canary() { # $1=binary $2=pid $3=vid $4=channel $5=outfile + sudo -n env DEVOURER_PID="$2" DEVOURER_VID="$3" DEVOURER_CHANNEL="$4" \ + DEVOURER_DUMP_CANARY=1 DEVOURER_TX_GAP_US=50000 \ + timeout 20 "$1" >"$5" 2>&1 || true +} + +# --- cells ------------------------------------------------------------------ +for dut in "${DUTS[@]}"; do + read -r PID VID CH_A CH_B FAMILY <<<"$dut" + name="$PID@$VID ($FAMILY)" + # Optional PID filter from argv. + if [ "$#" -gt 0 ]; then + want=0 + for p in "$@"; do [ "$p" = "$PID" ] && want=1; done + [ "$want" = "1" ] || continue + fi + if ! plugged "$PID" "$VID"; then + skip "$name not plugged" + continue + fi + echo "== DUT $name ==" + tag="${PID#0x}" + + # -- caps sanity + baselines -------------------------------------------- + base_a="$OUT/$tag-base-a.log" + run_step_demo "$base_a" --vid "$VID" --pid "$PID" --channel "$CH_A" + if [ "$(caps_field "$base_a" supported)" != "1" ]; then + skip "$name: TX-power API not wired for this family yet" + continue + fi + step_qdb="$(caps_field "$base_a" step_qdb)" + idx_max="$(caps_field "$base_a" max)" + steps24=$((24 / step_qdb)) + cck_a="$(state_field "$base_a" 1 cck)"; ofdm_a="$(state_field "$base_a" 1 ofdm)" + mcs7_a="$(state_field "$base_a" 1 mcs7)"; rb_a="$(state_field "$base_a" 1 rb)" + if [ -z "$ofdm_a" ] || [ "$ofdm_a" = "-1" ]; then + fail "$name: no baseline state readback" + continue + fi + echo " baseline ch$CH_A: cck=$cck_a ofdm=$ofdm_a mcs7=$mcs7_a rb=$rb_a (step_qdb=$step_qdb max=$idx_max)" + + base_b="$OUT/$tag-base-b.log" + run_step_demo "$base_b" --vid "$VID" --pid "$PID" --channel "$CH_B" + cck_b="$(state_field "$base_b" 1 cck)"; ofdm_b="$(state_field "$base_b" 1 ofdm)" + mcs7_b="$(state_field "$base_b" 1 mcs7)" + + # -- parity: offset untouched == master build (TXAGC canary) ------------- + if [ "${SKIP_PARITY:-0}" = "1" ]; then + skip "$name parity (SKIP_PARITY=1)" + elif ensure_master_build; then + run_canary "$MASTER_BUILD/WiFiDriverTxDemo" "$PID" "$VID" "$CH_A" "$OUT/$tag-canary-master.log" + run_canary "$TX_DEMO" "$PID" "$VID" "$CH_A" "$OUT/$tag-canary-new.log" + last_canary "$OUT/$tag-canary-master.log" | grep -Ev "$PARITY_EXCLUDE" >"$OUT/$tag-canary-master.regs" + last_canary "$OUT/$tag-canary-new.log" | grep -Ev "$PARITY_EXCLUDE" >"$OUT/$tag-canary-new.regs" + # Compare on the INTERSECTION of dumped addresses: this branch adds + # registers to the canary (e.g. the Jaguar3 TXAGC refs) that master + # doesn't dump — additions are reported, not failed; every register + # master dumps must be byte-identical. + rm -f "$OUT/$tag-common-master.regs" "$OUT/$tag-common-new.regs" + awk 'NR==FNR { m[$1" "$2]=$0; next } + ($1" "$2) in m { print m[$1" "$2] >> "'"$OUT/$tag-common-master.regs"'"; + print $0 >> "'"$OUT/$tag-common-new.regs"'" }' \ + "$OUT/$tag-canary-master.regs" "$OUT/$tag-canary-new.regs" + added=$(( $(wc -l <"$OUT/$tag-canary-new.regs") - $(wc -l <"$OUT/$tag-common-new.regs" 2>/dev/null || echo 0) )) + if [ ! -s "$OUT/$tag-canary-master.regs" ]; then + skip "$name parity (no canary block from master build — family lacks DUMP_CANARY?)" + elif diff -q "$OUT/$tag-common-master.regs" "$OUT/$tag-common-new.regs" >/dev/null 2>&1; then + pass "$name parity: canary byte-identical to master ($(wc -l <"$OUT/$tag-common-new.regs") regs common, $added new-only)" + else + fail "$name parity: canary diverged from master ($OUT/$tag-common-{master,new}.regs)" + diff "$OUT/$tag-common-master.regs" "$OUT/$tag-common-new.regs" | head -8 | sed 's/^/ /' + fi + # Jaguar2's canary omits the TXAGC block; the bring-up log carries the + # computed per-section indices — compare those (the fold refactor must + # be value-identical at offset 0). VHT lines are new-build-only (the + # approved 8822B VHT extension) and excluded. + if [ "$FAMILY" = "jaguar2" ]; then + # 8822B logs "TXAGC path N ..." per path; 8821C logs the vendor- + # formula line with the per-section bases + limits. VHT lines are + # new-build-only (the approved 8822B VHT extension) and excluded. + txagc_log() { grep -ohE "TXAGC path [01] ch[0-9]+ .*|per-rate TXAGC \(vendor formula\) .*" "$1" | grep -v VHT | sort -u; } + txagc_log "$OUT/$tag-canary-master.log" >"$OUT/$tag-txagc-master.txt" + txagc_log "$OUT/$tag-canary-new.log" >"$OUT/$tag-txagc-new.txt" + if [ ! -s "$OUT/$tag-txagc-master.txt" ]; then + skip "$name txagc-log parity (no TXAGC log lines from master)" + elif diff -q "$OUT/$tag-txagc-master.txt" "$OUT/$tag-txagc-new.txt" >/dev/null; then + pass "$name txagc-log parity: per-section indices identical to master" + else + fail "$name txagc-log parity: computed TXAGC diverged from master" + diff "$OUT/$tag-txagc-master.txt" "$OUT/$tag-txagc-new.txt" | head -6 | sed 's/^/ /' + fi + fi + else + skip "$name parity (master baseline build failed)" + fi + + # -- thermal plausibility -------------------------------------------------- + th="$OUT/$tag-thermal.log" + run_step_demo "$th" --vid "$VID" --pid "$PID" --channel "$CH_A" --thermal + raw="$(awk '// { sub(/^.*/,""); + for (i=1;i<=NF;i++) { split($i,kv,"="); if (kv[1]=="raw") { print kv[2]; exit } } }' "$th" | tr -d '\r')" + if [ -n "$raw" ] && [ "$raw" -ge 1 ] 2>/dev/null && [ "$raw" -le 63 ]; then + pass "$name thermal: raw=$raw plausible (baseline=$(awk '// { sub(/^.*/,""); for (i=1;i<=NF;i++) { split($i,kv,"="); if (kv[1]=="baseline") { print kv[2]; exit } } }' "$th"))" + else + fail "$name thermal: raw='$raw' implausible/missing" + fi + + # -- move: -24 qdB shifts every index by exactly steps24 ------------------ + move="$OUT/$tag-move.log" + run_step_demo "$move" --vid "$VID" --pid "$PID" --channel "$CH_A" \ + --offset-start -24 --offset-stop -24 + ofdm_m="$(state_field "$move" 2 ofdm)"; mcs7_m="$(state_field "$move" 2 mcs7)" + cck_m="$(state_field "$move" 2 cck)" + want_ofdm=$((ofdm_a - steps24)); want_mcs7=$((mcs7_a - steps24)) + want_cck=$((cck_a - steps24)); [ "$want_cck" -lt 0 ] && want_cck=0 + [ "$want_ofdm" -lt 0 ] && want_ofdm=0 + [ "$want_mcs7" -lt 0 ] && want_mcs7=0 + if [ "$ofdm_m" = "$want_ofdm" ] && [ "$mcs7_m" = "$want_mcs7" ] && [ "$cck_m" = "$want_cck" ]; then + pass "$name move: -24 qdB -> indices -$steps24 (cck $cck_a->$cck_m ofdm $ofdm_a->$ofdm_m mcs7 $mcs7_a->$mcs7_m)" + else + fail "$name move: want cck=$want_cck ofdm=$want_ofdm mcs7=$want_mcs7, got cck=$cck_m ofdm=$ofdm_m mcs7=$mcs7_m" + fi + + # -- rails ---------------------------------------------------------------- + lo="$OUT/$tag-rail-lo.log"; hi="$OUT/$tag-rail-hi.log" + run_step_demo "$lo" --vid "$VID" --pid "$PID" --channel "$CH_A" \ + --offset-start -200 --offset-stop -200 + run_step_demo "$hi" --vid "$VID" --pid "$PID" --channel "$CH_A" \ + --offset-start 200 --offset-stop 200 + if [ "$(state_field "$lo" 2 ofdm)" = "0" ] && [ "$(state_field "$lo" 2 satlo)" = "1" ]; then + pass "$name rail-low: all-0 + saturated_low" + else + fail "$name rail-low: ofdm=$(state_field "$lo" 2 ofdm) satlo=$(state_field "$lo" 2 satlo)" + fi + if [ "$(state_field "$hi" 2 ofdm)" = "$idx_max" ] && [ "$(state_field "$hi" 2 sathi)" = "1" ]; then + pass "$name rail-high: all-$idx_max + saturated_high" + else + fail "$name rail-high: ofdm=$(state_field "$hi" 2 ofdm) sathi=$(state_field "$hi" 2 sathi)" + fi + + # -- sticky: full switch re-folds, fast retune leaves TXAGC alone --------- + st="$OUT/$tag-sticky.log" + run_step_demo "$st" --vid "$VID" --pid "$PID" --channel "$CH_A" \ + --offset-start -24 --offset-stop -24 --switch-channel "$CH_B" --retune "$CH_A" + # line 2 = post-offset @CH_A, line 3 = post-SetMonitorChannel @CH_B, + # line 4 = post-FastRetune @CH_A (registers must still hold CH_B's values + # only if retune skipped TXAGC — i.e. line4 == line3). + off_sw="$(state_field "$st" 3 offset_qdb)" + ofdm_sw="$(state_field "$st" 3 ofdm)"; mcs7_sw="$(state_field "$st" 3 mcs7)" + want_ofdm_b=$((ofdm_b - steps24)); [ "$want_ofdm_b" -lt 0 ] && want_ofdm_b=0 + want_mcs7_b=$((mcs7_b - steps24)); [ "$want_mcs7_b" -lt 0 ] && want_mcs7_b=0 + if [ "$off_sw" = "-24" ] && [ "$ofdm_sw" = "$want_ofdm_b" ] && [ "$mcs7_sw" = "$want_mcs7_b" ]; then + pass "$name sticky-full: offset held across SetMonitorChannel and re-folded (ofdm $ofdm_b-$steps24=$ofdm_sw)" + else + fail "$name sticky-full: offset=$off_sw ofdm=$ofdm_sw (want -24 / $want_ofdm_b; ch$CH_B base $ofdm_b)" + fi + ofdm_rt="$(state_field "$st" 4 ofdm)"; mcs7_rt="$(state_field "$st" 4 mcs7)" + rb_rt="$(state_field "$st" 4 rb)" + if [ "$rb_rt" != "1" ]; then + skip "$name sticky-fast (no hw readback on this family/chip)" + elif [ "$ofdm_rt" = "$ofdm_sw" ] && [ "$mcs7_rt" = "$mcs7_sw" ]; then + pass "$name sticky-fast: FastRetune left TXAGC untouched" + else + fail "$name sticky-fast: ofdm $ofdm_sw->$ofdm_rt mcs7 $mcs7_sw->$mcs7_rt across FastRetune" + fi + + # -- 8822E TX+RX 0x41e8 quirk: no offset churn may write the path-B OFDM + # ref while RX is alive (any nonzero value desenses the EU's RX). + # Canary #1 dumps at the InitWrite channel-set (pre-power, BB-table + # default); canary #2 at --switch-channel still holds the FIRST + # channel's applied refs (the re-apply runs after the dump). TX-only: + # 0x41e8 ref must move between the dumps (written); TX+RX: it must be + # IDENTICAL (never written) while path A (0x18e8) moves in both. + if [ "$PID" = "0xa81a" ]; then + qref() { # $1=log $2=reg $3=which(1=first,2=last) -> 7-bit ref field + python3 - "$1" "$2" "$3" <<'PYEOF' +import re, sys +log, reg, which = sys.argv[1], sys.argv[2].lower(), int(sys.argv[3]) +vals = [int(m.group(1), 16) for m in + re.finditer(r"BB 0x0*" + reg.replace("0x", "") + r" = 0x([0-9A-Fa-f]+)", + open(log).read())] +if not vals: + print("nan"); sys.exit(0) +v = vals[0] if which == 1 else vals[-1] +print((v >> 10) & 0x7f) +PYEOF + } + qt="$OUT/$tag-quirk-txonly.log"; qr="$OUT/$tag-quirk-txrx.log" + sudo -n env DEVOURER_DUMP_CANARY=1 timeout 90 "$STEP_DEMO" \ + --vid "$VID" --pid "$PID" --channel "$CH_A" \ + --offset-start -24 --offset-stop -24 --switch-channel "$CH_B" \ + >"$qt" 2>&1 + sudo -n env DEVOURER_DUMP_CANARY=1 DEVOURER_TX_WITH_RX=thread \ + timeout 90 "$STEP_DEMO" \ + --vid "$VID" --pid "$PID" --channel "$CH_A" \ + --offset-start -24 --offset-stop -24 --switch-channel "$CH_B" \ + >"$qr" 2>&1 + b1_txonly="$(qref "$qt" 41e8 1)"; b2_txonly="$(qref "$qt" 41e8 2)" + b1_txrx="$(qref "$qr" 41e8 1)"; b2_txrx="$(qref "$qr" 41e8 2)" + a1_txrx="$(qref "$qr" 18e8 1)"; a2_txrx="$(qref "$qr" 18e8 2)" + if [ "$b1_txrx" = "nan" ] || [ "$a1_txrx" = "nan" ]; then + fail "$name quirk-41e8: no canary refs in TX+RX run" + elif [ "$b1_txrx" != "$b2_txrx" ]; then + fail "$name quirk-41e8: TX+RX run WROTE 0x41e8 ($b1_txrx -> $b2_txrx) — EU RX desense" + elif [ "$a1_txrx" = "$a2_txrx" ]; then + fail "$name quirk-41e8: TX+RX run never applied path-A power (0x18e8 $a1_txrx unchanged)" + elif [ "$b1_txonly" = "$b2_txonly" ]; then + fail "$name quirk-41e8: TX-only run did NOT write 0x41e8 ($b1_txonly) — path-B power missing" + else + pass "$name quirk-41e8: TX+RX kept 0x41e8 at table default ($b1_txrx) while path A moved ($a1_txrx->$a2_txrx); TX-only wrote it ($b1_txonly->$b2_txonly)" + fi + fi +done + +echo +echo "== txpwr-offset regcheck: PASS=$PASS FAIL=$FAIL SKIP=$SKIP ==" +[ "$FAIL" -eq 0 ] diff --git a/tests/txpwr_vht_onair_ab.sh b/tests/txpwr_vht_onair_ab.sh new file mode 100755 index 0000000..8e5f44c --- /dev/null +++ b/tests/txpwr_vht_onair_ab.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# On-air A/B for the 8822B VHT TXAGC extension: before it, 8822B VHT rates +# rode the hot BB-table default; now they get the efuse-calibrated, +# regulatory-clamped level (same bases as HT). Four cells against one +# continuous B210 power probe (fixed gain/geometry, uncalibrated dBFS — +# deltas are what matter): +# +# A master VHT2SS_MCS3 (BB-default VHT level) +# B new VHT2SS_MCS3 (efuse-calibrated VHT level) +# C master MCS7 (control: HT path untouched by this work) +# D new MCS7 +# +# PASS when |C-D| <= 1.0 dB (session stable / HT unchanged), B radiates +# (>= 10 dB over the idle floor), and B <= A + 0.5 dB (calibration cannot +# make VHT hotter). The A-B delta is the measured calibration correction. +# +# Estimation: median of the probe's ~50 ms window means — the DUT transmits +# in EVERY window (fixed frame cadence) while ambient traffic bursts into +# SOME windows, so the median tracks the DUT and rejects ambient. Default +# channel 36 (5 GHz): the 2.4 GHz ISM band's ambient swings cell means by +# several dB run-to-run (observed on ch6), swamping a few-step delta. +# +# MAGNITUDE CAVEAT: at bench range the B210 front end compresses on the DUT's +# frames at any RX gain (see tests/txpwr_offset_onair.sh, which measures TXAGC +# slope with a chip ground-station for exactly this reason), so the measured +# A-B delta is compressed toward zero — treat it as a lower bound and rely on +# the direction + the bounded-delta gate, not the dB value. +# +# Usage: sudo -v && tests/txpwr_vht_onair_ab.sh [ch] (default 36; 8822BU T3U) +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HERE="$ROOT/tests" +OUT="${TXPWR_VHT_AB_OUT:-/tmp/devourer-txpwr-vht-ab}" +MASTER_BUILD="${MASTER_BUILD:-/tmp/devourer-master-build}" +CH="${1:-36}" +FREQ="$(python3 -c "c=$CH; print((2407+c*5)*1e6 if c<=14 else (5000+c*5)*1e6)")" +# UHD's python module is a system package; the tests venv only carries it when +# created with --system-site-packages. Use whichever interpreter can import it. +PY="$HERE/.venv/bin/python" +{ [ -x "$PY" ] && "$PY" -c 'import uhd' 2>/dev/null; } || PY=python3 +mkdir -p "$OUT" + +cleanup() { + pkill -x WiFiDriverTxDem 2>/dev/null || true + [ -n "${PROBE_PID:-}" ] && kill "$PROBE_PID" 2>/dev/null + [ -n "${STAMP_PID:-}" ] && kill "$STAMP_PID" 2>/dev/null + true +} +trap cleanup EXIT INT TERM + +echo "== building (new + master baseline) ==" +cmake --build "$ROOT/build" -j --target WiFiDriverTxDemo >/dev/null || exit 1 +if [ ! -x "$MASTER_BUILD/WiFiDriverTxDemo" ]; then + wt="/tmp/devourer-master-worktree" + git -C "$ROOT" worktree add --force "$wt" origin/master >/dev/null 2>&1 + cmake -S "$wt" -B "$MASTER_BUILD" -DPKG_CONFIG_EXECUTABLE=/usr/bin/pkg-config >/dev/null 2>&1 + cmake --build "$MASTER_BUILD" -j --target WiFiDriverTxDemo >/dev/null 2>&1 || { + echo "master build failed"; exit 1; } +fi + +echo "== starting B210 probe (freq=$FREQ) ==" +: >"$OUT/probe.log" +"$PY" "$HERE/sdr_power_probe.py" --freq "$FREQ" --rate 4e6 --gain 40 \ + --duration 0 2>"$OUT/probe.err" | while IFS= read -r line; do + printf '%s %s\n' "$(date +%s.%N)" "$line" + done >>"$OUT/probe.log" & +PROBE_PID=$! +# B210 init (FPGA image + clocking) takes several seconds — poll for first data. +for i in $(seq 1 30); do + grep -q "sdr-power" "$OUT/probe.log" && break + sleep 1 +done +if ! grep -q "sdr-power" "$OUT/probe.log"; then + echo "SKIP: no SDR samples after 30s (probe.err: $(tail -1 "$OUT/probe.err" 2>/dev/null))" + exit 0 +fi + +# (The idle noise floor is measured at the END of the session — the B210's +# first seconds carry warm-up transients (gain/DC-cal settling) that read +# tens of dB above the true floor.) + +run_cell() { # $1=binary $2=rate $3=name -> echoes "t0 t1" + local t0 t1 + t0="$(date +%s.%N)" + sudo -n env DEVOURER_PID=0x012d DEVOURER_VID=0x2357 DEVOURER_CHANNEL="$CH" \ + DEVOURER_TX_RATE="$2" DEVOURER_TX_GAP_US=500 \ + timeout 12 "$1" >"$OUT/$3.log" 2>&1 || true + t1="$(date +%s.%N)" + sleep 2 + echo "$t0 $t1" +} + +# 3 interleaved repetitions per cell: each bring-up re-runs IQK/LCK and the +# PA warms differently, giving ~±2 dB between-run variance on a single rep +# (observed via the HT control) — the median across interleaved reps cancels +# the session drift a single A/B pair cannot. +REPS=3 +declare -A W +for r in $(seq 1 "$REPS"); do + for cell in A:master:VHT2SS_MCS3 B:new:VHT2SS_MCS3 C:master:MCS7 D:new:MCS7; do + IFS=: read -r key build rate <<<"$cell" + bin="$ROOT/build/WiFiDriverTxDemo" + [ "$build" = "master" ] && bin="$MASTER_BUILD/WiFiDriverTxDemo" + echo "== rep $r cell $key: $build $rate ==" + W["$key$r"]="$(run_cell "$bin" "$rate" "cell$key$r")" + done +done + +# End-of-session idle floor (all TX stopped, SDR fully settled). +IDLE_T0="$(date +%s.%N)"; sleep 5; IDLE_T1="$(date +%s.%N)" + +kill "$PROBE_PID" 2>/dev/null; wait "$PROBE_PID" 2>/dev/null; PROBE_PID="" + +# Median of the per-window dBFS readings over [t0+trim0, t1-trim1] (default +# skips bring-up, which transmits nothing; the idle floor uses a light trim). +# Median (not mean): robust to ambient bursts that land in a few windows. +win_med() { # $1=t0 $2=t1 [$3=trim0] [$4=trim1] + python3 - "$OUT/probe.log" "$1" "$2" "${3:-4}" "${4:-1}" <<'PYEOF' +import statistics, sys +log, t0, t1, ta, tb = sys.argv[1], *map(float, sys.argv[2:6]) +vals = [] +for line in open(log): + parts = line.split() + if len(parts) < 3 or parts[1] != "sdr-power": + continue + t = float(parts[0]) + if t0 + ta <= t <= t1 - tb: + for p in parts[2:]: + if p.startswith("dbfs="): + vals.append(float(p[5:])) +print(f"{statistics.median(vals):.2f}" if vals else "nan") +PYEOF +} + +FLOOR="$(win_med "$IDLE_T0" "$IDLE_T1" 0.5 0.5)" +med_of_reps() { # $1=cell key -> median of per-rep medians + local vals="" + for r in $(seq 1 "$REPS"); do + read -r t0 t1 <<<"${W[$1$r]}" + vals="$vals $(win_med "$t0" "$t1")" + done + python3 -c "import statistics,sys; v=[float(x) for x in sys.argv[1:] if x!='nan']; print(f'{statistics.median(v):.2f}' if v else 'nan')" $vals +} +A="$(med_of_reps A)"; B="$(med_of_reps B)" +C="$(med_of_reps C)"; D="$(med_of_reps D)" +echo "floor=$FLOOR dBFS A(master VHT)=$A B(new VHT)=$B C(master MCS7)=$C D(new MCS7)=$D (median of $REPS reps)" + +# Gates: session stability (HT control), the new VHT level radiates, and the +# calibration correction is bounded. The master VHT level is the silicon +# RESET default — the BB phy_reg table never inits the (write-only) 0x1dxx +# TXAGC block — so there is no register anchor for an exact expected delta; +# correctness comes by construction (VHT idx == HT 1SS/2SS idx, and the HT +# computation is master-parity-proven by txpwr_offset_regcheck's txagc-log +# cell). The delta is reported for the record (measured +2.4 dB on the T3U: +# the reset default UNDER-drove VHT2SS by ~5 steps vs the calibrated level). +python3 - "$FLOOR" "$A" "$B" "$C" "$D" <<'EOF' +import math, sys +floor, a, b, c, d = (float(x) for x in sys.argv[1:6]) +fails = [] +if any(math.isnan(v) for v in (a, b, c, d)): + fails.append("missing cell measurement") +else: + if abs(c - d) > 1.5: + fails.append(f"HT control moved: |C-D|={abs(c-d):.2f} dB > 1.5") + # A broken VHT write (e.g. zeroed TXAGC) would drop B 15-25 dB below A; + # the bounded-delta gate catches it. (No absolute floor gate: the idle + # floor reading varies with SDR settle state run-to-run, while the + # duty-matched A/B cells are stable.) + if abs(b - a) > 6.0: + fails.append(f"VHT correction implausibly large: B-A={b-a:+.2f} dB") + print(f"VHT calibration delta (master->new): {b-a:+.2f} dB; " + f"HT control drift: {d-c:+.2f} dB") +for f in fails: + print("FAIL:", f) +print("== txpwr VHT on-air A/B:", "FAIL ==" if fails else "PASS ==") +sys.exit(1 if fails else 0) +EOF diff --git a/txdemo/main.cpp b/txdemo/main.cpp index f260396..7575799 100644 --- a/txdemo/main.cpp +++ b/txdemo/main.cpp @@ -735,28 +735,26 @@ int main(int argc, char **argv) { mcs_sweep.size(), mcs_step_ms); } auto apply_txpwr = [&](int idx) { - /* TXAGC override is a Jaguar1 (RtlJaguarDevice) feature; a no-op otherwise. */ -#if defined(DEVOURER_HAVE_JAGUAR1) - if (!jag) + /* Unified runtime TX-power API — the flat-index override applies live on + * every generation (previously Jaguar1-only via SetTxPowerOverride + + * ApplyTxPower), so the ramp — and tests/link_probe.sh --axis power — + * covers Jaguar2/Jaguar3 emitters too. */ + if (!rtlDevice->GetTxPowerCaps().supported) return; - jag->SetTxPowerOverride(idx); - jag->ApplyTxPower(); /* SetMonitorChannel early-returns on same ch */ + rtlDevice->SetTxPowerIndexOverride(idx); 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 = jag->ReadBBReg(0xc20, 0x000000ff); - uint32_t ofdm6m = jag->ReadBBReg(0xc24, 0x000000ff); - printf("index=%d cck1m=%u ofdm6m=%u\n", idx, cck1m, - ofdm6m); + /* Confirm the TXAGC writes landed: representative path-A indices from + * GetTxPowerState — register readback where the family's TXAGC block is + * readable (rb=1), the software shadow otherwise (Jaguar2 / 8814A, + * whose TXAGC ports are write-only). */ + const devourer::TxPowerState st = rtlDevice->GetTxPowerState(); + printf("index=%d cck1m=%d ofdm6m=%d mcs7=%d rb=%d\n", + idx, st.cck_index, st.ofdm_index, st.mcs7_index, + st.hw_readback ? 1 : 0); } fflush(stdout); -#else - (void)idx; -#endif }; if (const char *e = std::getenv("DEVOURER_TX_PWR_START")) { pwr_ramp = true; @@ -933,14 +931,15 @@ int main(int argc, char **argv) { printf("TX #%ld rc=%d\n", tx_count, rc); fflush(stdout); } - /* Thermal telemetry is a Jaguar1 (RtlJaguarDevice) feature; skip on - * Jaguar3, where jag is null; compiled out when Jaguar1 isn't built. */ -#if defined(DEVOURER_HAVE_JAGUAR1) - if (jag && thermal_every > 0 && tx_count % thermal_every == 0) { - auto t = jag->GetThermalStatus(); + /* Thermal telemetry via the generation-agnostic GetThermalStatus + * (previously Jaguar1-only): every family reads its RF 0x42 meter — + * Jaguar1 [15:10] + EFUSE baseline, Jaguar2 [15:10] + efuse 0xBA, + * Jaguar3 [6:1] + efuse 0xd0 (E) / first-read cold reference (C). */ + if (thermal_every > 0 && tx_count % thermal_every == 0) { + auto t = rtlDevice->GetThermalStatus(); if (t.valid) { printf("raw=%u baseline=%u delta=%+d status=%s\n", - t.raw, t.baseline, t.delta, ThermalBucket(t)); + t.raw, t.baseline, t.delta, devourer::ThermalBucket(t)); if (t.delta >= thermal_warn_delta && !thermal_warned) { logger->warn("thermal: chip running hot — raw={} baseline={} " "delta=+{} (>= {}); TX power tracking backing off, " @@ -950,13 +949,12 @@ int main(int argc, char **argv) { } else if (t.delta < thermal_warn_delta) { thermal_warned = false; } - } else { + } else if (t.raw != 0) { printf("raw=%u baseline=none status=%s\n", - t.raw, ThermalBucket(t)); + t.raw, devourer::ThermalBucket(t)); } fflush(stdout); } -#endif /* DEVOURER_HAVE_JAGUAR1 */ if (rc) { consec_fail = 0; } else if (++consec_fail >= kMaxConsecFail) { diff --git a/txdemo/precoder_demo/main.cpp b/txdemo/precoder_demo/main.cpp index 0d5ecc8..220b554 100644 --- a/txdemo/precoder_demo/main.cpp +++ b/txdemo/precoder_demo/main.cpp @@ -212,7 +212,10 @@ int main(int argc, char **argv) { logger->info("DEVOURER_CHANNEL set — tuning TX to channel {}", channel); } - rtlDevice->SetTxPower(40); + /* DEVOURER_TX_POWER: flat TXAGC index. Unset = the family's calibrated + * default (SetTxPower is now a real flat override on every generation). */ + if (const char *p = std::getenv("DEVOURER_TX_POWER")) + rtlDevice->SetTxPower(static_cast(std::atoi(p))); rtlDevice->InitWrite(SelectedChannel{.Channel = static_cast(channel), .ChannelOffset = 0, .ChannelWidth = CHANNEL_WIDTH_20}); diff --git a/txdemo/stream_duplex_demo/main.cpp b/txdemo/stream_duplex_demo/main.cpp index 11062ee..be7ae0b 100644 --- a/txdemo/stream_duplex_demo/main.cpp +++ b/txdemo/stream_duplex_demo/main.cpp @@ -206,14 +206,9 @@ static void tx_thread(TxArgs args) { break; uint8_t op = ctl[0]; if (op == 1 && clen >= 2) { // SET_PWR - /* TXAGC override is a Jaguar1 (RtlJaguarDevice) feature; the SET_PWR - * control op is a no-op when Jaguar1 support isn't built. */ -#if defined(DEVOURER_HAVE_JAGUAR1) - if (auto *jag = dynamic_cast(args.rtl)) { - jag->SetTxPowerOverride(ctl[1]); - jag->ApplyTxPower(); - } -#endif + /* Flat TXAGC override via the generation-agnostic runtime TX-power + * API (previously Jaguar1-only): applies live on every family. */ + args.rtl->SetTxPowerIndexOverride(ctl[1]); } else if (op == 2 && clen >= 2) { // SET_RATE std::string spec(ctl.begin() + 1, ctl.end()); auto rt = devourer::build_stream_radiotap(devourer::parse_tx_mode_str(spec)); @@ -353,7 +348,12 @@ int main(int argc, char **argv) { if (const char *ch_env = std::getenv("DEVOURER_CHANNEL")) { channel = std::atoi(ch_env); } - rtlDevice->SetTxPower(40); + /* DEVOURER_TX_POWER: flat TXAGC index (see StreamTxDemo). Unset = each + * family's calibrated default — SetTxPower is now a real flat override on + * EVERY generation, so the old unconditional SetTxPower(40) (a no-op on + * Jaguar1/2) is gone. */ + if (const char *p = std::getenv("DEVOURER_TX_POWER")) + rtlDevice->SetTxPower(static_cast(std::atoi(p))); std::atomic should_stop{false}; diff --git a/txdemo/stream_tx_demo/main.cpp b/txdemo/stream_tx_demo/main.cpp index 06132a3..a77d9a9 100644 --- a/txdemo/stream_tx_demo/main.cpp +++ b/txdemo/stream_tx_demo/main.cpp @@ -200,33 +200,32 @@ int main(int argc, char **argv) { logger->info("DEVOURER_CHANNEL set — tuning TX to channel {}", channel); } - /* DEVOURER_TX_POWER overrides the per-rate "txpower" register value - * (default 40, low single-digits for an attenuated/noisy bench). Useful - * for stress-testing the RX path's corruption handling — lowering this - * forces marginal SNR, which raises the chip's CRC-failure rate so the - * corrupted-frame surfacing path actually gets exercised. */ - int tx_power = 40; - if (const char *p = std::getenv("DEVOURER_TX_POWER")) tx_power = std::atoi(p); - rtlDevice->SetTxPower(static_cast(tx_power)); + /* DEVOURER_TX_POWER forces a flat TXAGC index (low single-digits for an + * attenuated/noisy bench). Useful for stress-testing the RX path's + * corruption handling — lowering this forces marginal SNR, which raises the + * chip's CRC-failure rate so the corrupted-frame surfacing path actually + * gets exercised. Unset = each family's calibrated default (SetTxPower is + * now a real flat override on EVERY generation — the old unconditional + * SetTxPower(40) here was a no-op on Jaguar1/2 and would now flatten their + * efuse per-rate table; the 8822C's 40 default lives in its reference base). */ + if (const char *p = std::getenv("DEVOURER_TX_POWER")) + rtlDevice->SetTxPower(static_cast(std::atoi(p))); rtlDevice->InitWrite(SelectedChannel{.Channel = static_cast(channel), .ChannelOffset = 0, .ChannelWidth = CHANNEL_WIDTH_20}); - /* DEVOURER_TX_PWR_OVERRIDE: force an absolute per-rate TXAGC index (0..63), + /* DEVOURER_TX_PWR_OVERRIDE: force an absolute per-rate TXAGC index, * bypassing the EFUSE/SetTxPower table — the finest-grained, lowest TX-power * knob for pushing the link into the marginal-SNR regime where the RX's * corrupted-frame salvage path gets exercised (pairs with the B210 interferer * in tests/fused_fec_onair.sh). Applied once and held, unlike - * WiFiDriverTxDemo's DEVOURER_TX_PWR_START ramp. Must follow InitWrite so the - * channel-set has run; ApplyTxPower re-pushes the index to the registers. */ -#if defined(DEVOURER_HAVE_JAGUAR1) - if (const char *o = std::getenv("DEVOURER_TX_PWR_OVERRIDE"); o && jag) { + * WiFiDriverTxDemo's DEVOURER_TX_PWR_START ramp. Must follow InitWrite so it + * applies live. Generation-agnostic (IRtlDevice runtime TX-power API). */ + if (const char *o = std::getenv("DEVOURER_TX_PWR_OVERRIDE")) { int idx = std::atoi(o); - jag->SetTxPowerOverride(idx); - jag->ApplyTxPower(); + rtlDevice->SetTxPowerIndexOverride(idx); logger->info("DEVOURER_TX_PWR_OVERRIDE — forced absolute TXAGC index {}", idx); } -#endif /* Channel hopping for frequency diversity. DEVOURER_HOP_CHANNELS="1,6,11" * cycles the TX channel every DEVOURER_HOP_DWELL_FRAMES PSDUs (default 1 = * per-packet hop, which spreads an outer-FEC block's shards across channels diff --git a/txdemo/svc_tx_demo/main.cpp b/txdemo/svc_tx_demo/main.cpp index 07674fa..3ec50b0 100644 --- a/txdemo/svc_tx_demo/main.cpp +++ b/txdemo/svc_tx_demo/main.cpp @@ -161,9 +161,10 @@ int main(int argc, char** argv) { int channel = 6; if (const char* ch = std::getenv("DEVOURER_CHANNEL")) channel = std::atoi(ch); - int tx_power = 40; - if (const char* p = std::getenv("DEVOURER_TX_POWER")) tx_power = std::atoi(p); - rtlDevice->SetTxPower(static_cast(tx_power)); + /* DEVOURER_TX_POWER: flat TXAGC index. Unset = the family's calibrated + * default (SetTxPower is now a real flat override on every generation). */ + if (const char* p = std::getenv("DEVOURER_TX_POWER")) + rtlDevice->SetTxPower(static_cast(std::atoi(p))); rtlDevice->InitWrite(SelectedChannel{.Channel = static_cast(channel), .ChannelOffset = 0, .ChannelWidth = CHANNEL_WIDTH_20});