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 {};
}