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
+93 -6
View File
@@ -99,10 +99,14 @@ renamed cells):
signal chain changed.
\end{enumerate}
\begin{drnote}[No USBi download on DigiRadio]
Do \emph{not} rely on SigmaStudio \emph{Link Compile Download} on this
hardware path. Export-only workflow is sufficient: the ESP32 is the programmer
at every power-up.
\begin{drnote}[Export-only workflow still recommended]
The export-only workflow above remains the simplest path: the ESP32 replays
the committed export at every power-up, so \emph{Link Compile Download} is
not required for day-to-day firmware builds. Since firmware~0.9.0, however,
DigiRadio does expose SigmaStudio's Remote Connection over TCP for live DSP
work and bench debugging --- see Section~\ref{sec:adau1701-sigmastudio-tcp}.
A completed Download over that path is automatically persisted to flash and
becomes the boot program, exactly like re-exporting and rebuilding.
\end{drnote}
\section{Boot sequence (I\textsuperscript{2}C RAM load)}
@@ -134,14 +138,97 @@ the DSP program and writes it into RAM after each reset.
\label{fig:adau1701-boot}
\end{figure}
\texttt{SIGMA\_WRITE\_REGISTER\_BLOCK} streams each write in 64-byte I\textsuperscript{2}C
chunks (payloads may exceed 255 bytes). The active script comes from the
\texttt{SIGMA\_WRITE\_REGISTER\_BLOCK} streams each write in $\leq$64-byte
I\textsuperscript{2}C chunks, rounded down to a whole number of words for the
target region (Parameter RAM = 4~bytes/word, Program RAM = 5, control
registers = 2) so chunk boundaries never split a word or desynchronise the
running address (payloads may exceed 255 bytes). The active script comes from the
\texttt{dsp} data partition when a valid \texttt{DRAD} blob is present; otherwise
the embedded SigmaStudio export is used (Section~\ref{sec:api-dsp-program}).
After download, \texttt{loadAndApply()} restores the saved
\texttt{core::AudioProfile} from NVS (or factory defaults) so user settings
survive power cycles without re-writing the whole program.
\section{SigmaStudio Remote Connection (TCP bridge)}
\label{sec:adau1701-sigmastudio-tcp}
\texttt{net::SigmaStudioTcpServer}\label{cls:SigmaStudioTcpServer} exposes the
ADAU1701 to SigmaStudio running on a PC over TCP/IP, so SigmaStudio's own
\emph{Connect} and \emph{Link Compile Download} work against DigiRadio exactly
as they would against a USBi/ICP dongle. It listens on port \textbf{8086} in
both SoftAP setup mode and STA mode, started from \texttt{NetBootstrap}
alongside \texttt{SetupWebServer}. The socket is unauthenticated, consistent
with the existing LAN-exposed \texttt{POST /api/system/ota} and
\texttt{POST /api/dsp/program} endpoints.
\begin{drref}[Protocol source]
This implements the subset of SigmaStudio's TCPi wire protocol needed for
Connect, Download, register read/write, and runtime safeload, ported from the
\href{https://github.com/rarranzb/ADAU1701-TCPi-ESP32}{ADAU1701-TCPi-ESP32}
reference project (MIT licence). Analog Devices' TCPi protocol itself is
closed/undocumented; this is a single third-party reverse-engineering source,
cross-checked against DigiRadio's own generated \texttt{DigiRadio\_IC\_1.h}
memory-map constants (Program RAM at word-address \texttt{0x0400}, Parameter
RAM at \texttt{0x0000}, core control register at \texttt{0x081C}) which match
exactly. Verify with a real SigmaStudio Connect + Download session before
relying on it for production DSP work.
\end{drref}
\begin{table}[htbp]
\centering
\small
\begin{tabular}{@{}L{2.2cm}L{9.8cm}@{}}
\drhead Frame & Layout \\
\midrule
\texttt{0x09} WRITE &
\texttt{[op][safeload][rsvd][totalLen:2][chipAddr][dataLen:2][addr:2][payload]} \\
\texttt{0x0A} READ\_REQ &
\texttt{[op][totalLen:2][chipAddr][dataLen:2][addr:2]} \\
\texttt{0x0B} READ\_RESP &
\texttt{[op][size:2][chipAddr][dataLen:2][addr:2][status][data]} \\
\bottomrule
\end{tabular}
\caption{TCPi frame layout (all multi-byte fields big-endian).}
\label{tab:adau1701-tcpi-frames}
\end{table}
Direct (non-safeload) writes reuse the region-aware
\texttt{SIGMA\_WRITE\_REGISTER\_BLOCK} (Section~\ref{sec:adau1701-boot} boot
path). Safeload writes forward SigmaStudio's already-encoded 4-byte words
verbatim via \texttt{sigma\_safeload\_raw\_block()} --- unlike
\texttt{sigma\_safeload\_block()} (Section~\ref{sec:adau1701-safeload}), which
reconstructs the payload from a host \texttt{int}, this path never
reinterprets network bytes. A new \texttt{sigma\_studio\_lock()} /
\texttt{sigma\_studio\_unlock()} pair in \texttt{SigmaStudioFW.c} serialises
whole safeload/write sequences against \texttt{Adau1701Driver}'s own
REST-triggered safeload calls, so a live SigmaStudio session and
\texttt{/api/audio/*} traffic cannot interleave mid-transaction.
\subsection{Persisting a Download as the boot program}
A completed \emph{Link Compile Download} is captured and made the program
DigiRadio boots with next time --- the same outcome as re-exporting and
rebuilding. \texttt{SigmaStudioTcpServer} coalesces contiguous direct writes
into a handful of regions per connection (mirroring the five-block shape
of \texttt{EmbeddedDspProgramSource}), completion is detected the same way
the reference project detects it: a direct write to \texttt{0x081C} that sets
the DSPRUN bit. On completion the coalesced regions are serialised
(\texttt{core::serializeDspProgramBlob()}) and written to the \texttt{dsp}
flash partition via \texttt{adau1701::FlashDspProgramSource::storeBlob()} ---
the same function \texttt{POST /api/dsp/program} already uses
(Section~\ref{sec:api-dsp-program}). If a session is too fragmented to fit the
blob's own caps (32 regions / 16\,KiB per region / 200\,KiB total), the
persist step is skipped for that session only; the DSP still runs from what
was written live over I\textsuperscript{2}C.
\begin{drcaution}[Not ported from the reference project]
DigiRadio does not implement the reference project's EEPROM self-boot capture
(binary format for an external self-boot EEPROM) or its Wi-Fi/GPIO
configuration web UI --- DigiRadio has no self-boot EEPROM on this bus (the
24AA025E48 at \texttt{0x52} is an EUI-48 identity chip only), and Wi-Fi
provisioning already exists via \texttt{POST /api/wifi}.
\end{drcaution}
\section{Software architecture}
\label{sec:adau1701-stack}
+46 -16
View File
@@ -8,9 +8,9 @@
The Feasycom FSC-BT1035 (Qualcomm QCC3056) is the wireless output stage of
DigiRadio: it receives PCM from the ADAU1701 over I\textsuperscript{2}S and
streams Bluetooth audio with aptX, aptX~HD, and aptX~Adaptive. This chapter
documents how the ESP32-S3 controls the module over UART (AT commands with
RTS/CTS), why I\textsuperscript{2}S slave mode is mandatory, and how
\texttt{bt1035::Bt1035Driver} implements the bring-up sequence.
documents how the ESP32-S3 controls the module over UART (AT commands, no
hardware flow control), why I\textsuperscript{2}S slave mode is mandatory,
and how \texttt{bt1035::Bt1035Driver} implements the bring-up sequence.
\begin{drref}[Hardware context]
Board wiring (UART pins, I\textsuperscript{2}S to the module, flow control)
@@ -30,9 +30,9 @@ accepted and encoded for transmission.
Without firmware init the module may stay in a default mode that ignores the
I\textsuperscript{2}S bus from the ADAU1701. The mandatory
\texttt{AT+AUXCFG=3} and \texttt{AT+I2SCFG=67} commands select I\textsuperscript{2}S
slave input at 48\,kHz --- omitting them silently breaks the entire wireless
output (Section~\ref{sec:bt1035-i2s}).
\texttt{AT+AUXCFG=3} and \texttt{AT+I2SCFG=35} commands select I\textsuperscript{2}S
slave input at 48\,kHz, 24-bit --- omitting them silently breaks the entire
wireless output (Section~\ref{sec:bt1035-i2s}).
\section{Control interface}
\label{sec:bt1035-uart}
@@ -48,7 +48,7 @@ output (Section~\ref{sec:bt1035-i2s}).
Port & UART2 (not the console UART) \\
Baud rate & 115200 \\
Data format & 8N1 \\
Flow control & Hardware RTS/CTS (required) \\
Flow control & Disabled on the ESP32 UART (\texttt{UART\_HW\_FLOWCTRL\_DISABLE}) \\
Reset & GPIO active-low pulse at boot \\
SYS\_CTL & Held active to enable the module \\
\bottomrule
@@ -57,6 +57,18 @@ output (Section~\ref{sec:bt1035-i2s}).
\label{tab:bt1035-uart}
\end{table}
\begin{drnote}[Flow control disabled, not just unused]
Earlier revisions wired \texttt{RTS}/\texttt{CTS} through
\texttt{Bt1035Pins} and configured \texttt{UART\_HW\_FLOWCTRL\_CTS\_RTS}.
That was reverted: \texttt{Bt1035Driver.cpp} now configures
\texttt{UART\_HW\_FLOWCTRL\_DISABLE} and passes
\texttt{UART\_PIN\_NO\_CHANGE} for both lines --- GPIO14/21 are not driven by
this driver. No AT command in \texttt{core::Bt1035AtCommand} configures flow
control on the module side either; this has not caused observed boot
failures, but has not been independently verified against the module's own
flow-control expectation.
\end{drnote}
\subsection{Response handling}
Every command expects a module reply containing \texttt{OK} or
@@ -69,7 +81,7 @@ Every command expects a module reply containing \texttt{OK} or
\end{itemize}
Host tests in \drpath{components/core/test/bt1035_at_test.cpp} lock the
init sequence (\texttt{AT+AUXCFG=3}, \texttt{AT+I2SCFG=67}) and the parser.
init sequence (\texttt{AT+AUXCFG=3}, \texttt{AT+I2SCFG=35}) and the parser.
\section{Mandatory I\textsuperscript{2}S slave mode}
\label{sec:bt1035-i2s}
@@ -80,7 +92,9 @@ with shared BCLK/LRCLK (see Chapter~\ref{ch:hardware}). The init sequence
must use:
\begin{itemize}
\item \texttt{AT+AUXCFG=3} --- I\textsuperscript{2}S mode (§5.1.25)
\item \texttt{AT+I2SCFG=67} --- I\textsuperscript{2}S slave, 48\,kHz, 32-bit (§5.1.4)
\item \texttt{AT+I2SCFG=35} --- I\textsuperscript{2}S slave, 48\,kHz, 24-bit (§5.1.4),
matching the ADAU1701 serial output word length configured in the
SigmaStudio Hardware Configuration
\end{itemize}
\texttt{AT+AUXCFG=1} (Line-In) does \textbf{not} match the schematic.
AGENTS.md treats skipping I\textsuperscript{2}S init as a production bug.
@@ -101,16 +115,18 @@ I\textsuperscript{2}S path is ready before Wi-Fi starts.
>={Latex}, node distance=3mm]
\node[drstep, fill=black!6] (sys) {SYS\_CTL high, RESET\# pulse};
\node[drstep, fill=black!6, below=of sys] (uart)
{Install UART2 @ 115200, RTS/CTS};
\node[drstep, fill=black!8, below=of uart] (at)
{Install UART2 @ 115200, flow control disabled};
\node[drstep, fill=black!7, below=of uart] (swrst)
{Send \texttt{AT+RESET} (best-effort), settle 500\,ms, flush RX};
\node[drstep, fill=black!8, below=of swrst] (at)
{Send \texttt{AT} --- expect OK};
\node[drstep, fill=black!10, below=of at] (aux)
{Send \texttt{AT+AUXCFG=3} --- expect OK (I\textsuperscript{2}S)};
\node[drstep, fill=black!10, below=of aux] (i2s)
{Send \texttt{AT+I2SCFG=67} --- expect OK (slave 48\,kHz)};
{Send \texttt{AT+I2SCFG=35} --- expect OK (slave 48\,kHz, 24-bit)};
\node[drstep, fill=black!6, below=of i2s] (done)
{\texttt{Bt1035Driver::isBooted()} = true};
\foreach \a/\b in {sys/uart, uart/at, at/aux, aux/i2s, i2s/done} {
\foreach \a/\b in {sys/uart, uart/swrst, swrst/at, at/aux, aux/i2s, i2s/done} {
\draw[->] (\a) -- (\b);
}
\end{tikzpicture}
@@ -118,6 +134,17 @@ I\textsuperscript{2}S path is ready before Wi-Fi starts.
\label{fig:bt1035-boot}
\end{figure}
\begin{drnote}[AT+RESET is best-effort, not gated]
Unlike \texttt{AT+AUXCFG=3}/\texttt{AT+I2SCFG=35} (mandatory,
\texttt{core::bootInitSequence()}, boot fails if either is refused),
\texttt{AT+RESET} is sent separately and its result is discarded: it is
unverified whether this Feasycom firmware acknowledges the command before
rebooting, resets silently without a reply, or rejects it outright. Gating
boot on an unconfirmed ack would risk breaking an otherwise-working sequence.
The 500\,ms settle delay and RX flush after sending it mirror the delay
already used after the hardware GPIO reset pulse.
\end{drnote}
Pairing, codec selection, and volume over Bluetooth are handled by the
module's own firmware and NVS; DigiRadio firmware currently implements
only the I\textsuperscript{2}S bring-up required for the wired audio path.
@@ -158,9 +185,10 @@ The firmware enumerates every command it sends. Wire formats follow
\begin{tabular}{@{}L{2.4cm}L{3.4cm}L{5.5cm}@{}}
\drhead Enum & Line sent & Programming guide \\
\midrule
\texttt{Reset} & \texttt{AT+RESET} & software reset, best-effort \\
\texttt{Ping} & \texttt{AT} & link check \\
\texttt{I2sMode} & \texttt{AT+AUXCFG=3} & §5.1.25 Param=3 I2S \\
\texttt{I2sSlave48k32} & \texttt{AT+I2SCFG=67} & §5.1.4 slave 48\,kHz 32-bit \\
\texttt{I2sSlave48k24} & \texttt{AT+I2SCFG=35} & §5.1.4 slave 48\,kHz 24-bit \\
\texttt{PairDiscoverable} & \texttt{AT+PAIR=1} & §5.1.20 enter discoverable \\
\texttt{PairHidden} & \texttt{AT+PAIR=0} & §5.1.20 leave discoverable \\
\texttt{A2dpStat} & \texttt{AT+A2DPSTAT} & §5.3.1; states 1--5 \\
@@ -171,7 +199,9 @@ The firmware enumerates every command it sends. Wire formats follow
\bottomrule
\end{tabular}
\caption{Enumerated AT commands (\texttt{core::Bt1035AtCommand}). Boot
uses Ping + I2sMode + I2sSlave48k32; pairing commands are runtime.}
sends Reset (best-effort) then the mandatory Ping + I2sMode +
I2sSlave48k24 (\texttt{core::bootInitSequence()}); pairing commands are
runtime.}
\label{tab:bt1035-at}
\end{table}
@@ -258,7 +288,7 @@ bt1035::Bt1035Driver& bt = ...;
if (auto r = bt.sendCommand(core::Bt1035AtCommand::I2sMode); !r) {
// handle Bt1035Error
}
if (auto r = bt.sendCommand(core::Bt1035AtCommand::I2sSlave48k32); !r) {
if (auto r = bt.sendCommand(core::Bt1035AtCommand::I2sSlave48k24); !r) {
// handle Bt1035Error
}
\end{verbatim}
+19 -2
View File
@@ -79,11 +79,20 @@ Minimal HTTP server: gzipped setup UI and typed JSON REST handlers:
\item Audio: \texttt{/api/audio/*}
\item Bluetooth: \texttt{/api/bluetooth/*}
\item Presets: \texttt{/api/stations/*}
\item Streaming: \texttt{/api/streaming}
\end{itemize}
JSON parsing and serialisation delegate to the pure core; credentials persist via \texttt{ISecureStore};
tuner via \texttt{tuner::TunerService}; audio via
\texttt{audio::AudioService}; Bluetooth via \texttt{bluetooth::BluetoothService};
presets via \texttt{station::StationService}.
presets via \texttt{station::StationService}; streaming via
\texttt{webradio::WebRadioService}.
\section{WifiScanner}\label{cls:WifiScanner}
Stateless shell helper wrapping \texttt{esp\_wifi} scan for the setup UI's
Wi-Fi tab. \texttt{scanNearby()} temporarily switches an AP-only radio to
APSTA mode when needed, runs a blocking scan, dedupes access points by
SSID (keeping the strongest RSSI), and maps results to
\texttt{core::WifiScannedNetwork}. Called from \texttt{POST /api/wifi/scan}.
\section{NetBootstrap}\label{cls:NetBootstrap}
Owns network resources for setup or STA mode. Initialises encrypted NVS via
@@ -168,7 +177,7 @@ index (1--8), 12-digit MAC, and optional friendly name. Parsed by
UART driver for the FSC-BT1035 (Chapter~\ref{ch:bt1035}). \texttt{boot()}
pulses RESET\#, opens UART2 with RTS/CTS, and runs
\texttt{core::bootInitSequence()} (Ping + \texttt{AT+AUXCFG=3} +
\texttt{AT+I2SCFG=67}). Returns
\texttt{AT+I2SCFG=35}). Returns
\texttt{Bt1035Error} on timeout, ERROR response, or UART failure.
\section{Adau1701Driver}\label{cls:Adau1701Driver}
@@ -341,4 +350,12 @@ the inactive OTA slot, validates the app descriptor via
\texttt{core::validateOtaAppDescriptor()}, and exposes
\texttt{confirmBoot()} for rollback cancellation after a healthy network boot.
\section{WebRadioService}\label{cls:WebRadioService}
Thread-safe holder for the internet radio streaming config (enabled flag +
HTTP MP3 URL), backed by \texttt{core::ISecureStore}. A FreeRTOS mutex
guards an in-RAM copy so \texttt{setConfig()} from an HTTP handler and
\texttt{config()} polled by the streaming task in
\texttt{main/web\_radio\_stream.cpp} never race. Exposed on
\texttt{/api/streaming}.
% ------------------------------------------------------------------
+2 -2
View File
@@ -151,7 +151,7 @@ safeload registers.
\paragraph{FSC-BT1035 (Bluetooth).}
The module is controlled by AT commands over UART with hardware flow control.
The initialisation sequence includes enabling I\textsuperscript{2}S slave mode
(\texttt{AT+AUXCFG=3} and \texttt{AT+I2SCFG=67}), which is required for the
(\texttt{AT+AUXCFG=3} and \texttt{AT+I2SCFG=35}), which is required for the
wired digital audio path from the ADAU1701 to the module.
Command responses are parsed explicitly, with timeouts treated as errors.
Full driver API, boot flow, and error codes are in Chapter~\ref{ch:bt1035}.
@@ -218,7 +218,7 @@ bring-up:
defaults) via ADAU1701 safeload before network bring-up.
\item \textbf{FSC-BT1035} (UART): \texttt{bt1035::Bt1035Driver::boot()}
enables I\textsuperscript{2}S slave mode (\texttt{AT+AUXCFG=3},
\texttt{AT+I2SCFG=67}) after the DSP path is configured
\texttt{AT+I2SCFG=35}) after the DSP path is configured
(Chapter~\ref{ch:bt1035}).
\end{enumerate}
+2 -2
View File
@@ -132,7 +132,7 @@ in the module's own non-volatile memory.
\begin{drcaution}[I\textsuperscript{2}S slave mode]
The PCB routes ADAU1701 \texttt{SDATA\_OUT0} to the module over
I\textsuperscript{2}S (BCLK, LRCLK, PCM data). Firmware must initialise
\texttt{AT+AUXCFG=3} and \texttt{AT+I2SCFG=67}, not Line-In
\texttt{AT+AUXCFG=3} and \texttt{AT+I2SCFG=35}, not Line-In
(\texttt{AT+AUXCFG=1}). See Chapter~\ref{ch:bt1035}, Section~\ref{sec:bt1035-i2s}.
\end{drcaution}
@@ -525,7 +525,7 @@ ever used.}
\midrule
UART with flow control & TX/RX + RTS/CTS wired & verified \\
I\textsuperscript{2}S init (firmware) &
\texttt{AT+AUXCFG=3}, \texttt{AT+I2SCFG=67} & driver rule \\
\texttt{AT+AUXCFG=3}, \texttt{AT+I2SCFG=35} & driver rule \\
I\textsuperscript{2}S input & receives clocks from ADAU master (slave) & verified \\
Reset line & RESET (GPIO17) driven by ESP32 & verified \\
\bottomrule
+17 -6
View File
@@ -21,9 +21,18 @@ the program, not to drive the chip in operation.
The whole program can be built and exported offline, without a physical
ADAU1701 or a USBi programmer connected. A USBi block is placed in the
Hardware Configuration only because SigmaStudio requires a communication
channel to compile; it is never used for a real download in this project.
channel to compile.
\end{drkey}
\begin{drref}[Live connection is also possible]
Since firmware~0.9.0, DigiRadio's \texttt{net::SigmaStudioTcpServer}
(Chapter~\ref{ch:adau1701}, Section~\ref{sec:adau1701-sigmastudio-tcp})
exposes a TCP:8086 bridge so SigmaStudio can \emph{Connect} and
\emph{Link Compile Download} directly against a running board, for live DSP
tuning and bench debugging. The export-only workflow below remains the
simplest path for ordinary firmware builds.
\end{drref}
\section{Hardware Configuration}
\label{sec:ss-hwcfg}
@@ -270,9 +279,11 @@ The firmware's ADAU1701 driver replays the program data over
I\textsuperscript{2}C at boot, and uses the parameter addresses for
safeload updates.
\begin{drnote}[No download needed]
Since there is no ADAU1701 attached during development, do \emph{not} use
\emph{Link Compile Download} (it would fail trying to reach the chip).
\emph{Export System Files} compiles the schematic and writes the files
directly.
\begin{drnote}[Export System Files vs. Link Compile Download]
Without a physical ADAU1701/USBi attached, \emph{Export System Files} is what
you want: it compiles the schematic and writes the files directly, with no
chip connection required. \emph{Link Compile Download} instead needs a live
communication channel to a chip --- either the classic USBi/ICP dongle, or
DigiRadio's own TCP:8086 SigmaStudio bridge (Section~\ref{sec:adau1701-sigmastudio-tcp})
when connecting live to a running board.
\end{drnote}