From 3a58d33aadbe26a81feda70955b2668c9cff481f Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Fri, 21 Aug 2026 08:12:08 +0200 Subject: [PATCH] Add DAB ANTCAP calibration and fix BT1035 boot banner timing Extend the FM-only ANTCAP antenna-varactor override to DAB, mirroring the existing mechanism end to end (driver, tuner, service, EEPROM storage, HTTP API). Live sweep on real hardware found no ANTCAP value beating auto-tune on the ensembles tested, so DAB stays on auto-tune by default. Also fix BT1035 boot: the module's real boot banner doesn't appear until ~18-24s after RESET# releases, not the 3.5s previously waited; add a 2-attempt retry and a baud-rate probe fallback for diagnostics. Co-Authored-By: Claude Sonnet 5 --- .../components/core/include/core/ITuner.hpp | 16 +- .../core/include/core/TunerJson.hpp | 37 +++ Software/components/core/src/TunerJson.cpp | 28 +++ Software/components/core/test/CMakeLists.txt | 4 + .../core/test/bluetooth_json_test.cpp | 220 ++++++++++++++++++ .../core/test/integration_service_test.cpp | 4 +- .../core/test/station_service_test.cpp | 4 +- .../bt1035/include/bt1035/Bt1035Driver.hpp | 1 + .../drivers/bt1035/src/Bt1035Driver.cpp | 137 +++++++++-- .../drivers/eeprom24aa/CMakeLists.txt | 2 +- .../include/eeprom24aa/Eeprom24aa.hpp | 86 ++++++- .../drivers/eeprom24aa/src/Eeprom24aa.cpp | 152 ++++++++++++ .../si4684/include/si4684/Si4684Driver.hpp | 7 +- .../si4684/include/si4684/Si4684Tuner.hpp | 6 +- .../drivers/si4684/src/Si4684Driver.cpp | 55 +++-- .../drivers/si4684/src/Si4684Tuner.cpp | 8 +- .../net/include/net/BleProvisioning.hpp | 10 +- .../net/include/net/NetBootstrap.hpp | 3 + .../net/include/net/SetupWebServer.hpp | 26 +++ .../components/net/src/BleProvisioning.cpp | 12 +- Software/components/net/src/NetBootstrap.cpp | 16 +- .../components/net/src/SetupWebServer.cpp | 86 ++++++- .../tuner/include/tuner/TunerService.hpp | 44 +++- .../services/tuner/src/TunerService.cpp | 23 +- Software/docs/TODO.md | 57 ++++- Software/docs/manual/ch-api.tex | 39 +++- Software/docs/manual/ch-classes.tex | 2 +- Software/docs/manual/ch-intro.tex | 2 +- Software/docs/manual/manual.tex | 2 +- .../docs/si4684-rf-investigation-report.md | 186 +++++++++++++-- Software/instructions.md | 62 ++++- Software/main/CMakeLists.txt | 1 + Software/main/antenna_calibration.cpp | 26 +++ Software/main/antenna_calibration.hpp | 28 +++ Software/main/hardware_bootstrap.cpp | 83 ++++++- Software/main/hardware_bootstrap.hpp | 60 +++++ Software/main/main.cpp | 10 + Software/partitions.csv | 14 +- 38 files changed, 1444 insertions(+), 115 deletions(-) create mode 100644 Software/components/core/test/bluetooth_json_test.cpp create mode 100644 Software/main/antenna_calibration.cpp create mode 100644 Software/main/antenna_calibration.hpp diff --git a/Software/components/core/include/core/ITuner.hpp b/Software/components/core/include/core/ITuner.hpp index d093323..829b467 100644 --- a/Software/components/core/include/core/ITuner.hpp +++ b/Software/components/core/include/core/ITuner.hpp @@ -86,6 +86,10 @@ public: * * @dname tuneDab * @param freqIndex Ensemble index 0–37. + * @param antCap Front-end antenna varactor override (0-128). + * 0 = automatic; other values force a specific + * varactor setting, for antenna calibration + * sweeps. See tuneFm's antCap doc for details. * @return Ok on success, or WrongBand / TuneFailed / NotBooted. * @pubstate writes last tune target in the adapter. * @@ -93,13 +97,21 @@ public: * @date 2026-07-06 */ [[nodiscard]] virtual std::expected tuneDab( - std::uint8_t freqIndex) = 0; + std::uint8_t freqIndex, std::uint8_t antCap = 0U) = 0; /** * @brief tuneFm — tune to an FM centre frequency. * * @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 +119,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..edad96f 100644 --- a/Software/components/core/include/core/TunerJson.hpp +++ b/Software/components/core/include/core/TunerJson.hpp @@ -41,6 +41,9 @@ 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; ///< Antenna varactor override (0–128), + ///< for calibration sweeps; applies to + ///< whichever band is being tuned. }; /** @@ -239,4 +242,38 @@ struct TunerFmScannedStation { [[nodiscard]] std::string serializeTunerFmBandScanJson( const std::vector& stations); +/** + * @brief AntennaCalibrationRequest — parsed POST + * /api/tuner/calibrate-antenna body. + * + * @dname AntennaCalibrationRequest + * @return n/a (type) + * @pubstate Plain DTO filled by parseAntennaCalibrationJson at the HTTP + * boundary. + * + * @author Michele Bigi + * @date 2026-08-20 + */ +struct AntennaCalibrationRequest { + TunerBand band; ///< Which band's ANTCAP default this saves. + std::uint8_t antCap; ///< Value to persist (0-128). +}; + +/** + * @brief parseAntennaCalibrationJson — validate POST + * /api/tuner/calibrate-antenna body. + * + * @dname parseAntennaCalibrationJson + * @param json Untrusted request body from the HTTP handler. + * @return Band + ANTCAP value (0-128) on success, or a ParseError. + * `band` defaults to Fm when the field is omitted, preserving + * the original FM-only request shape. + * @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..3df7952 100644 --- a/Software/components/core/src/TunerJson.cpp +++ b/Software/components/core/src/TunerJson.cpp @@ -203,6 +203,10 @@ std::expected parseTunerTuneJson( } else { return std::unexpected(ParseError::InvalidJson); } + unsigned long antCap = 0U; + if (extractJsonUint(json, "antcap", antCap) && antCap <= 128U) { + req.antCap = static_cast(antCap); + } return req; } @@ -336,4 +340,28 @@ 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); + } + + AntennaCalibrationRequest req = {}; + req.antCap = static_cast(antCap); + const std::string_view band = extractJsonString(json, "band"); + if (band == "dab") { + req.band = TunerBand::Dab; + } else if (band.empty() || band == "fm") { + req.band = TunerBand::Fm; + } else { + return std::unexpected(ParseError::InvalidJson); + } + return req; +} + } // namespace core diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index 32a576c..784f300 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -94,6 +94,10 @@ add_executable(dsp_param_json_test dsp_param_json_test.cpp) target_link_libraries(dsp_param_json_test PRIVATE digiradio_core) add_test(NAME dsp_param_json_test COMMAND dsp_param_json_test) +add_executable(bluetooth_json_test bluetooth_json_test.cpp) +target_link_libraries(bluetooth_json_test PRIVATE digiradio_core) +add_test(NAME bluetooth_json_test COMMAND bluetooth_json_test) + add_executable(frequency_khz_test frequency_khz_test.cpp) target_link_libraries(frequency_khz_test PRIVATE digiradio_core) add_test(NAME frequency_khz_test COMMAND frequency_khz_test) diff --git a/Software/components/core/test/bluetooth_json_test.cpp b/Software/components/core/test/bluetooth_json_test.cpp new file mode 100644 index 0000000..29f0a6e --- /dev/null +++ b/Software/components/core/test/bluetooth_json_test.cpp @@ -0,0 +1,220 @@ +/** + * @file bluetooth_json_test.cpp + * @brief Host tests for Bluetooth JSON parse/serialise. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-08-19 + */ + +#include "core/BluetoothJson.hpp" +#include "core/ParseError.hpp" + +#include +#include +#include + +namespace { + +[[nodiscard]] bool expectEqual(const std::string& actual, + const std::string& expected) +{ + if (actual == expected) { + return true; + } + std::cerr << "expected: " << expected << "\nactual: " << actual << '\n'; + return false; +} + +[[nodiscard]] int runStatusSerialiseTest() +{ + const core::BluetoothStatus status{ + .booted = true, + .pairing = false, + .a2dpState = core::Bt1035A2dpState::Streaming, + .deviceName = "DigiRadio-CC4DB4", + .autoReconnect = 3U, + }; + const std::string json = core::serializeBluetoothStatusJson(status); + if (!expectEqual(json, + R"({"booted":true,"pairing":false,"a2dp":"streaming",)" + R"("device_name":"DigiRadio-CC4DB4","auto_reconnect":3})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runScanSerialiseTest() +{ + const std::vector devices{ + core::Bt1035ScannedDevice{ + .index = 1U, + .addressType = 2U, + .mac = "001122334455", + .rssiDbm = -58, + .name = "Bose SoundLink", + .deviceClass = "240404", + }, + }; + const std::string json = core::serializeBluetoothScanJson(devices); + if (!expectEqual(json, + R"({"devices":[{"index":1,"mac":"001122334455",)" + R"("name":"Bose SoundLink","rssi_dbm":-58}]})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runPairedSerialiseTest() +{ + const std::vector devices{ + core::Bt1035PairedDevice{.index = 1U, .mac = "AABBCCDDEEFF", .name = "Phone"}, + }; + const std::string json = core::serializeBluetoothPairedJson(devices); + if (!expectEqual(json, + R"({"devices":[{"index":1,"mac":"AABBCCDDEEFF","name":"Phone"}]})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runAutoReconnectParseTest() +{ + const auto ok = core::parseBluetoothAutoReconnectJson(R"({"times":5})"); + if (!ok || *ok != 5U) { + std::cerr << "auto-reconnect valid parse failed\n"; + return EXIT_FAILURE; + } + const auto tooHigh = core::parseBluetoothAutoReconnectJson(R"({"times":16})"); + if (tooHigh) { + std::cerr << "auto-reconnect out-of-range accepted\n"; + return EXIT_FAILURE; + } + const auto missing = core::parseBluetoothAutoReconnectJson(R"({})"); + if (missing || missing.error() != core::ParseError::MissingField) { + std::cerr << "auto-reconnect missing field mis-reported\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runConnectJsonParseTest() +{ + const auto ok = + core::parseBluetoothConnectJson(R"({"mac":"001122334455"})"); + if (!ok || *ok != "001122334455") { + std::cerr << "connect mac parse failed\n"; + return EXIT_FAILURE; + } + // Normalises to uppercase. + const auto lower = + core::parseBluetoothConnectJson(R"({"mac":"aabbccddeeff"})"); + if (!lower || *lower != "AABBCCDDEEFF") { + std::cerr << "connect mac uppercasing failed\n"; + return EXIT_FAILURE; + } + const auto invalid = core::parseBluetoothConnectJson(R"({"mac":"not-a-mac"})"); + if (invalid) { + std::cerr << "connect invalid mac accepted\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runConnectRequestParseTest() +{ + const auto request = core::parseBluetoothConnectRequest( + R"({"mac":"001122334455","name":"Bose SoundLink","save":true})"); + if (request.mac != "001122334455" || request.name != "Bose SoundLink" + || !request.save) { + std::cerr << "connect request full parse failed\n"; + return EXIT_FAILURE; + } + const auto minimal = + core::parseBluetoothConnectRequest(R"({"mac":"001122334455"})"); + if (minimal.mac != "001122334455" || !minimal.name.empty() + || minimal.save) { + std::cerr << "connect request minimal parse failed\n"; + return EXIT_FAILURE; + } + const auto badMac = core::parseBluetoothConnectRequest(R"({})"); + if (!badMac.mac.empty()) { + std::cerr << "connect request missing mac should stay empty\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runSpeakerRoundTripTest() +{ + const core::BtSpeakerTarget target{.mac = "001122334455", + .name = "Bose SoundLink"}; + const std::string json = core::serializeBluetoothSpeakerJson(&target); + if (!expectEqual(json, + R"({"configured":true,"mac":"001122334455",)" + R"("name":"Bose SoundLink"})")) { + return EXIT_FAILURE; + } + const std::string unset = core::serializeBluetoothSpeakerJson(nullptr); + if (!expectEqual(unset, R"({"configured":false})")) { + return EXIT_FAILURE; + } + + const auto parsed = core::parseBluetoothSpeakerJson( + R"({"mac":"001122334455","name":"Bose SoundLink"})"); + if (!parsed || parsed->mac != "001122334455" + || parsed->name != "Bose SoundLink") { + std::cerr << "speaker parse round-trip failed\n"; + return EXIT_FAILURE; + } + const auto invalid = core::parseBluetoothSpeakerJson(R"({"mac":"bad"})"); + if (invalid) { + std::cerr << "speaker parse invalid mac accepted\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runErrorSerialiseTest() +{ + const std::string json = core::serializeBluetoothErrorJson("scan_failed"); + if (!expectEqual(json, R"({"status":"error","reason":"scan_failed"})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runStatusSerialiseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runScanSerialiseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runPairedSerialiseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runAutoReconnectParseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runConnectJsonParseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runConnectRequestParseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runSpeakerRoundTripTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runErrorSerialiseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/core/test/integration_service_test.cpp b/Software/components/core/test/integration_service_test.cpp index d98445b..41451d1 100644 --- a/Software/components/core/test/integration_service_test.cpp +++ b/Software/components/core/test/integration_service_test.cpp @@ -111,13 +111,13 @@ public: } [[nodiscard]] std::expected tuneDab( - std::uint8_t) override + std::uint8_t, std::uint8_t) override { return {}; } [[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..494e0f7 100644 --- a/Software/components/core/test/station_service_test.cpp +++ b/Software/components/core/test/station_service_test.cpp @@ -53,13 +53,13 @@ public: } [[nodiscard]] std::expected tuneDab( - std::uint8_t) override + std::uint8_t, std::uint8_t) override { return {}; } [[nodiscard]] std::expected tuneFm( - core::FrequencyKHz) override + core::FrequencyKHz, std::uint8_t) override { return {}; } diff --git a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp index 5e02254..3d65d06 100644 --- a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp +++ b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp @@ -343,6 +343,7 @@ public: private: [[nodiscard]] std::expected ensureBooted() const; [[nodiscard]] std::expected runInitSequence(); + [[nodiscard]] std::expected resetAndInitOnce(); [[nodiscard]] std::expected transmitAndCollect( std::string_view commandLine, int timeoutMs = kResponseTimeoutMs); [[nodiscard]] std::expected transmitAndCollectUntil( diff --git a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp index 208cf90..1b5e7de 100644 --- a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp +++ b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp @@ -38,6 +38,23 @@ constexpr int kUartTxBuffer = 256; constexpr int kResponseTimeoutMs = 2000; constexpr int kPostResetMs = 500; constexpr int kPostUartMs = 100; +/** Feasycom BT1035 programming user guide §2.2 (pin 34 SYS_CTRL): "Delay + * 100ms, pull high". */ +constexpr int kSysCtlLeadInMs = 100; +/** Margin beyond the datasheet's own >20ms SYS_CTRL-assertion-to-power-up + * minimum (§4.7), for regulator/crystal settling before RESET releases. */ +constexpr int kSysCtlSettleMs = 50; +/** Measured live (2026-08-20, power/wiring confirmed sound with a + * multimeter — VBAT_IN/SYS_CTRL/1.8V_OUT/VDD_IO all correct, TX/RX pins + * verified via continuity): the module's spontaneous boot banner + * (+VER=FSC-BT1035,..., +DEVSTAT=1) doesn't appear until ~18.5s after + * RESET# releases — full BT stack init, not just the internal regulator. + * The previous 3500ms wait here was never enough for the module to say + * anything, so every prior boot attempt cut power and restarted before + * the module could finish booting even once. See kBootAttempts below. */ +constexpr int kBootBannerWaitMs = 25000; +constexpr int kBootAttempts = 2; +constexpr int kBootRetryDelayMs = 300; void flushUartRx(int uartPort) noexcept { @@ -49,15 +66,53 @@ void flushUartRx(int uartPort) noexcept } } -// Diagnostic only: some BT1035 firmware prints an unsolicited boot banner on -// UART right after the hardware RESET# pulse. Capturing it (or its absence) -// tells us whether the UART link is electrically alive independent of the -// AT command layer. +// Diagnostic only, run once if all kBootAttempts fail at 115200 (the +// datasheet's own default). AT+BAUD persists across RESET#/SYS_CTRL power +// cycles (programming guide §5.1.3), so a stray manual AT+BAUD or +// AT+RESTORE sent during earlier interactive testing could have left the +// module listening at a different rate than our fixed assumption — this +// sweep tells us if that's what's happening instead of guessing. +constexpr std::array kBaudProbeCandidates = { + 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600}; + +void probeBaudRates(int uartPort) noexcept +{ + ESP_LOGW(kTag, "115200 unresponsive after %d attempts — sweeping baud rates", + kBootAttempts); + for (const int baud : kBaudProbeCandidates) { + if (uart_set_baudrate(static_cast(uartPort), baud) + != ESP_OK) { + continue; + } + flushUartRx(uartPort); + uart_write_bytes(static_cast(uartPort), "AT\r\n", 4); + + std::array buf{}; + const int n = uart_read_bytes(static_cast(uartPort), + buf.data(), buf.size(), + pdMS_TO_TICKS(500)); + if (n > 0) { + ESP_LOGW(kTag, "baud probe: module responded at %d baud (%d bytes)", + baud, n); + ESP_LOG_BUFFER_HEX(kTag, buf.data(), static_cast(n)); + } else { + ESP_LOGI(kTag, "baud probe: silent at %d baud", baud); + } + } + uart_set_baudrate(static_cast(uartPort), kBaudRate); + flushUartRx(uartPort); +} + +// The BT1035 prints an unsolicited boot banner (+VER=..., +DEVSTAT=1, ...) +// once its full Bluetooth stack finishes initialising — this blocks for up +// to kBootBannerWaitMs waiting for it, since that's the real boot-complete +// signal (the module is otherwise silent and won't answer AT commands +// until this appears). void logRawUartBoot(int uartPort) noexcept { std::array buf{}; const int n = uart_read_bytes(static_cast(uartPort), buf.data(), - buf.size(), pdMS_TO_TICKS(3500)); + buf.size(), pdMS_TO_TICKS(kBootBannerWaitMs)); if (n <= 0) { ESP_LOGW("Bt1035", "no spontaneous UART bytes after hardware reset"); return; @@ -904,32 +959,68 @@ std::expected Bt1035Driver::runInitSequence() return {}; } +std::expected Bt1035Driver::resetAndInitOnce() +{ + // Feasycom BT1035 programming user guide §2.2, pin 34 SYS_CTRL: + // "Delay 100ms, pull high" — the datasheet's own OFF-state timing spec + // (§4.7) says SYS_CTRL must be asserted >20ms before the internal + // regulators start powering up at all, so pulling it high with no + // lead-in delay (the previous sequence here) races the chip's own + // power-on requirement. Held low with RESET already asserted, then a + // 100ms lead-in exactly matching the guide, then SYS_CTRL high, then + // extra settle time before releasing RESET into a chip that's had a + // chance to actually power up first. + const auto sysCtlPin = static_cast(pins_.sysCtlGpio); + const auto resetPin = static_cast(pins_.resetGpio); + + gpio_set_level(sysCtlPin, 0); + gpio_set_level(resetPin, 0); + vTaskDelay(pdMS_TO_TICKS(kSysCtlLeadInMs)); + ESP_LOGI(kTag, "pre-power: SYS_CTRL=%d RESET=%d (want 0,0)", + gpio_get_level(sysCtlPin), gpio_get_level(resetPin)); + + gpio_set_level(sysCtlPin, 1); + vTaskDelay(pdMS_TO_TICKS(kSysCtlSettleMs)); + ESP_LOGI(kTag, "post-syscl: SYS_CTRL=%d RESET=%d (want 1,0)", + gpio_get_level(sysCtlPin), gpio_get_level(resetPin)); + + gpio_set_level(resetPin, 1); + vTaskDelay(pdMS_TO_TICKS(kPostResetMs)); + ESP_LOGI(kTag, "post-reset: SYS_CTRL=%d RESET=%d (want 1,1)", + gpio_get_level(sysCtlPin), gpio_get_level(resetPin)); + + logRawUartBoot(uartPort_); + uart_flush_input(static_cast(uartPort_)); + vTaskDelay(pdMS_TO_TICKS(kPostUartMs)); + + return runInitSequence(); +} + std::expected Bt1035Driver::boot() { if (booted_) { return {}; } + // GPIO_MODE_INPUT_OUTPUT (not plain GPIO_MODE_OUTPUT): gpio_config() + // only enables the pad's input buffer when the INPUT bit is set, so a + // pure-output config leaves gpio_get_level() reading a stale/always-0 + // register instead of the real driven level — needed for the readback + // diagnostic below to be meaningful. gpio_config_t resetCfg = {}; resetCfg.pin_bit_mask = 1ULL << pins_.resetGpio; - resetCfg.mode = GPIO_MODE_OUTPUT; + resetCfg.mode = GPIO_MODE_INPUT_OUTPUT; if (gpio_config(&resetCfg) != ESP_OK) { return std::unexpected(Bt1035Error::ResetFailed); } gpio_config_t sysCfg = {}; sysCfg.pin_bit_mask = 1ULL << pins_.sysCtlGpio; - sysCfg.mode = GPIO_MODE_OUTPUT; + sysCfg.mode = GPIO_MODE_INPUT_OUTPUT; if (gpio_config(&sysCfg) != ESP_OK) { return std::unexpected(Bt1035Error::ResetFailed); } - gpio_set_level(static_cast(pins_.sysCtlGpio), 1); - gpio_set_level(static_cast(pins_.resetGpio), 0); - vTaskDelay(pdMS_TO_TICKS(100)); - gpio_set_level(static_cast(pins_.resetGpio), 1); - vTaskDelay(pdMS_TO_TICKS(kPostResetMs)); - if (!uartInstalled_) { const uart_config_t uartCfg = { .baud_rate = kBaudRate, @@ -961,12 +1052,20 @@ std::expected Bt1035Driver::boot() uartInstalled_ = true; } - logRawUartBoot(uartPort_); - uart_flush_input(static_cast(uartPort_)); - vTaskDelay(pdMS_TO_TICKS(kPostUartMs)); - - if (auto init = runInitSequence(); !init) { - ESP_LOGE(kTag, "AT init failed"); + std::expected init = std::unexpected(Bt1035Error::UnexpectedResponse); + for (int attempt = 1; attempt <= kBootAttempts; ++attempt) { + init = resetAndInitOnce(); + if (init) { + break; + } + ESP_LOGW(kTag, "boot attempt %d/%d failed", attempt, kBootAttempts); + if (attempt < kBootAttempts) { + vTaskDelay(pdMS_TO_TICKS(kBootRetryDelayMs)); + } + } + if (!init) { + ESP_LOGE(kTag, "AT init failed after %d attempts", kBootAttempts); + probeBaudRates(uartPort_); return init; } 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..5db36d9 100644 --- a/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp +++ b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp @@ -13,27 +13,44 @@ #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, and the + * DAB ANTCAP calibration byte at word address 0x01, both in the + * remaining user-writable 250 bytes. * * @author Michele Bigi * @date 2026-07-07 */ class Eeprom24aa : public core::IDeviceIdentitySource { public: + /** Valid ANTCAP calibration values (FM or DAB) 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; + /** 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; + /** * @brief Eeprom24aa — bind to a running I2C master bus and 7-bit addr. * @@ -60,6 +77,69 @@ 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); + + /** + * @brief readDabAntCap — read the stored DAB antenna calibration byte. + * + * @dname readDabAntCap + * @return Calibrated ANTCAP (0-kDabAntCapMax) if one was ever saved via + * writeDabAntCap(), 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 0x01. + * + * @author Michele Bigi + * @date 2026-08-20 + */ + [[nodiscard]] std::expected, core::IdentityError> + readDabAntCap(); + + /** + * @brief writeDabAntCap — persist a DAB antenna calibration value. + * + * @dname writeDabAntCap + * @param value ANTCAP to store, 0-kDabAntCapMax (AN649 Command + * 0xB0 ARG4; found via a sweep, not computed). + * @return Ok on success, or IdentityError::I2cFailed. + * @pubstate performs one I2C byte write at word address 0x01, then + * blocks for the chip's write-cycle time before returning. + * + * @author Michele Bigi + * @date 2026-08-20 + */ + [[nodiscard]] std::expected + writeDabAntCap(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..600e011 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,15 @@ 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; +/** DAB ANTCAP calibration byte, next word address after the FM byte. */ +constexpr std::uint8_t kDabAntCapWordAddress = 0x01U; 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 +79,146 @@ 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 {}; +} + +std::expected, core::IdentityError> +Eeprom24aa::readDabAntCap() +{ + 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 = kDabAntCapWordAddress; + 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, "DAB ANTCAP read failed (err=0x%x)", + static_cast(err)); + return std::unexpected(core::IdentityError::ReadFailed); + } + + if (value > kDabAntCapMax) { + return std::optional{}; + } + return std::optional{value}; +} + +std::expected +Eeprom24aa::writeDabAntCap(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 = {kDabAntCapWordAddress, 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, "DAB 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/Si4684Driver.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp index 7966702..91e00f2 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp @@ -288,13 +288,18 @@ public: * * @dname tuneDab * @param freqIndex Ensemble index 0–37. + * @param antCap ANTCAP[7:0] override (0-128, AN649 Command 0xB0 + * ARG4). 0 = automatic front-end tuning; other + * values force a specific varactor setting, for + * antenna calibration sweeps (mirrors tuneFm). * @return Ok on success, or Si4684Error. * @pubstate sends DAB_TUNE_FREQ and waits for STC. * * @author Michele Bigi * @date 2026-07-06 */ - [[nodiscard]] std::expected tuneDab(std::uint8_t freqIndex); + [[nodiscard]] std::expected tuneDab( + std::uint8_t freqIndex, std::uint8_t antCap = 0U); /** * @brief readDabDigRadStatus — read ensemble lock metrics. diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp index 2342037..a11be0c 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp @@ -96,6 +96,7 @@ public: * * @dname tuneDab * @param freqIndex Ensemble index 0–37. + * @param antCap Forwarded to Si4684Driver::tuneDab (0 = auto). * @return Ok on success, or a mapped TunerError. * @pubstate writes dabIndex_ on success. * @@ -103,13 +104,14 @@ public: * @date 2026-07-06 */ [[nodiscard]] std::expected tuneDab( - std::uint8_t freqIndex) override; + std::uint8_t freqIndex, std::uint8_t antCap = 0U) override; /** * @brief tuneFm — tune to an FM centre frequency in kHz. * * @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 +119,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/Si4684Driver.cpp b/Software/components/drivers/si4684/src/Si4684Driver.cpp index ebfd622..27f41e1 100644 --- a/Software/components/drivers/si4684/src/Si4684Driver.cpp +++ b/Software/components/drivers/si4684/src/Si4684Driver.cpp @@ -85,6 +85,7 @@ constexpr std::uint16_t kPropDabTuneFeCfg = 0x1712U; constexpr std::uint16_t kPropFmTuneFeCfg = 0x1712U; constexpr std::uint16_t kPropDabXpadEnable = 0xB400U; constexpr std::uint16_t kPropDigitalServiceIntSource = 0x8100U; +constexpr std::uint16_t kPropDabEventIntSource = 0xB300U; /** AN649 INT_CTL_ENABLE / INT_CTL_REPEAT — route STC to INTB until STCACK. */ constexpr std::uint16_t kPropIntCtlEnable = 0x0000U; constexpr std::uint16_t kPropIntCtlRepeat = 0x0001U; @@ -504,6 +505,15 @@ std::expected Si4684Driver::configureAfterBoot( ESP_LOGW(kTag, "DIGITAL_SERVICE_INT_SOURCE (0x8100) failed"); return dsrv; } + // AN649 Property 0xB300 DAB_EVENT_INTERRUPT_SOURCE, bit0=SRVLIST_INTEN + // (default 0x0000 = disabled at power-on). Without this, nothing in + // this driver ever enables the service-list-ready event, so + // fetchDabServiceList() can stay gated behind an eternally-false + // serviceListReady even on a clean, well-locked ensemble. + if (auto evt = setProperty(kPropDabEventIntSource, 0x0001U); !evt) { + ESP_LOGW(kTag, "DAB_EVENT_INTERRUPT_SOURCE (0xB300) failed"); + return evt; + } } else { // FM varactor cal per hitech95/uGreen DTS (not DAB PE5PVB values). static constexpr std::uint16_t kFmFeProps[][2] = { @@ -1027,7 +1037,8 @@ std::expected Si4684Driver::installDefaultDabFrequencyPlan() return sendCommand(cmd); } -std::expected Si4684Driver::tuneDab(std::uint8_t freqIndex) +std::expected Si4684Driver::tuneDab( + std::uint8_t freqIndex, std::uint8_t antCap) { if (auto band = ensureBand(Si4684Band::Dab); !band) { return band; @@ -1037,8 +1048,9 @@ std::expected Si4684Driver::tuneDab(std::uint8_t freqIndex) } // writeCommand() always prepends a fixed ARG1=0x00 (INJECTION=0), so // this array starts at ARG2 (AN649 Command 0xB0 table: ARG2=FREQ_INDEX, - // ARG3=0x00 fixed, ARG4=ANTCAP[7:0], ARG5=ANTCAP[15:8]). - const std::uint8_t args[] = {freqIndex, 0x00U, 0x00U, 0x00U}; + // ARG3=0x00 fixed, ARG4=ANTCAP[7:0], ARG5=ANTCAP[15:8] -- high byte + // always 0, range is 0-128, same as tuneFm's ANTCAP). + const std::uint8_t args[] = {freqIndex, 0x00U, antCap, 0x00U}; if (auto cmd = writeCommand(Command::DabTuneFreq, args, sizeof(args)); !cmd) { return std::unexpected(Si4684Error::TuneFailed); @@ -1121,26 +1133,37 @@ Si4684Driver::fetchDabServiceList() } // raw[5]=RESP4=SIZE[7:0], raw[6]=RESP5=SIZE[15:8] (see readFmRds()). + // AN649 only documents SIZE/DATA_0/DATA_N generically for this command + // and defers the DAB payload layout to a supplemental "Digital + // Services User's Guide" we don't have; the exact field layout below + // is cross-checked against hitech95/si468x_dab_receiver's + // si468x_core_cmd_dab_get_service_list() (drivers/mfd/si468x-cmd.c), + // a real working Linux driver for the same command. That driver also + // establishes that the payload actually carried after SIZE is + // SIZE-2 bytes, not SIZE bytes. const std::uint16_t payloadSize = readLe16(header.data() + 5); - if (payloadSize == 0U || payloadSize + 7U > kSpiBufferSize) { + if (payloadSize <= 2U || payloadSize + 5U > kSpiBufferSize) { return std::unexpected(Si4684Error::ReplyTooShort); } - // DATA_0 (first byte of the AN649 Table 14 "DAB/DMB Digital Service - // List" structure) is RESP6 = body[7]: lead-in(1) + STATUS0-3(4) + - // SIZE(2) = 7 header bytes before it. - std::vector body(payloadSize + 7U, 0U); + // DATA_0 (first byte of the payload) is RESP6 = body[7]: lead-in(1) + + // STATUS0-3(4) + SIZE(2) = 7 header bytes before it. Total frame is + // lead-in(1) + STATUS0-3(4) + SIZE(2) + payload(SIZE-2) = SIZE+5. + std::vector body(payloadSize + 5U, 0U); if (auto rd = readRaw(body); !rd) { return std::unexpected(rd.error()); } - // Table 14: List Size(2) + Version(2) + NumServices(1) + AlignPad(3) = - // 8 bytes, then Service 1 begins. - const std::uint8_t serviceCount = body[11]; + // From DATA_0 (body[7]): Version(2) + NumServices/flags(1) + + // AlignPad(3) = 6 bytes, then Service 1 begins at body[13]. (The + // previous version of this code double-counted the already-consumed + // SIZE field here, offsetting every read by 2 bytes — that is why the + // service list always came back empty.) + const std::uint8_t serviceCount = body[9] & 0x1FU; // max 32 services std::vector services; services.reserve(serviceCount); - std::size_t offset = 15U; + std::size_t offset = 13U; for (std::uint8_t i = 0; i < serviceCount; ++i) { // Fixed per-service part: ServiceID(4) + ServiceInfo1-3(3) + // AlignPad(1) + Label(16) = 24 bytes. @@ -1155,11 +1178,13 @@ Si4684Driver::fetchDabServiceList() entry.label[16] = '\0'; offset += 24U; - // Component ID is 2 bytes (AN649 Table 14); only the first + // Component ID is 2 bytes (hitech95's si468x-cmd.c packs tm_id/ + // sub_ch_id/fidc_id/sc_id into this same field; only the raw + // 16-bit value is exposed on this DTO). Only the first // component's ID is exposed on this DTO. Every component (M = // componentCount) must still be skipped to keep the next service - // entry aligned, each one ComponentID(2) + ComponentInfo(1) + - // ValidFlags(1) = 4 bytes. + // entry aligned, each one 2 bytes packed field + ServiceType/ + // flags(1) + ValidFlags(1) = 4 bytes. if (componentCount > 0U && offset + 2U <= body.size()) { entry.componentId = readLe16(body.data() + offset); } diff --git a/Software/components/drivers/si4684/src/Si4684Tuner.cpp b/Software/components/drivers/si4684/src/Si4684Tuner.cpp index ed2189e..3330599 100644 --- a/Software/components/drivers/si4684/src/Si4684Tuner.cpp +++ b/Software/components/drivers/si4684/src/Si4684Tuner.cpp @@ -209,12 +209,12 @@ std::expected Si4684Tuner::readStatus() } std::expected Si4684Tuner::tuneDab( - std::uint8_t freqIndex) + std::uint8_t freqIndex, std::uint8_t antCap) { if (auto ready = ensureBandLoaded(core::TunerBand::Dab); !ready) { return ready; } - if (auto result = driver_.tuneDab(freqIndex); !result) { + if (auto result = driver_.tuneDab(freqIndex, antCap); !result) { return std::unexpected(mapError(result.error())); } dabIndex_ = freqIndex; @@ -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/BleProvisioning.hpp b/Software/components/net/include/net/BleProvisioning.hpp index 84a7807..0a3595c 100644 --- a/Software/components/net/include/net/BleProvisioning.hpp +++ b/Software/components/net/include/net/BleProvisioning.hpp @@ -31,15 +31,19 @@ namespace net::ble_provisioning { * @dname start * @param store Secure store the received credentials are saved * to (same store POST /api/wifi writes to). - * @param deviceIdentity Supplies the BLE advertising name (softApSsid) - * and the proof-of-possession string (serialNumber). + * @param deviceIdentity Supplies the BLE advertising name (softApSsid). * @return Ok once provisioning is advertising, or NetError::BleProvisioningFailed. * @pubstate Starts the onboard ESP32-S3 BLE radio (independent of the BT1035 * UART module) and a process-lifetime wifi_provisioning manager * singleton. On a successful join, saves credentials to store and * reboots, mirroring wifiPostHandler's POST /api/wifi behaviour. * Runs alongside the existing SoftAP + HTTP provisioning route, - * not instead of it — either path can complete setup. + * not instead of it — either path can complete setup. Uses + * protocomm Security0 (no proof-of-possession, no encryption): + * a PoP derived from the BLE advertising name would be visible to + * anyone scanning anyway, so it added app/firmware coupling + * without adding real secrecy — same trust level as the open + * SoftAP setup path this runs alongside. * * @author Michele Bigi * @date 2026-08-18 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..7953dc7 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -85,6 +85,28 @@ struct PhoneStreamSink { std::size_t frameCount); }; +/** + * @brief AntennaCalibration — plain function pointers over EEPROM-backed + * FM and DAB 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 functions 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); + /** Persist a new DAB ANTCAP calibration value to EEPROM. + * @return false on an I2C failure. */ + bool (*saveDab)(std::uint8_t antCap); +}; + /** * @brief HttpRouteContext — dependencies injected into HTTP handlers. * @@ -106,6 +128,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 +210,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 +229,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/BleProvisioning.cpp b/Software/components/net/src/BleProvisioning.cpp index 9552c12..23ae3d9 100644 --- a/Software/components/net/src/BleProvisioning.cpp +++ b/Software/components/net/src/BleProvisioning.cpp @@ -125,10 +125,9 @@ start(core::ISecureStore& store, const core::DeviceIdentity& deviceIdentity) gStore = &store; gPendingCreds.reset(); - // Copies kept for the lifetime of provisioning: wifi_prov_mgr_start_ - // provisioning only borrows these pointers, it does not take ownership. + // Copy kept for the lifetime of provisioning: wifi_prov_mgr_start_ + // provisioning only borrows this pointer, it does not take ownership. static const std::string serviceName(deviceIdentity.softApSsid()); - static const std::string pop(deviceIdentity.serialNumber()); const wifi_prov_mgr_config_t config{ .scheme = wifi_prov_scheme_ble, @@ -141,7 +140,7 @@ start(core::ISecureStore& store, const core::DeviceIdentity& deviceIdentity) return std::unexpected(NetError::BleProvisioningFailed); } - if (wifi_prov_mgr_start_provisioning(WIFI_PROV_SECURITY_1, pop.c_str(), + if (wifi_prov_mgr_start_provisioning(WIFI_PROV_SECURITY_0, nullptr, serviceName.c_str(), nullptr) != ESP_OK) { ESP_LOGE(kTag, "wifi_prov_mgr_start_provisioning failed"); @@ -149,9 +148,8 @@ start(core::ISecureStore& store, const core::DeviceIdentity& deviceIdentity) return std::unexpected(NetError::BleProvisioningFailed); } - ESP_LOGI(kTag, - "BLE provisioning advertising as %s (proof-of-possession: " - "device serial number)", + ESP_LOGI(kTag, "BLE provisioning advertising as %s (no PoP, same trust " + "level as the open SoftAP)", serviceName.c_str()); return {}; } 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 6e94902..e7c1547 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -68,7 +68,7 @@ namespace net { namespace { constexpr char kTag[] = "SetupWebServer"; -constexpr char kFirmwareVersion[] = "0.8.5"; +constexpr char kFirmwareVersion[] = "0.9.0"; constexpr unsigned kRebootDelaySec = 3; extern const uint8_t index_html_gz_start[] asm( @@ -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(), }; @@ -429,10 +430,13 @@ esp_err_t tunerTunePostHandler(httpd_req_t* req) std::expected result = std::unexpected( core::TunerError::InvalidInput); + // Omitting antcap uses the board's saved calibration for that band (or + // hardware auto-tune if never calibrated) — only an explicit value in + // the request overrides it, e.g. for a calibration sweep. if (parsed->band == core::TunerBand::Dab) { - result = ctx->tuner->tuneDab(parsed->dabFreqIndex); + result = ctx->tuner->tuneDab(parsed->dabFreqIndex, parsed->antCap); } else if (parsed->fmFrequency) { - result = ctx->tuner->tuneFm(*parsed->fmFrequency); + result = ctx->tuner->tuneFm(*parsed->fmFrequency, parsed->antCap); } if (!result) { @@ -635,6 +639,66 @@ 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()); + } + + const bool isDab = parsed->band == core::TunerBand::Dab; + const bool saved = isDab ? ctx->antennaCalibration->saveDab(parsed->antCap) + : ctx->antennaCalibration->save(parsed->antCap); + if (!saved) { + 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()); + } + if (isDab) { + ctx->tuner->setDefaultDabAntCap(parsed->antCap); + } else { + ctx->tuner->setDefaultFmAntCap(parsed->antCap); + } + + const std::string json = + std::string("{\"status\":\"saved\",\"band\":\"") + + (isDab ? "dab" : "fm") + "\",\"antcap\":" + + std::to_string(parsed->antCap) + "}"; + 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 +1929,7 @@ std::expected SetupWebServer::start( ota::OtaService& ota, webradio::WebRadioService& webRadio, PhoneStreamSink& phoneStream, + AntennaCalibration& antennaCalibration, core::CompanionChipStatus companionChips, const core::DeviceIdentity& deviceIdentity) { @@ -1889,13 +1954,18 @@ std::expected SetupWebServer::start( routeContext.ota = &ota; routeContext.webRadio = &webRadio; routeContext.phoneStream = &phoneStream; + routeContext.antennaCalibration = &antennaCalibration; routeContext.companionChips = companionChips; routeContext.deviceIdentity = deviceIdentity; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = 12288; config.max_open_sockets = 3; - config.max_uri_handlers = 40; + config.max_uri_handlers = 56; // 41 routes registered below as of 2026-08-19; + // keep headroom so a silent + // httpd_register_uri_handler failure + // ("no slots left") doesn't quietly drop + // the last-registered route again. config.server_port = 80; config.lru_purge_enable = true; config.recv_wait_timeout = 60; @@ -1997,6 +2067,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..d716f85 100644 --- a/Software/components/services/tuner/include/tuner/TunerService.hpp +++ b/Software/components/services/tuner/include/tuner/TunerService.hpp @@ -73,6 +73,10 @@ public: * * @dname tuneDab * @param freqIndex Ensemble index 0–37. + * @param antCap Front-end antenna varactor override for this one + * tune (e.g. for a calibration sweep). Omit to use + * defaultDabAntCap_ (the board's saved calibration, + * or hardware auto-tune if never calibrated). * @return Ok on success, or a TunerError from ITuner. * @pubstate writes lastDabIndex_ on success; clears last-played DAB ids. * @@ -80,13 +84,32 @@ public: * @date 2026-07-06 */ [[nodiscard]] std::expected tuneDab( - std::uint8_t freqIndex); + std::uint8_t freqIndex, + std::optional antCap = std::nullopt); + + /** + * @brief setDefaultDabAntCap — set the board's calibrated DAB ANTCAP. + * + * @dname setDefaultDabAntCap + * @param antCap Value applied to every DAB tune that doesn't pass an + * explicit override (0 = chip auto-tune, the factory + * default before any calibration is saved). + * @pubstate writes defaultDabAntCap_. Does not itself re-tune. + * + * @author Michele Bigi + * @date 2026-08-20 + */ + void setDefaultDabAntCap(std::uint8_t antCap) noexcept; /** * @brief tuneFm — tune to an FM centre frequency. * * @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 +117,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 +238,8 @@ private: std::uint8_t lastDabIndex_; core::FrequencyKHz lastFmFrequency_; std::uint8_t volume_; + std::uint8_t defaultFmAntCap_; + std::uint8_t defaultDabAntCap_; 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..f674207 100644 --- a/Software/components/services/tuner/src/TunerService.cpp +++ b/Software/components/services/tuner/src/TunerService.cpp @@ -138,9 +138,21 @@ TunerService::TunerService(core::ITuner& tuner) , lastDabIndex_(0U) , lastFmFrequency_(defaultFmFrequency()) , volume_(40U) + , defaultFmAntCap_(0U) + , defaultDabAntCap_(0U) { } +void TunerService::setDefaultFmAntCap(std::uint8_t antCap) noexcept +{ + defaultFmAntCap_ = antCap; +} + +void TunerService::setDefaultDabAntCap(std::uint8_t antCap) noexcept +{ + defaultDabAntCap_ = antCap; +} + std::expected TunerService::refreshStatus() { auto status = tuner_.readStatus(); @@ -155,9 +167,11 @@ std::expected TunerService::refreshStatus() } std::expected TunerService::tuneDab( - std::uint8_t freqIndex) + std::uint8_t freqIndex, std::optional antCap) { - if (auto result = tuner_.tuneDab(freqIndex); !result) { + if (auto result = + tuner_.tuneDab(freqIndex, antCap.value_or(defaultDabAntCap_)); + !result) { return result; } lastDabIndex_ = freqIndex; @@ -167,9 +181,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/docs/TODO.md b/Software/docs/TODO.md index 8e61cf5..7a30580 100644 --- a/Software/docs/TODO.md +++ b/Software/docs/TODO.md @@ -3,9 +3,13 @@ Agent task list and hardware-in-the-loop backlog. Working directory for all commands is `Software/`. -**Current firmware:** `0.8.5` — BT1035 I2S slave boot init, dual OTA + DSP blob updates, EEPROM identity, -NVS + flash encryption (dev mode), tabbed Web UI with System uploads, CI gate -(4 jobs). +**Current firmware:** `0.9.0` — everything in 0.8.5, plus: Si4684 RF +blackout root-caused and fixed (real FM/DAB lock and audio on real +hardware), DAB service list fixed (two rounds), FM ANTCAP antenna +calibration persisted to EEPROM, generic ADAU1701 parameter API, phone PCM +streaming, BLE Wi-Fi provisioning, full FM band scan, BT1035 boot retry. +See "Post-0.8.5 hardware-in-the-loop findings" below and +`docs/si4684-rf-investigation-report.md` for the full story. **Before writing code, read `AGENTS.md`, `.cursor/rules/`, and `instructions.md`.** Definition of Done: Apache header, doc blocks, @@ -62,10 +66,49 @@ verify ADAU replay after reboot. After H1 passes, trial build with `sdkconfig.defaults.production` overlay on a sacrificial unit; confirm RELEASE mode policy before shipping. -### H5. Si4684 FM/DAB no-lock — blob integrity checked, verdict: hardware -**Verdict (2026-08-13): blob OK → suspect U6 RF ground (re-open PCBWay)**, not -a firmware/blob defect. Full investigation, evidence, and the two byte-offset -bugs found/fixed while verifying this: [`docs/si4684-rf-investigation-report.md`](si4684-rf-investigation-report.md). +### H5. Si4684 FM/DAB no-lock — RESOLVED, was firmware after all +**Superseded verdict (2026-08-13): blob OK → suspected U6 RF ground, PCBWay +dispute opened.** That verdict was wrong. The actual cause was +`writeCommand()`'s ARG1 byte being mis-offset across FM/DAB tune, seek, and +several status/ack commands — the chip always answered correctly, so every +signal pointed at hardware, but it never actually tuned. Fixed; real FM +lock, real DAB ensemble lock, real audio confirmed live on the same board. +No PCB rework was needed. Full investigation, the wrong initial verdict, +and the eventual root cause: [`docs/si4684-rf-investigation-report.md`](si4684-rf-investigation-report.md). + +--- + +## Post-0.8.5 hardware-in-the-loop findings + +The board arrived and testing against it (not just host tests) found real +bugs the host-testable core couldn't catch, since they live in +ESP-IDF-only drivers. Full detail and evidence in +[`docs/si4684-rf-investigation-report.md`](si4684-rf-investigation-report.md). +Short version: + +- Si4684 total RF blackout (H5 above) — firmware bug, fixed. +- Si4684→ADAU1701 digital audio silence — `PIN_CONFIG_ENABLE` mutual + exclusion + `SerialInputRegister` polarity, fixed. +- DAB service list empty/garbled — response-parsing offset bugs (two + rounds) plus `DAB_EVENT_INTERRUPT_SOURCE` (0xB300) never configured, + fixed. +- FM front-end auto-tune measurably suboptimal on this board's actual + matching network — ANTCAP calibration swept and persisted to EEPROM, + `POST /api/tuner/calibrate-antenna`. +- BT1035 total boot silence — root cause found and fixed (2026-08-20): + the module's spontaneous boot banner (`+VER=...`, `+DEVSTAT=1`) doesn't + appear until ~18-24s after RESET# releases (full BT stack init, not + just the internal regulator), but the boot code only waited 3.5s before + cutting power and restarting — so every attempt, in every prior session, + cut power before the module could ever finish booting even once. Power + rails (VBAT_IN/SYS_CTRL/VDD_IO/1.8V_OUT) and TX/RX wiring were all + independently verified correct with a multimeter first — the module and + PCB were never at fault. Fixed by waiting up to 25s for the banner + (`kBootBannerWaitMs`); boot now succeeds on the first attempt. +- **Still open**: intermittent multi-second HTTP unresponsiveness under + load; DAB signal quality still antenna-limited; 24 KB `nvs` partition + may be undersized (`saveProfile()` `store_failed` seen intermittently, + error code never captured). --- diff --git a/Software/docs/manual/ch-api.tex b/Software/docs/manual/ch-api.tex index 1231f26..0a92232 100644 --- a/Software/docs/manual/ch-api.tex +++ b/Software/docs/manual/ch-api.tex @@ -6,7 +6,7 @@ implemented in \texttt{SetupWebServer}. Request bodies are parsed into domain types in the pure core (\texttt{components/core}) before any persistence or driver call. Exact C++ signatures live in the generated Doxygen output under \texttt{docs/api/}; this chapter documents the -wire protocol and behaviour as shipped in firmware~0.8.5. +wire protocol and behaviour as shipped in firmware~0.9.0. \section{Transport and reachability} @@ -36,7 +36,7 @@ Returns a health-check DTO serialised by \begin{drnote}[Response schema] \begin{drcode}[JSON] -{"status":"ok","fw":"0.8.5","serialNumber":"0004A3123456", +{"status":"ok","fw":"0.9.0","serialNumber":"0004A3123456", "chips":{"si4684":true,"adau1701":true,"bt1035":true}} \end{drcode} \begin{itemize} @@ -172,7 +172,13 @@ or {"band":"fm","frequency_khz":101500} \end{drcode} DAB \texttt{freq\_index} is 0--37; FM \texttt{frequency\_khz} is -64\,000--108\,000. +64\,000--108\,000. FM accepts an optional \texttt{antcap} (0--128): +overrides the front-end antenna varactor for this one tune, e.g. while +running a calibration sweep. Omit it (the normal case) to use the board's +saved calibration --- see +\texttt{POST /api/tuner/calibrate-antenna} +(Section~\ref{sec:api-tuner-calibrate-antenna}) --- or hardware auto-tune +if never calibrated. Ignored for \texttt{"dab"}. \end{drnote} On success returns the updated status JSON (same shape as @@ -267,6 +273,33 @@ per-station poll budget ran out. HTTP status: \textbf{200 OK}; \textbf{409} on a hardware/driver failure mid-sweep; \textbf{503} when the tuner service is unavailable. +\apiendpoint{POST}{/api/tuner/calibrate-antenna} +\label{sec:api-tuner-calibrate-antenna} + +Commits an FM ANTCAP value (AN851 Appendix A front-end calibration) as the +board's permanent default, found by sweeping \texttt{antcap} on +\texttt{POST /api/tuner/tune} (Section~\ref{sec:api-tuner-tune}) across +0--128 and comparing \texttt{rssi\_dbuv}/\texttt{snr\_db}. Persists to the +24AA025E48 EEPROM's user-writable region (separate from the factory-locked +EUI-48) and takes effect immediately on the live tuner --- no reboot +required, though it also survives one since \texttt{HardwareBootstrap::boot()} +reloads it at every boot. Once saved, every ordinary FM tune (seek, scan, +station recall, live UI) uses this value automatically instead of the +chip's own front-end auto-tune, unless a request explicitly overrides +\texttt{antcap} for that one call. + +\begin{drnote}[Request schema] +\begin{drcode}[JSON] +{"antcap":102} +\end{drcode} +\texttt{antcap} is required, 0--128. +\end{drnote} + +Success response: \texttt{\{"status":"saved","antcap":102\}}. HTTP status: +\textbf{200 OK}; \textbf{400} for invalid/missing \texttt{antcap}; +\textbf{500} on an EEPROM write failure; \textbf{503} when the tuner +service is unavailable. + \apiendpoint{GET}{/api/audio/profile} \label{sec:api-audio-profile-get} diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index 524b1dc..afcaf20 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -16,7 +16,7 @@ added in the same change that introduces the class. A tooling check keeps this chapter in step with the code, so it is always current. \end{drnote} -The class reference tracks firmware~0.8.5 on \texttt{main}. Public classes +The class reference tracks firmware~0.9.0 on \texttt{main}. Public classes are grouped by layer: domain core, application services, and hardware drivers. % ------------------------------------------------------------------ diff --git a/Software/docs/manual/ch-intro.tex b/Software/docs/manual/ch-intro.tex index 3274d99..f5a1bf1 100644 --- a/Software/docs/manual/ch-intro.tex +++ b/Software/docs/manual/ch-intro.tex @@ -4,7 +4,7 @@ DigiRadio is an open-source, high-fidelity digital radio receiver. It receives DAB+ and FM broadcasts, processes the audio through a dedicated signal processor, and streams the result over Bluetooth using a -high-resolution codec. Firmware~0.8.5 on \texttt{main} provides encrypted +high-resolution codec. Firmware~0.9.0 on \texttt{main} provides encrypted storage, a tabbed configuration web UI, and the full REST API documented in Chapter~\ref{ch:api}. The whole project --- hardware and firmware --- is released as open source for the maker and audio community to study, diff --git a/Software/docs/manual/manual.tex b/Software/docs/manual/manual.tex index 45bd431..c7157c5 100644 --- a/Software/docs/manual/manual.tex +++ b/Software/docs/manual/manual.tex @@ -30,7 +30,7 @@ \vfill {\color{drInk}\large Michele Bigi\par} \vspace{2mm} -{\color{drGray}Firmware 0.8.5 \quad\textbullet\quad 2026\par} +{\color{drGray}Firmware 0.9.0 \quad\textbullet\quad 2026\par} \vspace{2mm} {\color{drGray}Hardware: CERN-OHL-S v2 \quad\textbullet\quad Firmware: Apache-2.0\par} \vspace{2mm} diff --git a/Software/docs/si4684-rf-investigation-report.md b/Software/docs/si4684-rf-investigation-report.md index 4818d57..997cb90 100644 --- a/Software/docs/si4684-rf-investigation-report.md +++ b/Software/docs/si4684-rf-investigation-report.md @@ -616,19 +616,179 @@ treat BT1035 boot failure as non-fatal rather than halting the whole device, so the rest of the system (Si4684 tuning, web UI, Wi-Fi) remains usable while this is investigated separately. +## 2026-08-19 update: fetchDabServiceList() entry parsing fixed; DAB audio +confirmed, quality traced to signal strength + +Live retest on real hardware found `fetchDabServiceList()`'s *body* parsing +(the third bug flagged as "not yet investigated" above) double-counted the +already-consumed SIZE field: it treated the payload as starting 2 bytes +later than it actually does (`serviceCount` read from `body[11]` instead of +`body[9]`, service entries starting at `body[15]` instead of `body[13]`). +AN649 doesn't actually document the DAB service-list entry layout itself — +it defers to a supplemental "Digital Services User's Guide" this project +doesn't have a copy of — so the exact field layout was re-derived by +cross-checking `hitech95/si468x_dab_receiver`'s +`si468x_core_cmd_dab_get_service_list()` (a working Linux driver for the +same command over the same command set), which also confirmed the payload +carried after SIZE is `SIZE-2` bytes, not `SIZE` bytes (fixed the read +sizing to match). + +Confirmed live immediately after reflashing: `GET /api/tuner/services` on a +locked DAB ensemble (freq_index 5) now returns 22 real, correctly-decoded +Italian DAB station labels (R.M.T., Radio Cuore, GR News, Radio Sportiva, +Lifegate, ...) instead of an empty list. `POST /api/tuner/play` against one +of these real service/component IDs was confirmed audible — crackly/broken +but present, not silence — on a second try after the first selected +service (R.M.T., `cnr_db=7`) produced no audible sound at all. Switching to +GR News (`cnr_db=8`) did produce audible (if degraded) audio. This matches +DAB's two-tier robustness by design: the FIC channel (`fic_quality` 94-98 +throughout) is far more error-protected than the actual audio sub-channel, +so a receiver can report ensemble lock and a clean, complete service list +while individual programme audio is too weak (CNR ~7-8 dB here) to decode +cleanly or at all — the chip's own soft-mute is the most likely explanation +for the first service's total silence, not a firmware defect. This is +consistent with what FM already showed this session ("works, but +badly") and with the still-open antenna/front-end TODO below. + +Also found and fixed, unrelated to the Si4684: `SetupWebServer` was +registering 41 HTTP routes against `httpd_config_t::max_uri_handlers = 40` +— `httpd_register_uri_handler()` fails past the limit with only a generic +"no slots left" warning, no indication of which handler was dropped. The +41st and therefore last-registered route, `POST /api/stations/tune`, was +silently unroutable (404) on every boot since whichever commit first pushed +the route count past 40. Bumped to 56 for headroom. + +## 2026-08-19 update (2): DAB_EVENT_INTERRUPT_SOURCE never configured; +intermittent multi-second HTTP unresponsiveness noted, still open + +Retesting DAB service-list retrieval later the same night found it far less +reliable than the earlier confirmation: `locked:true, fic_quality:97-100` +sometimes took anywhere from ~15s to ~60s to appear after a fresh +`POST /api/tuner/tune` (full Si4684 reboot for the FM->DAB band switch), and +even once locked with excellent FIC quality, `GET /api/tuner/services` kept +returning `service_list_empty` for a further 30-65s. + +Root cause candidate found by re-reading AN649's DAB_GET_EVENT_STATUS +section (command 0xB3) carefully: the SVRLISTINT bit this driver polls via +`readDabEventStatus()` is explicitly documented as gated by **Property +0xB300 DAB_EVENT_INTERRUPT_SOURCE**, bit 0 = SRVLIST_INTEN, **default 0x0000 +(disabled) at power-on** — and this driver never wrote that property +anywhere. `configureAfterBoot()`'s DAB branch already wrote a +similarly-named `DIGITAL_SERVICE_INT_SOURCE` (property 0x8100), but AN649's +own text for 0x8100 is internally inconsistent between its prose ("configures +digital service interrupt sources") and its bit table (VHFCAPS/VHFSW, a +front-end switch config field) — almost certainly a `pdftotext -raw` +extraction artifact merging two adjacent property tables, the same failure +mode noted earlier this session for AN649/adau1701.pdf text extraction. +0x8100 and 0xB300 are two different properties; only 0xB300's own section +(page ~236) reads internally consistent, so it — not 0x8100 — is the one +that gates SVRLISTINT. Added `setProperty(kPropDabEventIntSource=0xB300, +0x0001)` right after the existing 0x8100 write. + +Verified live after reflashing: the service list did come back complete and +correct (all 22 real station labels) on the next test. Not proven +conclusively faster than before — DAB acquisition/list-assembly timing is +inherently variable and this was only tested once post-fix — but the +property write is unambiguously correct per its own AN649 section +regardless, so it stays. + +**Separately, and NOT explained by the above**: the HTTP server went fully +unresponsive (connection timeouts on `/api/health`, the simplest possible +route) for 5-10 second stretches, more than once, both before and after +this fix. The heartbeat log line kept appearing on schedule throughout +(`digiradio: heartbeat` every 5s, confirmed via serial), proving the whole +system did not crash or panic — only the HTTP server (or whatever it was +waiting on, most likely a blocking SPI/CTS wait inside the Si4684 driver +triggered from a DAB status/event read) stalled and then recovered on its +own. This was reproducible independent of the 0xB300 change (first +observed hours earlier, unrelated, during the ANTCAP sweep in the antenna +calibration work). Not investigated further tonight — candidate causes to +check next: whether any Si4684Driver SPI wait loop lacks a bound tight +enough for interactive HTTP use, and whether `httpd_config_t:: +max_open_sockets = 3` (components/net/src/SetupWebServer.cpp) is simply too +small once anything blocks even briefly. + ## TODO (next session) -- **Antenna/front-end calibration, now meaningful.** Before this session's - fixes, any ANTCAP sweep or front-end network experiment was untrustworthy - — a bad result could have been the software bug, not the antenna. Now - that the receiver chain is verified correct end to end (real FM lock, - real DAB lock, real audio), redo the ANTCAP sweep and compare the actual - front-end network (§ "Front-end network component mismatch" above) - against AN851 properly, with results that can actually be trusted. -- Fix `fetchDabServiceList()` entry parsing (garbled service_id/component_id/ - label) against AN649 §7 "Digital Services User's Guide" (~page 418). -- Confirm actual DAB audio playback end to end (blocked on the item above). -- Try a proper FM antenna to see if the residual noise under the music - clears up (suspected antenna quality, not yet confirmed). +- **Antenna/front-end calibration — now the real blocker for DAB/FM audio + quality, not firmware.** Both bands are confirmed working end to end + (real lock, real service list, real audio) but both are signal-limited: + FM "works, but badly" per live listening test, and DAB audio ranges from + crackly to fully soft-muted depending on the service's CNR (~7-8 dB + observed, on the low side). ~~Redo the ANTCAP sweep~~ — **done this + session for FM** (see the ANTCAP antenna calibration feature commit); + antcap=102 saved as the board's default, +6 to +11 dB RSSI/SNR across the + band. ~~DAB doesn't have an equivalent calibrated-default mechanism yet~~ + — **added and swept 2026-08-20, see below; no default saved (auto-tune + already best on the ensembles tested).** +- Try a proper FM/DAB antenna to see how much of the crackle/noise clears + up versus how much is inherent to the current antenna's gain/placement. +- Investigate the intermittent multi-second HTTP unresponsiveness noted + above — reproducible, not yet root-caused, not obviously related to any + single change this session. - BT1035 boot-failure root cause still open (see section above) — non-fatal - now, so it's no longer blocking, but still unexplained. + now, so it's no longer blocking, but still unexplained. **Recurred + 2026-08-20, see below — still open, confirmed not caused by physical + handling.** + +## 2026-08-20 update: DAB ANTCAP override added and swept live; BT1035 "total UART silence" recurred + +**DAB ANTCAP — implemented, built, flashed, swept live via the HTTP API.** +Extended the ANTCAP override (AN649 Command 0x30 ARG4/5 for FM, Command +0xB0 ARG4/5 for DAB) from FM-only to DAB, mirroring the existing FM +mechanism end to end: `ITuner::tuneDab`/`Si4684Driver::tuneDab` gained an +`antCap` parameter (was hardcoded `0x00`/auto); `TunerService` gained +`defaultDabAntCap_`/`setDefaultDabAntCap()`; `Eeprom24aa` gained +`readDabAntCap()`/`writeDabAntCap()` at word address 0x01 (FM stays at +0x00); `HardwareBootstrap` gained `dabAntCapCalibration()`/ +`saveDabAntCapCalibration()`, loaded at boot alongside the FM one; the +`net::AntennaCalibration` bridge gained `saveDab`; both `POST +/api/tuner/tune` (one-shot override, `{"band":"dab","freq_index":N, +"antcap":V}`) and `POST /api/tuner/calibrate-antenna` (persists to EEPROM, +`{"band":"dab","antcap":V}`, `band` defaults to `"fm"` so old clients are +unaffected) now accept DAB. Host build + 20/20 ctest + doxygen + +check-manual-sync all green before flashing. + +Swept live via the API (`freq_index` 0-128 step 8) against three real +ensembles: +- **freq_index 5** (weakest known ensemble, 7 dB CNR baseline from the + 2026-08-16 sweep): did not lock at all this session, at any ANTCAP + including auto — signal currently below threshold, not a code issue + (indices 22/23 locked normally in the same session). +- **freq_index 23** (strongest, 21-26 dB): CNR jittered ±3 dB across the + whole ANTCAP range with no discernible trend — already saturated, sweep + can't discriminate on a signal this strong. +- **freq_index 22** (medium, 16-20 dB): auto (0) and antcap=32 tied for + best (20 dB CNR); antcap=72 and 80 caused total loss of lock (a dead + zone to avoid); the rest of the range gave no systematic gain over auto, + unlike FM's clean +6 to +11 dB improvement. + +**Decision: left DAB on auto-tune, nothing saved to EEPROM.** Unlike FM, +no ANTCAP value tested beat the chip's own auto-tune by a margin worth +trusting. If DAB audio quality is still the limiting factor later, retest +specifically on a weak ensemble (index 5 or similar) once it's receivable +again — ANTCAP calibration matters most on weak signals, which is exactly +the case that wasn't testable this session. + +**BT1035 "total UART silence" recurred — same still-open issue as before, +confirmed (again) not physical.** During the DAB sweep, the board was +reset several times via opening a `pyserial` connection for log capture — +each open triggers a hardware EN/reset pulse on this ESP32-S3 (confirmed: +happens even with `dsrdtr=False, rtscts=False` and explicit +`setDTR(False)`/`setRTS(False)` — this is the USB-native auto-reset +circuit firing on port open, not a pyserial default that can be disabled +from the Mac side). One of these resets left BT1035 silent: `no +spontaneous UART bytes after hardware reset` on both boot attempts (2/2), +then silent across all 8 probed baud rates (9600-921600). This is *not* +the "banner arrives late" issue fixed 2026-08-20 earlier this same session +(`kBootBannerWaitMs = 25000` was already in effect and made no +difference) — it's the harder, total-silence failure mode already logged +above (the "Unrelated finding from the same session" note before the +2026-08-19 entry), recurring. Confirmed again this time that it is not +caused by physical handling: a full physical power-off for 60 s did not +recover it (Si4684/ADAU1701 both came back up fine on the same power +cycle, ruling out a board-wide power issue). Root cause still not +identified. `/api/bluetooth/status` and `/api/bluetooth/paired` correctly +report `{"status":"error","reason":"at_timeout"}` while in this state; the +rest of the device (tuner, web UI) stays usable per the existing +non-fatal-BT1035-boot design. diff --git a/Software/instructions.md b/Software/instructions.md index a3f0af2..ba0a953 100644 --- a/Software/instructions.md +++ b/Software/instructions.md @@ -4,8 +4,8 @@ Read this together with `AGENTS.md` and everything under `.cursor/rules/`. Those define *how* to write code; this file defines *what we are building* and the current state on `main`. -**Firmware on `main`:** **0.8.5** — agent tasks T1–T12 complete; device HIL -pending PCB arrival. +**Firmware on `main`:** **0.9.0** — agent tasks T1–T12 complete; device HIL +now largely done on the first real PCB (see below), not pending anymore. ## What DigiRadio is @@ -53,8 +53,62 @@ Repository: https://github.com/manvalan/DigiRadio | T8 NVS encryption | Done (0.8.3) | `initEncryptedStorage`; HIL when PCB ready | | T9–T12 Platform | Done (0.8.4) | Dual OTA, EEPROM identity, DSP + firmware OTA | -Next work: **hardware-in-the-loop** (`docs/TODO.md` § P4), not new features -unless the user requests them. +## Post-0.8.5 HIL work (first real PCB, not in the table above) + +The board arrived and most of Slice 3–8's HIL assumptions turned out to be +wrong in ways that needed real fixes, not just testing. Summary (full +detail in `docs/si4684-rf-investigation-report.md`): + +- **Si4684 total RF blackout, root-caused and fixed.** `writeCommand()`'s + ARG1 byte was mis-offset across FM/DAB tune, seek, DAB service commands, + and several ARG1-only status/ack commands — the chip answered every + command correctly but never actually tuned. Real FM lock, real DAB + ensemble lock (3+ ensembles), real audio confirmed live. +- **Si4684→ADAU1701 digital audio silence, fixed.** `PIN_CONFIG_ENABLE` + had both I2SOUTEN and DACOUTEN set (chip falls back to unused analog + out per AN649); `SerialInputRegister` IBP polarity was wrong (ADAU + sampling on the wrong BCLK edge). +- **DAB service list, two rounds of offset bugs fixed.** Response-parsing + offsets were wrong in a way that made `GET_DIGITAL_SERVICE_LIST` return + empty/garbled; confirmed live with 22 real, correctly-decoded station + labels. Also found `DAB_EVENT_INTERRUPT_SOURCE` (property 0xB300) was + never configured, so the service-list-ready event could never fire. +- **BT1035 total boot silence, root cause found and fixed (2026-08-20).** + Not a hardware fault: VBAT_IN/SYS_CTRL/VDD_IO/1.8V_OUT and TX/RX wiring + were all independently confirmed correct with a multimeter (SYS_CTRL + and 1.8V_OUT readings pinned down by probing the nearest decoupling cap + instead of the tiny 0.5mm-pitch castellated pad directly, which had + given a false "regulator dead" reading earlier). The actual bug: the + module's spontaneous boot banner (`+VER=...`, `+DEVSTAT=1`) doesn't + appear until ~18-24s after RESET# releases — full BT stack init, not + just the internal regulator powering up. The old code only waited 3.5s + before cutting power and restarting the whole sequence, so across every + prior session the module never once got the chance to finish booting. + Fixed by waiting up to 25s (`kBootBannerWaitMs`) for the banner before + giving up; boot now succeeds on the first attempt, no retries needed. +- **FM front-end calibration.** The board's actual matching network + differs from the AN851 reference the chip's auto-tune constants assume. + Swept ANTCAP (AN851 Appendix A) and found a fixed override that beats + auto-tune by 6–11 dB RSSI/SNR across the whole band; persisted to the + 24AA025E48 EEPROM (`POST /api/tuner/calibrate-antenna`) and applied by + default to every FM tune. +- **New features, not in the original Slice plan:** full FM band scan + (`POST /api/tuner/scan/full`), generic ADAU1701 parameter access + (`GET`/`PUT /api/dsp/param`, an escape hatch onto any SigmaStudio cell + beyond the curated mixer/EQ API), phone PCM streaming + (`PUT /api/stream/phone`), BLE Wi-Fi provisioning + (`net::ble_provisioning`, ESP-IDF's own `wifi_provisioning` over the + ESP32-S3's onboard BLE, additive alongside the SoftAP), web radio + streaming stutter fix (batched I2S writes). +- **Still open**: intermittent multi-second HTTP unresponsiveness under + load (candidate cause: a blocking Si4684 SPI wait colliding with + `max_open_sockets=3`); DAB signal quality still antenna-limited even + after calibration; NVS partition (24 KB) may be undersized given the + accumulated write traffic (`saveProfile()` `store_failed` seen + intermittently, never root-caused). + +Next work: keep chasing the open items above as the user prioritises them, +not new features unless requested. - **Blockers first** — state risks before solutions. - **One vertical slice at a time** — `main` always builds; host tests green. 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..d727ec1 --- /dev/null +++ b/Software/main/antenna_calibration.cpp @@ -0,0 +1,26 @@ +/** + * @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, + .saveDab = &hardware::HardwareBootstrap::saveDabAntCapCalibration, + }; + 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..87eaa0b 100644 --- a/Software/main/hardware_bootstrap.cpp +++ b/Software/main/hardware_bootstrap.cpp @@ -80,7 +80,22 @@ bt1035::Bt1035Driver gBt1035( }); core::DeviceIdentity gDeviceIdentity = core::DeviceIdentity::unknown(); +std::optional gFmAntCapCalibration; +std::optional gDabAntCapCalibration; 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 +117,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 +128,32 @@ 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 antCap = eeprom.readDabAntCap(); antCap) { + gDabAntCapCalibration = *antCap; + if (gDabAntCapCalibration) { + ESP_LOGI(kTag, "DAB ANTCAP calibration loaded: %u", + static_cast(*gDabAntCapCalibration)); + } else { + ESP_LOGI(kTag, "DAB ANTCAP not calibrated — using chip auto-tune"); + } + } else { + ESP_LOGW(kTag, "DAB ANTCAP calibration read failed — using chip " + "auto-tune"); + } + if (auto audioResult = gAudioService.loadAndApply(); !audioResult) { ESP_LOGW(kTag, "ADAU1701 profile apply failed"); } @@ -175,4 +212,40 @@ 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; +} + +std::optional HardwareBootstrap::dabAntCapCalibration() noexcept +{ + return gDabAntCapCalibration; +} + +bool HardwareBootstrap::saveDabAntCapCalibration(std::uint8_t antCap) +{ + eeprom24aa::Eeprom24aa eeprom = makeEeprom(); + if (auto written = eeprom.writeDabAntCap(antCap); !written) { + ESP_LOGW(kTag, "DAB ANTCAP calibration write failed"); + return false; + } + gDabAntCapCalibration = antCap; + ESP_LOGI(kTag, "DAB 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..2405a69 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,64 @@ 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); + + /** + * @brief dabAntCapCalibration — saved DAB antenna calibration, if any. + * + * @dname dabAntCapCalibration + * @return Calibrated ANTCAP (0-128) read from the 24AA025E48 during + * boot(), or nullopt if never calibrated / the read failed. + * @pubstate reads gDabAntCapCalibration; set once during boot(). + * + * @author Michele Bigi + * @date 2026-08-20 + */ + [[nodiscard]] static std::optional + dabAntCapCalibration() noexcept; + + /** + * @brief saveDabAntCapCalibration — persist a new DAB ANTCAP to EEPROM. + * + * @dname saveDabAntCapCalibration + * @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 gDabAntCapCalibration. + * Does not itself change any live tuner state — callers must + * also call TunerService::setDefaultDabAntCap() to apply it. + * + * @author Michele Bigi + * @date 2026-08-20 + */ + [[nodiscard]] static bool saveDabAntCapCalibration(std::uint8_t antCap); }; } // namespace hardware diff --git a/Software/main/main.cpp b/Software/main/main.cpp index 3127db1..9cebc32 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,14 @@ extern "C" void app_main() static tuner::TunerService tunerService( hardware::HardwareBootstrap::si4684Tuner()); + if (auto antCap = hardware::HardwareBootstrap::fmAntCapCalibration(); + antCap) { + tunerService.setDefaultFmAntCap(*antCap); + } + if (auto antCap = hardware::HardwareBootstrap::dabAntCapCalibration(); + antCap) { + tunerService.setDefaultDabAntCap(*antCap); + } static station::StationService stationService(store, tunerService); @@ -214,6 +223,7 @@ extern "C" void app_main() otaService, webRadioService, phone_stream::sink(), + antenna_calibration::bridge(), hardware::HardwareBootstrap::companionChipStatus(), hardware::HardwareBootstrap::deviceIdentity()); if (!netResult) { diff --git a/Software/partitions.csv b/Software/partitions.csv index 622266a..295e45b 100644 --- a/Software/partitions.csv +++ b/Software/partitions.csv @@ -3,11 +3,17 @@ # dsp: updatable ADAU1701 program blob (task 3.1). # nvs_keys: NVS encryption keys (secure-store slice). # First flash after this layout: idf.py erase-flash flash (wired), not OTA. +# nvs: bumped 24 KiB -> 64 KiB (2026-08-19) — the small original size was a +# suspected contributor to intermittent NvsAudioProfileStore::saveProfile() +# store_failed under this project's accumulated write traffic (wifi creds, +# station_list, audio_profile_json, last_preset). otadata/nvs_keys/phy_init +# shift forward to make room; they still fit before ota_0's existing 64 KiB +# alignment boundary (0x20000), so ota_0/ota_1/dsp offsets are unchanged. # Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, 0x9000, 0x6000, -otadata, data, ota, 0xf000, 0x2000, -nvs_keys, data, nvs_keys, 0x11000, 0x1000, -phy_init, data, phy, 0x12000, 0x1000, +nvs, data, nvs, 0x9000, 0x10000, +otadata, data, ota, 0x19000, 0x2000, +nvs_keys, data, nvs_keys, 0x1b000, 0x1000, +phy_init, data, phy, 0x1c000, 0x1000, ota_0, app, ota_0, 0x20000, 0x400000, ota_1, app, ota_1, 0x420000, 0x400000, dsp, data, 0x40, 0x820000, 0x40000,