Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions docs/narrowband.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
4 changes: 3 additions & 1 deletion examples/common/caps_event.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/common/env_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ devourer::DeviceConfig devourer_config_from_env() {
cfg.tuning.nb_dac = static_cast<uint8_t>(v & 0x7);
if (env_long("DEVOURER_NB_ADC", &v))
cfg.tuning.nb_adc = static_cast<uint8_t>(v & 0x7);
if (env_long("DEVOURER_XTAL_CAP", &v))
cfg.tuning.xtal_cap = static_cast<uint8_t>(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;
Expand Down
24 changes: 24 additions & 0 deletions examples/tx/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/AdapterCaps.h
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
};
Expand Down
99 changes: 99 additions & 0 deletions src/CfoTracker.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#ifndef DEVOURER_CFO_TRACKER_H
#define DEVOURER_CFO_TRACKER_H

#include <cmath>
#include <cstdint>

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<double>(_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 */
9 changes: 9 additions & 0 deletions src/DeviceConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ struct DeviceConfig {
* field — bits [1:0] -> 0x8ac[9:8], bit 2 -> 0x8ac[16] (divider-mapping
* experiments only). */
std::optional<uint8_t> 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<uint8_t> 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> regulation;
Expand Down
17 changes: 17 additions & 0 deletions src/IRtlDevice.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}; }
Expand Down
5 changes: 5 additions & 0 deletions src/RxPacket.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
3 changes: 3 additions & 0 deletions src/jaguar1/FrameParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ std::vector<Packet> FrameParser::recvbuf2recvframe(std::span<uint8_t> 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 */
Expand Down
Loading
Loading