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/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..86274a2 100644 --- a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp +++ b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp @@ -38,6 +38,13 @@ constexpr int kUartTxBuffer = 256; constexpr int kResponseTimeoutMs = 2000; constexpr int kPostResetMs = 500; constexpr int kPostUartMs = 100; +/** Observed intermittently: the module sometimes needs a second RESET# + * pulse to come up (power-up timing jitter between cold/warm boots) — + * a single attempt with no retry was found to explain sporadic total + * boot failures ("no spontaneous UART bytes" -> "AT init failed") on + * otherwise-identical hardware/wiring. */ +constexpr int kBootAttempts = 3; +constexpr int kBootRetryDelayMs = 300; void flushUartRx(int uartPort) noexcept { @@ -904,6 +911,21 @@ std::expected Bt1035Driver::runInitSequence() return {}; } +std::expected Bt1035Driver::resetAndInitOnce() +{ + 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)); + + logRawUartBoot(uartPort_); + uart_flush_input(static_cast(uartPort_)); + vTaskDelay(pdMS_TO_TICKS(kPostUartMs)); + + return runInitSequence(); +} + std::expected Bt1035Driver::boot() { if (booted_) { @@ -924,12 +946,6 @@ std::expected Bt1035Driver::boot() 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 +977,19 @@ 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); return init; } diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 1522730..5cdf1c7 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( diff --git a/Software/docs/TODO.md b/Software/docs/TODO.md index 8e61cf5..128dc06 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,42 @@ 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 intermittent boot failure — root cause still unknown, but a + reset+init retry loop (up to 3 attempts) was added since the failure + looked like power-up timing jitter, not a permanent fault. +- **Still open**: BT1035 root cause; 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/instructions.md b/Software/instructions.md index a3f0af2..8d1a8c3 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,54 @@ 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 boot failure is intermittent, not fixed at the root** — the + module sometimes sends zero UART bytes after a hardware reset. Made + non-fatal early on; a retry loop (up to 3 reset+init attempts) was added + once the timing-jitter theory held up, but the underlying cause is + still open. +- **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**: BT1035 root cause; 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.