diff --git a/Software/components/core/include/core/ITuner.hpp b/Software/components/core/include/core/ITuner.hpp index d093323..d14948b 100644 --- a/Software/components/core/include/core/ITuner.hpp +++ b/Software/components/core/include/core/ITuner.hpp @@ -100,6 +100,14 @@ public: * * @dname tuneFm * @param frequency Validated FM centre frequency. + * @param antCap Front-end antenna varactor override (0-128). + * 0 = automatic (chip's own FE_VARM/VARB-derived + * tuning); other values force a specific varactor + * setting, for antenna calibration sweeps. Chip- + * specific concept (AN851 Appendix A on the + * Si4684), exposed here only because ANTCAP has no + * other reasonable home without duplicating the + * whole tune path per driver. * @return Ok on success, or WrongBand / TuneFailed / NotBooted. * @pubstate writes last tune target in the adapter. * @@ -107,7 +115,7 @@ public: * @date 2026-07-06 */ [[nodiscard]] virtual std::expected tuneFm( - FrequencyKHz frequency) = 0; + FrequencyKHz frequency, std::uint8_t antCap = 0U) = 0; /** * @brief seekFm — seek to the next valid FM station. diff --git a/Software/components/core/include/core/TunerJson.hpp b/Software/components/core/include/core/TunerJson.hpp index 53fae2d..8e2d662 100644 --- a/Software/components/core/include/core/TunerJson.hpp +++ b/Software/components/core/include/core/TunerJson.hpp @@ -41,6 +41,8 @@ struct TunerTuneRequest { TunerBand band; ///< Target band (Dab or Fm). std::uint8_t dabFreqIndex; ///< Band III ensemble index (0–37) when band is Dab. std::optional fmFrequency; ///< FM centre frequency when band is Fm. + std::optional antCap; ///< FM antenna varactor override (0–128), + ///< for calibration sweeps; ignored for Dab. }; /** @@ -239,4 +241,19 @@ struct TunerFmScannedStation { [[nodiscard]] std::string serializeTunerFmBandScanJson( const std::vector& stations); +/** + * @brief parseAntennaCalibrationJson — validate POST + * /api/tuner/calibrate-antenna body. + * + * @dname parseAntennaCalibrationJson + * @param json Untrusted request body from the HTTP handler. + * @return ANTCAP value (0-128) on success, or a ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-08-19 + */ +[[nodiscard]] std::expected +parseAntennaCalibrationJson(std::string_view json); + } // namespace core diff --git a/Software/components/core/src/TunerJson.cpp b/Software/components/core/src/TunerJson.cpp index e2fef81..ed1c5c8 100644 --- a/Software/components/core/src/TunerJson.cpp +++ b/Software/components/core/src/TunerJson.cpp @@ -200,6 +200,10 @@ std::expected parseTunerTuneJson( return std::unexpected(freq.error()); } req.fmFrequency = *freq; + unsigned long antCap = 0U; + if (extractJsonUint(json, "antcap", antCap) && antCap <= 128U) { + req.antCap = static_cast(antCap); + } } else { return std::unexpected(ParseError::InvalidJson); } @@ -336,4 +340,17 @@ std::string serializeTunerFmBandScanJson( return out.str(); } +std::expected parseAntennaCalibrationJson( + std::string_view json) +{ + if (json.find('{') == std::string_view::npos) { + return std::unexpected(ParseError::InvalidJson); + } + unsigned long antCap = 0U; + if (!extractJsonUint(json, "antcap", antCap) || antCap > 128U) { + return std::unexpected(ParseError::MissingField); + } + return static_cast(antCap); +} + } // namespace core diff --git a/Software/components/core/test/integration_service_test.cpp b/Software/components/core/test/integration_service_test.cpp index d98445b..e76ab39 100644 --- a/Software/components/core/test/integration_service_test.cpp +++ b/Software/components/core/test/integration_service_test.cpp @@ -117,7 +117,7 @@ public: } [[nodiscard]] std::expected tuneFm( - core::FrequencyKHz) override + core::FrequencyKHz, std::uint8_t) override { tunedFm = true; return {}; diff --git a/Software/components/core/test/station_service_test.cpp b/Software/components/core/test/station_service_test.cpp index d82a267..89b8d05 100644 --- a/Software/components/core/test/station_service_test.cpp +++ b/Software/components/core/test/station_service_test.cpp @@ -59,7 +59,7 @@ public: } [[nodiscard]] std::expected tuneFm( - core::FrequencyKHz) override + core::FrequencyKHz, std::uint8_t) override { return {}; } diff --git a/Software/components/drivers/eeprom24aa/CMakeLists.txt b/Software/components/drivers/eeprom24aa/CMakeLists.txt index 9357eb2..3c2f946 100644 --- a/Software/components/drivers/eeprom24aa/CMakeLists.txt +++ b/Software/components/drivers/eeprom24aa/CMakeLists.txt @@ -1,7 +1,7 @@ idf_component_register( SRCS "src/Eeprom24aa.cpp" INCLUDE_DIRS "include" - REQUIRES core driver esp_driver_i2c + REQUIRES core driver esp_driver_i2c freertos ) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp index a6bf946..8b97bb2 100644 --- a/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp +++ b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp @@ -13,27 +13,40 @@ #pragma once #include "core/IDeviceIdentitySource.hpp" +#include "core/IdentityError.hpp" #include "driver/i2c_master.h" #include +#include +#include namespace eeprom24aa { /** - * @brief Eeprom24aa — reads the factory EUI-48 from Microchip 24AA025E48. + * @brief Eeprom24aa — reads the factory EUI-48 from Microchip 24AA025E48; + * also stores one board-specific calibration byte in the chip's + * user-writable region. * * @dname Eeprom24aa * @return n/a (type) * @pubstate Borrows an existing I2C master bus (shared with ADAU1701). The - * EUI-48 lives at word address 0xFA..0xFF per the 24AA025E48 - * datasheet (DS20001191). + * EUI-48 lives at word address 0xFA..0xFF (read-only, factory + * programmed) per the 24AA025E48 datasheet (DS20001191); the FM + * ANTCAP calibration byte lives at word address 0x00 in the + * remaining user-writable 250 bytes. * * @author Michele Bigi * @date 2026-07-07 */ class Eeprom24aa : public core::IDeviceIdentitySource { public: + /** Valid FM ANTCAP calibration values are 0-128 (AN649/AN851); any + * stored byte above this, including the EEPROM's blank/erased 0xFF, + * reads back as "never calibrated" — no separate sentinel write + * needed for a fresh chip. */ + static constexpr std::uint8_t kFmAntCapMax = 128U; + /** * @brief Eeprom24aa — bind to a running I2C master bus and 7-bit addr. * @@ -60,6 +73,38 @@ public: [[nodiscard]] std::expected readDeviceIdentity() override; + /** + * @brief readFmAntCap — read the stored FM antenna calibration byte. + * + * @dname readFmAntCap + * @return Calibrated ANTCAP (0-kFmAntCapMax) if one was ever saved via + * writeFmAntCap(), nullopt if the byte is blank/out of range, + * or IdentityError on an I2C failure. + * @pubstate performs one I2C read of one byte at word address 0x00. + * + * @author Michele Bigi + * @date 2026-08-19 + */ + [[nodiscard]] std::expected, core::IdentityError> + readFmAntCap(); + + /** + * @brief writeFmAntCap — persist an FM antenna calibration value. + * + * @dname writeFmAntCap + * @param value ANTCAP to store, 0-kFmAntCapMax (AN851 Appendix A + * calibration procedure; found via a sweep, not + * computed). + * @return Ok on success, or IdentityError::I2cFailed. + * @pubstate performs one I2C byte write at word address 0x00, then + * blocks for the chip's write-cycle time before returning. + * + * @author Michele Bigi + * @date 2026-08-19 + */ + [[nodiscard]] std::expected + writeFmAntCap(std::uint8_t value); + 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 019c455..c7c1172 100644 --- a/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp +++ b/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp @@ -15,6 +15,8 @@ #include "driver/i2c_master.h" #include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" #include @@ -25,7 +27,13 @@ namespace { constexpr char kTag[] = "Eeprom24aa"; /** EUI-48 word address per Microchip 24AA025E48 datasheet (DS20001191). */ constexpr std::uint8_t kEui48WordAddress = 0xFAU; +/** FM ANTCAP calibration byte, in the chip's user-writable region (anywhere + * below the factory-locked 0xFA-0xFF EUI-48 block). */ +constexpr std::uint8_t kFmAntCapWordAddress = 0x00U; constexpr int kI2cTimeoutMs = 100; +/** DS20001191 §"Page Write"/"Byte Write": max write cycle time after STOP + * before the chip acknowledges further I2C traffic. */ +constexpr int kI2cWriteCycleMs = 5; } // namespace @@ -69,4 +77,75 @@ Eeprom24aa::readDeviceIdentity() return core::DeviceIdentity::fromEui48(core::Eui48::fromBytes(payload)); } +std::expected, core::IdentityError> +Eeprom24aa::readFmAntCap() +{ + 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 = kFmAntCapWordAddress; + std::uint8_t value = 0xFFU; + const esp_err_t err = i2c_master_transmit_receive( + dev, &wordAddress, 1U, &value, 1U, kI2cTimeoutMs); + + i2c_master_bus_rm_device(dev); + + if (err != ESP_OK) { + ESP_LOGW(kTag, "FM ANTCAP read failed (err=0x%x)", + static_cast(err)); + return std::unexpected(core::IdentityError::ReadFailed); + } + + if (value > kFmAntCapMax) { + return std::optional{}; + } + return std::optional{value}; +} + +std::expected +Eeprom24aa::writeFmAntCap(std::uint8_t value) +{ + 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 = {kFmAntCapWordAddress, value}; + 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, "FM ANTCAP 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/drivers/si4684/include/si4684/Si4684Tuner.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp index 2342037..31cf006 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp @@ -110,6 +110,7 @@ public: * * @dname tuneFm * @param frequency Validated FM centre frequency. + * @param antCap Forwarded to Si4684Driver::tuneFm (0 = auto). * @return Ok on success, or a mapped TunerError. * @pubstate writes fmFrequency_ on success. * @@ -117,7 +118,7 @@ public: * @date 2026-07-06 */ [[nodiscard]] std::expected tuneFm( - core::FrequencyKHz frequency) override; + core::FrequencyKHz frequency, std::uint8_t antCap = 0U) override; /** * @brief seekFm — seek FM with band wrap. diff --git a/Software/components/drivers/si4684/src/Si4684Tuner.cpp b/Software/components/drivers/si4684/src/Si4684Tuner.cpp index ed2189e..d8b31f0 100644 --- a/Software/components/drivers/si4684/src/Si4684Tuner.cpp +++ b/Software/components/drivers/si4684/src/Si4684Tuner.cpp @@ -225,12 +225,12 @@ std::expected Si4684Tuner::tuneDab( } std::expected Si4684Tuner::tuneFm( - core::FrequencyKHz frequency) + core::FrequencyKHz frequency, std::uint8_t antCap) { if (auto ready = ensureBandLoaded(core::TunerBand::Fm); !ready) { return ready; } - if (auto result = driver_.tuneFm(frequency); !result) { + if (auto result = driver_.tuneFm(frequency, antCap); !result) { return std::unexpected(mapError(result.error())); } fmFrequency_ = frequency; diff --git a/Software/components/net/include/net/NetBootstrap.hpp b/Software/components/net/include/net/NetBootstrap.hpp index b16ad21..2dde756 100644 --- a/Software/components/net/include/net/NetBootstrap.hpp +++ b/Software/components/net/include/net/NetBootstrap.hpp @@ -87,6 +87,8 @@ public: * @param ota Firmware OTA service for POST /api/system/ota. * @param webRadio Streaming config for GET/POST /api/streaming. * @param phoneStream I2S write-through for PUT /api/stream/phone. + * @param antennaCalibration EEPROM write-through for + * POST /api/tuner/calibrate-antenna. * @param companionChips Boot flags exposed on GET /api/health. * @param deviceIdentity EEPROM-derived SSID, hostname, and serial. * @return NetBootstrap on success, or a NetError. @@ -103,6 +105,7 @@ public: ota::OtaService& ota, webradio::WebRadioService& webRadio, PhoneStreamSink& phoneStream, + AntennaCalibration& antennaCalibration, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity); diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index ba6de91..57e39a7 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -85,6 +85,25 @@ struct PhoneStreamSink { std::size_t frameCount); }; +/** + * @brief AntennaCalibration — plain function pointer over EEPROM-backed + * FM ANTCAP storage, so net/ never includes eeprom24aa headers + * directly; main/ supplies it (HardwareBootstrap owns the I2C + * bus and EEPROM handle). + * + * @dname AntennaCalibration + * @return n/a (type) + * @pubstate Free function with process lifetime; no per-instance state. + * + * @author Michele Bigi + * @date 2026-08-19 + */ +struct AntennaCalibration { + /** Persist a new FM ANTCAP calibration value to EEPROM. + * @return false on an I2C failure. */ + bool (*save)(std::uint8_t antCap); +}; + /** * @brief HttpRouteContext — dependencies injected into HTTP handlers. * @@ -106,6 +125,7 @@ struct HttpRouteContext { ota::OtaService* ota; ///< Firmware OTA streaming. webradio::WebRadioService* webRadio; ///< Streaming config REST routes. PhoneStreamSink* phoneStream; ///< PUT /api/stream/phone I2S write-through. + AntennaCalibration* antennaCalibration; ///< POST /api/tuner/calibrate-antenna. core::CompanionChipStatus companionChips; ///< Boot flags for /api/health. core::DeviceIdentity deviceIdentity; ///< EEPROM-derived unit identity. }; @@ -187,6 +207,8 @@ public: * @param ota Firmware OTA service for POST /api/system/ota. * @param webRadio Streaming config for GET/POST /api/streaming. * @param phoneStream I2S write-through for PUT /api/stream/phone. + * @param antennaCalibration EEPROM write-through for + * POST /api/tuner/calibrate-antenna. * @param companionChips Boot flags for GET /api/health. * @param deviceIdentity Unit identity for /api/health serialNumber. * @return Ok on success, or NetError::HttpServerStartFailed. @@ -204,6 +226,7 @@ public: ota::OtaService& ota, webradio::WebRadioService& webRadio, PhoneStreamSink& phoneStream, + AntennaCalibration& antennaCalibration, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity); diff --git a/Software/components/net/src/NetBootstrap.cpp b/Software/components/net/src/NetBootstrap.cpp index 8bc6f55..7cdcda1 100644 --- a/Software/components/net/src/NetBootstrap.cpp +++ b/Software/components/net/src/NetBootstrap.cpp @@ -106,6 +106,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, ota::OtaService& ota, webradio::WebRadioService& webRadio, PhoneStreamSink& phoneStream, + AntennaCalibration& antennaCalibration, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -121,7 +122,8 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, if (auto webResult = webServer.start(store, NetState::SoftApSetup, tuner, audio, bluetooth, stations, integration, ota, webRadio, - phoneStream, companionChips, deviceIdentity); + phoneStream, antennaCalibration, companionChips, + deviceIdentity); !webResult) { return std::unexpected(webResult.error()); } @@ -168,6 +170,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, ota::OtaService& ota, webradio::WebRadioService& webRadio, PhoneStreamSink& phoneStream, + AntennaCalibration& antennaCalibration, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -198,7 +201,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, if (auto webResult = webServer.start(store, NetState::StaConnected, tuner, audio, bluetooth, stations, integration, ota, webRadio, - phoneStream, companionChips, deviceIdentity); + phoneStream, antennaCalibration, companionChips, + deviceIdentity); !webResult) { return std::unexpected(webResult.error()); } @@ -227,6 +231,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, ota::OtaService& ota, webradio::WebRadioService& webRadio, PhoneStreamSink& phoneStream, + AntennaCalibration& antennaCalibration, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -241,7 +246,8 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, if (store.hasWifiCredentials()) { auto staResult = startStaMode(store, tuner, audio, bluetooth, stations, integration, ota, webRadio, phoneStream, - companionChips, deviceIdentity); + antennaCalibration, companionChips, + deviceIdentity); if (staResult) { return staResult; } @@ -253,8 +259,8 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, } return startSetupMode(store, tuner, audio, bluetooth, stations, integration, - ota, webRadio, phoneStream, companionChips, - deviceIdentity); + ota, webRadio, phoneStream, antennaCalibration, + companionChips, deviceIdentity); } NetBootstrap::NetBootstrap(std::optional softAp, diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index d770a3d..1522730 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -99,6 +99,7 @@ extern const uint8_t index_html_gz_end[] asm( .ota = nullptr, .webRadio = nullptr, .phoneStream = nullptr, + .antennaCalibration = nullptr, .companionChips = {}, .deviceIdentity = core::DeviceIdentity::unknown(), }; @@ -432,7 +433,10 @@ esp_err_t tunerTunePostHandler(httpd_req_t* req) if (parsed->band == core::TunerBand::Dab) { result = ctx->tuner->tuneDab(parsed->dabFreqIndex); } else if (parsed->fmFrequency) { - result = ctx->tuner->tuneFm(*parsed->fmFrequency); + // Omitting antcap uses the board's saved calibration (or hardware + // auto-tune if never calibrated) — only an explicit value in the + // request overrides it, e.g. for a calibration sweep. + result = ctx->tuner->tuneFm(*parsed->fmFrequency, parsed->antCap); } if (!result) { @@ -635,6 +639,58 @@ esp_err_t tunerFullScanPostHandler(httpd_req_t* req) return httpd_resp_send(req, json.c_str(), json.size()); } +/** + * @brief tunerCalibrateAntennaPostHandler — save the FM ANTCAP found by + * a calibration sweep as the board's permanent default. + * + * @dname tunerCalibrateAntennaPostHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate writes the 24AA025E48 via route context antenna calibration + * bridge, then updates the live tuner default immediately. + * + * @author Michele Bigi + * @date 2026-08-19 + */ +esp_err_t tunerCalibrateAntennaPostHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->tuner == nullptr + || ctx->antennaCalibration == nullptr) { + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_send(req, nullptr, 0); + } + + std::array body{}; + if (!readRequestBody(req, body)) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, nullptr, 0); + } + + const auto parsed = + core::parseAntennaCalibrationJson(std::string_view(body.data())); + if (!parsed) { + const std::string json = core::serializeTunerErrorJson("invalid_json"); + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + if (!ctx->antennaCalibration->save(*parsed)) { + const std::string json = core::serializeTunerErrorJson("store_failed"); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + ctx->tuner->setDefaultFmAntCap(*parsed); + + const std::string json = + std::string("{\"status\":\"saved\",\"antcap\":") + + std::to_string(*parsed) + "}"; + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + /** * @brief audioProfileGetHandler — serve GET /api/audio/profile JSON. * @@ -1865,6 +1921,7 @@ std::expected SetupWebServer::start( ota::OtaService& ota, webradio::WebRadioService& webRadio, PhoneStreamSink& phoneStream, + AntennaCalibration& antennaCalibration, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -1889,6 +1946,7 @@ std::expected SetupWebServer::start( routeContext.ota = &ota; routeContext.webRadio = &webRadio; routeContext.phoneStream = &phoneStream; + routeContext.antennaCalibration = &antennaCalibration; routeContext.companionChips = companionChips; routeContext.deviceIdentity = deviceIdentity; @@ -2001,6 +2059,14 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &tunerFullScanUri); + const httpd_uri_t tunerCalibrateAntennaUri = { + .uri = "/api/tuner/calibrate-antenna", + .method = HTTP_POST, + .handler = tunerCalibrateAntennaPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &tunerCalibrateAntennaUri); + const httpd_uri_t audioProfileGetUri = { .uri = "/api/audio/profile", .method = HTTP_GET, diff --git a/Software/components/services/tuner/include/tuner/TunerService.hpp b/Software/components/services/tuner/include/tuner/TunerService.hpp index 46e67c6..ec6019e 100644 --- a/Software/components/services/tuner/include/tuner/TunerService.hpp +++ b/Software/components/services/tuner/include/tuner/TunerService.hpp @@ -87,6 +87,10 @@ public: * * @dname tuneFm * @param frequency Validated FM centre frequency. + * @param antCap Front-end antenna varactor override for this one + * tune (e.g. for a calibration sweep). Omit to use + * defaultFmAntCap_ (the board's saved calibration, + * or hardware auto-tune if never calibrated). * @return Ok on success, or a TunerError from ITuner. * @pubstate writes lastFmFrequency_ on success. * @@ -94,7 +98,22 @@ public: * @date 2026-07-06 */ [[nodiscard]] std::expected tuneFm( - core::FrequencyKHz frequency); + core::FrequencyKHz frequency, + std::optional antCap = std::nullopt); + + /** + * @brief setDefaultFmAntCap — set the board's calibrated ANTCAP. + * + * @dname setDefaultFmAntCap + * @param antCap Value applied to every FM tune that doesn't pass an + * explicit override (0 = chip auto-tune, the factory + * default before any calibration is saved). + * @pubstate writes defaultFmAntCap_. Does not itself re-tune. + * + * @author Michele Bigi + * @date 2026-08-19 + */ + void setDefaultFmAntCap(std::uint8_t antCap) noexcept; /** * @brief seekFm — seek FM in the given direction. @@ -200,6 +219,7 @@ private: std::uint8_t lastDabIndex_; core::FrequencyKHz lastFmFrequency_; std::uint8_t volume_; + std::uint8_t defaultFmAntCap_; std::optional lastPlayedServiceId_; std::optional lastPlayedComponentId_; }; diff --git a/Software/components/services/tuner/src/TunerService.cpp b/Software/components/services/tuner/src/TunerService.cpp index b9d0878..95d1a87 100644 --- a/Software/components/services/tuner/src/TunerService.cpp +++ b/Software/components/services/tuner/src/TunerService.cpp @@ -138,9 +138,15 @@ TunerService::TunerService(core::ITuner& tuner) , lastDabIndex_(0U) , lastFmFrequency_(defaultFmFrequency()) , volume_(40U) + , defaultFmAntCap_(0U) { } +void TunerService::setDefaultFmAntCap(std::uint8_t antCap) noexcept +{ + defaultFmAntCap_ = antCap; +} + std::expected TunerService::refreshStatus() { auto status = tuner_.readStatus(); @@ -167,9 +173,10 @@ std::expected TunerService::tuneDab( } std::expected TunerService::tuneFm( - core::FrequencyKHz frequency) + core::FrequencyKHz frequency, std::optional antCap) { - if (auto result = tuner_.tuneFm(frequency); !result) { + if (auto result = tuner_.tuneFm(frequency, antCap.value_or(defaultFmAntCap_)); + !result) { return result; } lastFmFrequency_ = frequency; diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index 73d67d1..8893b97 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -5,6 +5,7 @@ idf_component_register( "web_radio_stream.cpp" "esp32_i2s_sink.cpp" "phone_stream.cpp" + "antenna_calibration.cpp" "$<$:test_firmware.cpp>" "$<$:i2s_sdata_probe.cpp>" INCLUDE_DIRS "." diff --git a/Software/main/antenna_calibration.cpp b/Software/main/antenna_calibration.cpp new file mode 100644 index 0000000..9d9dc41 --- /dev/null +++ b/Software/main/antenna_calibration.cpp @@ -0,0 +1,25 @@ +/** + * @file antenna_calibration.cpp + * @brief net::AntennaCalibration implementation over HardwareBootstrap. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "antenna_calibration.hpp" + +#include "hardware_bootstrap.hpp" + +namespace antenna_calibration { + +net::AntennaCalibration& bridge() noexcept +{ + static net::AntennaCalibration instance{ + .save = &hardware::HardwareBootstrap::saveFmAntCapCalibration, + }; + return instance; +} + +} // namespace antenna_calibration diff --git a/Software/main/antenna_calibration.hpp b/Software/main/antenna_calibration.hpp new file mode 100644 index 0000000..bdb2b57 --- /dev/null +++ b/Software/main/antenna_calibration.hpp @@ -0,0 +1,28 @@ +/** + * @file antenna_calibration.hpp + * @brief net::AntennaCalibration implementation over HardwareBootstrap. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + */ +#pragma once + +#include "net/SetupWebServer.hpp" + +namespace antenna_calibration { + +/** + * @brief bridge — the process-lifetime AntennaCalibration instance. + * + * @dname bridge + * @return Function-pointer table bound to + * HardwareBootstrap::saveFmAntCapCalibration, for + * net::HttpRouteContext::antennaCalibration / + * POST /api/tuner/calibrate-antenna. + * @pubstate none + */ +[[nodiscard]] net::AntennaCalibration& bridge() noexcept; + +} // namespace antenna_calibration diff --git a/Software/main/hardware_bootstrap.cpp b/Software/main/hardware_bootstrap.cpp index 2a94568..518cda0 100644 --- a/Software/main/hardware_bootstrap.cpp +++ b/Software/main/hardware_bootstrap.cpp @@ -80,7 +80,21 @@ bt1035::Bt1035Driver gBt1035( }); core::DeviceIdentity gDeviceIdentity = core::DeviceIdentity::unknown(); +std::optional gFmAntCapCalibration; bool gReady = false; + +/** + * @brief makeEeprom — construct a transient EEPROM handle onto the + * shared I2C bus, matching the one built inline in boot(). + */ +[[nodiscard]] eeprom24aa::Eeprom24aa makeEeprom() +{ + auto* busHandle = + static_cast(gAdau1701.i2cBusHandle()); + return eeprom24aa::Eeprom24aa( + busHandle, + static_cast(board::pins::Eeprom24aaAddr)); +} } // namespace std::expected HardwareBootstrap::boot() @@ -102,11 +116,7 @@ std::expected HardwareBootstrap::boot() } } - auto* busHandle = - static_cast(gAdau1701.i2cBusHandle()); - eeprom24aa::Eeprom24aa eeprom(busHandle, - static_cast( - board::pins::Eeprom24aaAddr)); + eeprom24aa::Eeprom24aa eeprom = makeEeprom(); if (auto identity = eeprom.readDeviceIdentity(); identity) { gDeviceIdentity = std::move(*identity); ESP_LOGI(kTag, "unit serial %.*s", @@ -117,6 +127,19 @@ std::expected HardwareBootstrap::boot() ESP_LOGW(kTag, "EUI-48 read failed — using fallback identity"); } + if (auto antCap = eeprom.readFmAntCap(); antCap) { + gFmAntCapCalibration = *antCap; + if (gFmAntCapCalibration) { + ESP_LOGI(kTag, "FM ANTCAP calibration loaded: %u", + static_cast(*gFmAntCapCalibration)); + } else { + ESP_LOGI(kTag, "FM ANTCAP not calibrated — using chip auto-tune"); + } + } else { + ESP_LOGW(kTag, "FM ANTCAP calibration read failed — using chip " + "auto-tune"); + } + if (auto audioResult = gAudioService.loadAndApply(); !audioResult) { ESP_LOGW(kTag, "ADAU1701 profile apply failed"); } @@ -175,4 +198,22 @@ const core::DeviceIdentity& HardwareBootstrap::deviceIdentity() noexcept return gDeviceIdentity; } +std::optional HardwareBootstrap::fmAntCapCalibration() noexcept +{ + return gFmAntCapCalibration; +} + +bool HardwareBootstrap::saveFmAntCapCalibration(std::uint8_t antCap) +{ + eeprom24aa::Eeprom24aa eeprom = makeEeprom(); + if (auto written = eeprom.writeFmAntCap(antCap); !written) { + ESP_LOGW(kTag, "FM ANTCAP calibration write failed"); + return false; + } + gFmAntCapCalibration = antCap; + ESP_LOGI(kTag, "FM ANTCAP calibration saved: %u", + static_cast(antCap)); + return true; +} + } // namespace hardware diff --git a/Software/main/hardware_bootstrap.hpp b/Software/main/hardware_bootstrap.hpp index 9b49c09..169f411 100644 --- a/Software/main/hardware_bootstrap.hpp +++ b/Software/main/hardware_bootstrap.hpp @@ -17,7 +17,9 @@ #include "bt1035/Bt1035Driver.hpp" +#include #include +#include namespace audio { class AudioService; @@ -137,6 +139,35 @@ public: * @date 2026-07-07 */ [[nodiscard]] static const core::DeviceIdentity& deviceIdentity() noexcept; + + /** + * @brief fmAntCapCalibration — saved FM antenna calibration, if any. + * + * @dname fmAntCapCalibration + * @return Calibrated ANTCAP (0-128) read from the 24AA025E48 during + * boot(), or nullopt if never calibrated / the read failed. + * @pubstate reads gFmAntCapCalibration; set once during boot(). + * + * @author Michele Bigi + * @date 2026-08-19 + */ + [[nodiscard]] static std::optional + fmAntCapCalibration() noexcept; + + /** + * @brief saveFmAntCapCalibration — persist a new FM ANTCAP to EEPROM. + * + * @dname saveFmAntCapCalibration + * @param antCap Value found via a calibration sweep (0-128). + * @return true on success, false on an I2C failure. + * @pubstate writes the 24AA025E48 user region and gFmAntCapCalibration. + * Does not itself change any live tuner state — callers must + * also call TunerService::setDefaultFmAntCap() to apply it. + * + * @author Michele Bigi + * @date 2026-08-19 + */ + [[nodiscard]] static bool saveFmAntCapCalibration(std::uint8_t antCap); }; } // namespace hardware diff --git a/Software/main/main.cpp b/Software/main/main.cpp index 3127db1..d8dbda1 100644 --- a/Software/main/main.cpp +++ b/Software/main/main.cpp @@ -23,6 +23,7 @@ #include "si4684/Si4684Tuner.hpp" #include "station/StationService.hpp" #include "tuner/TunerService.hpp" +#include "antenna_calibration.hpp" #include "esp32_i2s_sink.hpp" #include "phone_stream.hpp" #include "web_radio_stream.hpp" @@ -179,6 +180,10 @@ extern "C" void app_main() static tuner::TunerService tunerService( hardware::HardwareBootstrap::si4684Tuner()); + if (auto antCap = hardware::HardwareBootstrap::fmAntCapCalibration(); + antCap) { + tunerService.setDefaultFmAntCap(*antCap); + } static station::StationService stationService(store, tunerService); @@ -214,6 +219,7 @@ extern "C" void app_main() otaService, webRadioService, phone_stream::sink(), + antenna_calibration::bridge(), hardware::HardwareBootstrap::companionChipStatus(), hardware::HardwareBootstrap::deviceIdentity()); if (!netResult) {