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
@@ -54,6 +54,25 @@ namespace {
return end != json.data() + valueStart;
}
[[nodiscard]] bool extractJsonBool(std::string_view json,
std::string_view key,
bool& out)
{
const std::string trueNeedle =
std::string("\"") + std::string(key) + "\":true";
const std::string falseNeedle =
std::string("\"") + std::string(key) + "\":false";
if (json.find(trueNeedle) != std::string_view::npos) {
out = true;
return true;
}
if (json.find(falseNeedle) != std::string_view::npos) {
out = false;
return true;
}
return false;
}
[[nodiscard]] std::expected<GainDb, ParseError> parseGainField(
std::string_view json, std::string_view key)
{
@@ -277,4 +296,17 @@ std::expected<EnhanceLevel, ParseError> parseEnhanceLevelJson(
return EnhanceLevel::tryFromLevel(level);
}
std::expected<bool, ParseError> parseBeepEnabledJson(std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
bool enabled = false;
if (!extractJsonBool(json, "enabled", enabled)) {
return std::unexpected(ParseError::MissingField);
}
return enabled;
}
} // namespace core
@@ -14,6 +14,7 @@
#include "core/BluetoothJson.hpp"
#include <cstdlib>
#include <cctype>
#include <sstream>
namespace core {
@@ -95,4 +96,112 @@ parseBluetoothAutoReconnectJson(std::string_view json)
return static_cast<std::uint8_t>(raw);
}
std::string serializeBluetoothScanJson(
const std::vector<Bt1035ScannedDevice>& devices)
{
std::ostringstream out;
out << "{\"devices\":[";
for (std::size_t i = 0; i < devices.size(); ++i) {
if (i > 0U) {
out << ',';
}
const Bt1035ScannedDevice& device = devices[i];
out << "{\"index\":" << static_cast<unsigned>(device.index)
<< ",\"mac\":";
appendJsonString(out, device.mac);
out << ",\"name\":";
appendJsonString(out, device.name);
out << ",\"rssi_dbm\":" << device.rssiDbm << "}";
}
out << "]}";
return out.str();
}
std::expected<std::string, ParseError>
parseBluetoothConnectJson(std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const std::string needle = "\"mac\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::size_t valueStart = start + needle.size();
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const std::string_view mac = json.substr(valueStart, valueEnd - valueStart);
if (!isValidBt1035Mac(mac)) {
return std::unexpected(ParseError::InvalidJson);
}
std::string normalized;
normalized.reserve(12U);
for (const char ch : mac) {
normalized.push_back(
static_cast<char>(std::toupper(static_cast<unsigned char>(ch))));
}
return normalized;
}
BluetoothConnectRequest parseBluetoothConnectRequest(std::string_view json)
{
BluetoothConnectRequest request{
.mac = {},
.name = {},
.save = false,
};
if (auto mac = parseBluetoothConnectJson(json); mac) {
request.mac = std::move(*mac);
}
const std::string nameNeedle = "\"name\":\"";
const std::size_t nameStart = json.find(nameNeedle);
if (nameStart != std::string_view::npos) {
const std::size_t valueStart = nameStart + nameNeedle.size();
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd != std::string_view::npos) {
request.name.assign(json.substr(valueStart, valueEnd - valueStart));
}
}
request.save = json.find("\"save\":true") != std::string_view::npos
|| json.find("\"save\": true") != std::string_view::npos;
return request;
}
std::string serializeBluetoothSpeakerJson(const BtSpeakerTarget* target)
{
if (target == nullptr) {
return "{\"configured\":false}";
}
std::ostringstream out;
out << "{\"configured\":true,\"mac\":";
appendJsonString(out, target->mac);
out << ",\"name\":";
appendJsonString(out, target->name);
out << '}';
return out.str();
}
std::expected<BtSpeakerTarget, ParseError>
parseBluetoothSpeakerJson(std::string_view json)
{
const auto mac = parseBluetoothConnectJson(json);
if (!mac) {
return std::unexpected(mac.error());
}
BtSpeakerTarget target{.mac = *mac, .name = {}};
const std::string nameNeedle = "\"name\":\"";
const std::size_t nameStart = json.find(nameNeedle);
if (nameStart != std::string_view::npos) {
const std::size_t valueStart = nameStart + nameNeedle.size();
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd != std::string_view::npos) {
target.name.assign(json.substr(valueStart, valueEnd - valueStart));
}
}
return target;
}
} // namespace core
+220 -3
View File
@@ -13,6 +13,7 @@
#include "core/Bt1035At.hpp"
#include <cctype>
#include <cstdlib>
#include <vector>
@@ -99,18 +100,22 @@ namespace {
std::string buildBt1035AtLine(Bt1035AtCommand command)
{
switch (command) {
case Bt1035AtCommand::Reset:
return "AT+RESET\r\n";
case Bt1035AtCommand::Ping:
return "AT\r\n";
case Bt1035AtCommand::I2sMode:
return "AT+AUXCFG=3\r\n";
case Bt1035AtCommand::I2sSlave48k32:
return "AT+I2SCFG=67\r\n";
case Bt1035AtCommand::I2sSlave48k24:
return "AT+I2SCFG=35\r\n";
case Bt1035AtCommand::PairDiscoverable:
return "AT+PAIR=1\r\n";
case Bt1035AtCommand::PairHidden:
return "AT+PAIR=0\r\n";
case Bt1035AtCommand::A2dpStat:
return "AT+A2DPSTAT\r\n";
case Bt1035AtCommand::A2dpEncoder:
return "AT+A2DPENC\r\n";
case Bt1035AtCommand::A2dpDisconnect:
return "AT+A2DPDISC\r\n";
case Bt1035AtCommand::QueryName:
@@ -145,7 +150,7 @@ std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noex
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
Bt1035AtCommand::Ping,
Bt1035AtCommand::I2sMode,
Bt1035AtCommand::I2sSlave48k32,
Bt1035AtCommand::I2sSlave48k24,
};
}
@@ -206,6 +211,43 @@ const char* a2dpStateToken(Bt1035A2dpState state) noexcept
return "unknown";
}
std::expected<Bt1035A2dpCodec, ParseError>
parseBt1035A2dpEncoderResponse(std::string_view response)
{
constexpr std::string_view kPrefix = "+A2DPENC=";
const std::size_t pos = response.find(kPrefix);
if (pos == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::size_t valueStart = pos + kPrefix.size();
char* end = nullptr;
const unsigned long raw =
std::strtoul(response.data() + valueStart, &end, 10);
if (end == response.data() + valueStart || raw < 1U || raw > 5U) {
return std::unexpected(ParseError::MissingField);
}
return static_cast<Bt1035A2dpCodec>(raw);
}
const char* a2dpCodecToken(Bt1035A2dpCodec codec) noexcept
{
switch (codec) {
case Bt1035A2dpCodec::Sbc:
return "sbc";
case Bt1035A2dpCodec::Aptx:
return "aptx";
case Bt1035A2dpCodec::AptxHd:
return "aptx-hd";
case Bt1035A2dpCodec::AptxLl:
return "aptx-ll";
case Bt1035A2dpCodec::AptxAdaptive:
return "aptx-adaptive";
}
return "unknown";
}
std::expected<std::string, ParseError>
parseBt1035NameResponse(std::string_view response)
{
@@ -286,4 +328,179 @@ parseBt1035PairedListResponse(std::string_view response)
return devices;
}
std::string buildBt1035StartScanLine(std::uint8_t scanType, std::uint8_t scanSeconds)
{
const std::uint8_t type = (scanType == 2U) ? 2U : 1U;
if (scanSeconds == 0U) {
return "AT+SCAN=" + std::to_string(type) + "\r\n";
}
return "AT+SCAN=" + std::to_string(type) + ","
+ std::to_string(scanSeconds) + "\r\n";
}
std::string buildBt1035StopScanLine()
{
return "AT+SCAN=0\r\n";
}
std::string buildBt1035EnablePrintLine()
{
return "AT+PRINT=1\r\n";
}
std::string buildBt1035DisconnectAllLine()
{
return "AT+DSCA\r\n";
}
std::string buildBt1035DisableAutoLinkLine()
{
return "AT+LINKCFG=0,0\r\n";
}
std::string buildBt1035ClearPairedListLine()
{
return "AT+PLIST=0\r\n";
}
bool isValidBt1035Mac(std::string_view mac) noexcept
{
if (mac.size() != 12U) {
return false;
}
for (const char ch : mac) {
if (!std::isxdigit(static_cast<unsigned char>(ch))) {
return false;
}
}
return true;
}
[[nodiscard]] std::string normalizeBt1035Mac(std::string_view mac)
{
std::string normalized;
normalized.reserve(12U);
for (const char ch : mac) {
if (ch == ':' || ch == '-') {
continue;
}
normalized.push_back(
static_cast<char>(std::toupper(static_cast<unsigned char>(ch))));
}
return normalized;
}
std::string buildBt1035A2dpConnectLine(std::string_view mac)
{
if (!isValidBt1035Mac(mac)) {
return {};
}
return std::string("AT+A2DPCONN=") + std::string(mac) + "\r\n";
}
std::string buildBt1035A2dpAudioLine(bool establish)
{
return establish ? "AT+A2DPAUDIO=1\r\n" : "AT+A2DPAUDIO=0\r\n";
}
std::expected<std::vector<Bt1035ScannedDevice>, ParseError>
parseBt1035ScanResponse(std::string_view response)
{
std::vector<Bt1035ScannedDevice> devices;
std::size_t pos = 0;
while ((pos = response.find("+SCAN", pos)) != std::string_view::npos) {
std::size_t start = pos + 5U;
if (start < response.size() && response[start] == '=') {
++start;
}
while (start < response.size()
&& (response[start] == ' ' || response[start] == '\t')) {
++start;
}
const std::size_t end = response.find_first_of("\r\n", start);
const std::string_view line =
end == std::string_view::npos ? response.substr(start)
: response.substr(start, end - start);
pos = end == std::string_view::npos ? response.size() : end + 1U;
if (line.empty() || line == "E" || line.front() == 'E') {
continue;
}
const std::string lineStr(line);
const std::size_t comma1 = lineStr.find(',');
const std::size_t comma2 =
comma1 == std::string_view::npos ? std::string_view::npos
: lineStr.find(',', comma1 + 1U);
const std::size_t comma3 =
comma2 == std::string_view::npos ? std::string_view::npos
: lineStr.find(',', comma2 + 1U);
const std::size_t comma4 =
comma3 == std::string_view::npos ? std::string_view::npos
: lineStr.find(',', comma3 + 1U);
if (comma4 == std::string_view::npos) {
continue;
}
char* endIdx = nullptr;
const unsigned long index =
std::strtoul(lineStr.c_str(), &endIdx, 10);
if (endIdx == lineStr.c_str() || index == 0U) {
continue;
}
const unsigned long addrType =
std::strtoul(lineStr.c_str() + comma1 + 1U, &endIdx, 10);
const std::string mac =
normalizeBt1035Mac(lineStr.substr(comma2 + 1U, comma3 - comma2 - 1U));
if (!isValidBt1035Mac(mac)) {
continue;
}
const long rssi =
std::strtol(lineStr.c_str() + comma3 + 1U, &endIdx, 10);
const unsigned long nameLen =
std::strtoul(lineStr.c_str() + comma4 + 1U, &endIdx, 10);
const std::size_t afterNameLen =
static_cast<std::size_t>(endIdx - lineStr.c_str());
std::string name;
std::string deviceClass;
if (nameLen > 0U) {
if (afterNameLen >= lineStr.size() || lineStr[afterNameLen] != ',') {
continue;
}
const std::size_t nameStart = afterNameLen + 1U;
if (nameStart + nameLen > lineStr.size()) {
continue;
}
name.assign(lineStr.substr(nameStart, nameLen));
const std::size_t afterName = nameStart + nameLen;
if (afterName < lineStr.size() && lineStr[afterName] == ',') {
deviceClass.assign(lineStr.substr(afterName + 1U));
}
} else if (afterNameLen < lineStr.size()
&& lineStr[afterNameLen] == ',') {
const std::size_t classStart = afterNameLen + 1U;
if (classStart < lineStr.size()) {
if (lineStr[classStart] == ',') {
deviceClass.assign(lineStr.substr(classStart + 1U));
} else {
deviceClass.assign(lineStr.substr(classStart));
}
}
}
Bt1035ScannedDevice entry = {};
entry.index = static_cast<std::uint8_t>(index);
entry.addressType = static_cast<std::uint8_t>(addrType);
entry.mac = mac;
entry.rssiDbm = static_cast<std::int16_t>(rssi);
entry.name = std::move(name);
entry.deviceClass = std::move(deviceClass);
devices.push_back(std::move(entry));
}
return devices;
}
} // namespace core
@@ -28,4 +28,18 @@ MixerState MixerState::factoryDefault() noexcept
};
}
MixerState MixerState::radioFirst() noexcept
{
const GainDb unity = GainDb::zero();
const GainDb muted = *GainDb::tryFromDb(GainDb::kMinDb);
return MixerState{
.si4684Left = unity,
.si4684Right = unity,
.esp32Left = muted,
.esp32Right = muted,
.mixLeft = unity,
.mixRight = muted,
};
}
} // namespace core
@@ -246,4 +246,74 @@ std::expected<SeekDirection, ParseError> parseTunerSeekJson(
return std::unexpected(ParseError::InvalidJson);
}
std::expected<TunerScanRequest, ParseError> parseTunerScanJson(
std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const std::string_view band = extractJsonString(json, "band");
if (band.empty()) {
return std::unexpected(ParseError::MissingField);
}
TunerScanRequest req = {};
unsigned long maxSteps = 0U;
if (band == "fm") {
req.band = TunerBand::Fm;
req.maxSteps = 45U;
} else if (band == "dab") {
req.band = TunerBand::Dab;
req.maxSteps = 38U;
} else {
return std::unexpected(ParseError::InvalidJson);
}
if (extractJsonUint(json, "max_steps", maxSteps)) {
if (maxSteps == 0U || maxSteps > 255U) {
return std::unexpected(ParseError::InvalidJson);
}
req.maxSteps = static_cast<std::uint8_t>(maxSteps);
}
const std::string_view name = extractJsonString(json, "name");
if (!name.empty()) {
req.nameFilter.assign(name.begin(), name.end());
for (char& ch : req.nameFilter) {
if (ch >= 'A' && ch <= 'Z') {
ch = static_cast<char>(ch - 'A' + 'a');
}
}
}
return req;
}
std::string serializeTunerScanJson(const TunerScanResult& result)
{
std::ostringstream out;
out << "{\"status\":\"" << (result.found ? "found" : "not_found") << '"'
<< ",\"band\":\"" << bandToken(result.band) << '"'
<< ",\"steps\":" << result.stepsTried;
if (result.fmFrequency) {
out << ",\"frequency_khz\":" << result.fmFrequency->value();
}
if (result.dabFreqIndex) {
out << ",\"freq_index\":" << static_cast<unsigned>(*result.dabFreqIndex);
}
if (result.dabServiceId) {
out << ",\"service_id\":" << *result.dabServiceId;
}
if (result.dabComponentId) {
out << ",\"component_id\":" << *result.dabComponentId;
}
if (result.stationName) {
out << ",\"station_name\":";
appendJsonString(out, result.stationName->value());
}
out << '}';
return out.str();
}
} // namespace core
@@ -0,0 +1,89 @@
/**
* @file WebRadioJson.cpp
* @brief WebRadioJson implementation.
*
* 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"
namespace core {
namespace {
constexpr std::size_t kMaxUrlLength = 200U;
constexpr std::string_view kHttpPrefix = "http://";
[[nodiscard]] std::string_view extractJsonString(std::string_view json,
std::string_view key)
{
const std::string needle =
std::string("\"") + std::string(key) + "\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
return {};
}
const std::size_t valueStart = start + needle.size();
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return {};
}
return json.substr(valueStart, valueEnd - valueStart);
}
[[nodiscard]] bool extractJsonBool(std::string_view json,
std::string_view key, bool& out)
{
const std::string trueNeedle =
std::string("\"") + std::string(key) + "\":true";
const std::string falseNeedle =
std::string("\"") + std::string(key) + "\":false";
if (json.find(trueNeedle) != std::string_view::npos) {
out = true;
return true;
}
if (json.find(falseNeedle) != std::string_view::npos) {
out = false;
return true;
}
return false;
}
} // namespace
std::expected<WebRadioConfig, ParseError> parseWebRadioConfigJson(
std::string_view json)
{
bool enabled = false;
if (!extractJsonBool(json, "enabled", enabled)) {
return std::unexpected(ParseError::MissingField);
}
const std::string_view url = extractJsonString(json, "url");
if (url.empty() || url.size() > kMaxUrlLength
|| url.substr(0, kHttpPrefix.size()) != kHttpPrefix) {
return std::unexpected(ParseError::InvalidJson);
}
return WebRadioConfig{.enabled = enabled, .url = std::string(url)};
}
std::string serializeWebRadioConfigJson(const WebRadioConfig& config)
{
return std::string(R"({"enabled":)") + (config.enabled ? "true" : "false")
+ R"(,"url":")" + config.url + "\"}";
}
std::string serializeWebRadioErrorJson(std::string_view reason)
{
return std::string(R"({"status":"error","reason":")")
+ std::string(reason) + "\"}";
}
} // namespace core
@@ -0,0 +1,73 @@
/**
* @file WifiScanJson.cpp
* @brief Wi-Fi scan JSON serialisation implementation.
*
* 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 <sstream>
namespace core {
namespace {
/**
* @brief appendJsonString — emit a JSON string literal.
*
* @dname appendJsonString
* @param out Output stream.
* @param text Raw UTF-8 text.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
void appendJsonString(std::ostringstream& out, std::string_view text)
{
out << '"';
for (const char ch : text) {
if (ch == '"' || ch == '\\') {
out << '\\';
}
out << ch;
}
out << '"';
}
} // namespace
std::string serializeWifiScanJson(
const std::vector<WifiScannedNetwork>& networks)
{
std::ostringstream out;
out << "{\"networks\":[";
for (std::size_t i = 0; i < networks.size(); ++i) {
if (i > 0U) {
out << ',';
}
const WifiScannedNetwork& network = networks[i];
out << "{\"ssid\":";
appendJsonString(out, network.ssid);
out << ",\"rssi_dbm\":" << network.rssiDbm << ",\"auth\":";
appendJsonString(out, network.auth);
out << ",\"channel\":" << static_cast<unsigned>(network.channel) << '}';
}
out << "]}";
return out.str();
}
std::string serializeWifiScanErrorJson(std::string_view reason)
{
return std::string("{\"status\":\"error\",\"reason\":\"") + std::string(reason)
+ "\"}";
}
} // namespace core