From a7a5311c2cf8bcebb146db8a7a93375bb4f11417 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 25 Aug 2026 15:22:03 +0200 Subject: [PATCH] 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 --- .../include/eeprom24aa/Eeprom24aa.hpp | 58 ++++++++++++ .../drivers/eeprom24aa/src/Eeprom24aa.cpp | 94 +++++++++++++++++++ .../net/include/net/SetupWebServer.hpp | 5 + .../components/net/src/SetupWebServer.cpp | 15 ++- Software/docs/TODO.md | 24 ++++- Software/docs/cursor-app-sync-2026-08-24.md | 67 +++++++++++++ Software/main/antenna_calibration.cpp | 8 ++ Software/main/hardware_bootstrap.cpp | 85 +++++++++++++---- Software/main/hardware_bootstrap.hpp | 20 ++++ 9 files changed, 351 insertions(+), 25 deletions(-) create mode 100644 Software/docs/cursor-app-sync-2026-08-24.md diff --git a/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp index 5db36d9..70a3c92 100644 --- a/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp +++ b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp @@ -23,6 +23,24 @@ namespace eeprom24aa { +/** + * @brief XtalCalibration — Si4684 crystal reference trim, EEPROM-backed. + * + * @dname XtalCalibration + * @return n/a (type) + * @pubstate Plain DTO mirroring POWER_UP ARG3/ARG8/ARG4-7 (AN649 Command + * 0x01); persisted as a set (all three or none) since a partial + * trim is meaningless. + * + * @author Michele Bigi + * @date 2026-08-24 + */ +struct XtalCalibration { + std::uint8_t ibias; ///< POWER_UP ARG3 IBIAS (0-127). + std::uint8_t ctun; ///< POWER_UP ARG8 CTUN (0-63). + std::uint32_t xtalFreqHz; ///< POWER_UP ARG4-7 XTAL_FREQ in Hz. +}; + /** * @brief Eeprom24aa — reads the factory EUI-48 from Microchip 24AA025E48; * also stores one board-specific calibration byte in the chip's @@ -50,6 +68,12 @@ public: /** Same range as kFmAntCapMax; kept as a separate name for the DAB * calibration byte's own doc comments below. */ static constexpr std::uint8_t kDabAntCapMax = 128U; + /** POWER_UP ARG3 IBIAS valid range (AN649 Command 0x01); 0xFF (blank + * EEPROM) reads back as "never calibrated". */ + static constexpr std::uint8_t kXtalIbiasMax = 127U; + /** POWER_UP ARG8 CTUN valid range (AN649 Command 0x01); 0xFF (blank + * EEPROM) reads back as "never calibrated". */ + static constexpr std::uint8_t kXtalCtunMax = 63U; /** * @brief Eeprom24aa — bind to a running I2C master bus and 7-bit addr. @@ -140,6 +164,40 @@ public: [[nodiscard]] std::expected writeDabAntCap(std::uint8_t value); + /** + * @brief readXtalCalibration — read the stored Si4684 crystal trim. + * + * @dname readXtalCalibration + * @return Calibrated ibias/ctun/xtalFreqHz if all three were saved via + * writeXtalCalibration(), nullopt if never calibrated (any of + * the three bytes still blank/out of range), or IdentityError + * on an I2C failure. + * @pubstate performs one I2C read of six bytes starting at word address + * 0x02. + * + * @author Michele Bigi + * @date 2026-08-24 + */ + [[nodiscard]] std::expected, core::IdentityError> + readXtalCalibration(); + + /** + * @brief writeXtalCalibration — persist the Si4684 crystal trim. + * + * @dname writeXtalCalibration + * @param calibration ibias (0-127), ctun (0-63), xtalFreqHz found by + * POST /api/tuner/xtal-calibrate, not computed. + * @return Ok on success, or IdentityError::I2cFailed. + * @pubstate performs one I2C write of six bytes starting at word address + * 0x02 (ibias, ctun, xtalFreqHz big-endian), then blocks for + * the chip's write-cycle time before returning. + * + * @author Michele Bigi + * @date 2026-08-24 + */ + [[nodiscard]] std::expected + writeXtalCalibration(const XtalCalibration& calibration); + private: i2c_master_bus_handle_t bus_; std::uint8_t addr7_; diff --git a/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp b/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp index 600e011..672ee5f 100644 --- a/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp +++ b/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp @@ -32,6 +32,11 @@ constexpr std::uint8_t kEui48WordAddress = 0xFAU; constexpr std::uint8_t kFmAntCapWordAddress = 0x00U; /** DAB ANTCAP calibration byte, next word address after the FM byte. */ constexpr std::uint8_t kDabAntCapWordAddress = 0x01U; +/** Si4684 crystal trim: IBIAS byte, CTUN byte, then XTAL_FREQ as 4 bytes + * big-endian -- 6 bytes total starting right after the DAB ANTCAP byte. */ +constexpr std::uint8_t kXtalIbiasWordAddress = 0x02U; +constexpr std::uint8_t kXtalCtunWordAddress = 0x03U; +constexpr std::uint8_t kXtalFreqHzWordAddress = 0x04U; constexpr int kI2cTimeoutMs = 100; /** DS20001191 §"Page Write"/"Byte Write": max write cycle time after STOP * before the chip acknowledges further I2C traffic. */ @@ -221,4 +226,93 @@ Eeprom24aa::writeDabAntCap(std::uint8_t value) return {}; } +std::expected, core::IdentityError> +Eeprom24aa::readXtalCalibration() +{ + if (bus_ == nullptr) { + return std::unexpected(core::IdentityError::I2cFailed); + } + + i2c_device_config_t devCfg = {}; + devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7; + devCfg.device_address = addr7_; + devCfg.scl_speed_hz = 100000; + + i2c_master_dev_handle_t dev = nullptr; + if (i2c_master_bus_add_device(bus_, &devCfg, &dev) != ESP_OK) { + ESP_LOGW(kTag, "i2c_master_bus_add_device failed"); + return std::unexpected(core::IdentityError::I2cFailed); + } + + const std::uint8_t wordAddress = kXtalIbiasWordAddress; + std::array payload = {}; + const esp_err_t err = i2c_master_transmit_receive( + dev, &wordAddress, 1U, payload.data(), payload.size(), kI2cTimeoutMs); + + i2c_master_bus_rm_device(dev); + + if (err != ESP_OK) { + ESP_LOGW(kTag, "Xtal calibration read failed (err=0x%x)", + static_cast(err)); + return std::unexpected(core::IdentityError::ReadFailed); + } + + const std::uint8_t ibias = payload[0]; + const std::uint8_t ctun = payload[1]; + const std::uint32_t xtalFreqHz = + (static_cast(payload[2]) << 24) + | (static_cast(payload[3]) << 16) + | (static_cast(payload[4]) << 8) + | static_cast(payload[5]); + + if (ibias > kXtalIbiasMax || ctun > kXtalCtunMax + || xtalFreqHz == 0xFFFFFFFFU) { + return std::optional{}; + } + return std::optional{ + XtalCalibration{.ibias = ibias, .ctun = ctun, + .xtalFreqHz = xtalFreqHz}}; +} + +std::expected +Eeprom24aa::writeXtalCalibration(const XtalCalibration& calibration) +{ + if (bus_ == nullptr) { + return std::unexpected(core::IdentityError::I2cFailed); + } + + i2c_device_config_t devCfg = {}; + devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7; + devCfg.device_address = addr7_; + devCfg.scl_speed_hz = 100000; + + i2c_master_dev_handle_t dev = nullptr; + if (i2c_master_bus_add_device(bus_, &devCfg, &dev) != ESP_OK) { + ESP_LOGW(kTag, "i2c_master_bus_add_device failed"); + return std::unexpected(core::IdentityError::I2cFailed); + } + + const std::array payload = { + kXtalIbiasWordAddress, + calibration.ibias, + calibration.ctun, + static_cast(calibration.xtalFreqHz >> 24), + static_cast(calibration.xtalFreqHz >> 16), + static_cast(calibration.xtalFreqHz >> 8), + static_cast(calibration.xtalFreqHz)}; + const esp_err_t err = + i2c_master_transmit(dev, payload.data(), payload.size(), kI2cTimeoutMs); + + i2c_master_bus_rm_device(dev); + + if (err != ESP_OK) { + ESP_LOGW(kTag, "Xtal calibration write failed (err=0x%x)", + static_cast(err)); + return std::unexpected(core::IdentityError::I2cFailed); + } + + vTaskDelay(pdMS_TO_TICKS(kI2cWriteCycleMs)); + return {}; +} + } // namespace eeprom24aa diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index b8af885..298212e 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -112,6 +112,11 @@ struct AntennaCalibration { * @return false if the chip was never booted or the reboot failed. */ bool (*recalibrateXtal)(std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz); + /** Persist a new Si4684 crystal trim (ibias/ctun/xtalFreqHz) to EEPROM, + * so it survives a reboot instead of only lasting until the next power + * cycle. @return false on an I2C failure. */ + bool (*saveXtal)(std::uint8_t ibias, std::uint8_t ctun, + std::uint32_t xtalFreqHz); }; /** diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 43fbd2c..4400422 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -741,11 +741,24 @@ esp_err_t tunerXtalCalibratePostHandler(httpd_req_t* req) return httpd_resp_send(req, json.c_str(), json.size()); } + // Persist every successful live recalibration (2026-08-24): each board + // may need its own crystal trim, so the value found by a calibration + // sweep must survive a reboot, not just last until the next power + // cycle. The live apply above already succeeded regardless of whether + // this write does -- report it separately rather than failing the + // whole request on an EEPROM hiccup. + bool persisted = false; + if (ctx->antennaCalibration->saveXtal != nullptr) { + persisted = ctx->antennaCalibration->saveXtal( + parsed->ibias, parsed->ctun, parsed->xtalFreqHz); + } + const std::string json = std::string("{\"status\":\"recalibrated\",\"ibias\":") + std::to_string(parsed->ibias) + ",\"ctun\":" + std::to_string(parsed->ctun) + ",\"xtal_freq_hz\":" - + std::to_string(parsed->xtalFreqHz) + "}"; + + std::to_string(parsed->xtalFreqHz) + ",\"persisted\":" + + (persisted ? "true" : "false") + "}"; httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, json.c_str(), json.size()); } diff --git a/Software/docs/TODO.md b/Software/docs/TODO.md index 45d17e0..29bdc85 100644 --- a/Software/docs/TODO.md +++ b/Software/docs/TODO.md @@ -204,7 +204,7 @@ Done in fw 0.8.5 unless noted: --- -## TODO — calibration functions need to become permanent, in-firmware, on-demand tools (2026-08-23) +## DONE (2026-08-24) — calibration functions are now permanent, in-firmware, on-demand tools Both ANTCAP calibration (`tools/si4684_antenna_calibration.py`) and Si4684 crystal calibration (`tools/si4684_xtal_calibration.py`, @@ -236,10 +236,24 @@ convergence-loop logic (currently in `tools/si4684_xtal_calibration.py`) moves into firmware, or stays host-side with just an EEPROM-persist step added at the end of the existing HTTP flow. -Not started — explicitly deferred to a future session, noted here only so -it isn't lost. See `docs/si4684-rf-investigation-report.md`'s 2026-08-23 -entry for full context on why this calibration was needed and how it -currently works. +**Resolved 2026-08-24.** `Eeprom24aa` gained `readXtalCalibration()` / +`writeXtalCalibration()` (word addresses 0x02 ibias, 0x03 ctun, 0x04-0x07 +xtalFreqHz big-endian, right after the existing FM/DAB ANTCAP bytes at +0x00/0x01). `HardwareBootstrap::boot()` now reads the ADAU1701 boot +earlier (moved before Si4684's, since the EEPROM read needs ADAU1701's +I2C bus) and loads the saved crystal trim before calling +`gSi4684.boot(...)`, falling back to the compiled-in defaults +(ibias=72, ctun=0, xtalFreqHz=19199750) when the EEPROM has never been +calibrated. `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 existing +ANTCAP save pattern. The convergence-loop logic +(`tools/si4684_xtal_calibration.py`) stays host-side, unchanged — only +the persistence gap was closed. + +See `docs/si4684-rf-investigation-report.md`'s 2026-08-23 entry for full +context on why this calibration was needed and how it currently works. --- diff --git a/Software/docs/cursor-app-sync-2026-08-24.md b/Software/docs/cursor-app-sync-2026-08-24.md new file mode 100644 index 0000000..e6d8c1e --- /dev/null +++ b/Software/docs/cursor-app-sync-2026-08-24.md @@ -0,0 +1,67 @@ +# Note per l'app companion (igiRadio) — sync 2026-08-24 + +Riepilogo dei cambi firmware di oggi rilevanti per l'app iOS. Incollare in Cursor come contesto. + +## 1. Nuovo endpoint: codec A2DP Bluetooth + +``` +GET /api/bluetooth/a2dp-codec +POST /api/bluetooth/a2dp-codec body: {"codec_mask": <0-63>} +``` + +- `GET` risponde `{"codec":"sbc"}` (valori possibili: `sbc`, `aptx`, `aptx-hd`, `aptx-ll`, `aptx-adaptive`; se il modulo BT1035 negozia AAC la risposta può non essere interpretabile — il datasheet Feasycom non documenta un codice di ritorno per AAC, quindi in quel caso l'app deve gestire un valore/errore sconosciuto senza andare in crash). +- `POST` con `codec_mask` è una bitmask: `BIT0`=AAC, `BIT1`=aptX, `BIT2`=aptX-LL, `BIT3`=aptX-HD, `BIT4`=aptX-Adaptive, `BIT5`=LDAC. `0` forza solo SBC (baseline obbligatorio). Risposta `{"status":"saved"}` o `{"status":"error","reason":"..."}`. +- **Importante**: il cambio vale solo per la prossima negoziazione — se un dispositivo Bluetooth è già connesso, serve chiamare `POST /api/bluetooth/disconnect` e far riconnettere il device (dal lato telefono/speaker) perché il nuovo codec venga effettivamente usato. Se l'app espone questa funzione in UI, considerare di mostrare un messaggio tipo "riconnetti il dispositivo Bluetooth per applicare". + +## 2. Fix: salvataggio profilo audio (EQ/mixer) ora persiste davvero + +Prima di oggi, `PUT /api/audio/profile` applicava le modifiche dal vivo ma **falliva sempre** silenziosamente nel salvataggio permanente (bug: nome chiave NVS troppo lungo). L'app probabilmente vedeva errori intermittenti o impostazioni che sparivano al riavvio della scheda. Ora è risolto e verificato: le modifiche sopravvivono a un riavvio. Nessun cambio di formato richiesto lato app — stesso schema JSON di sempre. + +## 3. Fix: il parser JSON del firmware ora tollera JSON non-compatto + +Prima di oggi il parser lato firmware richiedeva JSON strettamente compatto (`{"key":"value"}`, **nessuno spazio** dopo i due punti) — un `JSONEncoder` Swift in modalità non-compatta (es. con `.prettyPrinted`, o formattazione di default in alcune configurazioni) poteva produrre `"key": "value"` e far fallire il parsing lato server con `invalid_json` o `missing_field`. + +Questo è ora risolto per tutti gli endpoint (tuner, audio profile, stazioni, bluetooth, wifi, streaming, DSP param). **Se nell'app c'era un workaround per forzare JSON compatto** (es. `JSONEncoder().outputFormatting` impostato esplicitamente senza spazi, o costruzione manuale di stringhe JSON), non è più necessario ma può restare — è comunque compatibile. + +## 4. Nomi campi corretti (promemoria, riscontrati oggi durante i test manuali) + +Attenzione a questi nomi campo esatti attesi dal firmware — un nome sbagliato produce `missing_field`, non necessariamente un errore chiaro: + +- `POST /api/tuner/tune` per DAB: `{"band":"dab","freq_index":<0-37>}` — **non** `"frequency"`. +- `POST /api/tuner/tune` per FM: `{"band":"fm","frequency_khz":}`. +- `POST /api/stations` per una stazione FM: `{"name":"...","band":"fm","fm_frequency_khz":}` — **non** `"frequency_khz"` a livello radice. +- `POST /api/stations` per una stazione DAB: `{"name":"...","band":"dab","dab_freq_index":<0-37>}` (opzionali `dab_service_id`, `dab_component_id`). +- `POST /api/stations/remove`: `{"index":}` — **non** `{"name":"..."}`. + +Se l'app usa nomi diversi da questi in qualche punto, verificare contro `components/core/src/StationListJson.cpp` e `components/core/src/TunerJson.cpp` nel repo firmware (fonte di verità). + +## 5. Nessun cambio di schema per mixer/EQ + +Lo schema di `GET|PUT /api/audio/profile` è invariato: + +```json +{ + "mixer": { + "si4684_left_db": 0, "si4684_right_db": 0, + "esp32_left_db": -96, "esp32_right_db": -96, + "mix_left_db": 0, "mix_right_db": -96 + }, + "master": {"left_db": 0, "right_db": 0}, + "eq": [ + {"gain_db": 0, "center_hz": 20, "q": 1.414}, + {"gain_db": 0, "center_hz": 100, "q": 1}, + {"gain_db": 0, "center_hz": 400, "q": 1}, + {"gain_db": 0, "center_hz": 1000, "q": 1}, + {"gain_db": 0, "center_hz": 3000, "q": 1}, + {"gain_db": 0, "center_hz": 8000, "q": 1} + ], + "enhancements": {"stereo_level": 0, "bass_level": 0} +} +``` + +Nota: `esp32_left_db`/`esp32_right_db`/`mix_right_db` a `-96` = leg ESP32 (streaming web radio) mutato, `si4684_*`/`mix_left_db` a `0` = leg radio (FM/DAB) aperto — è il default "radio-first" di fabbrica. Se l'app vuole passare a streaming web-radio senza sentire anche la radio in sovrapposizione, deve invertire questi gain (radio a `-96`, esp32 a `0`) — non è automatico solo abilitando `POST /api/streaming`. + +## 6. Bug noto, non ancora risolto + +- `PUT /api/audio/profile` con `enhancements` (stereo/bass) diverso da zero **sovrascrive** eventuali modifiche manuali dell'EQ — bug già tracciato, non toccato oggi. +- La banda EQ indice 0 (20 Hz) è in realtà un filtro passa-alto fisso non modificabile dal DSP — il valore `gain_db` mostrato/inviato per quella banda è cosmetico, non ha effetto reale sul suono. diff --git a/Software/main/antenna_calibration.cpp b/Software/main/antenna_calibration.cpp index aab8b0d..90d4033 100644 --- a/Software/main/antenna_calibration.cpp +++ b/Software/main/antenna_calibration.cpp @@ -25,6 +25,13 @@ bool recalibrateXtal(std::uint8_t ibias, std::uint8_t ctun, .recalibrateXtal(ibias, ctun, xtalFreqHz)); } +/** Persist to EEPROM, 2026-08-24: see net::AntennaCalibration::saveXtal. */ +bool saveXtal(std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz) +{ + return hardware::HardwareBootstrap::saveXtalCalibration(ibias, ctun, + xtalFreqHz); +} + } // namespace net::AntennaCalibration& bridge() noexcept @@ -33,6 +40,7 @@ net::AntennaCalibration& bridge() noexcept .save = &hardware::HardwareBootstrap::saveFmAntCapCalibration, .saveDab = &hardware::HardwareBootstrap::saveDabAntCapCalibration, .recalibrateXtal = &recalibrateXtal, + .saveXtal = &saveXtal, }; return instance; } diff --git a/Software/main/hardware_bootstrap.cpp b/Software/main/hardware_bootstrap.cpp index 169e210..2062fa1 100644 --- a/Software/main/hardware_bootstrap.cpp +++ b/Software/main/hardware_bootstrap.cpp @@ -160,25 +160,9 @@ std::expected 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(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 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(xtalIbias), + static_cast(xtalCtun), + static_cast(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(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(ibias), static_cast(ctun), + static_cast(xtalFreqHz)); + return true; +} + } // namespace hardware diff --git a/Software/main/hardware_bootstrap.hpp b/Software/main/hardware_bootstrap.hpp index 2405a69..ca55874 100644 --- a/Software/main/hardware_bootstrap.hpp +++ b/Software/main/hardware_bootstrap.hpp @@ -197,6 +197,26 @@ public: * @date 2026-08-20 */ [[nodiscard]] static bool saveDabAntCapCalibration(std::uint8_t antCap); + + /** + * @brief saveXtalCalibration — persist the Si4684 crystal trim. + * + * @dname saveXtalCalibration + * @param ibias Value found via POST /api/tuner/xtal-calibrate. + * @param ctun Value found via POST /api/tuner/xtal-calibrate. + * @param xtalFreqHz Value found via POST /api/tuner/xtal-calibrate. + * @return true on success, false on an I2C failure. + * @pubstate writes the 24AA025E48 user region. Does not itself reboot + * the Si4684 — callers apply the values live via + * Si4684Tuner::recalibrateXtal() first; boot() reads this back + * on the next power-up so the trim survives a reboot. + * + * @author Michele Bigi + * @date 2026-08-24 + */ + [[nodiscard]] static bool saveXtalCalibration(std::uint8_t ibias, + std::uint8_t ctun, + std::uint32_t xtalFreqHz); }; } // namespace hardware