Runtime TX-power API: quarter-dB offset + unified knobs + thermal readout on all three generations#188
Merged
Merged
Conversation
Generation-agnostic TX-power control surface for the adaptive link: devourer::TxPowerCaps/TxPowerState + quantize_offset_qdb (src/TxPower.h), ThermalStatus moved to the generation-agnostic src/ThermalStatus.h (alias kept in the Jaguar1 HAL so existing references compile unchanged), and six IRtlDevice virtuals with no-op defaults: GetTxPowerCaps, SetTxPowerOffsetQdb (quarter-dB offset relative to the efuse per-rate table, returns applied qdB), SetTxPowerIndexOverride, ReApplyTxPower, GetTxPowerState, GetThermalStatus. No behavior change: defaults return unsupported/invalid; per-family apply paths untouched. TxPowerQuantSelftest guards the qdB->step rounding (round-nearest, ties away from zero) in ctest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold a runtime TX-power offset (index steps, 0.5 dB each) into the single
per-rate computation ComputeTxPowerIndex — the flat override or EFUSE
per-rate base (regulatory-bounded upstream in GetTxPowerIndexBase) plus the
offset, clamped once at the 6-bit rails with saturation flags. Used by both
the per-rate TXAGC fanout (0xc20..0xc4c, packed 0x1998 on 8814) and the
0xc54/0xe54 power-training word — which previously ignored even the flat
override (the one TX-power register the ramp didn't move) and could
underflow its uint32_t ladder below index 10.
RtlJaguarDevice implements the IRtlDevice contract: caps {63, 0.5 dB/step},
SetTxPowerOffsetQdb (live via ApplyTxPower once brought up, recorded
otherwise, refused while a CW tone holds the chip), SetTxPowerIndexOverride,
ReApplyTxPower, GetTxPowerState (register readback from 0xc20/0xc24/0xc30;
software shadow with hw_readback=false on the 8814's write-only TXAGC
port), and GetThermalStatus now overriding the base virtual.
Offset state is atomic so state snapshots are readable cross-thread;
setters remain control-plane-thread calls (IRtlDevice threading contract).
Hardware-validated (tests/txpwr_offset_regcheck.sh) on 8812AU + 8821AU +
8814AU: bring-up TXAGC canary byte-identical to master with the offset
untouched; -24 qdB moves cck/ofdm/mcs7 by exactly -12; rails clamp with
flags; offset survives SetMonitorChannel (re-folded against the new
channel group's EFUSE base) and FastRetune (hop path leaves TXAGC alone).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TxPowerStepDemo (examples/txpower/): reference consumer of the runtime TX-power API — pure CLI configuration (no env vars), prints TxPowerCaps, steps SetTxPowerOffsetQdb / SetTxPowerIndexOverride, and emits machine-readable <devourer-txpwr-caps>/<devourer-txpwr-state> markers with optional thermal lines. The shape of an adaptive-link controller's power leg. tests/txpwr_offset_regcheck.sh drives it per plugged DUT (PASS/FAIL/SKIP, tone_mask_regcheck pattern): master-baseline TXAGC canary parity with the offset untouched (one-time worktree build, hop_parity_check's live-register exclusions), exact-step move at -24 qdB, rail clamps + saturation flags, and stickiness across SetMonitorChannel (re-fold) + FastRetune (untouched). Jaguar2/Jaguar3 rows skip cleanly until their families are wired. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
apply_tx_power gains an offset_steps fold (after the regulatory min(), inside
the 6-bit rail clamp with saturation flags) on both the shared 8822B path and
the 8821C vendor-formula path. The 8822B now programs the VHT TXAGC sections
(hw_rate 0x2c-0x3f; dword 0x34 straddles VHT1SS MCS8/9 + VHT2SS MCS0/1) from
the same stream-count bases the vendor phy_get_pg_txpwr_idx uses — previously
VHT rode the silicon reset default, uncalibrated and out of the offset's
reach; at 2.4 GHz the worldwide-min table has no VHT rows, so the VHT clamp
is bounded by the HT limit there.
RtlJaguar2Device implements the IRtlDevice contract (caps {63, 0.5 dB/step});
knobs are live once brought up via apply_tx_power_current (flat+offset
composed before set_tx_power_flat's &0x3f wrap), recorded before. Fixes the
dead SetTxPower (stored an override nothing read) by routing it through the
unified path, and DEVOURER_TX_PWR now goes through the same knob. A full
SetMonitorChannel re-folds the knobs against the new channel group (gated on
a knob being active, so the legacy pure-tune stays byte-identical);
FastRetune never touches TXAGC. GetThermalStatus reads RF[A] 0x42[15:10] +
the efuse baseline at 0xBA (vendor-parity, no trigger needed).
The Jaguar2 TXAGC block (0x1d00/0x1d80) is WRITE-ONLY — reads return 0
(hardware-verified on both variants) — so GetTxPowerState reports a software
shadow of the last apply with hw_readback=false, like the 8814A.
Hardware-validated on 8822BU (T3U) + 8821CU (tests/txpwr_offset_regcheck.sh:
canary + TXAGC-log parity vs master at offset 0, exact-step moves, rails,
sticky re-fold, thermal plausibility) and on-air (tests/txpwr_vht_onair_ab.sh,
B210 @ ch36, 3 interleaved reps, median-of-windows): HT control drift
-0.47 dB, VHT2SS calibration delta -1.76 dB vs the reset default — bounded,
and the level is now kernel-derivation parity by construction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ermal (M4)
The offset shifts the per-path TXAGC reference anchor: a live step is the new
light apply_tx_power_refs_8822e (the gated ref writes of the by-rate apply —
0x18e8[16:10], optionally 0x41e8, 0x18a0/0x41a0[22:16] — WITHOUT the 0x3a00
diff walk, which is offset-invariant), ~8 register ops. Refs are clamped to
the 7-bit field in both writers — the masked BB write truncates mod 128, so
an over-range ref would previously have wrapped to near-silent TX.
RtlJaguar3Device implements the IRtlDevice contract (caps {127, 0.25 dB/
step}): apply_tx_power_current composes flat+offset / efuse-refs+offset with
cached per-channel-group bases (InitWrite's power block refactored onto it),
under _reg_mu whenever the coex thread may run — fixing the pre-existing
SetTxPower live-apply race against the coex tick's pwr_track (field-disjoint
RMWs on the SAME 0x18a0/0x41a0 dwords) — and SetTxPower now routes through
the unified knob, no longer re-zeroing the 8822E per-rate diffs or writing
0x41e8 on a TX+RX session. The 0x41e8 RX-desense quirk is enforced
structurally: InitWrite's want_rx local is promoted to _rx_wanted and every
ref write derives skip_path_b_ofdm_ref from it (8822E only; the 8822C keeps
its path-B writes). set_tx_power_ref gains the same skip flag. A full
SetMonitorChannel re-folds active knobs against the new channel group's
efuse refs (gated so the legacy path stays byte-identical); FastRetune never
touches TXAGC. GetThermalStatus lands via Jaguar3Calibration::read_thermal:
8822E reads RF 0x42[6:1] against the efuse 0xd0 baseline (shared one-time
trigger), 8822C against its first-read cold reference. The canary gains the
TXAGC refs + first diff dword.
Hardware-validated (tests/txpwr_offset_regcheck.sh) on 8822CU (7/7) +
8822EU (8/8): canary parity vs master on the common register set (RF[B]
0x1a excluded — bit 18 latches prior-session residue, A/B/A/B-verified
build-independent), exact -24-step moves with register readback, rails +
flags, sticky re-fold across SetMonitorChannel (E: ch36 ref 75 -> ch149 ref
68, both -24), FastRetune untouched, thermal plausible on both variants,
and the quirk cell: TX+RX kept 0x41e8 at table default while path A moved,
TX-only wrote it. Locking soak: 17 offset steps (-16..+16 qdB) interleaved
with coex ticks (pwr_track active), clean run, every step applied at the
native 1-qdB granularity.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
IRtlDevice::SetTxPower becomes a base-default forward to SetTxPowerIndexOverride (the primary knob; a family without the API wired ignores it) and the three concrete overrides are deleted — this ALIGNS the previously divergent semantics: J1's was the pre-efuse fallback member (silently ignored with loaded EFUSE), J2's a dead store, J3's an unlocked flat ref. J1's SetTxPowerOverride/ApplyTxPower become [[deprecated]] inline forwards (Rtl8812aDevice-alias precedent). Demos: the txdemo DEVOURER_TX_PWR_START ramp and thermal telemetry drop their Jaguar1-only dynamic_cast guards — the ramp (and tests/link_probe.sh --axis power) now covers Jaguar2/Jaguar3 emitters, thermal markers print on every family, and the readback line reports GetTxPowerState (register readback or the software shadow, rb= flag). StreamTxDemo/duplex/svc/precoder demos stop calling SetTxPower(40) unconditionally — under unified semantics that would have flattened J1/J2's efuse per-rate table (it was a no-op there before); DEVOURER_TX_POWER now applies only when set, and the duplex SET_PWR control op rides the unified API on every generation. No new env vars. TxPowerCaps.step_measured flips where the on-air slope matrix confirmed the step (see the docs lever row + CLAUDE.md TX-power section for the API summary and the two measured caveats: the 8821AU's 5 GHz chain ignores BB TXAGC — 2.4 GHz-only lever — and the 8822EU's TSSI trims reshape its ref->power transfer). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tests/txpwr_offset_onair.sh measures what one TXAGC index step is WORTH on air (the regcheck proves the offset moves registers by exactly N steps; slope x steps = offset truth): 6 fixed-index cells per DUT via the unified DEVOURER_TX_PWR knob, sensed by a SECOND devourer adapter's per-frame RSSI (dB-linear phystatus, canonical-SA-filtered) — NOT the B210. Measured on this bench: the B210 front end hard-limits on frames at ANY RX gain with the antennas inches apart (idx 8 == idx 56 == -56.9 dBFS at gain 0), so wideband SDR power cannot see TXAGC level changes at all; the earlier SDR "slopes" were limiter artifacts. sdr_power_probe.py gains --gated (block-gated, duty-independent power) + per-block DC removal (the B210 DC residual reads as a constant ~-42 dBFS pedestal at band center) for the setups where the SDR is linear; the VHT A/B script now flags its delta as magnitude- compressed (direction/boundedness gates only). Measured matrix (all seven DUTs PASS): 8812AU 0.496, 8814AU 0.425, 8822BU 0.488, 8821CU 0.581 dB/idx @ch36 and 8821AU 0.428 @Ch6 -> the 0.5 nominal; 8822CU 0.325 @ch36 -> the 0.25 nominal (rms 0.60). Two findings encoded as expectations: the 8821AU's 5 GHz chain IGNORES BB TXAGC (flat across two grounds while registers move; the power lever is 2.4 GHz-only there), and the 8822EU's TSSI/kfree trims reshape its ref->power transfer (0.3->0.9 dB/idx across the range, ~0.55 avg) — asserted as a monotone lever, not a fixed step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The build-mingw job builds an explicit target list (the POSIX-only demos are excluded), and every registered ctest binary must be on it — the new txpower_quant_math test failed as Not Run because its binary was never built. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jul 6, 2026
Jaguar3: no dynamic initial gain (DIG) — IGI is static after bring-up (parity gap vs Jaguar1/2)
#190
Closed
josephnef
added a commit
that referenced
this pull request
Jul 6, 2026
…driven) (#194) ## Context From the forum: *"no need to manually set txpower for adaptive link, driver already supports TX power per rate, per packet"* ([gist](https://gist.github.com/gilankpam/bd40aa0b69f64f568138cd3939c53afc)). That's right about the mechanism — each packet's **rate** selects its TXAGC row, which devourer already does (radiotap rate per-packet → the per-rate table, now runtime-shiftable via #188). The gist also confirms the vendor **skips `IEEE80211_RADIOTAP_DBM_TX_POWER` on injection**, so there's no absolute-dBm per-packet path in the vendor either. But the 8822B/8821C silicon has a genuine per-**packet** power knob that devourer didn't wire and the vendor uses only for phydm dynamic tracking: the TX-descriptor **`TXPWR_OFSET`** field (`+0x14[30:28]`) — a hardware LUT applied per-frame *on top of* the rate's power, at **zero USB cost** (a bitfield in the descriptor `send_packet` already builds). Vendor LUT (`PHYDM_OFFSET_*`): `0`=none, `1`=−3, `2`=−7, `3`=−11, `4`=+3, `5`=+6 dB. ## Measured first Before wiring anything (`tests/txpkt_pwr_ofset_onair.sh`, chip-RSSI ground station), confirmed the field moves on-air power **standalone** (no phydm dynamic-txpwr enable needed): | step | nominal | measured Δ (raw≈dB) | |---|---|---| | 1 | −3 | −4 | | 2 | −7 | **−8** | | 3 | −11 | **−13** | | 4 | +3 | +3 | | 5 | +6 | +5 (RSSI ceiling-clipped near-field) | 5/5 correct direction, negative rungs tracking dB nearly exactly. Real, not a gated no-op. ## What - **`send_packet` honours a per-frame radiotap `DBM_TX_POWER`** as a dB delta, quantized to the LUT (`jaguar2::txpkt_pwr_step_for_db`, unit-tested) — the adaptive-link per-packet integration point (wfb-style injection sets it per frame). - **`RtlJaguar2Device::SetTxPacketPowerStep(step)`** — a session default for rate-less frames (also the measurement knob, `DEVOURER_TX_PKT_OFSET` in txdemo). Both paths on-air-validated **identical** (`+6`→rssi 101, `−11`→rssi 83, whether via the descriptor default or the radiotap field). `offset 0` keeps the descriptor byte-identical. ## Scope / honesty - The **classic 8812AU has no such descriptor field** — its per-packet power *is* rate selection, already supported. So for the most common wfb chip, the forum note is literally true and nothing new is needed. - Jaguar3's 2-bit `TXPWR_OFSET_TYPE` is a different variant, left unwired (and 8822E unwires it in the vendor). ## Validation - **Unit** (`ctest`): `tests/txpkt_pwr_selftest.cpp` — the dB→LUT quantizer (nearest-rung, ties, clamps). Gated on Jaguar2; added to the mingw target list. - **On-air**: `tests/txpkt_pwr_ofset_onair.sh` — the descriptor LUT sweep (5/5) **and** the radiotap per-packet path (2/2, identical to the descriptor path). Composes with the runtime per-rate `SetTxPowerOffsetQdb` (#188): that sets the operating point, this trims per-frame. No new library env vars (the demo-side `DEVOURER_TX_PKT_OFSET` / `DEVOURER_TX_PKT_PWR_DB` are measurement drivers). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
josephnef
added a commit
that referenced
this pull request
Jul 6, 2026
…#196) ## Why From `/tmp/adaptive-link-recipes.md` (the OpenIPC-FPV "Adaptive Link" topic): the **#1 hand-tuning chore** is the universal law **"higher MCS ⇒ lower max clean power"** — the PA garbles dense QAM first, so each MCS has a power ceiling above which video breaks. The whole ecosystem hand-calibrates this per adapter ("raise power until garbage, back off 5"; e.g. Johhn's 8812AU: 58@MCS0/1 → 30@MCS5) and bakes it into per-MCS power tables (`wlan_adapters.yaml`). This automates it, reusing the diagnostics this session already shipped. ## What `tests/per_mcs_power_ceiling.sh` — for each MCS, sweep the flat TXAGC index (`DEVOURER_TX_PWR`) while a second devourer adapter reports per-frame stats (`DEVOURER_STREAM_OUT`). The ceiling per MCS is the highest power that still **delivers** cleanly — the point just before frames garble. Output is the `mcs → safe-power` table + the law-check. **The metric matters, and the measurement corrected my first guess:** the EVM *knee* (PA compression) turned out to be nearly the same power for *every* MCS, so keying on it gave a flat table. What actually differs per MCS is the decode threshold — a denser constellation garbles at lower power — so **frame-delivery collapse**, not the EVM knee, is the right signal. ## Validation (8812AU, 8×8 sweep, chip-RSSI ground — no SDR) Per-MCS delivery (frames/cell) shows the cliff directly: | MCS | ceiling idx | note | |---|---|---| | 0–3 | **56** | deliver across the whole range | | 4 | **44** | 0 frames at idx56 | | 5–7 | **32** | delivery collapses to ~0 at idx44 | `RESULT ceiling MCS0=56 → top=32 (drop 24 index steps ≈ 12 dB); 0 law inversions → LAW REPRODUCED.` This matches the community's known 8812AU shape. Chip-RSSI is the sensor (not the B210 — its front end limits on near-field frames, measured earlier this session). Fixed near-field geometry is valid: the delivery cliff is the PA's clean limit regardless of distance. ## Scope Tooling only — no library change. The table feeds the runtime controls already shipped: `SetTxPowerOffsetQdb` (per-rate baseline, #188) + the Jaguar2 per-packet offset (#194). A runtime per-MCS *clamp* was considered and rejected: per-MCS TXAGC is individually addressable only on Jaguar1; J2/J3 group rates, so it'd be invasive for little gain. The calibration table is the valuable artifact. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The adaptive-link power lever as a runtime API (no new env vars): adjust TX power live, in quarter-dB, relative to the efuse-calibrated per-rate table, on Jaguar1/2/3 — with readback, saturation signaling, and thermal telemetry. Closes the gap where the TXAGC ramp was Jaguar1-only via
dynamic_castandSetTxPowerhad three divergent semantics (J1: pre-efuse fallback silently ignored with loaded EFUSE; J2: dead store; J3: flat ref applied with no lock).API (
IRtlDevice, seesrc/TxPower.h)SetTxPowerOffsetQdb(qdb)— ±offset relative to the per-rate table, quantized to the family step (returns applied qdB), folded after the regulatorymin()inside the hardware rail clamp (per-rate shape preserved until rates saturate;saturated_low/highflags). Live mid-stream; sticky:SetMonitorChannelre-folds against the new channel group's table,FastRetunenever touches TXAGC.SetTxPowerIndexOverride(idx)— flat absolute index,-1reverts to efuse; composes with the offset.SetTxPowernow forwards here on every generation.ReApplyTxPower(),GetTxPowerState()(register readback, or a software shadow where TXAGC is write-only — the Jaguar2 block and the 8814A's packed port, both hardware-verified),GetTxPowerCaps()(index max 63/127, step 0.5 dB J1/J2 / 0.25 dB J3,step_measured).GetThermalStatus()on all three generations (RF 0x42 meter; J2 reads the efuse 0xBA baseline with no trigger, 8822E the efuse 0xd0 baseline, 8822C a first-read cold reference).TxPowerStepDemo(examples/txpower/, CLI-only) is the reference consumer. The txdemoDEVOURER_TX_PWR_STARTramp, thermal markers, andtests/link_probe.sh --axis powernow cover all three generations.Fixes riding along
SetTxPowerrace: the live apply ran with no_reg_muwhile the coex tick'spwr_trackRMWs the same0x18a0/0x41a0dwords (field-disjoint: thermal[7:0]vs CCK ref[22:16]— compose cleanly once locked). It also re-zeroed the 8822E per-rate diffs and wrote the0x41e8path-B ref on TX+RX sessions (RX desense). All three fixed; the TX+RX quirk is now enforced structurally (_rx_wantedmember feeds every ref write).0xc54/0xe54): now honors the flat override/offset (previously efuse-only — the one TX-power register the ramp didn't move) and uses signed arithmetic (the uint32 ladder underflowed below index 10).0x2c–0x3f) programmed from the same stream-count bases as HT (vendorphy_get_pg_txpwr_idxparity); previously VHT rode the silicon reset default — uncalibrated, regulatory-unclamped, and out of the offset's reach. At 2.4 GHz the worldwide-min table has no VHT rows, so the VHT clamp is bounded by the HT limit.SetTxPower(40)unconditionally — under unified semantics that would have flattened J1/J2's efuse table (it was a no-op there before);DEVOURER_TX_POWERapplies only when set, and the duplexSET_PWRcontrol op rides the unified API.Validation (7 plugged adapters + B210)
Register level —
tests/txpwr_offset_regcheck.sh: 49 PASS / 0 FAIL / 3 expected SKIPs across 8812AU, 8821AU, 8814AU, 8822BU, 8821CU, 8822CU, 8822EU: bring-up TXAGC canary byte-identical to a master-build baseline with the knobs untouched (the key no-regression invariant; plus a TXAGC-log value-parity cell on J2, whose registers are write-only), exact-step moves (−24 qdB = −12/−24 indices with readback), rail clamps + flags, stickiness acrossSetMonitorChannel(re-fold verified against the new group's efuse base) andFastRetune(untouched), thermal plausibility, the 8822B VHT dwords, and the 8822E TX+RX cell (offset churn leaves0x41e8at table default while path A moves; TX-only writes it). A J3 locking soak ran 17 offset steps interleaved with live coex/pwr_track ticks cleanly.On-air —
tests/txpwr_offset_onair.sh(7/7): dB-per-index slope via 6 fixed-index cells, sensed by a second devourer adapter's per-frame RSSI. Measured: 8812AU 0.496, 8814AU 0.425, 8822BU 0.488, 8821CU 0.581 dB/idx @ ch36 and 8821AU 0.428 @ ch6 → the 0.5 nominal; 8822CU 0.325 → the 0.25 nominal.caps.step_measuredreflects exactly what was measured.Two hardware findings, encoded in caps + test expectations rather than papered over:
Bench note baked into the harness: with antennas inches apart the B210's front end hard-limits on the DUT's frames at any RX gain (idx 8 ≡ idx 56 at gain 0), so wideband SDR power cannot see TXAGC levels on this rig — hence the chip-RSSI sensor.
sdr_power_probe.pygains--gated(duty-independent block gating) + per-block DC removal for setups where the SDR is linear.ctestgreen (incl. the newTxPowerQuantSelftestrounding guard); per-chip subset builds (J1-only / J3-only) verified.Behavior changes to be aware of
SetTxPower/DEVOURER_TX_PWRis now a real flat override on J1 (previously a silent no-op with loaded EFUSE — it always documented this behavior).SetTxPowerOverride/ApplyTxPowerare[[deprecated]]inline forwards (one release cycle,Rtl8812aDevice-alias precedent).🤖 Generated with Claude Code