diff --git a/docs/narrowband.md b/docs/narrowband.md index 5b14298..088d638 100644 --- a/docs/narrowband.md +++ b/docs/narrowband.md @@ -149,8 +149,39 @@ chip-specific traps. The ones this port paid for, current-state: pair is therefore **bimodal per bring-up** at 5 MHz/5 GHz — it syncs on one power-up and is deaf on the next — while a closer-crystal peer decodes the same transmitter and the same pair is stable at 2.4 GHz. This is physics, not - a driver bug; the durable fix is a crystal-cap trim lever (tracked - separately). + a driver bug — and it drifts, so a fixed trim is not enough (below). The + manual lever is `IRtlDevice::SetXtalCap` (env `DEVOURER_XTAL_CAP`): + the AFE crystal load-capacitance trim pulls the chip's reference oscillator a + few ppm, so trimming one end of a marginal pair moves the offset off the sync + boundary. The trim range is per generation (`GetAdapterCaps().xtal_cap_max`: + 0x3f on Jaguar1/2, 0x7f on Jaguar3); the efuse default is `xtal_cap_default`. + Bench-measured authority (SDR, CW tone, live-stepped on one warmed chip — + `tests/xtal_cfo_sweep.sh sdr`): ~51 Hz/code at 5.2 GHz, a smooth monotonic + pull, ~0.6 ppm over the mid-range and ~1.25 ppm across the full range — + enough to move a marginal pair (typically 1–2 ppm) off the boundary, and it + does not detune 20 MHz. Note the open-loop caveat: a crystal drifts ~3 ppm + during cold-start warm-up (this is *why* 5 MHz/5 GHz is bimodal per + power-up), which is larger and faster than the trim step, so a fixed trim is + a per-link qualification lever, not fire-and-forget. The production answer is + the **closed loop** (`DEVOURER_CFO_TRACK`, all three generations): the receiver reads the + per-frame CFO tail from the OFDM phy-status (offset differs per generation — + see below), and a + bang-bang controller (`src/CfoTracker.h`, ported from phydm + `phydm_cfo_tracking`) steps the crystal cap on a ~2 s cadence to drive the + CFO toward its minimum, tracking the warm-up drift live. It auto-detects the + cap→LO polarity (which flips per silicon) and holds at the CFO-minimizing + cap; bench-verified to converge (measured CFO monotonically reduced as it + trims) while the link stays decoding. Caveats: the loop needs a + marginally-decoding link to bootstrap (no frames → no CFO error signal), it + runs on the receiver only, and where a pair's absolute offset exceeds the + ~1 ppm crystal-cap authority it converges to the rail (the best it can do) + rather than the deadband. The cfo_tail→kHz scale is the coarse vendor macro + (see `CfoTracker.h`) — the loop uses the CFO sign + deadband, not a + calibrated kHz. Wired on all three generations: Jaguar2/Jaguar3 share the + jgr2/jgr3 type1 phy-status (CFO tail at DW5), while Jaguar1's 11AC + phy-status carries it as the named `cfotail[0]` field of + `_phy_status_rpt_8812` (DW2 byte 1) — the same `CfoTracker` control law + feeds off whichever the parser fills. 4. **The same die-family can encode the divider differently.** The 8814A's bandwidth register looked like a mode selector (`0x8ac[1:0]`) with no @@ -204,5 +235,10 @@ fully, and **Jaguar1 on the 8812AU/8811AU and the 8814AU** — every generation. The 8821A is the one exclusion (its DAC-clock divide starves TX; see the walls). Test scripts: `tests/jaguar2_narrowband_sdr.sh` and -`tests/jaguar1_nb_divide_sweep.sh` (SDR occupied-bandwidth), and -`tests/narrowband_cross_rx.sh` (cross-generation decode). +`tests/jaguar1_nb_divide_sweep.sh` (SDR occupied-bandwidth), +`tests/narrowband_cross_rx.sh` (cross-generation decode), +`tests/xtal_cfo_sweep.sh` (crystal-cap trim authority), +`tests/jaguar1_cfo_track_smoke.sh` (closed-loop tick fires against ambient +traffic), and `tests/jaguar1_cfo_convergence.sh` (two-adapter Jaguar1 link: +the receiver's loop engages and steps its cap to reduce a real inter-crystal +offset — the same convergence behaviour validated on Jaguar2/Jaguar3). diff --git a/examples/common/caps_event.h b/examples/common/caps_event.h index 19275b8..6e080c1 100644 --- a/examples/common/caps_event.h +++ b/examples/common/caps_event.h @@ -66,7 +66,9 @@ inline void emit_adapter_caps(EventSink &sink, IRtlDevice *dev) { ev.f("per_pkt_txpwr", c.per_packet_txpower ? 1 : 0) .f("narrowband", c.narrowband_ok ? 1 : 0) .f("fastretune", c.fastretune_ok ? 1 : 0) - .f("per_chain_rssi", c.per_chain_rssi ? 1 : 0); + .f("per_chain_rssi", c.per_chain_rssi ? 1 : 0) + .f("xtal_cap_max", c.xtal_cap_max) + .f("xtal_cap_default", c.xtal_cap_default); } } // namespace devourer diff --git a/examples/common/env_config.cpp b/examples/common/env_config.cpp index b6b6f41..3514658 100644 --- a/examples/common/env_config.cpp +++ b/examples/common/env_config.cpp @@ -109,6 +109,9 @@ devourer::DeviceConfig devourer_config_from_env() { cfg.tuning.nb_dac = static_cast(v & 0x7); if (env_long("DEVOURER_NB_ADC", &v)) cfg.tuning.nb_adc = static_cast(v & 0x7); + if (env_long("DEVOURER_XTAL_CAP", &v)) + cfg.tuning.xtal_cap = static_cast(v & 0x7f); + cfg.tuning.cfo_track = env_flag("DEVOURER_CFO_TRACK"); if (const char *e = env_str("DEVOURER_REGULATION")) { if (str_ieq(e, "ETSI")) cfg.tuning.regulation = devourer::Regulation::ETSI; diff --git a/examples/tx/main.cpp b/examples/tx/main.cpp index e15ae40..b47aeeb 100644 --- a/examples/tx/main.cpp +++ b/examples/tx/main.cpp @@ -928,6 +928,30 @@ int main(int argc, char **argv) { #endif if (cw) { logger->info("CW tone hold — idling until SIGINT (Ctrl-C to stop)"); + /* DEVOURER_XTAL_STEP="c1,c2,..." + DEVOURER_XTAL_STEP_MS=N: step the + * crystal-cap trim live on the warmed carrier (no re-bring-up), logging + * a `xtal.step` event per value. Lets an SDR measure the LO shift per + * cap on ONE chip — the thermal drift between adjacent steps is seconds, + * not a cold power-cycle. This is also the primitive a closed-loop CFO + * controller drives. */ + const char *steps = std::getenv("DEVOURER_XTAL_STEP"); + if (steps) { + int dwell = 4000; + if (const char *ms = std::getenv("DEVOURER_XTAL_STEP_MS")) + dwell = std::atoi(ms); + std::string s(steps); + size_t pos = 0; + while (!g_devourer_should_stop && pos < s.size()) { + size_t comma = s.find(',', pos); + int cap = std::strtol(s.substr(pos, comma - pos).c_str(), nullptr, 0); + pos = (comma == std::string::npos) ? s.size() : comma + 1; + int applied = rtlDevice->SetXtalCap(cap); + devourer::Ev(*g_ev, "xtal.step").f("cap", applied); + logger->info("xtal.step cap=0x{:02x}", applied); + for (int t = 0; t < dwell && !g_devourer_should_stop; t += 100) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } while (!g_devourer_should_stop) std::this_thread::sleep_for(std::chrono::milliseconds(200)); #if defined(DEVOURER_HAVE_JAGUAR1) diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index bc11f75..0d3bd2b 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -114,6 +114,9 @@ struct AdapterCaps { /* --- feature flags --- */ bool per_packet_txpower = false; /* Jaguar2 descriptor TXPWR_OFSET LUT only */ bool narrowband_ok = false; /* 5/10 MHz re-clock (Jaguar2/Jaguar3) */ + uint8_t xtal_cap_max = 0; /* crystal-cap trim range top (0 = no trim; + * 0x3f on Jaguar1/2, 0x7f on Jaguar3) */ + uint8_t xtal_cap_default = 0; /* efuse/default crystal-cap code */ bool fastretune_ok = false; /* lean FastRetune override exists */ bool per_chain_rssi = false; /* frame parser fills per-chain rssi (>=2ch) */ }; diff --git a/src/CfoTracker.h b/src/CfoTracker.h new file mode 100644 index 0000000..3344939 --- /dev/null +++ b/src/CfoTracker.h @@ -0,0 +1,99 @@ +#ifndef DEVOURER_CFO_TRACKER_H +#define DEVOURER_CFO_TRACKER_H + +#include +#include + +namespace devourer { + +/* Closed-loop carrier-frequency-offset tracker (issue #217). Ported from the + * vendor phydm_cfo_tracking control law: accumulate the per-frame path-A CFO + * tail from the RX phy-status, and on a periodic tick step the crystal-cap + * trim (IRtlDevice::SetXtalCap) by ±1 to drive the average CFO toward zero. + * + * A bang-bang integrator with hysteresis: it starts correcting once |CFO| + * exceeds an enable threshold and stops inside a deadband, so it doesn't + * dither when already aligned. One cap step per tick keeps it gentle enough + * to track slow crystal warm-up drift without hunting. The receiver trims its + * OWN crystal to null the offset it measures against the transmitter — the + * natural single-ended loop in monitor mode (the TX is passive). + * + * CFO units: the phy-status tail is a signed HW code; the vendor + * CFO_HW_RPT_2_KHZ macro is code * 2.5, but that assumes the 20 MHz FFT grid — + * for the narrowband (5/10 MHz) re-clock the true kHz scale is smaller (bench + * cross-check against the SDR LO shift suggests the 2.5 figure reads ~10x + * high here), so treat the reported magnitude as a relative error signal, not + * a calibrated kHz. The controller only needs the SIGN (which way to trim) and + * a deadband; both hold under an uncalibrated linear scale. Thresholds are the + * vendor's 11/10 (in the same reported units). */ +class CfoTracker { + public: + /* Per-frame CFO tail from the OFDM phy-status (rx_pkt_attrib::cfo_tail). */ + void add(int8_t cfo_tail) { + _sum += cfo_tail; + ++_cnt; + } + + /* Periodic control tick. `cur_cap` is the current crystal-cap code, + * `cap_max` the trim ceiling. Returns the new cap to apply, or -1 for no + * change (no frames since last tick, inside the deadband, or already at a + * rail). `avg_khz_out` (optional) receives the measured average CFO. */ + int step(int cur_cap, int cap_max, double *avg_khz_out = nullptr) { + if (_cnt == 0) + return -1; + const double avg_khz = (static_cast(_sum) / _cnt) * 2.5; + _sum = 0; + _cnt = 0; + if (avg_khz_out) + *avg_khz_out = avg_khz; + const double mag = std::fabs(avg_khz); + + /* Auto-detect the cap->CFO polarity: the sign that reduces a positive CFO + * flips per silicon (some cap circuits raise the LO with cap, some lower + * it). If the previous step made |CFO| worse, the guess was backwards — + * flip it, once. */ + if (_stepped && mag > _prev_mag + 1.0) + _invert = !_invert; + _stepped = false; + + if (!_adjust) { + if (mag > kEnableThKhz) + _adjust = true; + else { + _prev_mag = mag; + return -1; + } + } else if (mag <= kStopThKhz) { + _adjust = false; + _prev_mag = mag; + return -1; + } + int dir = avg_khz > 0 ? +1 : -1; + if (_invert) + dir = -dir; + int nc = cur_cap + dir; + if (nc < 0) + nc = 0; + if (nc > cap_max) + nc = cap_max; + _prev_mag = mag; + if (nc == cur_cap) + return -1; + _stepped = true; + return nc; + } + + private: + static constexpr double kEnableThKhz = 11.0; + static constexpr double kStopThKhz = 10.0; + long _sum = 0; + int _cnt = 0; + bool _adjust = false; + bool _invert = false; + bool _stepped = false; + double _prev_mag = 0.0; +}; + +} /* namespace devourer */ + +#endif /* DEVOURER_CFO_TRACKER_H */ diff --git a/src/DeviceConfig.h b/src/DeviceConfig.h index 36b9e93..f01a1b5 100644 --- a/src/DeviceConfig.h +++ b/src/DeviceConfig.h @@ -172,6 +172,15 @@ struct DeviceConfig { * field — bits [1:0] -> 0x8ac[9:8], bit 2 -> 0x8ac[16] (divider-mapping * experiments only). */ std::optional nb_adc; + /* env: DEVOURER_XTAL_CAP — crystal-cap trim code applied at the end of + * bring-up (IRtlDevice::SetXtalCap). The CFO lever for narrowband at the + * edge of its budget; unset = efuse/default. Raw code, 0..0x3f (Jaguar1/2) + * or 0..0x7f (Jaguar3). */ + std::optional xtal_cap; + /* env: DEVOURER_CFO_TRACK — closed-loop CFO tracking (Jaguar3): auto-trim + * the crystal cap from the RX-measured per-frame CFO, tracking warm-up + * drift so narrowband stays locked (issue #217). Off by default. */ + bool cfo_track = false; /* env: DEVOURER_REGULATION — Jaguar1 regulatory domain override for the * TX-power limit tables (unset = efuse). */ std::optional regulation; diff --git a/src/IRtlDevice.h b/src/IRtlDevice.h index d5b5fe5..160c117 100644 --- a/src/IRtlDevice.h +++ b/src/IRtlDevice.h @@ -119,6 +119,23 @@ class IRtlDevice { * any knob. Returns false when unsupported or the chip isn't brought up. */ virtual bool ReApplyTxPower() { return false; } + /* Crystal (XTAL) load-capacitance trim — the CFO lever. Writes the AFE + * crystal-cap field (a per-chip register), pulling the chip's reference + * oscillator a few ppm to align a marginal TX/RX crystal pair; the payoff + * is narrowband at the edge of its CFO budget (5 MHz at 5 GHz). `cap` is a + * raw trim code in [0, GetAdapterCaps().xtal_cap_max]; cap < 0 reverts to + * the efuse/default value. Both physical caps (Xi/Xo) are set together. + * Returns the applied code, or -1 when unsupported. Sticky across channel + * changes (an AFE register, untouched by the RF retune). */ + virtual int SetXtalCap(int cap) { + (void)cap; + return -1; + } + + /* Current crystal-cap code (the last SetXtalCap value, or the efuse default + * at bring-up). -1 when unsupported. */ + virtual int GetXtalCap() { return -1; } + /* Snapshot of the knob state + representative effective indices (register * readback where the family's TXAGC block is readable). */ virtual devourer::TxPowerState GetTxPowerState() { return {}; } diff --git a/src/RxPacket.h b/src/RxPacket.h index 44582c1..7c04433 100644 --- a/src/RxPacket.h +++ b/src/RxPacket.h @@ -59,6 +59,11 @@ struct rx_pkt_attrib * bytes — a link-quality metric, NOT a per-subcarrier or content signal. * Surfaced for the DEVOURER_DUMP_BODY Tier-2 health diagnostic. */ int8_t evm[4]; + /* Path-A CFO tail from the OFDM phy-status (signed HW units; kHz = raw * + * 2.5, phydm CFO_HW_RPT_2_KHZ). The carrier-frequency offset between this + * receiver's crystal and the transmitter's — the closed-loop CFO tracker's + * input (see IRtlDevice::SetXtalCap). 0 when the phy-status carries none. */ + int8_t cfo_tail = 0; RX_PACKET_TYPE pkt_rpt_type; }; diff --git a/src/jaguar1/FrameParser.cpp b/src/jaguar1/FrameParser.cpp index 26951ce..65d95fc 100644 --- a/src/jaguar1/FrameParser.cpp +++ b/src/jaguar1/FrameParser.cpp @@ -249,6 +249,9 @@ std::vector FrameParser::recvbuf2recvframe(std::span ptr) { ret.back().RxAtrib.evm[1] = driver_data.rxevm[1]; ret.back().RxAtrib.evm[2] = driver_data.rxevm_cd[0]; ret.back().RxAtrib.evm[3] = driver_data.rxevm_cd[1]; + /* Path-A CFO tail — the closed-loop CFO tracker input (#217). 8812 + * 11AC phy-status carries it as a named field (DW2 byte1). */ + ret.back().RxAtrib.cfo_tail = driver_data.cfotail[0]; } else { /* pkt_rpt_type == TX_REPORT1-CCX, TX_REPORT2-TX RTP,HIS_REPORT-USB HISR * RTP, C2H_PACKET */ diff --git a/src/jaguar1/RtlJaguarDevice.cpp b/src/jaguar1/RtlJaguarDevice.cpp index 3bc9fe6..94613b0 100644 --- a/src/jaguar1/RtlJaguarDevice.cpp +++ b/src/jaguar1/RtlJaguarDevice.cpp @@ -64,6 +64,10 @@ void RtlJaguarDevice::InitWrite(SelectedChannel channel) { SetMonitorChannel(channel); _logger->info("In Monitor Mode"); + /* DEVOURER_XTAL_CAP — crystal-cap trim (issue #217, narrowband CFO lever). */ + if (_cfg.tuning.xtal_cap) + SetXtalCap(*_cfg.tuning.xtal_cap); + /* DEVOURER_BF_ARM_SOUNDER=1 — beamforming self-sounding probe (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. See @@ -632,6 +636,10 @@ void RtlJaguarDevice::Init(Action_ParsedRadioPacket packetProcessor, StartWithMonitorMode(channel); SetMonitorChannel(channel); + /* DEVOURER_XTAL_CAP — crystal-cap trim (issue #217, narrowband CFO lever). */ + if (_cfg.tuning.xtal_cap) + SetXtalCap(*_cfg.tuning.xtal_cap); + /* DEVOURER_BF_ARM_BFEE=aa:bb:cc:dd:ee:ff — beamforming self-sounding probe * (beamformee side): arm the hardware CSI responder so an NDPA+NDP from the * given beamformer MAC triggers a hardware-built VHT Compressed Beamforming @@ -722,7 +730,55 @@ void RtlJaguarDevice::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { int rx_urbs = _cfg.rx.urbs.value_or(8); if (rx_urbs < 1) rx_urbs = 1; - auto on_data = [this](const uint8_t *data, int n) { + + /* Closed-loop CFO tracking on the receiver (#217): tick the controller on a + * ~2 s cadence from the RX loop, mirroring Jaguar2/3. The crystal-cap field + * (0x2C, per-die position — see SetXtalCap) is written READ-FREE from a base + * cached before the RX flood: a control-transfer read races the async + * bulk-IN and throws under load. */ + uint32_t r2c_base = 0, r2c_mask = 0x7FF80000; + int r2c_shift = 19; + bool cfo_ok = false; + if (_cfg.tuning.cfo_track) { + switch (_eepromManager->version_id.ICType) { + case CHIP_8814A: r2c_mask = 0x07FF8000; r2c_shift = 15; break; + case CHIP_8821: r2c_mask = 0x00FFF000; r2c_shift = 12; break; + default: r2c_mask = 0x7FF80000; r2c_shift = 19; break; + } + try { + r2c_base = _device.rtw_read(0x002C) & ~r2c_mask; + cfo_ok = true; + } catch (...) { + _logger->info("Jaguar1 cfo.track: AFE base read failed — disabled"); + } + } + auto cfo_next = std::chrono::steady_clock::now() + std::chrono::seconds(2); + auto cfo_tick = [&]() { + if (!cfo_ok || std::chrono::steady_clock::now() < cfo_next) + return; + cfo_next += std::chrono::seconds(2); + double avg_khz = 0; + const int cur = _xtal_cap < 0 ? (_eepromManager->crystal_cap & 0x3F) + : _xtal_cap; + const int nc = _cfo.step(cur, 0x3f, &avg_khz); /* 6-bit on Jaguar1 */ + DVR_DEBUG(_logger, "Jaguar1 cfo.track tick: cfo~{} (raw*2.5) cap=0x{:02x} {}", + static_cast(avg_khz), cur, + nc >= 0 ? "step" : "hold"); + if (nc >= 0) { + try { + const uint32_t c = static_cast(nc); + const uint32_t field = (c | (c << 6)) << r2c_shift; + _device.rtw_write(0x002C, r2c_base | (field & r2c_mask)); + _xtal_cap = nc; + _logger->info("Jaguar1 cfo.track: cfo~{} (raw*2.5) xtal_cap=0x{:02x}", + static_cast(avg_khz), _xtal_cap); + } catch (...) { + } + } + }; + + auto on_data = [&](const uint8_t *data, int n) { + cfo_tick(); FrameParser fp{_logger}; for (auto &p : fp.recvbuf2recvframe( std::span{const_cast(data), (size_t)n})) { @@ -731,6 +787,8 @@ void RtlJaguarDevice::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { if (!p.RxAtrib.crc_err) { _rxq.add(p.RxAtrib.rssi[0], p.RxAtrib.snr[0], p.RxAtrib.evm[0]); _rxpaths.add(p.RxAtrib.rssi, _eepromManager->numTotalRfPath); + if (_cfg.tuning.cfo_track) + _cfo.add(p.RxAtrib.cfo_tail); /* closed-loop CFO input (#217) */ } _packetProcessor(p); } @@ -856,6 +914,27 @@ devourer::TxCaps RtlJaguarDevice::GetTxCaps() { return devourer::tx_caps_for_chains(_eepromManager->numTotalRfPath); } +int RtlJaguarDevice::SetXtalCap(int cap) { + /* hal_set_crystal_cap (8812/8821/8814A): the 6-bit trim goes into REG + * MAC_PHY_CTRL (0x2C) as cap|(cap<<6), but the field position differs per + * die: 8812/8811 [30:19], 8814A [26:15], 8821A [23:12]. cap < 0 reverts to + * the efuse default (EepromManager::crystal_cap). */ + const uint8_t c = cap < 0 ? (_eepromManager->crystal_cap & 0x3F) + : static_cast(cap & 0x3F); + const uint32_t val = static_cast(c) | (static_cast(c) << 6); + uint32_t mask; + switch (_eepromManager->version_id.ICType) { + case CHIP_8814A: mask = 0x07FF8000; break; /* 0x2C[26:21]=[20:15] */ + case CHIP_8821: mask = 0x00FFF000; break; /* 0x2C[23:18]=[17:12] */ + default: mask = 0x7FF80000; break; /* 8812/8811: 0x2C[30:25]=[24:19] */ + } + _device.phy_set_bb_reg(0x002C, mask, val); + _xtal_cap = c; + _logger->info("Jaguar1: crystal-cap set to 0x{:02x}{}", c, + cap < 0 ? " (efuse default)" : ""); + return c; +} + devourer::AdapterCaps RtlJaguarDevice::GetAdapterCaps() { devourer::AdapterCaps c; c.supported = true; @@ -885,6 +964,8 @@ devourer::AdapterCaps RtlJaguarDevice::GetAdapterCaps() { c.narrowband_ok = true; } c.fastretune_ok = true; /* phy_SwChnl8812_fast (8812/8821) + full-path fallback */ + c.xtal_cap_max = 0x3f; /* 6-bit AFE crystal-cap trim (0x2C) */ + c.xtal_cap_default = _eepromManager->crystal_cap & 0x3f; devourer::set_standard_freq_ranges(c); /* Identity from the EFUSE version-id. The die name is refined by the RF-type: diff --git a/src/jaguar1/RtlJaguarDevice.h b/src/jaguar1/RtlJaguarDevice.h index 3809f64..0f24f60 100644 --- a/src/jaguar1/RtlJaguarDevice.h +++ b/src/jaguar1/RtlJaguarDevice.h @@ -19,6 +19,7 @@ #include "RadioManagementModule.h" #include "FrameParser.h" #include "TxMode.h" +#include "CfoTracker.h" extern "C" { @@ -123,6 +124,8 @@ class RtlJaguarDevice : public IRtlDevice { int SetTxPowerOffsetQdb(int qdb) override; void SetTxPowerIndexOverride(int idx) override; bool ReApplyTxPower() override; + int SetXtalCap(int cap) override; + int GetXtalCap() override { return _xtal_cap; } devourer::TxPowerState GetTxPowerState() override; /* Per-chip TX caps (IRtlDevice): n_ss + STBC/LDPC/SGI/bw from the EFUSE * RF-type. STBC needs >=2 chains, so 1T1R cuts (8811AU/8821AU) report @@ -278,6 +281,8 @@ class RtlJaguarDevice : public IRtlDevice { * the chip's table default across channel sets). Atomic so the toggle * thread and a control-plane SetMonitorChannel see a consistent value. */ std::atomic _rx_path_mask{-1}; + int _xtal_cap = -1; /* current crystal-cap code (SetXtalCap) */ + devourer::CfoTracker _cfo; /* closed-loop CFO tracker (#217) */ std::thread _therm_thread; std::atomic _therm_stop{false}; diff --git a/src/jaguar2/FrameParserJaguar2.h b/src/jaguar2/FrameParserJaguar2.h index 1e40891..d95eba3 100644 --- a/src/jaguar2/FrameParserJaguar2.h +++ b/src/jaguar2/FrameParserJaguar2.h @@ -232,12 +232,13 @@ inline void parse_phy_sts_jgr2(const uint8_t *physts, uint16_t physts_len, a.rssi[0] = physts[1]; } else { /* type1: DW0/1 pwdb[4] at bytes 1..4; DW4 rxevm[4] at bytes 16..19; - * DW6 rxsnr[4] at bytes 24..27. */ + * DW5 cfo_tail[4] at bytes 20..23; DW6 rxsnr[4] at bytes 24..27. */ for (int i = 0; i < 4; i++) { a.rssi[i] = physts[1 + i]; a.evm[i] = static_cast(physts[16 + i]); a.snr[i] = static_cast(physts[24 + i]); } + a.cfo_tail = static_cast(physts[20]); /* path-A CFO tail (#217) */ /* DW1 byte7 flags: [5]=ldpc [6]=stbc [7]=beamformed (phy_sts_rpt_jgr2_type1). * DW1 byte5: l_rxsc[3:0] / ht_rxsc[7:4]; the vendor derives RX bandwidth from * the active rxsc (legacy OFDM uses l_rxsc, HT/VHT uses ht_rxsc): rxsc 1-8 = diff --git a/src/jaguar2/HalJaguar2.cpp b/src/jaguar2/HalJaguar2.cpp index 102dd3a..7113c62 100644 --- a/src/jaguar2/HalJaguar2.cpp +++ b/src/jaguar2/HalJaguar2.cpp @@ -339,6 +339,14 @@ void HalJaguar2::read_efuse_logical_map(uint8_t *map, uint16_t map_size, } } +uint8_t HalJaguar2::efuse_logical_byte(uint16_t off) { + if (!_efuse_valid) { + read_efuse_logical_map(_efuse_map, sizeof(_efuse_map), /*dump=*/false); + _efuse_valid = true; + } + return off < sizeof(_efuse_map) ? _efuse_map[off] : 0xFF; +} + uint8_t HalJaguar2::read_efuse_rfe() { constexpr uint16_t kRfeOff = 0xCA; /* EEPROM_RFE_OPTION_8822B */ read_efuse_logical_map(_efuse_map, sizeof(_efuse_map), diff --git a/src/jaguar2/HalJaguar2.h b/src/jaguar2/HalJaguar2.h index ef53c9e..7a9a824 100644 --- a/src/jaguar2/HalJaguar2.h +++ b/src/jaguar2/HalJaguar2.h @@ -52,6 +52,11 @@ class HalJaguar2 { * apply_tx_power. `dump`=true logs the raw physical bytes + a few key fields. */ void read_efuse_logical_map(uint8_t *map, uint16_t map_size, bool dump); + /* One logical EFUSE byte from the cached map (lazy-reads the map on first + * use). 0xFF where unprogrammed / out of range. Used for the crystal-cap + * default (logical 0xB9). */ + uint8_t efuse_logical_byte(uint16_t off); + /* Program the per-rate TXAGC (0x1d00 path A / 0x1d80 path B) from the EFUSE * power-by-rate calibration for `channel` at bandwidth `bw` (0=20/1=40/2=80; * 5/6 = 5/10 MHz narrowband, folded to the 20 MHz column — the RF runs in diff --git a/src/jaguar2/RtlJaguar2Device.cpp b/src/jaguar2/RtlJaguar2Device.cpp index 8ea3f38..659400d 100644 --- a/src/jaguar2/RtlJaguar2Device.cpp +++ b/src/jaguar2/RtlJaguar2Device.cpp @@ -196,6 +196,11 @@ void RtlJaguar2Device::bring_up(SelectedChannel channel) { } _brought_up = true; + /* DEVOURER_XTAL_CAP — apply the crystal-cap trim once the AFE is up + * (issue #217, the narrowband CFO lever). */ + if (_cfg.tuning.xtal_cap) + SetXtalCap(*_cfg.tuning.xtal_cap); + /* 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 @@ -378,10 +383,49 @@ void RtlJaguar2Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { _logger->info("RtlJaguar2Device: entering RX loop (ch={})", _channel.Channel); + /* Closed-loop CFO tracking on the receiver (#217): tick the controller on a + * ~2 s cadence from the RX loop. The crystal-cap fields (0x24[30:25] + + * 0x28[6:1]) are written READ-FREE from bases cached before the RX flood — + * a control-transfer read races the async bulk-IN and throws under load. */ + uint32_t r24_base = 0, r28_base = 0; + bool cfo_ok = false; + if (_cfg.tuning.cfo_track) { + try { + r24_base = _device.rtw_read(0x0024) & ~0x7E000000u; + r28_base = _device.rtw_read(0x0028) & ~0x0000007Eu; + cfo_ok = true; + } catch (...) { + _logger->info("Jaguar2 cfo.track: AFE base read failed — disabled"); + } + } + auto cfo_next = std::chrono::steady_clock::now() + std::chrono::seconds(2); + auto cfo_tick = [&]() { + if (!cfo_ok || std::chrono::steady_clock::now() < cfo_next) + return; + cfo_next += std::chrono::seconds(2); + double avg_khz = 0; + const int cur = _xtal_cap < 0 ? 0x20 : _xtal_cap; + const int nc = _cfo.step(cur, 0x3f, &avg_khz); /* 6-bit on Jaguar2 */ + DVR_DEBUG(_logger, "Jaguar2 cfo.track tick: cfo~{} (raw*2.5) cap=0x{:02x} {}", + static_cast(avg_khz), cur, nc >= 0 ? "step" : "hold"); + if (nc >= 0) { + try { + const uint32_t c = static_cast(nc); + _device.rtw_write(0x0024, r24_base | (c << 25)); + _device.rtw_write(0x0028, r28_base | (c << 1)); + _xtal_cap = nc; + _logger->info("Jaguar2 cfo.track: cfo~{} (raw*2.5) xtal_cap=0x{:02x}", + static_cast(avg_khz), _xtal_cap); + } catch (...) { + } + } + }; + /* RX loop: async bulk-IN URB queue; walk the aggregated 8822B RX descriptors * per completion and hand each PSDU to the packet processor. */ uint64_t frames = 0, reads = 0; auto on_data = [&](const uint8_t *data, int n) { + cfo_tick(); if (++reads <= 8) _logger->info("Jaguar2 RX: completion #{} -> {} bytes", reads, n); uint32_t off = 0; @@ -417,6 +461,8 @@ void RtlJaguar2Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { _rxq.add(p.RxAtrib.rssi[0], p.RxAtrib.snr[0], p.RxAtrib.evm[0]); _rxpaths.add(p.RxAtrib.rssi, _variant == jaguar2::ChipVariant::C8821C ? 1 : 2); + if (_cfg.tuning.cfo_track) + _cfo.add(p.RxAtrib.cfo_tail); /* closed-loop CFO input (#217) */ } _packetProcessor(p); } @@ -684,6 +730,22 @@ void RtlJaguar2Device::FastRetune(uint8_t channel, bool cache_rf) { _rfe, _channel.ChannelOffset); } +int RtlJaguar2Device::SetXtalCap(int cap) { + /* hal_set_crystal_cap (8822B/8821C): the 6-bit trim goes into 0x24[30:25] + * AND 0x28[6:1] (Xo and Xi legs). cap < 0 reverts to the efuse default + * (0xB9; 0x20 when unprogrammed). */ + uint8_t def = _hal.efuse_logical_byte(0xB9); + if (def == 0xFF) + def = 0x20; + uint8_t c = cap < 0 ? def : static_cast(cap & 0x3F); + _device.phy_set_bb_reg(0x0024, 0x7E000000, c); + _device.phy_set_bb_reg(0x0028, 0x0000007E, c); + _xtal_cap = c; + _logger->info("Jaguar2: crystal-cap set to 0x{:02x}{}", c, + cap < 0 ? " (efuse default)" : ""); + return c; +} + RxEnergy RtlJaguar2Device::GetRxEnergy() { /* Scalar FA/CCA/IGI come from the DIG thread's cached snapshot (no USB); * append a fresh NHM power histogram (11AC register map). NHM's registers @@ -806,6 +868,10 @@ devourer::AdapterCaps RtlJaguar2Device::GetAdapterCaps() { * RF18 re-latch edge after the re-clock (see the set_channel_bw narrowband * branch). */ c.narrowband_ok = true; + c.xtal_cap_max = 0x3f; /* 6-bit AFE crystal-cap trim (0x24/0x28) */ + c.xtal_cap_default = _hal.efuse_logical_byte(0xB9) == 0xFF + ? 0x20 + : (_hal.efuse_logical_byte(0xB9) & 0x3f); c.fastretune_ok = true; c.per_packet_txpower = true; /* TX descriptor TXPWR_OFSET LUT — Jaguar2 only */ devourer::set_standard_freq_ranges(c); diff --git a/src/jaguar2/RtlJaguar2Device.h b/src/jaguar2/RtlJaguar2Device.h index 101aab1..f195395 100644 --- a/src/jaguar2/RtlJaguar2Device.h +++ b/src/jaguar2/RtlJaguar2Device.h @@ -12,6 +12,7 @@ #include "TxMode.h" #include "RtlAdapter.h" #include "SelectedChannel.h" +#include "CfoTracker.h" #include "HalJaguar2.h" #include "HalmacJaguar2MacInit.h" #include "HalmacJaguar2Fw.h" @@ -82,6 +83,8 @@ class RtlJaguar2Device : public IRtlDevice { int SetTxPowerOffsetQdb(int qdb) override; void SetTxPowerIndexOverride(int idx) override; bool ReApplyTxPower() override; + int SetXtalCap(int cap) override; + int GetXtalCap() override { return _xtal_cap; } devourer::TxPowerState GetTxPowerState() override; devourer::ThermalStatus GetThermalStatus() override; /* Per-chip TX caps (IRtlDevice): the 8821C is 1T1R (no STBC), the 8822B @@ -158,6 +161,8 @@ class RtlJaguar2Device : public IRtlDevice { devourer::RxPathActivityAccumulator _rxpaths; Logger_t _logger; jaguar2::ChipVariant _variant; + int _xtal_cap = -1; /* current crystal-cap code (SetXtalCap) */ + devourer::CfoTracker _cfo; /* closed-loop CFO tracker (DEVOURER_CFO_TRACK) */ jaguar2::HalJaguar2 _hal; jaguar2::HalmacJaguar2MacInit _macinit; jaguar2::HalmacJaguar2Fw _fw; diff --git a/src/jaguar3/FrameParserJaguar3.h b/src/jaguar3/FrameParserJaguar3.h index 7978382..5c27143 100644 --- a/src/jaguar3/FrameParserJaguar3.h +++ b/src/jaguar3/FrameParserJaguar3.h @@ -228,10 +228,12 @@ inline void parse_phy_sts_jgr3(const uint8_t *physts, uint16_t physts_len, const uint8_t l_rxsc = physts[5] & 0x0f, ht_rxsc = (physts[5] >> 4) & 0x0f; const uint8_t rxsc = (a.data_rate >= 4 && a.data_rate <= 11) ? l_rxsc : ht_rxsc; a.bw = rxsc >= 13 ? 2 : rxsc >= 9 ? 1 : 0; - /* Per-stream EVM/SNR only exist on the type1 page (DW4 rxevm[4] at 16..19, - * DW6 rxsnr[4] at 24..27); other OFDM pages (2/3/4/5/6) reuse those offsets - * for other data, so leave evm/snr at 0 there. */ + /* Per-stream EVM/SNR + per-path CFO tail only exist on the type1 page + * (phy_sts_rpt_jgr3_type1: DW4 rxevm[4] at 16..19, DW5 cfo_tail[4] at + * 20..23, DW6 rxsnr[4] at 24..27); other OFDM pages (2/3/4/5/6) reuse those + * offsets for other data, so leave them at 0 there. */ if (page_num == 1) { + a.cfo_tail = static_cast(physts[20]); /* path-A CFO tail */ for (int i = 0; i < 4; i++) { a.evm[i] = static_cast(physts[16 + i]); a.snr[i] = static_cast(physts[24 + i]); diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index eb921bb..e881da4 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -56,6 +56,10 @@ void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, _hal.coex_wlan_only_init(); /* lock antenna to WLAN (disable BT/LTE coex) */ _brought_up = true; + /* DEVOURER_XTAL_CAP — crystal-cap trim (issue #217, narrowband CFO lever). */ + if (_cfg.tuning.xtal_cap) + SetXtalCap(*_cfg.tuning.xtal_cap); + /* tuning.disable_cca — MAC EDCCA-disable research knob (see SetCcaMode). */ if (_cfg.tuning.disable_cca) SetCcaMode(true); @@ -152,8 +156,54 @@ void RtlJaguar3Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { _logger->info("Jaguar3: entering RX loop (kernel-style async URB queue)"); uint64_t frames = 0, reads = 0; + /* Closed-loop CFO tracking runs on the RECEIVER (#217): tick the controller + * on a ~2 s wall-clock cadence from within the RX loop, off the per-frame + * CFO fed below. (The coex thread's copy only runs on the TX path.) The + * crystal-cap register (0x1040) is written READ-FREE from a base cached + * before the RX flood — a control-transfer READ races the async bulk-IN and + * throws under load, so the tick composes the field onto the cached base and + * write32s it. */ + uint32_t reg1040_base = 0; + bool reg1040_ok = false; + if (_cfg.tuning.cfo_track) { + try { + reg1040_base = _device.rtw_read(0x1040) & ~0x00FFFC00u; + reg1040_ok = true; + } catch (...) { + _logger->info("Jaguar3 cfo.track: 0x1040 base read failed — disabled"); + } + } + auto cfo_next = std::chrono::steady_clock::now() + std::chrono::seconds(2); + auto cfo_tick = [&]() { + if (!_cfg.tuning.cfo_track || !reg1040_ok) + return; + if (std::chrono::steady_clock::now() < cfo_next) + return; + cfo_next += std::chrono::seconds(2); + double avg_khz = 0; + const int cur = _xtal_cap < 0 ? 0x20 : _xtal_cap; + const int nc = _cfo.step(cur, 0x7f, &avg_khz); + DVR_DEBUG(_logger, "Jaguar3 cfo.track tick: cfo~{} (raw*2.5) cap=0x{:02x} {}", + static_cast(avg_khz), cur, nc >= 0 ? "step" : "hold"); + if (nc >= 0) { + const uint32_t field = static_cast(nc) | + (static_cast(nc) << 7); /* 14-bit */ + try { + std::lock_guard lk(_reg_mu); + _device.rtw_write(0x1040, + reg1040_base | ((field << 10) & 0x00FFFC00u)); + _xtal_cap = nc; + } catch (...) { + return; + } + } + if (nc >= 0) /* log only on an actual step, not every idle tick */ + _logger->info("Jaguar3 cfo.track: cfo~{} (raw*2.5) xtal_cap=0x{:02x}", + static_cast(avg_khz), _xtal_cap); + }; /* Process one bulk-IN completion: walk the aggregated 8822C RX descriptors. */ auto on_data = [&](const uint8_t *data, int n) { + cfo_tick(); if (++reads <= 8) _logger->info("Jaguar3 RX: async completion #{} -> {} bytes", reads, n); uint32_t off = 0; @@ -188,6 +238,8 @@ void RtlJaguar3Device::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { if (!p.RxAtrib.crc_err) { _rxq.add(p.RxAtrib.rssi[0], p.RxAtrib.snr[0], p.RxAtrib.evm[0]); _rxpaths.add(p.RxAtrib.rssi, 2); /* 8822C/8822E are 2T2R */ + if (_cfg.tuning.cfo_track) + _cfo.add(p.RxAtrib.cfo_tail); /* closed-loop CFO input (#217) */ } /* TX-BF apply gate (DEVOURER_BF_TXBF): a VHT Compressed Beamforming * Report (category 0x15, action 0x00) from the target peer (addr2) means @@ -452,6 +504,10 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { * Applied before the coex thread starts so the writes don't contend. */ if (_cfg.tuning.disable_cca) SetCcaMode(true); + /* DEVOURER_XTAL_CAP — crystal-cap trim (issue #217); before the coex thread + * so the AFE write doesn't contend with the periodic coex re-apply. */ + if (_cfg.tuning.xtal_cap) + SetXtalCap(*_cfg.tuning.xtal_cap); _coex_thread = std::thread([this] { coex_runtime_loop(); }); _logger->info("Jaguar3: ready for TX (monitor inject)"); } @@ -889,6 +945,21 @@ devourer::TxCaps RtlJaguar3Device::GetTxCaps() { return devourer::tx_caps_for_chains(2); /* 8822C/8822E are 2T2R */ } +int RtlJaguar3Device::SetXtalCap(int cap) { + /* phydm_set_crystal_cap_reg (8822C/8822E): a 7-bit code into + * 0x1040[23:17] AND 0x1040[16:10] (Xo and Xi), reg = cap | (cap << 7). + * cap < 0 reverts to the default (efuse readback is the 2-byte 8822C + * format, not wired here — fall back to 0x20). */ + const uint8_t c = cap < 0 ? 0x20 : static_cast(cap & 0x7F); + const uint32_t reg = static_cast(c) | (static_cast(c) << 7); + std::lock_guard lk(_reg_mu); + _device.phy_set_bb_reg(0x1040, 0x00FFFC00, reg); + _xtal_cap = c; + _logger->info("Jaguar3: crystal-cap set to 0x{:02x}{}", c, + cap < 0 ? " (default)" : ""); + return c; +} + devourer::AdapterCaps RtlJaguar3Device::GetAdapterCaps() { devourer::AdapterCaps c; c.supported = true; @@ -902,6 +973,8 @@ devourer::AdapterCaps RtlJaguar3Device::GetAdapterCaps() { c.bw_mask = devourer::bw_mask_for_generation(c.generation); c.fastretune_ok = true; c.narrowband_ok = true; /* 5/10 MHz baseband re-clock — Jaguar3 only */ + c.xtal_cap_max = 0x7f; /* 7-bit AFE crystal-cap trim (0x1040) */ + c.xtal_cap_default = 0x20; devourer::set_standard_freq_ranges(c); if (_variant == jaguar3::ChipVariant::C8822E) { diff --git a/src/jaguar3/RtlJaguar3Device.h b/src/jaguar3/RtlJaguar3Device.h index b523eb2..2069850 100644 --- a/src/jaguar3/RtlJaguar3Device.h +++ b/src/jaguar3/RtlJaguar3Device.h @@ -7,6 +7,7 @@ #include #include "logger.h" +#include "CfoTracker.h" #include "IRtlDevice.h" #include "TxMode.h" #include "RtlAdapter.h" @@ -83,6 +84,8 @@ class RtlJaguar3Device : public IRtlDevice { int SetTxPowerOffsetQdb(int qdb) override; void SetTxPowerIndexOverride(int idx) override; bool ReApplyTxPower() override; + int SetXtalCap(int cap) override; + int GetXtalCap() override { return _xtal_cap; } devourer::TxPowerState GetTxPowerState() override; devourer::ThermalStatus GetThermalStatus() override; /* Per-chip TX caps (IRtlDevice): 8822C/8822E are 2T2R (STBC ok). */ @@ -165,6 +168,7 @@ class RtlJaguar3Device : public IRtlDevice { const devourer::DeviceConfig _cfg; Logger_t _logger; jaguar3::ChipVariant _variant; + int _xtal_cap = -1; /* current crystal-cap code (SetXtalCap) */ jaguar3::HalJaguar3 _hal; jaguar3::RadioManagementJaguar3 _radioManagement; SelectedChannel _channel{}; @@ -183,6 +187,7 @@ class RtlJaguar3Device : public IRtlDevice { /* Rolling per-frame RX link-quality aggregate (GetRxQuality). */ devourer::RxQualityAccumulator _rxq; devourer::RxPathActivityAccumulator _rxpaths; + devourer::CfoTracker _cfo; /* closed-loop CFO tracker (DEVOURER_CFO_TRACK) */ /* Frame counter for periodic NDPA sounding (DEVOURER_TX_NDPA=N). */ uint64_t _ndpa_ctr = 0; /* TX beamforming apply state (DEVOURER_BF_TXBF). The entry is configured at diff --git a/tests/jaguar1_cfo_convergence.sh b/tests/jaguar1_cfo_convergence.sh new file mode 100755 index 0000000..8ed7c78 --- /dev/null +++ b/tests/jaguar1_cfo_convergence.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Jaguar1 closed-loop CFO CONVERGENCE demo (#217): a real two-adapter J1 link. +# One J1 die transmits the canonical beacon; a second J1 die receives with the +# CFO loop armed. Unlike the ambient smoke, this measures the genuine inter- +# crystal offset between the pair and, if it exceeds the controller deadband, +# shows the receiver stepping its crystal cap to null it — the same convergence +# behaviour demonstrated on Jaguar2/Jaguar3, now on Jaguar1. +# +# Narrowband (default 10 MHz) is used deliberately: quartering/halving the FFT +# grid makes a given Hz offset land as a proportionally larger cfo_tail code, so +# the loop engages at offsets a 20 MHz link would sit inside the deadband for. +# +# Usage: sudo tests/jaguar1_cfo_convergence.sh [TX_PID] [RX_PID] [CH] [NB] [SEC] +set -euo pipefail + +TX_PID="${1:-0x8812}" # 8812AU transmits the beacon +RX_PID="${2:-0x8813}" # 8814AU receives + runs the loop +CH="${3:-36}" # 5 GHz: offset ~2.2x larger than 2.4 GHz for the same ppm +NB="${4:-10}" # 10 MHz narrowband +DUR="${5:-25}" +BIN="$(cd "$(dirname "$0")/.." && pwd)/build" +TXLOG="$(mktemp /tmp/j1-cfo-tx.XXXXXX.log)" +RXLOG="$(mktemp /tmp/j1-cfo-rx.XXXXXX.log)" + +cleanup() { pkill -9 -x txdemo 2>/dev/null || true; pkill -9 -x rxdemo 2>/dev/null || true; } +trap cleanup EXIT +cleanup; sleep 1 + +echo "== J1 CFO convergence: TX=$TX_PID RX=$RX_PID ch=$CH NB=${NB}MHz ${DUR}s ==" + +# Transmitter: canonical beacon flood, narrowband, max duty. +DEVOURER_PID="$TX_PID" DEVOURER_CHANNEL="$CH" DEVOURER_NB_BW="$NB" \ +DEVOURER_TX_GAP_US=0 DEVOURER_LOG_LEVEL=info \ + "$BIN/txdemo" >"$TXLOG" 2>&1 & +sleep 3 # let the TX bring-up + narrowband retune settle + +# Receiver: same channel/BW, CFO loop armed, debug so every tick prints. +DEVOURER_PID="$RX_PID" DEVOURER_CHANNEL="$CH" DEVOURER_NB_BW="$NB" \ +DEVOURER_CFO_TRACK=1 DEVOURER_LOG_LEVEL=debug \ + timeout "${DUR}s" "$BIN/rxdemo" >"$RXLOG" 2>&1 || true + +echo "--- TX airing? (beacon send count) ---" +grep -cF '"ev":"tx' "$TXLOG" 2>/dev/null || true +echo "--- RX decoded the beacon? (rx.txhit) ---" +grep -F '"ev":"rx.txhit"' "$RXLOG" | tail -1 || echo "(no txhit — pair did not decode)" +echo "--- CFO loop trajectory ---" +grep "cfo.track" "$RXLOG" || echo "(no tick lines)" +echo +echo "--- verdict ---" +if grep -q "cfo.track.*step" "$RXLOG"; then + echo "PASS: loop ENGAGED — receiver stepped its crystal cap to reduce inter-crystal CFO" +elif grep -q "cfo.track tick" "$RXLOG"; then + echo "HOLD: loop ran but inter-crystal offset is within deadband (well-matched pair)" +else + echo "NO-TICK: no frames decoded to drive the loop (check TX/decode above)" +fi +echo "(logs: TX=$TXLOG RX=$RXLOG)" diff --git a/tests/jaguar1_cfo_track_smoke.sh b/tests/jaguar1_cfo_track_smoke.sh new file mode 100755 index 0000000..1c6fa85 --- /dev/null +++ b/tests/jaguar1_cfo_track_smoke.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Jaguar1 closed-loop CFO tracker smoke (#217): confirm the loop actually fires +# on a plugged Jaguar1 die — reads the phy-status cfo_tail and runs the ~2 s +# cfo.track tick without crashing under RX load. Convergence needs a marginal +# narrowband TX partner; this only proves the plumbing executes on real silicon. +# +# Usage: sudo tests/jaguar1_cfo_track_smoke.sh [CHANNEL] [SECONDS] +# PID 0x8812 (8812AU) | 0x8813 (8814AU) | ... +set -euo pipefail + +PID="${1:?usage: $0 [channel] [seconds]}" +CH="${2:-6}" +DUR="${3:-12}" +# NB=10|5 narrows the RX passband (needs a matching TX partner to decode); +# NB=0 (default) leaves 20 MHz so ambient beacons feed the loop and prove the +# tick fires on real silicon. +NB="${NB:-0}" +RX="$(dirname "$0")/../build/rxdemo" +LOG="$(mktemp /tmp/j1-cfo-smoke.XXXXXX.log)" + +cleanup() { pkill -9 -x rxdemo 2>/dev/null || true; } +trap cleanup EXIT + +echo "== Jaguar1 CFO-track smoke: PID=$PID ch=$CH NB=$NB ${DUR}s ==" +NB_ENV=() +[ "$NB" != "0" ] && NB_ENV=(DEVOURER_NB_BW="$NB") +env "${NB_ENV[@]}" \ +DEVOURER_PID="$PID" \ +DEVOURER_CHANNEL="$CH" \ +DEVOURER_CFO_TRACK=1 \ +DEVOURER_LOG_LEVEL="${DEVOURER_LOG_LEVEL:-info}" \ + timeout "${DUR}s" "$RX" >"$LOG" 2>&1 || true + +echo "--- adapter.caps ---" +grep -F '"ev":"adapter.caps"' "$LOG" | head -1 || echo "(no caps event)" +echo "--- frames seen (rx.txhit / rx totals) ---" +grep -cF '"ev":"rx' "$LOG" || true +echo "--- cfo.track tick lines (need DEVOURER_LOG_LEVEL=debug for per-tick) ---" +if grep -F 'cfo.track' "$LOG"; then + echo "PASS: closed-loop CFO tick fired on this die" +else + echo "NOTE: no cfo.track line — either no frames decoded (no CFO error" + echo "signal to tick on) or AFE base read failed. Full log: $LOG" +fi +echo "(log: $LOG)" diff --git a/tests/sdr_cw_peak.py b/tests/sdr_cw_peak.py new file mode 100755 index 0000000..705f3e6 --- /dev/null +++ b/tests/sdr_cw_peak.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Report the CW-tone peak frequency offset (Hz) from the tuned center — a +direct readout of the chip LO, hence of a crystal-cap trim (tests/ +xtal_cfo_sweep.sh). Parabolic-interpolated for sub-bin resolution. USRP B210. + + python3 tests/sdr_cw_peak.py 5220e6 +""" +import sys +import numpy as np +import uhd + +freq = float(sys.argv[1]) +rate = 46.08e6 +gain = 50 +n = int(8e5) + +u = uhd.usrp.MultiUSRP() +s = u.recv_num_samps(n, freq, rate, [0], gain)[0] +s = s - np.mean(s) # drop the DC/LO-leakage bin so it can't win the argmax + +nfft = 1 << 20 +win = s[:nfft] * np.hanning(min(len(s), nfft)) +if len(win) < nfft: + win = np.concatenate([win, np.zeros(nfft - len(win))]) +spec = np.abs(np.fft.fftshift(np.fft.fft(win))) +freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1.0 / rate)) + +pk = int(np.argmax(spec)) +d = 0.0 +if 0 < pk < nfft - 1: + a, b, c = spec[pk - 1], spec[pk], spec[pk + 1] + denom = a - 2 * b + c + if denom: + d = 0.5 * (a - c) / denom +print(f"{freqs[pk] + d * rate / nfft:.0f}") diff --git a/tests/xtal_cfo_sweep.sh b/tests/xtal_cfo_sweep.sh new file mode 100755 index 0000000..3783f3d --- /dev/null +++ b/tests/xtal_cfo_sweep.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# xtal_cfo_sweep.sh — validate the crystal-cap trim lever (issue #217). Two +# modes: +# +# sdr (default): arm a CW tone on the DUT, step DEVOURER_XTAL_CAP live on +# the warmed carrier, and measure the LO peak-frequency shift per cap +# with the B210. Proves the trim moves the reference oscillator +# (~1 ppm across the range) independent of decode. Measure the middle +# of the range — the crystal pulling curve saturates at the extremes, +# and near DC the peak-finder can lock onto the tone image. +# +# decode: sweep the trim on one end of a marginal narrowband pair and count +# delivered frames (the issue's bimodal->stable test). Note the +# bimodality is intermittent per thermal state — a session where the +# pair is already stable can't show the flip; the sdr mode is the +# deterministic proof. +# +# sudo tests/xtal_cfo_sweep.sh sdr [PID=0xc812] [FREQ=5220e6] +# sudo tests/xtal_cfo_sweep.sh decode [TX=0bda:c812] [RX=0bda:c811] +set -u +cd "$(dirname "$0")/.." +MODE=${1:-sdr} + +cleanup() { sudo pkill -INT -x txdemo 2>/dev/null; sudo pkill -INT -x rxdemo 2>/dev/null; + sleep 1; sudo pkill -KILL -x txdemo rxdemo 2>/dev/null; } +trap cleanup EXIT INT TERM + +if [ "$MODE" = sdr ]; then + PID=${PID:-0xc812}; FREQ=${FREQ:-5220e6}; CH=${CH:-44} + # Live-step the cap on a warmed CW carrier; measure the LO peak per step. + sudo env DEVOURER_VID=0x0bda DEVOURER_PID=$PID DEVOURER_CHANNEL=$CH \ + DEVOURER_CW_TONE=1 DEVOURER_XTAL_CAP=0x20 \ + DEVOURER_XTAL_STEP="0x10,0x20,0x30,0x40,0x50" DEVOURER_XTAL_STEP_MS=8000 \ + timeout -s INT -k 5 90 ./build/txdemo >/tmp/xcfo_tx.log 2>&1 & + echo "warming the crystal 30s before stepping..."; sleep 30 + echo "LO peak offset (Hz) vs live crystal-cap step (one warmed chip):" + for cap in 0x10 0x20 0x30 0x40 0x50; do + hz=$(sudo python3 tests/sdr_cw_peak.py "$FREQ" 2>/dev/null | tail -1) + printf " cap=%-4s : %s Hz\n" "$cap" "$hz" + sleep 8 + done + cleanup +else + TX=${2:-0bda:c812}; RX=${3:-0bda:c811}; CH=${CH:-44}; N=${N:-3} + TXV=${TX%:*}; TXP=${TX#*:}; RXV=${RX%:*}; RXP=${RX#*:} + hits() { grep '"ev":"rx.txhit"' "$1" | tail -1 | grep -oE '"hits":[0-9]+' | cut -d: -f2; } + cell() { local cap="$1" out="" + for i in $(seq 1 "$N"); do + sudo env DEVOURER_VID=0x$TXV DEVOURER_PID=0x$TXP DEVOURER_CHANNEL=$CH DEVOURER_NB_BW=5 \ + ${cap:+DEVOURER_XTAL_CAP=$cap} DEVOURER_TX_GAP_US=500 \ + timeout -s INT -k 5 26 ./build/txdemo >/tmp/xcfo_tx.log 2>&1 & + sleep 9 + sudo env DEVOURER_VID=0x$RXV DEVOURER_PID=0x$RXP DEVOURER_CHANNEL=$CH DEVOURER_NB_BW=5 \ + timeout -s INT -k 5 10 ./build/rxdemo >/tmp/xcfo_rx.log 2>&1 + out="$out $(hits /tmp/xcfo_rx.log)" + sudo pkill -INT -x txdemo 2>/dev/null; wait 2>/dev/null; sleep 2 + done + printf " cap=%-6s :%s\n" "${cap:-default}" "$out" + } + echo "5 MHz decode vs TX crystal-cap ($TX -> $RX, ch$CH, $N bring-ups):" + cell "" + for c in 0x00 0x10 0x20 0x30 0x40 0x50 0x60; do cell "$c"; done +fi