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
@@ -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,