Add internet radio streaming with runtime API, modernize web UI, remove auto-tune/beep at boot

Streaming (main feature this session):
- New WebRadioConfig/WebRadioJson core types, ISecureStore-backed persistence
- New webradio::WebRadioService (thread-safe live config) + GET/POST /api/streaming
- web_radio_stream task now runtime-toggleable (no reboot), no hardcoded URL
- Content-Type diagnostic: warns clearly when a URL is a webpage, not an audio stream

Boot cleanup:
- Removed boot-time auto FM/DAB tune, auto-beep, and the (now-concluded) Si4684
  crystal IBIAS/CTUN empirical sweep from main.cpp — tuning/beep are on-demand
  via the existing REST API only

Web UI:
- Modernized styling (cards, gradients, toggle switches, light/dark theme)
- New Stream tab wired to /api/streaming

Fixes found via real idf.py build (not just clangd):
- Restored wrongly-removed si4684/Si4684Tuner.hpp include in main.cpp
- Fixed MP3Decode() argument types in web_radio_stream.cpp (unsigned char**/int*)

Quality-gate fixes:
- Host-test stub headers (esp_log.h, freertos/*) so TunerService.cpp's
  scanForStation logging/pacing compiles for station_service_test /
  integration_service_test instead of running stale binaries
- Added WifiScanner and WebRadioService manual sections; filled in missing
  Doxygen docs on BluetoothService, i2s_sdata_probe, test_firmware, Bt1035At
- Ignore clangd's .cache/ index directory

Also includes prior uncommitted work carried in the tree: Wi-Fi/Bluetooth
device scan REST API and UI (WifiScanner, BT scan), SigmaStudio TCP bridge,
and the current ADAU1701 SigmaStudio DSP program export.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 21:15:22 +02:00
co-authored by Claude Sonnet 5
parent 8a8523515d
commit 6f7b6dd12c
131 changed files with 20309 additions and 1702 deletions
@@ -21,6 +21,7 @@ add_library(digiradio_core STATIC
"${CORE_SRC_DIR}/WifiSsid.cpp"
"${CORE_SRC_DIR}/WifiCredentials.cpp"
"${CORE_SRC_DIR}/WifiProvisionJson.cpp"
"${CORE_SRC_DIR}/WifiScanJson.cpp"
"${CORE_SRC_DIR}/EmbeddedBlobReader.cpp"
"${CORE_SRC_DIR}/TunerJson.cpp"
"${CORE_SRC_DIR}/FrequencyKHz.cpp"
@@ -50,6 +51,7 @@ add_library(digiradio_core STATIC
"${CORE_SRC_DIR}/RegisterWrite.cpp"
"${CORE_SRC_DIR}/DspProgramBlob.cpp"
"${CORE_SRC_DIR}/OtaAppDescriptor.cpp"
"${CORE_SRC_DIR}/WebRadioJson.cpp"
)
target_include_directories(digiradio_core PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/../include"
@@ -75,6 +77,10 @@ add_executable(wifi_provision_test wifi_provision_test.cpp)
target_link_libraries(wifi_provision_test PRIVATE digiradio_core)
add_test(NAME wifi_provision_test COMMAND wifi_provision_test)
add_executable(wifi_scan_test wifi_scan_test.cpp)
target_link_libraries(wifi_scan_test PRIVATE digiradio_core)
add_test(NAME wifi_scan_test COMMAND wifi_scan_test)
add_executable(embedded_blob_reader_test embedded_blob_reader_test.cpp)
target_link_libraries(embedded_blob_reader_test PRIVATE digiradio_core)
add_test(NAME embedded_blob_reader_test COMMAND embedded_blob_reader_test)
@@ -101,6 +107,7 @@ add_test(NAME enhancements_design_test COMMAND enhancements_design_test)
add_executable(station_service_test station_service_test.cpp)
target_include_directories(station_service_test PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/host_stubs"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/station/include"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/tuner/include"
)
@@ -123,8 +130,13 @@ add_executable(broadcast_metadata_test broadcast_metadata_test.cpp)
target_link_libraries(broadcast_metadata_test PRIVATE digiradio_core)
add_test(NAME broadcast_metadata_test COMMAND broadcast_metadata_test)
add_executable(web_radio_json_test web_radio_json_test.cpp)
target_link_libraries(web_radio_json_test PRIVATE digiradio_core)
add_test(NAME web_radio_json_test COMMAND web_radio_json_test)
add_executable(integration_service_test integration_service_test.cpp)
target_include_directories(integration_service_test PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/host_stubs"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/integration/include"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/station/include"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/tuner/include"
@@ -72,6 +72,31 @@ namespace {
return EXIT_SUCCESS;
}
[[nodiscard]] int runBeepEnabledJsonTest()
{
const auto onParsed = core::parseBeepEnabledJson(R"({"enabled":true})");
if (!onParsed || *onParsed != true) {
std::cerr << "beep enabled=true parse mismatch\n";
return EXIT_FAILURE;
}
const auto offParsed = core::parseBeepEnabledJson(R"({"enabled":false})");
if (!offParsed || *offParsed != false) {
std::cerr << "beep enabled=false parse mismatch\n";
return EXIT_FAILURE;
}
const auto missing = core::parseBeepEnabledJson(R"({})");
if (missing) {
std::cerr << "beep missing field should fail\n";
return EXIT_FAILURE;
}
const auto malformed = core::parseBeepEnabledJson("not json");
if (malformed) {
std::cerr << "beep malformed body should fail\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
@@ -79,5 +104,8 @@ int main()
if (runRoundTripTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runBeepEnabledJsonTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
@@ -13,6 +13,7 @@
#include "core/Bt1035At.hpp"
#include "core/Bt1035PairedDevice.hpp"
#include "core/Bt1035ScannedDevice.hpp"
#include "core/BluetoothJson.hpp"
#include <cstdlib>
@@ -32,7 +33,7 @@ namespace {
std::cerr << "I2S mode must be in init sequence\n";
return EXIT_FAILURE;
}
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k32) {
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k24) {
std::cerr << "I2SCFG must be in init sequence\n";
return EXIT_FAILURE;
}
@@ -43,9 +44,15 @@ namespace {
return EXIT_FAILURE;
}
const std::string i2sCfg =
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k32);
if (i2sCfg != "AT+I2SCFG=67\r\n") {
std::cerr << "I2SCFG=67 command line mismatch\n";
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k24);
if (i2sCfg != "AT+I2SCFG=35\r\n") {
std::cerr << "I2SCFG=35 command line mismatch\n";
return EXIT_FAILURE;
}
const std::string reset =
core::buildBt1035AtLine(core::Bt1035AtCommand::Reset);
if (reset != "AT+RESET\r\n") {
std::cerr << "AT+RESET command line mismatch\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
@@ -136,6 +143,115 @@ namespace {
return EXIT_SUCCESS;
}
[[nodiscard]] int runScanConnectTest()
{
if (core::buildBt1035StartScanLine(1U, 20U) != "AT+SCAN=1,20\r\n") {
std::cerr << "SCAN start line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035StartScanLine(1U, 0U) != "AT+SCAN=1\r\n") {
std::cerr << "SCAN default line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035StartScanLine(2U, 15U) != "AT+SCAN=2,15\r\n") {
std::cerr << "BLE SCAN line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035EnablePrintLine() != "AT+PRINT=1\r\n") {
std::cerr << "PRINT line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035DisableAutoLinkLine() != "AT+LINKCFG=0,0\r\n") {
std::cerr << "LINKCFG line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035ClearPairedListLine() != "AT+PLIST=0\r\n") {
std::cerr << "PLIST clear line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035DisconnectAllLine() != "AT+DSCA\r\n") {
std::cerr << "DSCA line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035StopScanLine() != "AT+SCAN=0\r\n") {
std::cerr << "SCAN stop line mismatch\n";
return EXIT_FAILURE;
}
if (!core::isValidBt1035Mac("AABBCCDDEEFF")
|| core::isValidBt1035Mac("bad")) {
std::cerr << "MAC validation failed\n";
return EXIT_FAILURE;
}
if (core::buildBt1035A2dpConnectLine("aabbccddeeff")
!= "AT+A2DPCONN=aabbccddeeff\r\n") {
std::cerr << "A2DPCONN line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035A2dpAudioLine(true) != "AT+A2DPAUDIO=1\r\n") {
std::cerr << "A2DPAUDIO=1 line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035A2dpAudioLine(false) != "AT+A2DPAUDIO=0\r\n") {
std::cerr << "A2DPAUDIO=0 line mismatch\n";
return EXIT_FAILURE;
}
const auto scanned = core::parseBt1035ScanResponse(
"+SCAN=1,0,112233445566,-55,7,MyPhone,240404\r\n"
"+SCAN=2,0,FFEEDDCCBBAA,-70,0,,240404\r\n"
"+SCAN=E\r\n"
"OK\r\n");
if (!scanned || scanned->size() != 2U || (*scanned)[0U].mac != "112233445566"
|| (*scanned)[0U].name != "MyPhone" || (*scanned)[0U].rssiDbm != -55
|| !(*scanned)[1U].name.empty()) {
std::cerr << "SCAN parse failed\n";
return EXIT_FAILURE;
}
const auto colonMac = core::parseBt1035ScanResponse(
"+SCAN=1,2,AA:BB:CC:DD:EE:FF,-60,4,Test,240404\r\n+SCAN=E\r\n");
if (!colonMac || colonMac->size() != 1U
|| (*colonMac)[0U].mac != "AABBCCDDEEFF") {
std::cerr << "SCAN colon MAC parse failed\n";
return EXIT_FAILURE;
}
const auto userLogScan = core::parseBt1035ScanResponse(
"+DEVSTAT=9\r\n\r\nOK\r\n\r\n"
"+SCAN=1,2,6960BB7EC5AA,-71,8,HY300PRO,1A0114\r\n\r\n"
"+SCAN=2,2,BC87FAE69D6E,-49,0\r\n\r\n"
"+DEVSTAT=1\r\n");
if (!userLogScan || userLogScan->size() != 2U
|| (*userLogScan)[0U].name != "HY300PRO"
|| (*userLogScan)[0U].mac != "6960BB7EC5AA"
|| (*userLogScan)[1U].mac != "BC87FAE69D6E"
|| !(*userLogScan)[1U].name.empty()
|| (*userLogScan)[1U].rssiDbm != -49) {
std::cerr << "SCAN user-log payload parse failed\n";
return EXIT_FAILURE;
}
const auto bareEmptyName = core::parseBt1035ScanResponse(
"+SCAN=2,2,BC87FAE69D6E,-49,0\r\n");
if (!bareEmptyName || bareEmptyName->size() != 1U
|| (*bareEmptyName)[0U].mac != "BC87FAE69D6E"
|| !(*bareEmptyName)[0U].name.empty()
|| !(*bareEmptyName)[0U].deviceClass.empty()) {
std::cerr << "SCAN bare empty-name line parse failed\n";
return EXIT_FAILURE;
}
const std::string scanJson =
core::serializeBluetoothScanJson(*scanned);
if (scanJson.find("\"mac\":\"112233445566\"") == std::string::npos
|| scanJson.find("\"rssi_dbm\":-55") == std::string::npos) {
std::cerr << "scan JSON serialise failed: " << scanJson << '\n';
return EXIT_FAILURE;
}
const auto mac =
core::parseBluetoothConnectJson(R"({"mac":"112233445566"})");
if (!mac || *mac != "112233445566") {
std::cerr << "connect JSON parse failed\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
@@ -149,5 +265,8 @@ int main()
if (runNameAutoConnPairedParseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runScanConnectTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
@@ -0,0 +1,21 @@
/**
* @file esp_log.h
* @brief Host-test stand-in for ESP-IDF logging macros.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Not shipped to device firmware: idf.py builds resolve the real ESP-IDF
* esp_log.h first. Only used so services/tuner/src/TunerService.cpp — which
* logs scan progress on real hardware — can also compile against
* components/core/test host tests without pulling in ESP-IDF.
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#define ESP_LOGE(tag, fmt, ...) ((void)0)
#define ESP_LOGW(tag, fmt, ...) ((void)0)
#define ESP_LOGI(tag, fmt, ...) ((void)0)
#define ESP_LOGD(tag, fmt, ...) ((void)0)
#define ESP_LOGV(tag, fmt, ...) ((void)0)
@@ -0,0 +1,19 @@
/**
* @file FreeRTOS.h
* @brief Host-test stand-in for the FreeRTOS core header.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Not shipped to device firmware: idf.py builds resolve the real FreeRTOS
* headers first. Only used so services/tuner/src/TunerService.cpp — which
* paces scan steps with vTaskDelay on real hardware — can also compile
* against components/core/test host tests without pulling in FreeRTOS.
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
using TickType_t = unsigned int;
#define pdMS_TO_TICKS(ms) (static_cast<TickType_t>(ms))
@@ -0,0 +1,18 @@
/**
* @file task.h
* @brief Host-test stand-in for the FreeRTOS task API.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Not shipped to device firmware: idf.py builds resolve the real FreeRTOS
* headers first. vTaskDelay is a no-op here — host tests assert scan logic
* against a FakeTuner, not real hardware settle timing.
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "freertos/FreeRTOS.h"
inline void vTaskDelay(TickType_t) {}
@@ -15,6 +15,7 @@
#include "core/FrequencyKHz.hpp"
#include "core/IDsp.hpp"
#include "core/ISecureStore.hpp"
#include "core/BtSpeakerTarget.hpp"
#include "core/StationName.hpp"
#include "core/TunerBand.hpp"
#include "integration/IntegrationService.hpp"
@@ -68,6 +69,12 @@ public:
{
return {};
}
[[nodiscard]] std::expected<void, core::DspError> setBeepEnabled(
bool) override
{
return {};
}
};
class TrackingTuner final : public core::ITuner {
@@ -217,9 +224,60 @@ public:
return {};
}
[[nodiscard]] bool hasBtSpeakerTarget() const override
{
return btSpeaker_.has_value();
}
[[nodiscard]] std::expected<void, core::StoreError> saveBtSpeakerTarget(
const core::BtSpeakerTarget& target) override
{
btSpeaker_ = target;
return {};
}
[[nodiscard]] std::expected<core::BtSpeakerTarget, core::StoreError>
loadBtSpeakerTarget() const override
{
if (!btSpeaker_) {
return std::unexpected(core::StoreError::NotFound);
}
return *btSpeaker_;
}
[[nodiscard]] std::expected<void, core::StoreError> clearBtSpeakerTarget()
override
{
btSpeaker_.reset();
return {};
}
[[nodiscard]] bool hasWebRadioConfig() const override
{
return webRadioJson_.has_value();
}
[[nodiscard]] std::expected<void, core::StoreError>
saveWebRadioConfigJson(std::string_view json) override
{
webRadioJson_ = std::string(json);
return {};
}
[[nodiscard]] std::expected<std::string, core::StoreError>
loadWebRadioConfigJson() const override
{
if (!webRadioJson_) {
return std::unexpected(core::StoreError::NotFound);
}
return *webRadioJson_;
}
private:
std::optional<std::string> stationJson_;
std::optional<std::uint8_t> lastPresetIndex_;
std::optional<core::BtSpeakerTarget> btSpeaker_;
std::optional<std::string> webRadioJson_;
};
[[nodiscard]] core::Station makeFmStation(const char* name, std::uint32_t khz)
@@ -171,9 +171,54 @@ public:
return {};
}
[[nodiscard]] bool hasBtSpeakerTarget() const override
{
return false;
}
[[nodiscard]] std::expected<void, core::StoreError> saveBtSpeakerTarget(
const core::BtSpeakerTarget&) override
{
return {};
}
[[nodiscard]] std::expected<core::BtSpeakerTarget, core::StoreError>
loadBtSpeakerTarget() const override
{
return std::unexpected(core::StoreError::NotFound);
}
[[nodiscard]] std::expected<void, core::StoreError> clearBtSpeakerTarget()
override
{
return {};
}
[[nodiscard]] bool hasWebRadioConfig() const override
{
return webRadioJson_.has_value();
}
[[nodiscard]] std::expected<void, core::StoreError>
saveWebRadioConfigJson(std::string_view json) override
{
webRadioJson_ = std::string(json);
return {};
}
[[nodiscard]] std::expected<std::string, core::StoreError>
loadWebRadioConfigJson() const override
{
if (!webRadioJson_) {
return std::unexpected(core::StoreError::NotFound);
}
return *webRadioJson_;
}
private:
std::optional<std::string> stationJson_;
std::optional<std::uint8_t> lastPresetIndex_;
std::optional<std::string> webRadioJson_;
};
[[nodiscard]] core::Station makeFmStation(const char* name, std::uint32_t khz)
@@ -0,0 +1,73 @@
/**
* @file web_radio_json_test.cpp
* @brief Host tests for WebRadioConfig JSON round-trip.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-08
*/
#include "core/WebRadioJson.hpp"
#include <cstdlib>
#include <iostream>
namespace {
[[nodiscard]] int runRoundTripTest()
{
const core::WebRadioConfig config{
.enabled = true, .url = "http://edge.radiomontecarlo.net/RMC.mp3"};
const std::string json = core::serializeWebRadioConfigJson(config);
const auto parsed = core::parseWebRadioConfigJson(json);
if (!parsed || parsed->enabled != true || parsed->url != config.url) {
std::cerr << "round-trip mismatch\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runValidationTest()
{
const auto missingUrl =
core::parseWebRadioConfigJson(R"({"enabled":false})");
if (missingUrl) {
std::cerr << "missing url should fail\n";
return EXIT_FAILURE;
}
const auto badScheme = core::parseWebRadioConfigJson(
R"({"enabled":true,"url":"https://example.com/x.mp3"})");
if (badScheme) {
std::cerr << "non-http scheme should fail\n";
return EXIT_FAILURE;
}
const auto malformed = core::parseWebRadioConfigJson("not json");
if (malformed) {
std::cerr << "malformed body should fail\n";
return EXIT_FAILURE;
}
const auto ok = core::parseWebRadioConfigJson(
R"({"enabled":false,"url":"http://example.com/x.mp3"})");
if (!ok || ok->enabled != false) {
std::cerr << "valid disabled config should parse\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
{
if (runRoundTripTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runValidationTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
@@ -0,0 +1,121 @@
/**
* @file wifi_scan_test.cpp
* @brief Host tests for Wi-Fi scan JSON serialisation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#include "core/WifiScanJson.hpp"
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
namespace {
/**
* @brief expectEqual — assert two strings match.
*
* @dname expectEqual
* @param actual Observed value.
* @param expected Expected value.
* @return true when equal.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[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;
}
/**
* @brief runWifiScanSerialiseTest — verify scan list JSON shape.
*
* @dname runWifiScanSerialiseTest
* @return EXIT_SUCCESS or EXIT_FAILURE.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] int runWifiScanSerialiseTest()
{
const std::vector<core::WifiScannedNetwork> networks = {
{
.ssid = "HomeNet",
.rssiDbm = -42,
.auth = "wpa2",
.channel = 6,
},
{
.ssid = "Guest\"WiFi",
.rssiDbm = -71,
.auth = "open",
.channel = 11,
},
};
const std::string json = core::serializeWifiScanJson(networks);
const std::string expected =
R"({"networks":[{"ssid":"HomeNet","rssi_dbm":-42,"auth":"wpa2","channel":6},{"ssid":"Guest\"WiFi","rssi_dbm":-71,"auth":"open","channel":11}]})";
if (!expectEqual(json, expected)) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
/**
* @brief runWifiScanErrorSerialiseTest — verify error JSON.
*
* @dname runWifiScanErrorSerialiseTest
* @return EXIT_SUCCESS or EXIT_FAILURE.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] int runWifiScanErrorSerialiseTest()
{
if (!expectEqual(core::serializeWifiScanErrorJson("scan_failed"),
R"({"status":"error","reason":"scan_failed"})")) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
/**
* @brief main — host test entry point.
*
* @dname main
* @return EXIT_SUCCESS when all tests pass.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
int main()
{
if (runWifiScanSerialiseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runWifiScanErrorSerialiseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}