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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 08:12:08 +02:00
co-authored by Claude Sonnet 5
parent 9ad2e42fe2
commit 3a58d33aad
38 changed files with 1444 additions and 115 deletions
@@ -86,6 +86,10 @@ public:
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @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<void, TunerError> 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<void, TunerError> tuneFm(
FrequencyKHz frequency) = 0;
FrequencyKHz frequency, std::uint8_t antCap = 0U) = 0;
/**
* @brief seekFm — seek to the next valid FM station.
@@ -41,6 +41,9 @@ struct TunerTuneRequest {
TunerBand band; ///< Target band (Dab or Fm).
std::uint8_t dabFreqIndex; ///< Band III ensemble index (037) when band is Dab.
std::optional<FrequencyKHz> fmFrequency; ///< FM centre frequency when band is Fm.
std::optional<std::uint8_t> antCap; ///< Antenna varactor override (0128),
///< for calibration sweeps; applies to
///< whichever band is being tuned.
};
/**
@@ -239,4 +242,38 @@ struct TunerFmScannedStation {
[[nodiscard]] std::string serializeTunerFmBandScanJson(
const std::vector<TunerFmScannedStation>& 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<AntennaCalibrationRequest, ParseError>
parseAntennaCalibrationJson(std::string_view json);
} // namespace core
@@ -203,6 +203,10 @@ std::expected<TunerTuneRequest, ParseError> parseTunerTuneJson(
} else {
return std::unexpected(ParseError::InvalidJson);
}
unsigned long antCap = 0U;
if (extractJsonUint(json, "antcap", antCap) && antCap <= 128U) {
req.antCap = static_cast<std::uint8_t>(antCap);
}
return req;
}
@@ -336,4 +340,28 @@ std::string serializeTunerFmBandScanJson(
return out.str();
}
std::expected<AntennaCalibrationRequest, ParseError>
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<std::uint8_t>(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
@@ -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;
}
@@ -111,13 +111,13 @@ public:
}
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
std::uint8_t) override
std::uint8_t, std::uint8_t) override
{
return {};
}
[[nodiscard]] std::expected<void, core::TunerError> tuneFm(
core::FrequencyKHz) override
core::FrequencyKHz, std::uint8_t) override
{
tunedFm = true;
return {};
@@ -53,13 +53,13 @@ public:
}
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
std::uint8_t) override
std::uint8_t, std::uint8_t) override
{
return {};
}
[[nodiscard]] std::expected<void, core::TunerError> tuneFm(
core::FrequencyKHz) override
core::FrequencyKHz, std::uint8_t) override
{
return {};
}
@@ -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,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<int, 8> 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<uart_port_t>(uartPort), baud)
!= ESP_OK) {
continue;
}
flushUartRx(uartPort);
uart_write_bytes(static_cast<uart_port_t>(uartPort), "AT\r\n", 4);
std::array<char, 64> buf{};
const int n = uart_read_bytes(static_cast<uart_port_t>(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<std::size_t>(n));
} else {
ESP_LOGI(kTag, "baud probe: silent at %d baud", baud);
}
}
uart_set_baudrate(static_cast<uart_port_t>(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<std::uint8_t, 128> buf{};
const int n = uart_read_bytes(static_cast<uart_port_t>(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<void, Bt1035Error> Bt1035Driver::runInitSequence()
return {};
}
std::expected<void, Bt1035Error> 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<gpio_num_t>(pins_.sysCtlGpio);
const auto resetPin = static_cast<gpio_num_t>(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<uart_port_t>(uartPort_));
vTaskDelay(pdMS_TO_TICKS(kPostUartMs));
return runInitSequence();
}
std::expected<void, Bt1035Error> 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<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 +1052,20 @@ 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);
probeBaudRates(uartPort_);
return init;
}
@@ -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)
@@ -13,27 +13,44 @@
#pragma once
#include "core/IDeviceIdentitySource.hpp"
#include "core/IdentityError.hpp"
#include "driver/i2c_master.h"
#include <cstdint>
#include <expected>
#include <optional>
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<core::DeviceIdentity, core::IdentityError>
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<std::optional<std::uint8_t>, 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<void, core::IdentityError>
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<std::optional<std::uint8_t>, 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<void, core::IdentityError>
writeDabAntCap(std::uint8_t value);
private:
i2c_master_bus_handle_t bus_;
std::uint8_t addr7_;
@@ -15,6 +15,8 @@
#include "driver/i2c_master.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <array>
@@ -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<std::optional<std::uint8_t>, 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<unsigned>(err));
return std::unexpected(core::IdentityError::ReadFailed);
}
if (value > kFmAntCapMax) {
return std::optional<std::uint8_t>{};
}
return std::optional<std::uint8_t>{value};
}
std::expected<void, core::IdentityError>
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<std::uint8_t, 2> 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<unsigned>(err));
return std::unexpected(core::IdentityError::I2cFailed);
}
vTaskDelay(pdMS_TO_TICKS(kI2cWriteCycleMs));
return {};
}
std::expected<std::optional<std::uint8_t>, 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<unsigned>(err));
return std::unexpected(core::IdentityError::ReadFailed);
}
if (value > kDabAntCapMax) {
return std::optional<std::uint8_t>{};
}
return std::optional<std::uint8_t>{value};
}
std::expected<void, core::IdentityError>
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<std::uint8_t, 2> 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<unsigned>(err));
return std::unexpected(core::IdentityError::I2cFailed);
}
vTaskDelay(pdMS_TO_TICKS(kI2cWriteCycleMs));
return {};
}
} // namespace eeprom24aa
@@ -288,13 +288,18 @@ public:
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @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<void, Si4684Error> tuneDab(std::uint8_t freqIndex);
[[nodiscard]] std::expected<void, Si4684Error> tuneDab(
std::uint8_t freqIndex, std::uint8_t antCap = 0U);
/**
* @brief readDabDigRadStatus — read ensemble lock metrics.
@@ -96,6 +96,7 @@ public:
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @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<void, core::TunerError> 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<void, core::TunerError> tuneFm(
core::FrequencyKHz frequency) override;
core::FrequencyKHz frequency, std::uint8_t antCap = 0U) override;
/**
* @brief seekFm — seek FM with band wrap.
@@ -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<void, Si4684Error> 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<void, Si4684Error> Si4684Driver::installDefaultDabFrequencyPlan()
return sendCommand(cmd);
}
std::expected<void, Si4684Error> Si4684Driver::tuneDab(std::uint8_t freqIndex)
std::expected<void, Si4684Error> 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<void, Si4684Error> 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<std::uint8_t> 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<std::uint8_t> 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<Si4684DabService> 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);
}
@@ -209,12 +209,12 @@ std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
}
std::expected<void, core::TunerError> 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<void, core::TunerError> Si4684Tuner::tuneDab(
}
std::expected<void, core::TunerError> 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;
@@ -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
@@ -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);
@@ -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);
@@ -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 {};
}
+11 -5
View File
@@ -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<SoftApHost> softAp,
+82 -4
View File
@@ -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<void, core::TunerError> 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<char, 128> 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<void, NetError> 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<void, NetError> 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<void, NetError> 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,
@@ -73,6 +73,10 @@ public:
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @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<void, core::TunerError> tuneDab(
std::uint8_t freqIndex);
std::uint8_t freqIndex,
std::optional<std::uint8_t> 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<void, core::TunerError> tuneFm(
core::FrequencyKHz frequency);
core::FrequencyKHz frequency,
std::optional<std::uint8_t> 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<std::uint32_t> lastPlayedServiceId_;
std::optional<std::uint32_t> lastPlayedComponentId_;
};
@@ -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<core::TunerStatus, core::TunerError> TunerService::refreshStatus()
{
auto status = tuner_.readStatus();
@@ -155,9 +167,11 @@ std::expected<core::TunerStatus, core::TunerError> TunerService::refreshStatus()
}
std::expected<void, core::TunerError> TunerService::tuneDab(
std::uint8_t freqIndex)
std::uint8_t freqIndex, std::optional<std::uint8_t> 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<void, core::TunerError> TunerService::tuneDab(
}
std::expected<void, core::TunerError> TunerService::tuneFm(
core::FrequencyKHz frequency)
core::FrequencyKHz frequency, std::optional<std::uint8_t> antCap)
{
if (auto result = tuner_.tuneFm(frequency); !result) {
if (auto result = tuner_.tuneFm(frequency, antCap.value_or(defaultFmAntCap_));
!result) {
return result;
}
lastFmFrequency_ = frequency;
+50 -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,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).
---
+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}
+173 -13
View File
@@ -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.
+58 -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,62 @@ 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 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 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**: 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.
+1
View File
@@ -5,6 +5,7 @@ idf_component_register(
"web_radio_stream.cpp"
"esp32_i2s_sink.cpp"
"phone_stream.cpp"
"antenna_calibration.cpp"
"$<$<BOOL:${CONFIG_TEST_FIRMWARE}>:test_firmware.cpp>"
"$<$<BOOL:${CONFIG_I2S_SDATA_PROBE}>:i2s_sdata_probe.cpp>"
INCLUDE_DIRS "."
+26
View File
@@ -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
+28
View File
@@ -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
+78 -5
View File
@@ -80,7 +80,22 @@ bt1035::Bt1035Driver gBt1035(
});
core::DeviceIdentity gDeviceIdentity = core::DeviceIdentity::unknown();
std::optional<std::uint8_t> gFmAntCapCalibration;
std::optional<std::uint8_t> 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<i2c_master_bus_handle_t>(gAdau1701.i2cBusHandle());
return eeprom24aa::Eeprom24aa(
busHandle,
static_cast<std::uint8_t>(board::pins::Eeprom24aaAddr));
}
} // namespace
std::expected<void, HardwareBootError> HardwareBootstrap::boot()
@@ -102,11 +117,7 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
}
}
auto* busHandle =
static_cast<i2c_master_bus_handle_t>(gAdau1701.i2cBusHandle());
eeprom24aa::Eeprom24aa eeprom(busHandle,
static_cast<std::uint8_t>(
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<void, HardwareBootError> 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<unsigned>(*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<unsigned>(*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<std::uint8_t> 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<unsigned>(antCap));
return true;
}
std::optional<std::uint8_t> 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<unsigned>(antCap));
return true;
}
} // namespace hardware
+60
View File
@@ -17,7 +17,9 @@
#include "bt1035/Bt1035Driver.hpp"
#include <cstdint>
#include <expected>
#include <optional>
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<std::uint8_t>
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<std::uint8_t>
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
+10
View File
@@ -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) {
+10 -4
View File
@@ -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,
1 # DigiRadio partition table (16 MB flash — ESP32-S3-WROOM-1)
3 # dsp: updatable ADAU1701 program blob (task 3.1).
4 # nvs_keys: NVS encryption keys (secure-store slice).
5 # First flash after this layout: idf.py erase-flash flash (wired), not OTA.
6 # nvs: bumped 24 KiB -> 64 KiB (2026-08-19) — the small original size was a
7 # suspected contributor to intermittent NvsAudioProfileStore::saveProfile()
8 # store_failed under this project's accumulated write traffic (wifi creds,
9 # station_list, audio_profile_json, last_preset). otadata/nvs_keys/phy_init
10 # shift forward to make room; they still fit before ota_0's existing 64 KiB
11 # alignment boundary (0x20000), so ota_0/ota_1/dsp offsets are unchanged.
12 # Name, Type, SubType, Offset, Size, Flags
13 nvs, data, nvs, 0x9000, 0x6000, nvs, data, nvs, 0x9000, 0x10000,
14 otadata, data, ota, 0xf000, 0x2000, otadata, data, ota, 0x19000, 0x2000,
15 nvs_keys, data, nvs_keys, 0x11000, 0x1000, nvs_keys, data, nvs_keys, 0x1b000, 0x1000,
16 phy_init, data, phy, 0x12000, 0x1000, phy_init, data, phy, 0x1c000, 0x1000,
17 ota_0, app, ota_0, 0x20000, 0x400000,
18 ota_1, app, ota_1, 0x420000, 0x400000,
19 dsp, data, 0x40, 0x820000, 0x40000,