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