Firmware review pass: BT1035 boot retry, BluetoothJson tests, doc catch-up

BT1035Driver::boot() had zero retry on the reset+AT-init sequence — a
single hardware RESET# pulse followed immediately by AT commands,
with no second attempt if the module didn't come up in time. This is
the most plausible explanation for the intermittent "no spontaneous
UART bytes after hardware reset" / "AT init failed" boot failures
logged in docs/si4684-rf-investigation-report.md and observed again
live this morning on otherwise-identical hardware/wiring — classic
power-up timing jitter, not a permanent fault. Extracted the reset+
init sequence into resetAndInitOnce() and wrapped it in a 3-attempt
retry loop with a short delay between attempts; the one-time GPIO
config and UART driver install stay outside the loop since they don't
need repeating. Root cause of the underlying jitter is still open.

BluetoothJson.hpp was the only *Json.hpp module in the core with zero
host test coverage (status/scan/paired serialisation, auto-reconnect/
connect/speaker parsing) — every sibling module already has one.
Added bluetooth_json_test.cpp following the existing tuner_json_test
pattern; ctest now covers 20 suites instead of 19.

Documentation catch-up, found doing a full firmware re-review at the
user's request:
- POST /api/tuner/calibrate-antenna and the antcap field on
  POST /api/tuner/tune (added in a previous commit, never documented)
  are now in ch-api.tex.
- kFirmwareVersion was still hardcoded "0.8.5" despite the RF fixes,
  BLE provisioning, phone streaming, antenna calibration, and generic
  DSP param API landed since that version's actual release commit
  (0a1188a). Bumped to 0.9.0 everywhere it's mentioned (health JSON,
  the manual's title page, intro, classes, and API chapters).
- instructions.md and docs/TODO.md still described the firmware as
  frozen at 0.8.5 awaiting hardware-in-the-loop testing that has since
  happened extensively; docs/TODO.md's H5 verdict specifically still
  said "suspect U6 RF ground (re-open PCBWay)" for a bug that turned
  out to be firmware, not hardware — actively misleading, corrected.
  Both files now summarise the post-0.8.5 HIL findings and current
  open items (BT1035 root cause, intermittent HTTP unresponsiveness
  under load, antenna-limited signal quality, possibly-undersized 24 KB
  nvs partition).

Verified: idf.py build, doxygen (0 warnings), check-manual-sync,
check_si4684_blobs, ctest (20/20), two-pass xelatex manual build all
green. Flashed and confirmed live: fw reports 0.9.0, BT1035 booted on
the first attempt post-flash.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178rASQ6ZETPMUamvpoR2KR
This commit is contained in:
2026-08-19 09:23:36 +02:00
co-authored by Claude Sonnet 5
parent f612335632
commit c0eee4a4ad
11 changed files with 393 additions and 30 deletions
@@ -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)
@@ -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 <cstdlib>
#include <iostream>
#include <string>
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<core::Bt1035ScannedDevice> 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<core::Bt1035PairedDevice> 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;
}
@@ -343,6 +343,7 @@ public:
private:
[[nodiscard]] std::expected<void, Bt1035Error> ensureBooted() const;
[[nodiscard]] std::expected<void, Bt1035Error> runInitSequence();
[[nodiscard]] std::expected<void, Bt1035Error> resetAndInitOnce();
[[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollect(
std::string_view commandLine, int timeoutMs = kResponseTimeoutMs);
[[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollectUntil(
@@ -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<void, Bt1035Error> Bt1035Driver::runInitSequence()
return {};
}
std::expected<void, Bt1035Error> Bt1035Driver::resetAndInitOnce()
{
gpio_set_level(static_cast<gpio_num_t>(pins_.sysCtlGpio), 1);
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
vTaskDelay(pdMS_TO_TICKS(100));
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
vTaskDelay(pdMS_TO_TICKS(kPostResetMs));
logRawUartBoot(uartPort_);
uart_flush_input(static_cast<uart_port_t>(uartPort_));
vTaskDelay(pdMS_TO_TICKS(kPostUartMs));
return runInitSequence();
}
std::expected<void, Bt1035Error> Bt1035Driver::boot()
{
if (booted_) {
@@ -924,12 +946,6 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
return std::unexpected(Bt1035Error::ResetFailed);
}
gpio_set_level(static_cast<gpio_num_t>(pins_.sysCtlGpio), 1);
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
vTaskDelay(pdMS_TO_TICKS(100));
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
vTaskDelay(pdMS_TO_TICKS(kPostResetMs));
if (!uartInstalled_) {
const uart_config_t uartCfg = {
.baud_rate = kBaudRate,
@@ -961,12 +977,19 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
uartInstalled_ = true;
}
logRawUartBoot(uartPort_);
uart_flush_input(static_cast<uart_port_t>(uartPort_));
vTaskDelay(pdMS_TO_TICKS(kPostUartMs));
if (auto init = runInitSequence(); !init) {
ESP_LOGE(kTag, "AT init failed");
std::expected<void, Bt1035Error> 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;
}
@@ -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(
+43 -7
View File
@@ -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).
---
+36 -3
View File
@@ -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}
+1 -1
View File
@@ -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.
% ------------------------------------------------------------------
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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}
+50 -4
View File
@@ -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 T1T12 complete; device HIL
pending PCB arrival.
**Firmware on `main`:** **0.9.0** — agent tasks T1T12 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 |
| T9T12 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 38'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 611 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.