Firmware review pass: BT1035 boot retry, BluetoothJson tests, doc catch-up
BT1035Driver::boot() had zero retry on the reset+AT-init sequence — a
single hardware RESET# pulse followed immediately by AT commands,
with no second attempt if the module didn't come up in time. This is
the most plausible explanation for the intermittent "no spontaneous
UART bytes after hardware reset" / "AT init failed" boot failures
logged in docs/si4684-rf-investigation-report.md and observed again
live this morning on otherwise-identical hardware/wiring — classic
power-up timing jitter, not a permanent fault. Extracted the reset+
init sequence into resetAndInitOnce() and wrapped it in a 3-attempt
retry loop with a short delay between attempts; the one-time GPIO
config and UART driver install stay outside the loop since they don't
need repeating. Root cause of the underlying jitter is still open.
BluetoothJson.hpp was the only *Json.hpp module in the core with zero
host test coverage (status/scan/paired serialisation, auto-reconnect/
connect/speaker parsing) — every sibling module already has one.
Added bluetooth_json_test.cpp following the existing tuner_json_test
pattern; ctest now covers 20 suites instead of 19.
Documentation catch-up, found doing a full firmware re-review at the
user's request:
- POST /api/tuner/calibrate-antenna and the antcap field on
POST /api/tuner/tune (added in a previous commit, never documented)
are now in ch-api.tex.
- kFirmwareVersion was still hardcoded "0.8.5" despite the RF fixes,
BLE provisioning, phone streaming, antenna calibration, and generic
DSP param API landed since that version's actual release commit
(0a1188a). Bumped to 0.9.0 everywhere it's mentioned (health JSON,
the manual's title page, intro, classes, and API chapters).
- instructions.md and docs/TODO.md still described the firmware as
frozen at 0.8.5 awaiting hardware-in-the-loop testing that has since
happened extensively; docs/TODO.md's H5 verdict specifically still
said "suspect U6 RF ground (re-open PCBWay)" for a bug that turned
out to be firmware, not hardware — actively misleading, corrected.
Both files now summarise the post-0.8.5 HIL findings and current
open items (BT1035 root cause, intermittent HTTP unresponsiveness
under load, antenna-limited signal quality, possibly-undersized 24 KB
nvs partition).
Verified: idf.py build, doxygen (0 warnings), check-manual-sync,
check_si4684_blobs, ctest (20/20), two-pass xelatex manual build all
green. Flashed and confirmed live: fw reports 0.9.0, BT1035 booted on
the first attempt post-flash.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178rASQ6ZETPMUamvpoR2KR
This commit is contained in:
@@ -94,6 +94,10 @@ add_executable(dsp_param_json_test dsp_param_json_test.cpp)
|
||||
target_link_libraries(dsp_param_json_test PRIVATE digiradio_core)
|
||||
add_test(NAME dsp_param_json_test COMMAND dsp_param_json_test)
|
||||
|
||||
add_executable(bluetooth_json_test bluetooth_json_test.cpp)
|
||||
target_link_libraries(bluetooth_json_test PRIVATE digiradio_core)
|
||||
add_test(NAME bluetooth_json_test COMMAND bluetooth_json_test)
|
||||
|
||||
add_executable(frequency_khz_test frequency_khz_test.cpp)
|
||||
target_link_libraries(frequency_khz_test PRIVATE digiradio_core)
|
||||
add_test(NAME frequency_khz_test COMMAND frequency_khz_test)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file bluetooth_json_test.cpp
|
||||
* @brief Host tests for Bluetooth JSON parse/serialise.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-19
|
||||
*/
|
||||
|
||||
#include "core/BluetoothJson.hpp"
|
||||
#include "core/ParseError.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] bool expectEqual(const std::string& actual,
|
||||
const std::string& expected)
|
||||
{
|
||||
if (actual == expected) {
|
||||
return true;
|
||||
}
|
||||
std::cerr << "expected: " << expected << "\nactual: " << actual << '\n';
|
||||
return false;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runStatusSerialiseTest()
|
||||
{
|
||||
const core::BluetoothStatus status{
|
||||
.booted = true,
|
||||
.pairing = false,
|
||||
.a2dpState = core::Bt1035A2dpState::Streaming,
|
||||
.deviceName = "DigiRadio-CC4DB4",
|
||||
.autoReconnect = 3U,
|
||||
};
|
||||
const std::string json = core::serializeBluetoothStatusJson(status);
|
||||
if (!expectEqual(json,
|
||||
R"({"booted":true,"pairing":false,"a2dp":"streaming",)"
|
||||
R"("device_name":"DigiRadio-CC4DB4","auto_reconnect":3})")) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runScanSerialiseTest()
|
||||
{
|
||||
const std::vector<core::Bt1035ScannedDevice> devices{
|
||||
core::Bt1035ScannedDevice{
|
||||
.index = 1U,
|
||||
.addressType = 2U,
|
||||
.mac = "001122334455",
|
||||
.rssiDbm = -58,
|
||||
.name = "Bose SoundLink",
|
||||
.deviceClass = "240404",
|
||||
},
|
||||
};
|
||||
const std::string json = core::serializeBluetoothScanJson(devices);
|
||||
if (!expectEqual(json,
|
||||
R"({"devices":[{"index":1,"mac":"001122334455",)"
|
||||
R"("name":"Bose SoundLink","rssi_dbm":-58}]})")) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runPairedSerialiseTest()
|
||||
{
|
||||
const std::vector<core::Bt1035PairedDevice> devices{
|
||||
core::Bt1035PairedDevice{.index = 1U, .mac = "AABBCCDDEEFF", .name = "Phone"},
|
||||
};
|
||||
const std::string json = core::serializeBluetoothPairedJson(devices);
|
||||
if (!expectEqual(json,
|
||||
R"({"devices":[{"index":1,"mac":"AABBCCDDEEFF","name":"Phone"}]})")) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runAutoReconnectParseTest()
|
||||
{
|
||||
const auto ok = core::parseBluetoothAutoReconnectJson(R"({"times":5})");
|
||||
if (!ok || *ok != 5U) {
|
||||
std::cerr << "auto-reconnect valid parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto tooHigh = core::parseBluetoothAutoReconnectJson(R"({"times":16})");
|
||||
if (tooHigh) {
|
||||
std::cerr << "auto-reconnect out-of-range accepted\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto missing = core::parseBluetoothAutoReconnectJson(R"({})");
|
||||
if (missing || missing.error() != core::ParseError::MissingField) {
|
||||
std::cerr << "auto-reconnect missing field mis-reported\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runConnectJsonParseTest()
|
||||
{
|
||||
const auto ok =
|
||||
core::parseBluetoothConnectJson(R"({"mac":"001122334455"})");
|
||||
if (!ok || *ok != "001122334455") {
|
||||
std::cerr << "connect mac parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
// Normalises to uppercase.
|
||||
const auto lower =
|
||||
core::parseBluetoothConnectJson(R"({"mac":"aabbccddeeff"})");
|
||||
if (!lower || *lower != "AABBCCDDEEFF") {
|
||||
std::cerr << "connect mac uppercasing failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto invalid = core::parseBluetoothConnectJson(R"({"mac":"not-a-mac"})");
|
||||
if (invalid) {
|
||||
std::cerr << "connect invalid mac accepted\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runConnectRequestParseTest()
|
||||
{
|
||||
const auto request = core::parseBluetoothConnectRequest(
|
||||
R"({"mac":"001122334455","name":"Bose SoundLink","save":true})");
|
||||
if (request.mac != "001122334455" || request.name != "Bose SoundLink"
|
||||
|| !request.save) {
|
||||
std::cerr << "connect request full parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto minimal =
|
||||
core::parseBluetoothConnectRequest(R"({"mac":"001122334455"})");
|
||||
if (minimal.mac != "001122334455" || !minimal.name.empty()
|
||||
|| minimal.save) {
|
||||
std::cerr << "connect request minimal parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto badMac = core::parseBluetoothConnectRequest(R"({})");
|
||||
if (!badMac.mac.empty()) {
|
||||
std::cerr << "connect request missing mac should stay empty\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runSpeakerRoundTripTest()
|
||||
{
|
||||
const core::BtSpeakerTarget target{.mac = "001122334455",
|
||||
.name = "Bose SoundLink"};
|
||||
const std::string json = core::serializeBluetoothSpeakerJson(&target);
|
||||
if (!expectEqual(json,
|
||||
R"({"configured":true,"mac":"001122334455",)"
|
||||
R"("name":"Bose SoundLink"})")) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::string unset = core::serializeBluetoothSpeakerJson(nullptr);
|
||||
if (!expectEqual(unset, R"({"configured":false})")) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
const auto parsed = core::parseBluetoothSpeakerJson(
|
||||
R"({"mac":"001122334455","name":"Bose SoundLink"})");
|
||||
if (!parsed || parsed->mac != "001122334455"
|
||||
|| parsed->name != "Bose SoundLink") {
|
||||
std::cerr << "speaker parse round-trip failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto invalid = core::parseBluetoothSpeakerJson(R"({"mac":"bad"})");
|
||||
if (invalid) {
|
||||
std::cerr << "speaker parse invalid mac accepted\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runErrorSerialiseTest()
|
||||
{
|
||||
const std::string json = core::serializeBluetoothErrorJson("scan_failed");
|
||||
if (!expectEqual(json, R"({"status":"error","reason":"scan_failed"})")) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
if (runStatusSerialiseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runScanSerialiseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runPairedSerialiseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runAutoReconnectParseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runConnectJsonParseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runConnectRequestParseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runSpeakerRoundTripTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runErrorSerialiseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -343,6 +343,7 @@ public:
|
||||
private:
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> ensureBooted() const;
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> runInitSequence();
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> resetAndInitOnce();
|
||||
[[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollect(
|
||||
std::string_view commandLine, int timeoutMs = kResponseTimeoutMs);
|
||||
[[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollectUntil(
|
||||
|
||||
@@ -38,6 +38,13 @@ constexpr int kUartTxBuffer = 256;
|
||||
constexpr int kResponseTimeoutMs = 2000;
|
||||
constexpr int kPostResetMs = 500;
|
||||
constexpr int kPostUartMs = 100;
|
||||
/** Observed intermittently: the module sometimes needs a second RESET#
|
||||
* pulse to come up (power-up timing jitter between cold/warm boots) —
|
||||
* a single attempt with no retry was found to explain sporadic total
|
||||
* boot failures ("no spontaneous UART bytes" -> "AT init failed") on
|
||||
* otherwise-identical hardware/wiring. */
|
||||
constexpr int kBootAttempts = 3;
|
||||
constexpr int kBootRetryDelayMs = 300;
|
||||
|
||||
void flushUartRx(int uartPort) noexcept
|
||||
{
|
||||
@@ -904,6 +911,21 @@ std::expected<void, Bt1035Error> Bt1035Driver::runInitSequence()
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::resetAndInitOnce()
|
||||
{
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.sysCtlGpio), 1);
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(kPostResetMs));
|
||||
|
||||
logRawUartBoot(uartPort_);
|
||||
uart_flush_input(static_cast<uart_port_t>(uartPort_));
|
||||
vTaskDelay(pdMS_TO_TICKS(kPostUartMs));
|
||||
|
||||
return runInitSequence();
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
||||
{
|
||||
if (booted_) {
|
||||
@@ -924,12 +946,6 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
||||
return std::unexpected(Bt1035Error::ResetFailed);
|
||||
}
|
||||
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.sysCtlGpio), 1);
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(100));
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(kPostResetMs));
|
||||
|
||||
if (!uartInstalled_) {
|
||||
const uart_config_t uartCfg = {
|
||||
.baud_rate = kBaudRate,
|
||||
@@ -961,12 +977,19 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
||||
uartInstalled_ = true;
|
||||
}
|
||||
|
||||
logRawUartBoot(uartPort_);
|
||||
uart_flush_input(static_cast<uart_port_t>(uartPort_));
|
||||
vTaskDelay(pdMS_TO_TICKS(kPostUartMs));
|
||||
|
||||
if (auto init = runInitSequence(); !init) {
|
||||
ESP_LOGE(kTag, "AT init failed");
|
||||
std::expected<void, Bt1035Error> init = std::unexpected(Bt1035Error::UnexpectedResponse);
|
||||
for (int attempt = 1; attempt <= kBootAttempts; ++attempt) {
|
||||
init = resetAndInitOnce();
|
||||
if (init) {
|
||||
break;
|
||||
}
|
||||
ESP_LOGW(kTag, "boot attempt %d/%d failed", attempt, kBootAttempts);
|
||||
if (attempt < kBootAttempts) {
|
||||
vTaskDelay(pdMS_TO_TICKS(kBootRetryDelayMs));
|
||||
}
|
||||
}
|
||||
if (!init) {
|
||||
ESP_LOGE(kTag, "AT init failed after %d attempts", kBootAttempts);
|
||||
return init;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ namespace net {
|
||||
|
||||
namespace {
|
||||
constexpr char kTag[] = "SetupWebServer";
|
||||
constexpr char kFirmwareVersion[] = "0.8.5";
|
||||
constexpr char kFirmwareVersion[] = "0.9.0";
|
||||
constexpr unsigned kRebootDelaySec = 3;
|
||||
|
||||
extern const uint8_t index_html_gz_start[] asm(
|
||||
|
||||
Reference in New Issue
Block a user