Persist Si4684 crystal calibration (ibias/ctun/xtalFreqHz) to EEPROM

Extends the existing FM/DAB ANTCAP EEPROM persistence pattern
(Eeprom24aa::writeFmAntCap/writeDabAntCap) to the crystal trim found by
POST /api/tuner/xtal-calibrate, which previously only applied live and
was lost on every reboot.

- Eeprom24aa gains readXtalCalibration()/writeXtalCalibration() at word
  addresses 0x02 (ibias), 0x03 (ctun), 0x04-0x07 (xtalFreqHz,
  big-endian), right after the existing FM/DAB ANTCAP bytes.
- HardwareBootstrap::boot() now boots ADAU1701 before Si4684 (needed so
  the EEPROM read, which borrows ADAU1701's I2C bus, can happen before
  Si4684's boot() call, which takes the crystal trim as an argument),
  loads the saved trim if present, and falls back to the compiled-in
  defaults (ibias=72, ctun=0, xtalFreqHz=19199750) otherwise.
- POST /api/tuner/xtal-calibrate now persists every successful live
  recalibration automatically ("persisted":true/false in the response)
  via a new saveXtalCalibration()/net::AntennaCalibration::saveXtal
  bridge, mirroring the ANTCAP save pattern.

Verified live: boot log confirms "Xtal not calibrated" before the first
save, "Xtal calibration loaded: ibias=72 ctun=0 xtal_freq_hz=19199750"
after, surviving a reboot; DAB/FM tuning unaffected (DAB CNR 17-19dB,
locked).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:22:03 +02:00
co-authored by Claude Sonnet 5
parent d17902f19c
commit a7a5311c2c
9 changed files with 351 additions and 25 deletions
+66 -19
View File
@@ -160,25 +160,9 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
return {};
}
// xtalCtun=0, xtalFreqHz=19199750 (2026-08-23): the compiled-in
// defaults (ctun=31, xtal=19200000 nominal) were never measured
// against this board's actual crystal (Abracon ABM8-19.200MHZ-10-1-U-T,
// CL=10pF per part number, plus two external 15pF load caps per the
// schematic). CTUN=0 was found audibly best via A/B listening (0/5/31),
// then xtalFreqHz was trimmed properly using the chip's own FM_RSQ
// FREQOFF measurement (tools/si4684_xtal_calibration.py) against two
// real, GPS-locked broadcast carriers 87.6/105.1 MHz -- converged to
// -3 to -4 ppm residual on both (cross-check confirms it's the
// crystal, not something frequency-dependent), down from +70 ppm
// uncorrected. See POST /api/tuner/xtal-calibrate to re-trim live if
// this ever needs revisiting (e.g. after a board/crystal change).
if (auto tunerResult =
gSi4684.boot(si4684::Si4684Band::Dab, 72U, 0U, 19199750U);
!tunerResult) {
ESP_LOGE(kTag, "Si4684 boot failed: error %d", static_cast<int>(tunerResult.error()));
return std::unexpected(HardwareBootError::Si4684BootFailed);
}
// ADAU1701 boots first (independent I2C/SPI chips, no cross-dependency)
// so its I2C bus is available for the EEPROM read below, needed to load
// the Si4684 crystal trim before Si4684 itself boots.
if (!gAdau1701.isBooted()) {
auto dspResult = gAdau1701.boot();
if (!dspResult) {
@@ -188,6 +172,51 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
}
eeprom24aa::Eeprom24aa eeprom = makeEeprom();
// Fallback defaults (2026-08-23): ctun=0, xtalFreqHz=19199750 -- the
// compiled-in defaults (ctun=31, xtal=19200000 nominal) were never
// measured against this board's actual crystal (Abracon
// ABM8-19.200MHZ-10-1-U-T, CL=10pF per part number, plus two external
// 15pF load caps per the schematic). CTUN=0 was found audibly best via
// A/B listening (0/5/31), then xtalFreqHz was trimmed properly using
// the chip's own FM_RSQ FREQOFF measurement
// (tools/si4684_xtal_calibration.py) against two real, GPS-locked
// broadcast carriers 87.6/105.1 MHz -- converged to -3 to -4 ppm
// residual on both, down from +70 ppm uncorrected. Used only when the
// EEPROM has never been calibrated (or every board would need the same
// physical crystal tolerance, which isn't guaranteed). See POST
// /api/tuner/xtal-calibrate to re-trim live, and POST it again to
// persist -- see saveXtalCalibration() below.
std::uint8_t xtalIbias = 72U;
std::uint8_t xtalCtun = 0U;
std::uint32_t xtalFreqHz = 19199750U;
if (auto xtal = eeprom.readXtalCalibration(); xtal) {
if (*xtal) {
xtalIbias = (*xtal)->ibias;
xtalCtun = (*xtal)->ctun;
xtalFreqHz = (*xtal)->xtalFreqHz;
ESP_LOGI(kTag,
"Xtal calibration loaded: ibias=%u ctun=%u "
"xtal_freq_hz=%lu",
static_cast<unsigned>(xtalIbias),
static_cast<unsigned>(xtalCtun),
static_cast<unsigned long>(xtalFreqHz));
} else {
ESP_LOGI(kTag,
"Xtal not calibrated — using compiled-in defaults");
}
} else {
ESP_LOGW(kTag, "Xtal calibration read failed — using compiled-in "
"defaults");
}
if (auto tunerResult = gSi4684.boot(si4684::Si4684Band::Dab, xtalIbias,
xtalCtun, xtalFreqHz);
!tunerResult) {
ESP_LOGE(kTag, "Si4684 boot failed: error %d", static_cast<int>(tunerResult.error()));
return std::unexpected(HardwareBootError::Si4684BootFailed);
}
if (auto identity = eeprom.readDeviceIdentity(); identity) {
gDeviceIdentity = std::move(*identity);
ESP_LOGI(kTag, "unit serial %.*s",
@@ -315,4 +344,22 @@ bool HardwareBootstrap::saveDabAntCapCalibration(std::uint8_t antCap)
return true;
}
bool HardwareBootstrap::saveXtalCalibration(std::uint8_t ibias,
std::uint8_t ctun,
std::uint32_t xtalFreqHz)
{
eeprom24aa::Eeprom24aa eeprom = makeEeprom();
const eeprom24aa::XtalCalibration calibration{
.ibias = ibias, .ctun = ctun, .xtalFreqHz = xtalFreqHz};
if (auto written = eeprom.writeXtalCalibration(calibration); !written) {
ESP_LOGW(kTag, "Xtal calibration write failed");
return false;
}
ESP_LOGI(kTag,
"Xtal calibration saved: ibias=%u ctun=%u xtal_freq_hz=%lu",
static_cast<unsigned>(ibias), static_cast<unsigned>(ctun),
static_cast<unsigned long>(xtalFreqHz));
return true;
}
} // namespace hardware