From e06b48ab37583ca58ac8fa8cff46149fb38f17b0 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:14:12 +0300 Subject: [PATCH] Jaguar2 thermal TX-power tracking (8822B/8821C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jaguar2 was the only devourer chip family with no thermal TX-power tracking: HalJaguar2::apply_tx_power programs the efuse-calibrated TXAGC once at bring-up and it never moves. On a sustained TX session (the OpenIPC long-range video case) the PA heats, gain drops, and on-air power drifts down with nothing correcting it — the droop Jaguar1 (PowerTracking8812a) and Jaguar3 (Halrf8822c/e::pwr_track) both compensate. Port the vendor-faithful MIX_MODE power tracking for both variants. A ~2 s tick reads the RF 0x42[15:10] thermal meter (4-sample rolling average), computes the delta vs the efuse baseline (0xBA), looks up the per-variant/per-band delta-swing table, and writes the compensation as the vendor's coarse-TXAGC (0xc94/0xe94) + BB-scale (0xc1c/0xe1c) split (get_mix_mode_tx_agc_bb_swing_offset_8822b). Both 2.4 and 5 GHz; 8822B is 2T2R (both PA chains driven from the single die sensor, each with its own swing curve), 8821C is 1T1R (path A, 0xc94[6:1]). No LCK retrigger — the vendor disables it for these chips. The tick runs on a dedicated thread started at the end of bring_up so it covers both Init (RX) and InitWrite (TX-only, the sustained-TX case the DIG thread never reaches), serialized against the channel set and TX-power setters by a new _reg_mu (the RF read window must not tear). Default on; DEVOURER_THERMAL_TRACK=0 disables it, and an unprogrammed efuse baseline (0xFF) disables it automatically. Hardware validated on a T3U (8822B) and CF-811AC (8821C): - functional soak (both variants, TX-only, max duty): thread starts, meter reads live and rises under heating, swing applied, TX alive, off-inert, clean shutdown. - two-adapter on-air txhit (8822B -> 8812AU, ch6/1M): canonical beacon aired + received with tracking live. - USRP power measurement at a matched hot state: tracking ON delivers +3.74 dB more on-air power than OFF, matching the logged swing index of 8 (~+4.0 dB expected) — the compensation reaches the antenna and restores the PA droop OFF leaves uncorrected. Tests: tests/jaguar2_thermal_track.sh (functional + off-inert), tests/jaguar2_thermal_track_onair.sh (two-adapter txhit), tests/jaguar2_thermal_track_sdr.py + run_*.sh (SDR power proof). Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/common/env_config.cpp | 3 + src/DeviceConfig.h | 6 + src/jaguar2/Halrf8821c.cpp | 108 +++++++++++++ src/jaguar2/Halrf8821c.h | 17 +++ src/jaguar2/Halrf8822b.cpp | 137 +++++++++++++++++ src/jaguar2/Halrf8822b.h | 18 +++ src/jaguar2/Jaguar2Calibration.h | 17 +++ src/jaguar2/RtlJaguar2Device.cpp | 73 ++++++++- src/jaguar2/RtlJaguar2Device.h | 32 +++- tests/jaguar2_thermal_track.sh | 99 ++++++++++++ tests/jaguar2_thermal_track_onair.sh | 47 ++++++ tests/jaguar2_thermal_track_sdr.py | 204 +++++++++++++++++++++++++ tests/run_jaguar2_thermal_track_sdr.sh | 36 +++++ 13 files changed, 788 insertions(+), 9 deletions(-) create mode 100755 tests/jaguar2_thermal_track.sh create mode 100755 tests/jaguar2_thermal_track_onair.sh create mode 100755 tests/jaguar2_thermal_track_sdr.py create mode 100755 tests/run_jaguar2_thermal_track_sdr.sh diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp index 511810b..b243ef2 100644 --- a/examples/common/env_config.cpp +++ b/examples/common/env_config.cpp @@ -99,6 +99,9 @@ devourer::DeviceConfig devourer_config_from_env() { cfg.tuning.skip_rfe_init = env_flag("DEVOURER_SKIP_RFEINIT"); cfg.tuning.skip_coex = env_flag("DEVOURER_SKIP_COEX"); cfg.tuning.skip_dig = env_flag("DEVOURER_SKIP_DIG"); + /* Default-on knob: unset = tracking on; only "0" disables it. */ + if (const char *e = env_str("DEVOURER_THERMAL_TRACK")) + cfg.tuning.thermal_track = std::strcmp(e, "0") != 0; cfg.tuning.disable_cca = env_flag("DEVOURER_DIS_CCA"); if (env_long("DEVOURER_RFE", &v)) cfg.tuning.rfe_type = static_cast(v); diff --git a/src/DeviceConfig.h b/src/DeviceConfig.h index 798700d..dc31414 100644 --- a/src/DeviceConfig.h +++ b/src/DeviceConfig.h @@ -151,6 +151,12 @@ struct DeviceConfig { bool skip_rfe_init = false; /* env: DEVOURER_SKIP_RFEINIT (J2) */ bool skip_coex = false; /* env: DEVOURER_SKIP_COEX (J2) */ bool skip_dig = false; /* env: DEVOURER_SKIP_DIG (J2) */ + /* env: DEVOURER_THERMAL_TRACK — Jaguar2 thermal TX-power tracking + * (default ON; "0" disables). A ~2 s tick reads the RF 0x42 thermal + * meter, computes the delta vs the efuse baseline, and writes the vendor + * MIX_MODE swing compensation (0xc94/0xe94 TXAGC + 0xc1c/0xe1c BB scale) + * so on-air power holds flat as the PA heats over a sustained TX link. */ + bool thermal_track = true; /* env: DEVOURER_DIS_CCA — Jaguar3 EDCCA-disable at bring-up (before the * coex thread starts). Runtime equivalent: SetCcaMode. */ bool disable_cca = false; diff --git a/src/jaguar2/Halrf8821c.cpp b/src/jaguar2/Halrf8821c.cpp index 95c2a82..aca503b 100644 --- a/src/jaguar2/Halrf8821c.cpp +++ b/src/jaguar2/Halrf8821c.cpp @@ -847,6 +847,114 @@ void Halrf8821c::iqk_trigger(bool band2g) { _iqk_fail_report[RX_IQK], _rxiqk_fail_code); } +/* ===== Thermal TX-power tracking ================================= + * 1T1R (path A only). Vendor MIX_MODE port — tables from reference/8821cu/hal/ + * phydm/halrf/rtl8821c/halhwimg8821c_rf.c (delta_swingidx_mp_*_txpwrtrk_8821c, + * D_S_SIZE=30); tx_scaling_table_jaguar shared with the 8822B (37 entries). The + * 8821C writes the coarse TXAGC swing to 0xc94[6:1] (6-bit) vs the 8822B's + * [29:25], and has no path B. */ +namespace { +constexpr int D_S = 30; + +const uint32_t tx_scaling_table_jaguar[37] = { + 0x081, 0x088, 0x090, 0x099, 0x0A2, 0x0AC, 0x0B6, 0x0C0, 0x0CC, 0x0D8, + 0x0E5, 0x0F2, 0x101, 0x110, 0x120, 0x131, 0x143, 0x156, 0x16A, 0x180, + 0x197, 0x1AF, 0x1C8, 0x1E3, 0x200, 0x21E, 0x23E, 0x261, 0x285, 0x2AB, + 0x2D3, 0x2FE, 0x32B, 0x35C, 0x38E, 0x3C4, 0x3FE}; + +const uint8_t k2ga_p[D_S] = {0, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 5, + 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 9, 9, 9, 9}; +const uint8_t k2ga_n[D_S] = {0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 4, + 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 7, 7, 8, 8, 9}; +const uint8_t k5ga_p[3][D_S] = { + {0, 1, 1, 2, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 11, 11, 12, 12, 12, 12, 12, 12, 12}, + {0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 5, 6, 7, 7, 8, 8, 9, 10, 10, 11, 11, 12, 12, 12, 12, 12, 12, 12, 12}, + {0, 1, 1, 1, 2, 3, 3, 3, 4, 4, 4, 5, 6, 6, 7, 7, 8, 8, 9, 10, 10, 11, 11, 12, 12, 12, 12, 12, 12, 12}}; +const uint8_t k5ga_n[3][D_S] = { + {0, 1, 1, 2, 3, 3, 3, 4, 4, 5, 5, 6, 6, 6, 7, 8, 8, 8, 9, 9, 9, 10, 10, 11, 11, 12, 12, 12, 12, 12}, + {0, 1, 1, 1, 2, 3, 3, 4, 4, 5, 5, 5, 6, 6, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 12, 12, 12, 12, 12}, + {0, 1, 2, 2, 3, 4, 4, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 9, 10, 10, 11, 11, 12, 12, 12, 12, 12, 12}}; +} /* namespace */ + +void Halrf8821c::set_pwr_track_ctx(uint8_t baseline, uint8_t channel) { + _pt_baseline = baseline; + _pt_channel = channel; + for (auto &x : _pt_avg) + x = 0; + _pt_avg_idx = 0; + _pt_last_swing = 0x7fffffff; + _pt_default_ofdm = 24; + const uint32_t cur = bb_get(0xc1c, 0xFFE00000); + for (int i = 0; i < 37; i++) + if (tx_scaling_table_jaguar[i] == cur) { + _pt_default_ofdm = static_cast(i); + break; + } + _logger->info("Jaguar2 8821C thermal-track: baseline={} ch={} default_ofdm={}", + baseline, channel, _pt_default_ofdm); +} + +void Halrf8821c::pwr_track_write(int swing, int cur_ofdm_idx) { + const int tx_pwr_idx = cur_ofdm_idx < 0 ? 63 : (cur_ofdm_idx > 63 ? 63 : cur_ofdm_idx); + int headroom = 63 - tx_pwr_idx; + if (headroom > 0xF) + headroom = 0xF; + const int def = _pt_default_ofdm; + const int ub = def + 10; + int tx_agc_index, bb_swing_index; + if (swing >= 0 && swing <= headroom) { + tx_agc_index = swing; + bb_swing_index = def; + } else if (swing > headroom) { + tx_agc_index = headroom; + bb_swing_index = def + (swing - headroom); + if (bb_swing_index > ub) + bb_swing_index = ub; + } else { + tx_agc_index = 0; + bb_swing_index = def > -swing ? def + swing : 0; + } + if (bb_swing_index < 0) + bb_swing_index = 0; + if (bb_swing_index > 36) + bb_swing_index = 36; + bb_set(0xc94, 0x0000007E, static_cast(tx_agc_index) & 0x3f); /* [6:1] */ + bb_set(0xc1c, 0xFFE00000, tx_scaling_table_jaguar[bb_swing_index]); +} + +void Halrf8821c::pwr_track(int current_ofdm_index) { + if (_pt_baseline == 0xff) + return; + const bool g2 = _pt_channel <= 14; + int sub = 0; + if (!g2) + sub = _pt_channel <= 64 ? 0 : (_pt_channel <= 144 ? 1 : 2); + const int cur = static_cast((rf_get(0, 0x42) >> 10) & 0x3f); + _pt_avg[_pt_avg_idx] = cur; + _pt_avg_idx = (_pt_avg_idx + 1) & 3; + int sum = 0, cnt = 0; + for (int i = 0; i < 4; i++) + if (_pt_avg[i]) { + sum += _pt_avg[i]; + cnt++; + } + if (!cnt) + return; /* meter not ready — no valid sample yet */ + const int avg = sum / cnt; + int delta = avg > _pt_baseline ? avg - _pt_baseline : _pt_baseline - avg; + if (delta > D_S - 1) + delta = D_S - 1; + const uint8_t *up = g2 ? k2ga_p : k5ga_p[sub]; + const uint8_t *dn = g2 ? k2ga_n : k5ga_n[sub]; + const int swing = avg > _pt_baseline ? up[delta] : -static_cast(dn[delta]); + if (swing == _pt_last_swing) + return; + _pt_last_swing = swing; + pwr_track_write(swing, current_ofdm_index); + _logger->info("Jaguar2 8821C thermal-track: avg={} d={} swing={}", avg, delta, + swing); +} + /* Calibration-factory hook, called by make_jaguar2_calibration() in * Halrf8822b.cpp when the ChipVariant is C8821C. */ std::unique_ptr diff --git a/src/jaguar2/Halrf8821c.h b/src/jaguar2/Halrf8821c.h index 4c5b58e..d7edc87 100644 --- a/src/jaguar2/Halrf8821c.h +++ b/src/jaguar2/Halrf8821c.h @@ -35,7 +35,16 @@ class Halrf8821c : public Jaguar2Calibration { /* phy_iq_calibrate_8821c entry (SW path). band2g: true=2.4G. */ void iqk_trigger(bool band2g) override; + /* Thermal TX-power tracking — see Jaguar2Calibration. 1T1R: + * path A only (0xc94[6:1] TXAGC + 0xc1c[31:21] BB scale). */ + void set_pwr_track_ctx(uint8_t baseline, uint8_t channel) override; + void pwr_track(int current_ofdm_index) override; + private: + /* MIX_MODE split + register write, path A (get_mix_mode..._8822b family, + * 8821C register layout). */ + void pwr_track_write(int swing, int current_ofdm_index); + /* --- MASKDWORD/RF primitives (dm API shims; shared with 8822B) --- */ uint32_t bb_get(uint16_t addr, uint32_t mask); void bb_set(uint16_t addr, uint32_t mask, uint32_t data); @@ -104,6 +113,14 @@ class Halrf8821c : public Jaguar2Calibration { bool _iqk_fail_report[2] = {true, true}; /* [TX_IQK|RX_IQK] */ uint16_t _rxiqk_agc[2] = {}; uint32_t _iqc_matrix[2] = {0x20000000, 0x20000000}; /* inert (no reload) */ + + /* --- thermal TX-power tracking state (path A only) --- */ + uint8_t _pt_baseline = 0xff; /* efuse 0xBA; 0xff = disabled */ + uint8_t _pt_channel = 0; + uint8_t _pt_default_ofdm = 24; /* reverse-mapped 0xc1c[31:21]; 24 = 0 dB */ + int _pt_avg[4] = {}; + int _pt_avg_idx = 0; + int _pt_last_swing = 0x7fffffff; }; } /* namespace jaguar2 */ diff --git a/src/jaguar2/Halrf8822b.cpp b/src/jaguar2/Halrf8822b.cpp index c03d1be..19c8bfa 100644 --- a/src/jaguar2/Halrf8822b.cpp +++ b/src/jaguar2/Halrf8822b.cpp @@ -677,6 +677,143 @@ void Halrf8822b::iqk_trigger(bool band2g) { _rxiqk_fail_code[1]); } +/* ===== Thermal TX-power tracking ================================= + * Vendor-faithful MIX_MODE port of odm_txpowertracking_new_callback_thermal_meter + * + odm_tx_pwr_track_set_pwr8822b / get_mix_mode_tx_agc_bb_swing_offset_8822b. + * Tables transcribed from reference/rtl88x2bu/hal/phydm/halrf/rtl8822b/ + * halhwimg8822b_rf.c (delta_swingidx_mp_*_txpwrtrk_8822b, D_S_SIZE=30) and + * tx_scaling_table_jaguar (halrf_powertracking_ce.c, TXSCALE_TABLE_SIZE=37). */ +namespace { +constexpr int D_S = 30; /* DELTA_SWINGIDX_SIZE */ + +/* BB TX-scale multipliers (0xc1c/0xe1c[31:21]); index 24 = 0 dB, 0.5 dB/step. */ +const uint32_t tx_scaling_table_jaguar[37] = { + 0x081, 0x088, 0x090, 0x099, 0x0A2, 0x0AC, 0x0B6, 0x0C0, 0x0CC, 0x0D8, + 0x0E5, 0x0F2, 0x101, 0x110, 0x120, 0x131, 0x143, 0x156, 0x16A, 0x180, + 0x197, 0x1AF, 0x1C8, 0x1E3, 0x200, 0x21E, 0x23E, 0x261, 0x285, 0x2AB, + 0x2D3, 0x2FE, 0x32B, 0x35C, 0x38E, 0x3C4, 0x3FE}; + +/* 2.4 GHz swing (path A = 2ga, path B = 2gb), warming (p) / cooling (n). */ +const uint8_t k2ga_p[D_S] = {0, 1, 2, 3, 3, 4, 5, 6, 6, 7, 8, 9, 9, 10, 11, + 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 22, 22}; +const uint8_t k2ga_n[D_S] = {0, 1, 2, 3, 3, 4, 5, 6, 6, 7, 8, 9, 10, 11, 12, + 13, 13, 14, 15, 16, 17, 18, 18, 18, 18, 18, 18, 18, 18, 18}; +const uint8_t k2gb_p[D_S] = {0, 1, 1, 2, 3, 4, 4, 5, 6, 7, 7, 8, 9, 10, 11, + 12, 12, 13, 14, 15, 16, 17, 17, 18, 19, 20, 21, 22, 22, 22}; +const uint8_t k2gb_n[D_S] = {0, 1, 2, 3, 3, 4, 4, 5, 6, 7, 8, 9, 10, 11, 12, + 13, 13, 14, 15, 16, 16, 17, 18, 18, 18, 18, 18, 18, 18, 18}; + +/* 5 GHz swing, 3 sub-bands: [0]=ch36-64, [1]=ch100-144, [2]=ch149-177. */ +const uint8_t k5ga_p[3][D_S] = { + {0, 1, 2, 2, 3, 4, 5, 5, 6, 7, 8, 9, 9, 10, 11, 12, 13, 14, 14, 15, 16, 17, 18, 19, 19, 20, 20, 20, 20, 20}, + {0, 1, 2, 2, 3, 4, 4, 5, 6, 6, 7, 7, 8, 9, 9, 10, 11, 11, 12, 13, 14, 15, 16, 16, 17, 17, 18, 18, 18, 18}, + {0, 1, 2, 3, 3, 4, 5, 5, 6, 6, 7, 8, 8, 9, 10, 11, 12, 12, 13, 14, 15, 15, 16, 17, 17, 18, 18, 18, 18, 18}}; +const uint8_t k5ga_n[3][D_S] = { + {0, 1, 2, 2, 3, 3, 4, 5, 6, 7, 8, 8, 9, 9, 10, 11, 11, 12, 12, 12, 13, 13, 14, 14, 14, 15, 15, 15, 15, 15}, + {0, 1, 2, 2, 3, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 10, 10, 11, 11, 12, 12, 13, 13, 14, 14, 14, 14, 14, 14, 14}, + {0, 1, 2, 2, 3, 4, 4, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 12, 12, 13, 13, 14, 14, 14, 14, 14, 14, 14}}; +const uint8_t k5gb_p[3][D_S] = { + {0, 1, 2, 2, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 11, 12, 13, 14, 15, 15, 16, 17, 18, 18, 19, 19, 19, 19, 19, 19}, + {0, 1, 2, 2, 3, 4, 5, 6, 6, 7, 8, 8, 9, 9, 10, 11, 12, 12, 13, 14, 15, 16, 17, 17, 18, 18, 18, 18, 18, 18}, + {0, 1, 2, 2, 3, 4, 5, 5, 6, 6, 7, 8, 8, 9, 10, 10, 11, 12, 13, 14, 15, 15, 16, 16, 17, 17, 17, 17, 17, 17}}; +const uint8_t k5gb_n[3][D_S] = { + {0, 1, 2, 3, 3, 4, 5, 6, 6, 7, 8, 9, 9, 10, 10, 11, 11, 12, 12, 12, 13, 13, 14, 14, 14, 15, 15, 15, 15, 15}, + {0, 1, 1, 2, 2, 3, 3, 4, 5, 5, 6, 7, 7, 8, 8, 9, 10, 10, 11, 12, 12, 13, 13, 14, 14, 14, 14, 14, 14, 14}, + {0, 1, 2, 2, 3, 3, 4, 4, 5, 6, 6, 7, 7, 8, 9, 9, 10, 10, 11, 12, 12, 13, 13, 14, 14, 14, 14, 14, 14, 14}}; +} /* namespace */ + +void Halrf8822b::set_pwr_track_ctx(uint8_t baseline, uint8_t channel) { + _pt_baseline = baseline; + _pt_channel = channel; + for (auto &a : _pt_avg) + for (auto &x : a) + x = 0; + _pt_avg_idx[0] = _pt_avg_idx[1] = 0; + _pt_last_swing[0] = _pt_last_swing[1] = 0x7fffffff; + /* default_ofdm_index = reverse-map the current 0xc1c[31:21] BB swing to a + * tx_scaling_table_jaguar index (the level the phydm BB table left); 24 + * (0 dB) if no exact match — matches the vendor `else` fallback. */ + _pt_default_ofdm = 24; + const uint32_t cur = bb_get(0xc1c, 0xFFE00000); + for (int i = 0; i < 37; i++) + if (tx_scaling_table_jaguar[i] == cur) { + _pt_default_ofdm = static_cast(i); + break; + } + _logger->info("Jaguar2 8822B thermal-track: baseline={} ch={} default_ofdm={}", + baseline, channel, _pt_default_ofdm); +} + +void Halrf8822b::pwr_track_write(uint8_t path, int swing, int cur_ofdm_idx) { + /* MIX_MODE split: coarse steps to TXAGC (0xc94), remnant to BB swing (0xc1c), + * bounded by the headroom above the current OFDM index and the BB table. */ + const int tx_pwr_idx = cur_ofdm_idx < 0 ? 63 : (cur_ofdm_idx > 63 ? 63 : cur_ofdm_idx); + int headroom = 63 - tx_pwr_idx; /* tx_power_index_offset */ + if (headroom > 0xF) + headroom = 0xF; + const int def = _pt_default_ofdm; + const int ub = def + 10; + int tx_agc_index, bb_swing_index; + if (swing >= 0 && swing <= headroom) { + tx_agc_index = swing; + bb_swing_index = def; + } else if (swing > headroom) { + tx_agc_index = headroom; + bb_swing_index = def + (swing - headroom); + if (bb_swing_index > ub) + bb_swing_index = ub; + } else { /* swing < 0 (cooling) */ + tx_agc_index = 0; + bb_swing_index = def > -swing ? def + swing : 0; + } + if (bb_swing_index < 0) + bb_swing_index = 0; + if (bb_swing_index > 36) + bb_swing_index = 36; + const uint16_t c94 = (path == 0) ? 0xc94 : 0xe94; + const uint16_t c1c = (path == 0) ? 0xc1c : 0xe1c; + bb_set(c94, 0x3E000000, static_cast(tx_agc_index) & 0x1f); /* [29:25] */ + bb_set(c1c, 0xFFE00000, tx_scaling_table_jaguar[bb_swing_index]); +} + +void Halrf8822b::pwr_track(int current_ofdm_index) { + if (_pt_baseline == 0xff) + return; + const bool g2 = _pt_channel <= 14; + const int sub = g2 ? 0 : (_pt_channel <= 64 ? 0 : (_pt_channel <= 144 ? 1 : 2)); + /* Single die thermal sensor: only path A's RF 0x42 meter reads back (path B + * reads 0 — its sensor isn't enabled in this bring-up), and both PA chains + * share the die and heat together, so one measured delta drives both. Each + * path still uses its own delta-swing curve (2ga/2gb, 5ga/5gb). */ + const int cur = static_cast((rf_get(0, 0x42) >> 10) & 0x3f); + _pt_avg[0][_pt_avg_idx[0]] = cur; + _pt_avg_idx[0] = (_pt_avg_idx[0] + 1) & 3; + int sum = 0, cnt = 0; + for (int i = 0; i < 4; i++) + if (_pt_avg[0][i]) { + sum += _pt_avg[0][i]; + cnt++; + } + if (!cnt) + return; /* meter not ready — no valid sample yet */ + const int avg = sum / cnt; + int delta = avg > _pt_baseline ? avg - _pt_baseline : _pt_baseline - avg; + if (delta > D_S - 1) + delta = D_S - 1; + const int paths = _2t2r ? 2 : 1; + for (int p = 0; p < paths; p++) { + const uint8_t *up = g2 ? (p == 0 ? k2ga_p : k2gb_p) : (p == 0 ? k5ga_p[sub] : k5gb_p[sub]); + const uint8_t *dn = g2 ? (p == 0 ? k2ga_n : k2gb_n) : (p == 0 ? k5ga_n[sub] : k5gb_n[sub]); + const int swing = avg > _pt_baseline ? up[delta] : -static_cast(dn[delta]); + if (swing == _pt_last_swing[p]) + continue; /* no change — skip the USB write */ + _pt_last_swing[p] = swing; + pwr_track_write(static_cast(p), swing, current_ofdm_index); + _logger->info("Jaguar2 8822B thermal-track: path{} avg={} d={} swing={}", + p, avg, delta, swing); + } +} + #if defined(DEVOURER_HAVE_JAGUAR2_8821C) /* Defined in Halrf8821c.cpp. */ std::unique_ptr diff --git a/src/jaguar2/Halrf8822b.h b/src/jaguar2/Halrf8822b.h index 51f7181..938ef67 100644 --- a/src/jaguar2/Halrf8822b.h +++ b/src/jaguar2/Halrf8822b.h @@ -29,7 +29,17 @@ class Halrf8822b : public Jaguar2Calibration { * per-path LOK/TXK/RXK, backing up and restoring MAC/BB/RF around it. */ void iqk_trigger(bool band2g) override; + /* Thermal TX-power tracking — see Jaguar2Calibration. 2T2R: + * both paths tracked (0xc94/0xe94 + 0xc1c/0xe1c). */ + void set_pwr_track_ctx(uint8_t baseline, uint8_t channel) override; + void pwr_track(int current_ofdm_index) override; + private: + /* MIX_MODE split (get_mix_mode_tx_agc_bb_swing_offset_8822b) + register + * write for one path: coarse swing -> 0xc94/0xe94[29:25] TXAGC, remnant -> + * 0xc1c/0xe1c[31:21] BB scale via tx_scaling_table_jaguar. */ + void pwr_track_write(uint8_t path, int swing, int current_ofdm_index); + /* --- MASKDWORD/RF primitives (dm API shims) --- */ uint32_t bb_get(uint16_t addr, uint32_t mask); void bb_set(uint16_t addr, uint32_t mask, uint32_t data); @@ -93,6 +103,14 @@ class Halrf8822b : public Jaguar2Calibration { uint8_t _lna_idx = 0; bool _isbnd = false; uint32_t _tmp_gntwl = 0; + + /* --- thermal TX-power tracking state --- */ + uint8_t _pt_baseline = 0xff; /* efuse 0xBA; 0xff = disabled */ + uint8_t _pt_channel = 0; /* for band-table select each tick */ + uint8_t _pt_default_ofdm = 24; /* reverse-mapped 0xc1c[31:21]; 24 = 0 dB */ + int _pt_avg[2][4] = {}; /* AVG_THERMAL_NUM_8822B=4 rolling window */ + int _pt_avg_idx[2] = {}; + int _pt_last_swing[2] = {0x7fffffff, 0x7fffffff}; /* write-on-change */ }; } /* namespace jaguar2 */ diff --git a/src/jaguar2/Jaguar2Calibration.h b/src/jaguar2/Jaguar2Calibration.h index 1dc8d86..284e618 100644 --- a/src/jaguar2/Jaguar2Calibration.h +++ b/src/jaguar2/Jaguar2Calibration.h @@ -26,6 +26,23 @@ class Jaguar2Calibration { /* phy_iq_calibrate entry (SW path). band2g: true = 2.4 GHz. Runs the full * per-path LOK/TXK/RXK, backing up and restoring MAC/BB/RF around it. */ virtual void iqk_trigger(bool band2g) = 0; + + /* Thermal TX-power tracking (8822B/8821C, default no-op so a + * future variant compiles without a stub). `set_pwr_track_ctx` wires the + * efuse thermal baseline + channel (band-table select) once bring-up is + * complete and primes the per-path rolling average + the reverse-mapped + * default BB-swing index (from 0xc1c[31:21]). `pwr_track` runs one + * compensation tick — reads the RF 0x42[15:10] meter per path, averages, + * and writes the vendor MIX_MODE swing (0xc94/0xe94 TXAGC coarse split + + * 0xc1c/0xe1c BB scale). `current_ofdm_index` is the live path-A OFDM TXAGC + * software shadow used for the MIX_MODE headroom (63 − idx); -1 = TXAGC + * never written (fall back to all-swing-into-BB). Serialization against the + * channel set / TX-power setters is the caller's responsibility. */ + virtual void set_pwr_track_ctx(uint8_t baseline, uint8_t channel) { + (void)baseline; + (void)channel; + } + virtual void pwr_track(int current_ofdm_index) { (void)current_ofdm_index; } }; /* Factory: returns the calibration impl for the given chip. cut / is_2t2r come diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 530613f..206c8fa 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -35,6 +35,7 @@ RtlJaguar2Device::RtlJaguar2Device(RtlAdapter device, Logger_t logger, RtlJaguar2Device::~RtlJaguar2Device() { /* Safety net: restore the chip if a CW tone is still armed. */ StopCwTone(); + stop_pwrtrack(); stop_dig(); } @@ -115,11 +116,13 @@ void RtlJaguar2Device::bring_up(SelectedChannel channel) { * halrf_iqk_8821c port reports a clean pass and — with the AFE quad * (0xc58/0xc5c/0xc60/0xc6c) added to its backup/restore so afe_setting(false)'s * IQK-exit values don't persist — no longer disturbs the OFDM/HT TX path. */ + /* Persistent calibration (IQK + thermal tracking) — construct once here now + * that cut / rf-type are known, so the thermal tick can reuse it post-bring-up. */ + _cal = jaguar2::make_jaguar2_calibration( + _variant, _device, _logger, _hal.chip_version().cut, + _hal.chip_version().rf_2t2r != 0); if (!_cfg.tuning.skip_iqk) { - auto cal = jaguar2::make_jaguar2_calibration( - _variant, _device, _logger, _hal.chip_version().cut, - _hal.chip_version().rf_2t2r != 0); - cal->iqk_trigger(channel.Channel <= 14); + _cal->iqk_trigger(channel.Channel <= 14); } else { _logger->info("Jaguar2: IQK SKIPPED (tuning.skip_iqk)"); } @@ -171,6 +174,52 @@ void RtlJaguar2Device::bring_up(SelectedChannel channel) { channel.ChannelOffset); } _brought_up = true; + + /* Thermal TX-power tracking: prime the calibration with the + * efuse baseline + channel and start the ~2 s tick. Covers both Init (RX) + * and InitWrite (TX-only). Disabled by knob or an unprogrammed efuse + * baseline (0xFF), where a delta would be meaningless. */ + if (_cfg.tuning.thermal_track && _cal) { + uint8_t raw = 0, baseline = 0xff; + _hal.read_thermal(raw, baseline); + if (baseline != 0xff) { + _cal->set_pwr_track_ctx(baseline, static_cast(channel.Channel)); + start_pwrtrack(); + } else { + _logger->info("Jaguar2: thermal tracking disabled (unprogrammed efuse " + "baseline 0xBA=0xFF)"); + } + } +} + +void RtlJaguar2Device::start_pwrtrack() { + _pwrtrack_stop = false; + _pwrtrack_thread = std::thread([this] { + /* ~2 s cadence via steady_clock, polled in short slices so the dtor join + * returns promptly on shutdown. */ + auto next = std::chrono::steady_clock::now(); + while (!_pwrtrack_stop && !g_devourer_should_stop) { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + if (std::chrono::steady_clock::now() < next) + continue; + next += std::chrono::seconds(2); + std::lock_guard lk(_reg_mu); + /* Don't perturb a bare CW/continuous carrier — the tick's 0xc94/0xc1c + * writes would corrupt the tone and its saved-state restore. */ + if (_cw_active || _cont_active) + continue; + int cck = -1, ofdm = -1, mcs7 = -1; + _hal.txagc_shadow(cck, ofdm, mcs7); + _cal->pwr_track(ofdm); + } + }); + _logger->info("RtlJaguar2Device: thermal-track thread started"); +} + +void RtlJaguar2Device::stop_pwrtrack() { + _pwrtrack_stop = true; + if (_pwrtrack_thread.joinable()) + _pwrtrack_thread.join(); } void RtlJaguar2Device::apply_tx_power_current() { @@ -545,6 +594,8 @@ void RtlJaguar2Device::StopContinuousTx() { } void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { + /* Serialize against the thermal-track tick's RF-window read. */ + std::lock_guard lk(_reg_mu); _channel = channel; /* Retune the RF/BB to the new channel. set_channel_bw is a pure tune (RF18 + * bandwidth registers) — no per-channel LCK/IQK/TX-power — so it is cheap @@ -566,6 +617,8 @@ void RtlJaguar2Device::SetMonitorChannel(SelectedChannel channel) { void RtlJaguar2Device::FastRetune(uint8_t channel, bool cache_rf) { if (channel == _channel.Channel) return; + /* Serialize against the thermal-track tick's RF-window read. */ + std::lock_guard lk(_reg_mu); if (_hal.fast_retune(channel, static_cast(_channel.ChannelWidth), _channel.ChannelOffset, cache_rf)) { _channel.Channel = channel; @@ -609,6 +662,9 @@ devourer::TxPowerCaps RtlJaguar2Device::GetTxPowerCaps() { } int RtlJaguar2Device::SetTxPowerOffsetQdb(int qdb) { + /* Serialize the TXAGC write + shadow update against the thermal tick, which + * reads the OFDM shadow for its MIX_MODE headroom. */ + std::lock_guard lk(_reg_mu); if (_cw_active) { _logger->warn("SetTxPowerOffsetQdb refused: CW tone active (TXAGC does " "not modulate a bare LO carrier)"); @@ -628,6 +684,7 @@ int RtlJaguar2Device::SetTxPowerOffsetQdb(int qdb) { } void RtlJaguar2Device::SetTxPowerIndexOverride(int idx) { + std::lock_guard lk(_reg_mu); if (_cw_active) { _logger->warn("SetTxPowerIndexOverride refused: CW tone active"); return; @@ -638,6 +695,7 @@ void RtlJaguar2Device::SetTxPowerIndexOverride(int idx) { } bool RtlJaguar2Device::ReApplyTxPower() { + std::lock_guard lk(_reg_mu); if (!_brought_up || _cw_active) return false; apply_tx_power_current(); @@ -742,6 +800,8 @@ devourer::ThermalStatus RtlJaguar2Device::GetThermalStatus() { devourer::ThermalStatus t; if (!_brought_up) return t; /* RF reads need a powered chip */ + /* Serialize the RF-window read against the thermal-track tick. */ + std::lock_guard lk(_reg_mu); uint8_t raw = 0, baseline = 0xFF; _hal.read_thermal(raw, baseline); t.raw = raw; @@ -946,7 +1006,10 @@ bool RtlJaguar2Device::send_packet(const uint8_t *packet, size_t length) { SelectedChannel RtlJaguar2Device::GetSelectedChannel() { return _channel; } -void RtlJaguar2Device::Stop() { stop_dig(); } +void RtlJaguar2Device::Stop() { + stop_pwrtrack(); + stop_dig(); +} void RtlJaguar2Device::SetTxMode(const devourer::TxMode &mode) { _tx_mode_default = mode; diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 0269ebf..101aab1 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -2,6 +2,8 @@ #define RTL_JAGUAR2_DEVICE_H #include +#include +#include #include #include @@ -14,6 +16,7 @@ #include "HalmacJaguar2MacInit.h" #include "HalmacJaguar2Fw.h" #include "ChipVariant.h" +#include "Jaguar2Calibration.h" /* RtlJaguar2Device is the orchestrator for the Realtek "Jaguar2" 802.11ac family * — RTL8822BU (chip 8822B, 2T2R, USB). It is the Jaguar2 sibling of @@ -181,14 +184,17 @@ class RtlJaguar2Device : public IRtlDevice { /* CW single-tone (StartCwTone/StopCwTone) saved state for a clean restore: * the pre-tone RF 0x00 (path A) and the four RFE-pinmux BB words - * (0xCB0/0xEB0/0xCB4/0xEB4). _cw_active guards double start/stop. */ - bool _cw_active = false; + * (0xCB0/0xEB0/0xCB4/0xEB4). _cw_active guards double start/stop. Atomic so + * the thermal-track tick reads a coherent value for its skip-during-tone + * guard. */ + std::atomic _cw_active{false}; uint32_t _cw_rf00 = 0; uint32_t _cw_bb[4] = {0, 0, 0, 0}; /* Modulated continuous TX (StartContinuousTx/StopContinuousTx) guard + saved - * pre-continuous rCCAonSec (0x838) for a clean restore. */ - bool _cont_active = false; + * pre-continuous rCCAonSec (0x838) for a clean restore. Atomic — see + * _cw_active. */ + std::atomic _cont_active{false}; uint32_t _cont_cca838 = 0; /* DIG (dynamic initial gain) background thread — periodically runs @@ -197,6 +203,24 @@ class RtlJaguar2Device : public IRtlDevice { std::atomic _dig_stop{false}; void stop_dig(); + /* Per-chip halrf calibration (IQK + thermal TX-power tracking). + * Persistent (not a bring_up local) so the thermal tick can reuse it after + * bring-up; constructed in bring_up once cut / rf-type are known. */ + std::unique_ptr _cal; + + /* Thermal TX-power tracking background thread. Reads the RF + * thermal meter every ~2 s and writes the MIX_MODE swing compensation via + * _cal->pwr_track(). Started at the end of bring_up so it covers BOTH the RX + * (Init) and TX-only (InitWrite) sessions — the sustained-TX case the DIG + * thread (RX-only) never reaches. Serialized against SetMonitorChannel / + * FastRetune / the TX-power setters / GetThermalStatus by _reg_mu (the RF + * read window is a multi-transfer sequence that must not tear). */ + std::mutex _reg_mu; + std::thread _pwrtrack_thread; + std::atomic _pwrtrack_stop{false}; + void start_pwrtrack(); + void stop_pwrtrack(); + /* StartRxLoop stop request (StopRxLoop). volatile (not atomic) to match the * signal-flag pattern used across the library (g_devourer_should_stop). */ volatile bool _rx_stop = false; diff --git a/tests/jaguar2_thermal_track.sh b/tests/jaguar2_thermal_track.sh new file mode 100755 index 0000000..c656bf0 --- /dev/null +++ b/tests/jaguar2_thermal_track.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Hardware validation for Jaguar2 thermal TX-power tracking. +# +# Exercises the new ~2 s thermal-track tick end-to-end on real hardware and +# asserts three invariants per plugged Jaguar2 family: +# +# A. SOAK — txdemo TX-only (InitWrite path, the sustained-TX case) at max +# duty (DEVOURER_TX_GAP_US=0) with tracking ON: the thermal-track thread +# starts, the RF 0x42 meter reads a plausible value (raw near baseline), +# the MIX_MODE swing is written, TX keeps airing, and shutdown does not +# hang. Every measured delta stays small (<= DELTA_MAX) — a garbage meter +# read (e.g. a dead path-B sensor returning 0) would spike delta and fail. +# B. OFF — with DEVOURER_THERMAL_TRACK=0 the feature is fully inert: no +# thread, no meter reads, no swing writes (the pre-feature code path). +# +# Usage: sudo tests/jaguar2_thermal_track.sh [channel] [soak_secs] +set -u + +HERE="$(cd "$(dirname "$0")" && pwd)" +BUILD="$HERE/../build" +TXDEMO="$BUILD/txdemo" +CH="${1:-36}" +SOAK="${2:-25}" +DELTA_MAX=15 # a real bus-powered soak drifts a few units; 27 = garbage read + +declare -A ADAPTERS=( + [8822B_T3U]="0x2357 0x012d" + [8821C_CF811]="0x0bda 0xc811" +) + +CHILD="" +cleanup() { + [[ -n "$CHILD" ]] && kill -INT "$CHILD" 2>/dev/null + sleep 0.3 + pkill -KILL -x txdemo 2>/dev/null +} +trap cleanup EXIT INT TERM + +[[ -x "$TXDEMO" ]] || { echo "ERROR: txdemo not built" >&2; exit 1; } + +present() { local v=${1#0x} p=${2#0x}; lsusb -d "${v}:${p}" >/dev/null 2>&1; } + +run_txdemo() { # VID PID THERMAL_TRACK secs -> log on stdout + local vid=$1 pid=$2 tt=$3 secs=$4 out=$5 + DEVOURER_VID=$vid DEVOURER_PID=$pid DEVOURER_CHANNEL=$CH \ + DEVOURER_TX_RATE=MCS7 DEVOURER_TX_GAP_US=0 \ + DEVOURER_THERMAL_POLL_MS=1000 DEVOURER_THERMAL_TRACK=$tt \ + DEVOURER_LOG_LEVEL=info \ + timeout "$secs" "$TXDEMO" >"$out" 2>&1 & + CHILD=$!; wait "$CHILD" 2>/dev/null; CHILD="" +} + +RC=0 +for name in "${!ADAPTERS[@]}"; do + read -r VID PID <<<"${ADAPTERS[$name]}" + if ! present "$VID" "$PID"; then + echo "== $name ($VID:$PID): not plugged, skipping"; continue + fi + echo "======================================================================" + echo "== $name ($VID:$PID) ch=$CH soak=${SOAK}s" + echo "======================================================================" + + # ---- A. SOAK (tracking ON) -------------------------------------------------- + LOG=$(mktemp) + run_txdemo "$VID" "$PID" 1 "$SOAK" "$LOG" + STARTED=$(grep -c "thermal-track thread started" "$LOG") + SWINGS=$(grep -c "thermal-track: .*swing=" "$LOG") + THERMS=$(grep -c '"ev":"thermal"' "$LOG") + MAXD=$(grep -oE 'thermal-track: .* d=[0-9]+' "$LOG" | grep -oE 'd=[0-9]+' \ + | grep -oE '[0-9]+' | sort -n | tail -1) + MAXD=${MAXD:-0} + echo " thread-start=$STARTED meter-reads=$THERMS swing-writes=$SWINGS max-delta=$MAXD" + echo " last thermal marker: $(grep '"ev":"thermal"' "$LOG" | tail -1)" + echo " last swing line : $(grep 'thermal-track: .*swing=' "$LOG" | tail -1)" + [[ "$STARTED" -ge 1 ]] || { echo " FAIL: thermal-track thread never started"; RC=1; } + [[ "$THERMS" -ge 1 ]] || { echo " FAIL: no live thermal meter reads"; RC=1; } + if [[ "$MAXD" -gt "$DELTA_MAX" ]]; then + echo " FAIL: measured delta spiked to $MAXD (> $DELTA_MAX) — garbage meter read"; RC=1 + fi + if grep -qiE 'wedge|deadlock|terminate|abort|segfault|what\(\)' "$LOG"; then + echo " FAIL: crash/wedge keyword in soak log"; RC=1 + fi + rm -f "$LOG" + + # ---- B. OFF is fully inert -------------------------------------------------- + OLOG=$(mktemp) + run_txdemo "$VID" "$PID" 0 8 "$OLOG" + OFF_LINES=$(grep -c 'thermal-track' "$OLOG") + if [[ "$OFF_LINES" -eq 0 ]]; then + echo " PASS: DEVOURER_THERMAL_TRACK=0 inert (no thread, no swing writes)" + else + echo " FAIL: tracking-off still emitted $OFF_LINES thermal-track lines"; RC=1 + fi + rm -f "$OLOG" +done + +echo "======================================================================" +[[ $RC -eq 0 ]] && echo "RESULT: PASS" || echo "RESULT: FAIL" +exit $RC diff --git a/tests/jaguar2_thermal_track_onair.sh b/tests/jaguar2_thermal_track_onair.sh new file mode 100755 index 0000000..802d0de --- /dev/null +++ b/tests/jaguar2_thermal_track_onair.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# On-air confirmation for Jaguar2 thermal tracking: with the +# tracking tick running, the canonical txdemo beacon must still air and be +# received. TX = 8822B (T3U), RX = known-good 8812AU monitor, count rx.txhit. +# +# Usage: sudo tests/jaguar2_thermal_track_onair.sh [channel] [secs] +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +BUILD="$HERE/../build" +TX_VID=0x2357 TX_PID=0x012d # T3U (8822B) transmitter +RX_VID=0x0bda RX_PID=0x8812 # 8812AU known-good monitor receiver +# Default ch6 @ 1M: max link budget for two close-in bus dongles, and it +# exercises the 2.4 GHz thermal swing-table branch. 5 GHz needs the SDR bench +# (the close-pair 5 GHz link budget is too tight for a monitor-RX txhit). +CH="${1:-6}" SECS="${2:-12}" RATE="${DEVOURER_TX_RATE:-1M}" + +TXPID="" RXPID="" RXLOG=$(mktemp) +cleanup() { + [[ -n "$TXPID" ]] && kill -INT "$TXPID" 2>/dev/null + [[ -n "$RXPID" ]] && kill -INT "$RXPID" 2>/dev/null + sleep 0.3; pkill -KILL -x txdemo 2>/dev/null; pkill -KILL -x rxdemo 2>/dev/null + rm -f "$RXLOG" +} +trap cleanup EXIT INT TERM + +echo "== RX 8812AU ch$CH ==" +DEVOURER_VID=$RX_VID DEVOURER_PID=$RX_PID DEVOURER_CHANNEL=$CH \ + timeout $((SECS + 4)) "$BUILD/rxdemo" >"$RXLOG" 2>/dev/null & +RXPID=$! +sleep 3 + +echo "== TX 8822B ch$CH $RATE, thermal tracking ON ==" +DEVOURER_VID=$TX_VID DEVOURER_PID=$TX_PID DEVOURER_CHANNEL=$CH \ + DEVOURER_TX_RATE=$RATE DEVOURER_TX_GAP_US=2000 DEVOURER_THERMAL_TRACK=1 \ + timeout "$SECS" "$BUILD/txdemo" >/dev/null 2>&1 & +TXPID=$! +wait "$TXPID" 2>/dev/null; TXPID="" +sleep 1 +kill -INT "$RXPID" 2>/dev/null; wait "$RXPID" 2>/dev/null; RXPID="" + +HITS=$(grep -c '"ev":"rx.txhit"' "$RXLOG") +echo "rx.txhit received: $HITS" +if [[ "$HITS" -ge 1 ]]; then + echo "RESULT: PASS (canonical beacon aired + received with tracking live)" +else + echo "RESULT: FAIL (no txhit — TX broke with tracking on)"; exit 1 +fi diff --git a/tests/jaguar2_thermal_track_sdr.py b/tests/jaguar2_thermal_track_sdr.py new file mode 100755 index 0000000..29d13a7 --- /dev/null +++ b/tests/jaguar2_thermal_track_sdr.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""SDR on-air validation for Jaguar2 thermal TX-power tracking. + +The chip reaches its thermal plateau (delta ~6-8 on a bus-powered dongle) within +seconds, so a cold->hot power sweep has too little delta spread. Instead this +measures the compensation DIRECTLY, on-air, at a matched hot steady state: + + 1. Pre-heat the PA to steady state (txdemo, tracking OFF). + 2. Measure on-air power P_off with tracking OFF (USRP gated dBFS). + 3. Restart txdemo with tracking ON (chip stays hot), let the ~2 s tick ramp + its swing to the plateau, measure P_on. + +At the same hot state the tracking-ON pass adds the delta-swing compensation the +tracking-OFF pass omits, so P_on - P_off is exactly the PA gain droop the +tracker restores on-air. It is cross-checked against the swing index the driver +logged (8822B TXAGC step = 0.5 dB), so the number is grounded, not just a delta. + +A single USRP probe streams for the whole run (one clean acquisition — a +back-to-back re-open can fail to reacquire and read ~0). + +Run via tests/run_jaguar2_thermal_track_sdr.sh (uv venv w/ system uhd + sudo). +""" +import argparse +import os +import re +import statistics +import subprocess +import sys +import threading +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import regress # noqa: E402 +from devourer_events import parse_event # noqa: E402 + +SDR_RE = re.compile(r"sdr-power dbfs=([+-]?[\d.]+)") +GATED_RE = re.compile(r"gated_dbfs=([+-]?[\d.]+)") +SWING_RE = re.compile(r"thermal-track:.* d=(\d+) swing=([+-]?\d+)") +CH_FREQ_MHZ = {1: 2412, 6: 2437, 11: 2462, 36: 5180, 44: 5220, + 100: 5500, 149: 5745, 161: 5805} + + +class Sdr: + """One long-lived USRP power probe; samples tagged with monotonic time.""" + def __init__(self, freq, rate, gain, log): + self.samples = [] # (t_mono, dbfs) + self.lock = threading.Lock() + self.streaming = threading.Event() + self.proc = regress._register_local_proc(subprocess.Popen( + [sys.executable, str(HERE / "sdr_power_probe.py"), + "--freq", str(freq), "--rate", str(rate), "--gain", str(gain), + "--gated"], stdout=subprocess.PIPE, stderr=log, text=True, + preexec_fn=regress._child_preexec)) + threading.Thread(target=self._read, daemon=True).start() + + def _read(self): + for raw in self.proc.stdout: + self.streaming.set() + m = GATED_RE.search(raw) or SDR_RE.search(raw) + if m: + with self.lock: + self.samples.append((time.monotonic(), float(m.group(1)))) + + def window(self, t_start, t_end): + with self.lock: + return [db for t, db in self.samples if t_start <= t <= t_end] + + def stop(self): + self.proc.terminate() + + +def run_txdemo(track_on, args, txdemo, log): + env = dict(os.environ, + DEVOURER_VID=args.vid, DEVOURER_PID=args.pid, + DEVOURER_CHANNEL=str(args.channel), + DEVOURER_TX_RATE=args.rate, DEVOURER_TX_GAP_US="0", + DEVOURER_THERMAL_POLL_MS=str(args.thermal_ms), + DEVOURER_THERMAL_TRACK=("1" if track_on else "0")) + deltas = [] + proc = regress._register_local_proc(subprocess.Popen( + [str(txdemo)], env=env, stdout=subprocess.PIPE, stderr=log, text=True, + preexec_fn=regress._child_preexec)) + + def read(): + for raw in proc.stdout: + ev = parse_event(raw.rstrip("\n")) + if ev and ev.get("ev") == "thermal" and ev.get("delta") is not None: + deltas.append(int(ev["delta"])) + threading.Thread(target=read, daemon=True).start() + return proc, deltas + + +def kill(proc, comm=None): + proc.terminate() + time.sleep(0.4) + if comm: + subprocess.run(["pkill", "-x", comm], check=False) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--vid", default="0x2357") # T3U 8822B + ap.add_argument("--pid", default="0x012d") + ap.add_argument("--channel", type=int, default=6) + ap.add_argument("--rate", default="6M") + ap.add_argument("--preheat", type=float, default=70) # reach steady state + ap.add_argument("--settle", type=float, default=18) # swing ramp after ON restart + ap.add_argument("--meas", type=float, default=25) # SDR averaging window + ap.add_argument("--thermal-ms", type=int, default=500) + ap.add_argument("--sdr-freq", type=float, default=0) + ap.add_argument("--sdr-rate", type=float, default=25e6) + ap.add_argument("--sdr-gain", type=float, default=40.0) + ap.add_argument("--outdir", default="/tmp/tt_sdr2") + args = ap.parse_args() + + txdemo = HERE.parent / "build" / "txdemo" + if not txdemo.exists(): + sys.exit("txdemo not built") + sdr_freq = args.sdr_freq or CH_FREQ_MHZ.get(args.channel, 0) * 1e6 + if sdr_freq <= 0: + sys.exit(f"no freq for channel {args.channel}; pass --sdr-freq") + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + regress._install_cleanup_handlers() + + print(f"# DUT {args.vid}:{args.pid} ch{args.channel} {args.rate} | " + f"SDR {sdr_freq/1e6:.1f}MHz rate {args.sdr_rate/1e6:.0f}M\n") + + sdr = Sdr(sdr_freq, args.sdr_rate, args.sdr_gain, + open(outdir / f"sdr-{stamp}.log", "w")) + if not sdr.streaming.wait(timeout=25): + sys.exit("SDR probe never streamed") + time.sleep(1.0) + + # ---- Phase 1: tracking OFF, pre-heat then measure ------------------------ + off_log = open(outdir / f"tx-off-{stamp}.log", "w") + tx_off, d_off = run_txdemo(False, args, txdemo, off_log) + print(f"-- OFF: pre-heating {args.preheat:.0f}s to steady state --", flush=True) + time.sleep(args.preheat) + t0 = time.monotonic() + print(f"-- OFF: measuring on-air power {args.meas:.0f}s --", flush=True) + time.sleep(args.meas) + p_off = sdr.window(t0, time.monotonic()) + delta_off = statistics.median(d_off[-20:]) if d_off else None + kill(tx_off, "txdemo") + + # ---- Phase 2: tracking ON, chip still hot, settle then measure ----------- + on_log_path = outdir / f"tx-on-{stamp}.log" + on_log = open(on_log_path, "w") + tx_on, d_on = run_txdemo(True, args, txdemo, on_log) + print(f"-- ON: restarting hot + settling {args.settle:.0f}s (swing ramps) --", + flush=True) + time.sleep(args.settle) + t1 = time.monotonic() + print(f"-- ON: measuring on-air power {args.meas:.0f}s --", flush=True) + time.sleep(args.meas) + p_on = sdr.window(t1, time.monotonic()) + delta_on = statistics.median(d_on[-20:]) if d_on else None + kill(tx_on, "txdemo") + sdr.stop() + subprocess.run(["pkill", "-f", "tests/sdr_power_probe.py"], check=False) + + # swing the ON tick actually applied at the measured delta + swings = [] + for line in open(on_log_path): + m = SWING_RE.search(line) + if m: + swings.append(int(m.group(2))) + swing_hot = max(swings) if swings else 0 + + print("\n================ VERDICT ================") + if len(p_off) < 5 or len(p_on) < 5: + sys.exit(f"INCONCLUSIVE: too few SDR samples (off={len(p_off)} on={len(p_on)})") + m_off, m_on = statistics.fmean(p_off), statistics.fmean(p_on) + sd_off = statistics.pstdev(p_off) if len(p_off) > 1 else 0 + sd_on = statistics.pstdev(p_on) if len(p_on) > 1 else 0 + gain = m_on - m_off + expect = swing_hot * 0.5 # 8822B TXAGC step = 0.5 dB + print(f" OFF hot (Δ~{delta_off}): {m_off:.2f} dBFS (sd {sd_off:.2f}, n {len(p_off)})") + print(f" ON hot (Δ~{delta_on}): {m_on:.2f} dBFS (sd {sd_on:.2f}, n {len(p_on)})") + print(f" on-air gain from tracking = {gain:+.2f} dB") + print(f" swing applied (ON) = {swing_hot} idx => expected ~{expect:+.2f} dB") + print("-----------------------------------------") + # PASS: tracking measurably raises hot on-air power (restoring the droop), + # by an amount consistent with the swing it logged. + if gain >= 1.0 and gain >= 0.4 * expect and swing_hot >= 2: + print(f"RESULT: PASS — at a matched hot state the tracker adds {gain:+.2f} dB " + f"on-air (swing {swing_hot} idx ~ {expect:+.1f} dB expected), i.e. it " + f"restores the PA droop that OFF leaves uncorrected.") + sys.exit(0) + if swing_hot < 2: + print(f"RESULT: WEAK — chip barely heated (swing {swing_hot} idx); raise " + f"--preheat or lower --rate.") + sys.exit(1) + print(f"RESULT: FAIL — expected ~{expect:+.1f} dB of on-air gain, measured " + f"{gain:+.2f} dB (compensation not reaching the air).") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/run_jaguar2_thermal_track_sdr.sh b/tests/run_jaguar2_thermal_track_sdr.sh new file mode 100755 index 0000000..27a5fca --- /dev/null +++ b/tests/run_jaguar2_thermal_track_sdr.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# SDR on-air validation runner for Jaguar2 thermal tracking. +# Builds devourer, sets up a uv venv (system site-packages for UHD's `uhd`), +# and runs the soak orchestrator under sudo (USB claim needs root). Extra args +# forward to jaguar2_thermal_track_sdr.py, e.g.: +# ./tests/run_jaguar2_thermal_track_sdr.sh --channel 6 --secs 180 +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" + +cleanup() { + for comm in txdemo sdr_power_probe.py; do pkill -x "$comm" 2>/dev/null || true; done + pkill -f "tests/sdr_power_probe.py" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +echo "== building devourer ==" +cmake --build "$ROOT/build" -j >/dev/null + +echo "== preparing uv venv (system site-packages for uhd) ==" +cd "$HERE" +if ! command -v uv >/dev/null 2>&1; then + echo "uv not found — install it or run the orchestrator with system python3." >&2 + exit 1 +fi +[ -d "$HERE/.venv" ] || uv venv --system-site-packages "$HERE/.venv" +uv pip install --python "$HERE/.venv/bin/python" -q -e "$HERE" >/dev/null 2>&1 || true + +PY="$HERE/.venv/bin/python" +if ! "$PY" -c "import uhd, numpy" 2>/dev/null; then + echo "WARNING: 'import uhd' failed in venv; falling back to system python3." >&2 + PY="$(command -v python3)" +fi + +echo "== running SDR soak (sudo) ==" +exec sudo --preserve-env=UHD_IMAGES_DIR "$PY" "$HERE/jaguar2_thermal_track_sdr.py" "$@"