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
@@ -13,15 +13,124 @@
#include "tuner/TunerService.hpp"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <algorithm>
#include <cctype>
#include <string>
namespace tuner {
namespace {
constexpr char kTag[] = "TunerSvc";
constexpr int kDabTuneSettleMs = 600;
/** FM scan step size (100 kHz spacing). */
constexpr std::uint32_t kFmScanStepKhz = 100U;
constexpr int kFmTuneSettleMs = 120;
constexpr int kFmNamePollMs = 250;
constexpr int kFmNamePollAttempts = 6;
constexpr std::int8_t kMinFmScanRssiDbuV = 5;
constexpr std::int8_t kMinFmScanSnrDb = 10;
/** Max |READFREQ commanded| for scan hit without RDS lock (kHz). */
constexpr std::uint32_t kFmScanChipFreqSlackKhz = 150U;
constexpr std::uint8_t kMinDabFicQuality = 35U;
/** European FM band top used for scan/seek wrap (107.9 MHz). */
constexpr std::uint32_t kFmScanMaxKhz = 107900U;
/** European FM band bottom used for full-band scan (87.5 MHz). */
constexpr std::uint32_t kFmScanMinKhz = 87500U;
[[nodiscard]] core::FrequencyKHz fmScanStartFrequency(
core::FrequencyKHz current,
std::string_view nameFilter)
{
if (!nameFilter.empty()) {
return current;
}
const std::uint32_t khz = current.value();
if (khz > kFmScanMaxKhz || khz < kFmScanMinKhz) {
return *core::FrequencyKHz::tryFromKhz(kFmScanMinKhz);
}
return current;
}
[[nodiscard]] core::FrequencyKHz defaultFmFrequency()
{
return *core::FrequencyKHz::tryFromKhz(101500U);
}
[[nodiscard]] std::string toLowerAscii(std::string_view text)
{
std::string lowered;
lowered.reserve(text.size());
for (const char ch : text) {
lowered.push_back(static_cast<char>(std::tolower(
static_cast<unsigned char>(ch))));
}
return lowered;
}
[[nodiscard]] bool nameMatchesFilter(const std::optional<core::BroadcastLabel>& label,
std::string_view filterLower)
{
if (filterLower.empty()) {
return true;
}
if (!label) {
return false;
}
const std::string haystack = toLowerAscii(label->value());
return haystack.find(filterLower) != std::string::npos;
}
[[nodiscard]] bool nameMatchesFilter(std::string_view label,
std::string_view filterLower)
{
if (filterLower.empty()) {
return true;
}
const std::string haystack = toLowerAscii(label);
return haystack.find(filterLower) != std::string::npos;
}
[[nodiscard]] bool fmStatusUsableForScan(const core::TunerStatus& status,
std::uint32_t commandedKhz,
bool requireLocked)
{
if (!status.fmRssiDbuV || *status.fmRssiDbuV < kMinFmScanRssiDbuV) {
return false;
}
if (requireLocked) {
return status.locked;
}
if (!status.fmSnrDb || *status.fmSnrDb < kMinFmScanSnrDb) {
return false;
}
if (status.fmChipReadFrequency) {
const std::uint32_t chipKhz = status.fmChipReadFrequency->value();
const std::uint32_t diff =
chipKhz > commandedKhz ? chipKhz - commandedKhz
: commandedKhz - chipKhz;
if (diff > kFmScanChipFreqSlackKhz) {
return false;
}
}
return true;
}
[[nodiscard]] core::TunerScanResult makeScanResult(
const core::TunerScanRequest& request,
std::uint16_t steps,
bool found)
{
core::TunerScanResult result = {};
result.found = found;
result.band = request.band;
result.stepsTried = steps;
return result;
}
} // namespace
TunerService::TunerService(core::ITuner& tuner)
@@ -77,6 +186,160 @@ std::expected<core::FrequencyKHz, core::TunerError> TunerService::seekFm(
return result;
}
std::expected<core::TunerScanResult, core::TunerError>
TunerService::scanForStation(const core::TunerScanRequest& request)
{
const std::string_view nameFilter = request.nameFilter;
if (request.band == core::TunerBand::Fm) {
const core::FrequencyKHz scanStart =
fmScanStartFrequency(lastFmFrequency_, nameFilter);
if (auto tuned = tuneFm(scanStart); !tuned) {
return std::unexpected(tuned.error());
}
const std::uint32_t startFreq = lastFmFrequency_.value();
const bool requireLocked = !nameFilter.empty();
std::uint32_t freqKhz = startFreq;
ESP_LOGI(kTag, "FM scan start from %u kHz (max %u steps, name='%.*s')",
static_cast<unsigned>(startFreq), static_cast<unsigned>(request.maxSteps),
static_cast<int>(nameFilter.size()), nameFilter.data());
for (std::uint16_t step = 1U; step <= request.maxSteps; ++step) {
freqKhz += kFmScanStepKhz;
if (freqKhz > kFmScanMaxKhz) {
freqKhz = kFmScanMinKhz;
}
if (freqKhz == startFreq) {
ESP_LOGI(kTag, "FM scan wrapped to start frequency");
break;
}
const auto next = core::FrequencyKHz::tryFromKhz(freqKhz);
if (!next) {
return std::unexpected(core::TunerError::TuneFailed);
}
if (auto tuned = tuneFm(*next); !tuned) {
ESP_LOGW(kTag, "FM scan step %u tune failed at %u kHz (err=%d)",
static_cast<unsigned>(step), static_cast<unsigned>(freqKhz),
static_cast<int>(tuned.error()));
return std::unexpected(tuned.error());
}
vTaskDelay(pdMS_TO_TICKS(kFmTuneSettleMs));
std::optional<core::BroadcastLabel> stationName;
bool usable = false;
for (int attempt = 0; attempt < kFmNamePollAttempts; ++attempt) {
auto status = refreshStatus();
if (!status) {
return std::unexpected(status.error());
}
if (attempt == 0) {
ESP_LOGI(kTag,
"FM scan step %u at %u kHz: valid=%d rssi=%d dBuV "
"snr=%d dB",
static_cast<unsigned>(step),
static_cast<unsigned>(lastFmFrequency_.value()),
static_cast<int>(status->locked),
status->fmRssiDbuV ? *status->fmRssiDbuV : -128,
status->fmSnrDb ? *status->fmSnrDb : -128);
}
if (!fmStatusUsableForScan(*status, freqKhz, requireLocked)) {
break;
}
usable = true;
stationName = status->fmStationName;
if (nameFilter.empty()
|| nameMatchesFilter(stationName, nameFilter)) {
break;
}
if (attempt + 1 < kFmNamePollAttempts) {
vTaskDelay(pdMS_TO_TICKS(kFmNamePollMs));
}
}
if (!usable) {
continue;
}
if (nameFilter.empty() || nameMatchesFilter(stationName, nameFilter)) {
auto status = refreshStatus();
if (!status) {
return std::unexpected(status.error());
}
core::TunerScanResult result = makeScanResult(request, step, true);
result.fmFrequency = status->fmFrequency;
result.stationName = status->fmStationName;
ESP_LOGI(kTag, "FM scan found %u kHz after %u steps",
static_cast<unsigned>(result.fmFrequency ? result.fmFrequency->value() : 0U),
static_cast<unsigned>(step));
return result;
}
}
ESP_LOGW(kTag, "FM scan: no station found");
return makeScanResult(request, request.maxSteps, false);
}
ESP_LOGI(kTag, "DAB scan start (max %u ensembles, name='%.*s')",
static_cast<unsigned>(request.maxSteps),
static_cast<int>(nameFilter.size()), nameFilter.data());
for (std::uint16_t step = 0U; step < request.maxSteps; ++step) {
const std::uint8_t freqIndex = static_cast<std::uint8_t>(step);
if (auto tuned = tuneDab(freqIndex); !tuned) {
return std::unexpected(tuned.error());
}
vTaskDelay(pdMS_TO_TICKS(kDabTuneSettleMs));
auto status = refreshStatus();
if (!status) {
return std::unexpected(status.error());
}
if (!status->locked || !status->dabFicQuality
|| *status->dabFicQuality < kMinDabFicQuality) {
continue;
}
auto services = listDabServices();
if (!services || services->empty()) {
continue;
}
for (const core::TunerServiceEntry& service : *services) {
const std::string_view label(service.label.data());
if (!nameMatchesFilter(label, nameFilter)) {
continue;
}
if (auto played =
playDabService(service.serviceId, service.componentId);
!played) {
continue;
}
core::TunerScanResult result =
makeScanResult(request, static_cast<std::uint16_t>(step + 1U), true);
result.dabFreqIndex = freqIndex;
result.dabServiceId = service.serviceId;
result.dabComponentId = service.componentId;
if (auto parsed = core::BroadcastLabel::tryFromChipBytes(label);
parsed) {
result.stationName = std::move(*parsed);
}
ESP_LOGI(kTag, "DAB scan found idx=%u svc=%u after %u steps",
static_cast<unsigned>(freqIndex),
static_cast<unsigned>(service.serviceId),
static_cast<unsigned>(step + 1U));
return result;
}
}
ESP_LOGW(kTag, "DAB scan: no station found");
return makeScanResult(request, request.maxSteps, false);
}
std::expected<std::vector<core::TunerServiceEntry>, core::TunerError>
TunerService::listDabServices()
{