Files
DigiRadio/Software/docs/manual/ch-api.tex
micheleandClaude Sonnet 5 c0eee4a4ad 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
2026-08-19 09:23:36 +02:00

780 lines
29 KiB
TeX

\chapter{HTTP API}
\label{ch:api}
The setup web interface is a thin client over a typed JSON REST API
implemented in \texttt{SetupWebServer}. Request bodies are parsed into
domain types in the pure core (\texttt{components/core}) before any
persistence or driver call. Exact C++ signatures live in the generated
Doxygen output under \texttt{docs/api/}; this chapter documents the
wire protocol and behaviour as shipped in firmware~0.9.0.
\section{Transport and reachability}
\begin{itemize}
\item \textbf{Port:} TCP~80 on the ESP32-S3 HTTP server.
\item \textbf{Setup mode (\texttt{NetState::SoftApSetup}):} join the
SoftAP \texttt{DigiRadio-setup} (open network), then open
\url{http://192.168.4.1/}. The root path serves a gzipped HTML
page embedded from flash.
\item \textbf{STA mode (\texttt{NetState::StaConnected}):} after
successful provisioning and reboot, the same API is available at
the device's DHCP address on the configured LAN.
\end{itemize}
All responses use \texttt{Content-Type: application/json} except
\texttt{GET /}, which returns gzipped HTML with
\texttt{Content-Encoding: gzip}.
\section{Endpoints}
\apiendpoint{GET}{/api/health}
\label{sec:api-health}
Returns a health-check DTO serialised by
\texttt{core::serializeHealthStatusJson()} from a
\texttt{core::HealthStatus} value.
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"status":"ok","fw":"0.9.0","serialNumber":"0004A3123456",
"chips":{"si4684":true,"adau1701":true,"bt1035":true}}
\end{drcode}
\begin{itemize}
\item \texttt{status} --- coarse indicator; \texttt{ok} when the
firmware is running normally.
\item \texttt{fw} --- firmware release string
(\texttt{core::FirmwareVersion}).
\item \texttt{serialNumber} --- canonical EEPROM serial, or
\texttt{unknown} when the 24AA025E48 read fails.
\item \texttt{chips} --- companion-chip boot flags after
\texttt{HardwareBootstrap::boot()} (Slice~8 integration).
\end{itemize}
\end{drnote}
HTTP status: \textbf{200 OK} on success.
\apiendpoint{POST}{/api/wifi}
\label{sec:api-wifi}
Accepts Wi-Fi credentials for station (STA) join. The body is parsed by
\texttt{core::parseWifiProvisionJson()} into a \texttt{core::WifiCredentials}
value (SSID as \texttt{core::WifiSsid}, password as \texttt{core::Secret}).
On success the credentials are persisted through
\texttt{core::ISecureStore} (device implementation:
\texttt{secure\_store::NvsSecureStore}) and the device schedules a reboot
so the next boot can enter STA mode.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"ssid":"MyNetwork","password":"secret1234"}
\end{drcode}
\begin{itemize}
\item \texttt{ssid} --- required, 1--32 characters (802.11 SSID limits).
\item \texttt{password} --- optional for open networks; for WPA-PSK,
8--63 characters. Validated by
\texttt{core::WifiCredentials::isPasswordValid()}.
\end{itemize}
\end{drnote}
\begin{drnote}[Success response]
\begin{drcode}[JSON]
{"status":"saved","reboot_in_sec":3}
\end{drcode}
Serialised by \texttt{core::serializeWifiProvisionSavedJson()}. The device
reboots after the indicated delay.
\end{drnote}
\begin{drnote}[Error response]
\begin{drcode}[JSON]
{"status":"error","reason":"invalid_ssid"}
\end{drcode}
Serialised by \texttt{core::serializeWifiProvisionErrorJson()}. Reason tokens (never include secrets):
\texttt{invalid\_json}, \texttt{missing\_field}, \texttt{invalid\_ssid},
\texttt{invalid\_password}, \texttt{store\_failed}.
\end{drnote}
HTTP status: \textbf{200 OK} on save success; \textbf{400 Bad Request} for
parse/validation failures; \textbf{500 Internal Server Error} when NVS
persistence fails.
\apiendpoint{POST}{/api/wifi/scan}
\label{sec:api-wifi-scan}
Lists nearby access points via \texttt{net::WifiScanner::scanNearby()} (a
blocking \texttt{esp\_wifi} scan). Empty request body.
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"networks":[{"ssid":"MyNetwork","rssi_dbm":-52,"auth":"wpa2","channel":6}]}
\end{drcode}
Deduplicated and sorted by signal strength; \texttt{ssid} may be empty for
hidden networks.
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{500} with
\texttt{\{"status":"error","reason":"scan\_failed"\}} on driver failure.
\apiendpoint{GET}{/api/tuner/status}
\label{sec:api-tuner-status}
Returns a tuner snapshot serialised by
\texttt{core::serializeTunerStatusJson()} from \texttt{core::TunerStatus}.
The handler calls \texttt{tuner::TunerService::refreshStatus()}. DAB and FM
tuning workflows are in Chapter~\ref{ch:si4684}, Sections~\ref{sec:si4684-dab-session}
and~\ref{sec:si4684-fm-session}.
\begin{drnote}[Response schema (DAB example)]
\begin{drcode}[JSON]
{"booted":true,"band":"dab","locked":true,"volume":63,
"dab":{"freq_index":12,"fic_quality":80,"cnr_db":25,
"playing_service_id":42,"playing_component_id":7,
"dynamic_label":"Live show"},
"fm":null}
\end{drcode}
For FM, \texttt{fm} carries \texttt{frequency\_khz}, \texttt{rssi\_dbuv},
\texttt{snr\_db}, \texttt{stereo}, optional \texttt{station\_name} (RDS PS),
and \texttt{radiotext} (RDS RT); \texttt{dab} is \texttt{null}.
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{500} with
\texttt{\{"status":"error","reason":...\}} on driver failure;
\textbf{503} when the tuner service is unavailable. FM responses may include
\texttt{station\_name} and \texttt{radiotext}; DAB responses may include
\texttt{dynamic\_label} when a programme is playing.
\apiendpoint{GET}{/api/tuner/services}
\label{sec:api-tuner-services}
Lists DAB programmes for the current ensemble via
\texttt{TunerService::listDabServices()}.
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"services":[{"service_id":12345,"component_id":1,"label":"BBC R1"}]}
\end{drcode}
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{409 Conflict} when the list cannot be
fetched (wrong band, empty ensemble, hardware error).
\apiendpoint{POST}{/api/tuner/tune}
\label{sec:api-tuner-tune}
Tunes to a DAB ensemble or FM frequency. Body parsed by
\texttt{core::parseTunerTuneJson()}.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"band":"dab","freq_index":12}
\end{drcode}
or
\begin{drcode}[JSON]
{"band":"fm","frequency_khz":101500}
\end{drcode}
DAB \texttt{freq\_index} is 0--37; FM \texttt{frequency\_khz} is
64\,000--108\,000. FM accepts an optional \texttt{antcap} (0--128):
overrides the front-end antenna varactor for this one tune, e.g. while
running a calibration sweep. Omit it (the normal case) to use the board's
saved calibration --- see
\texttt{POST /api/tuner/calibrate-antenna}
(Section~\ref{sec:api-tuner-calibrate-antenna}) --- or hardware auto-tune
if never calibrated. Ignored for \texttt{"dab"}.
\end{drnote}
On success returns the updated status JSON (same shape as
\texttt{GET /api/tuner/status}). HTTP status: \textbf{200 OK};
\textbf{400} for invalid JSON; \textbf{409} for tune failures.
\apiendpoint{POST}{/api/tuner/play}
\label{sec:api-tuner-play}
Starts a DAB audio service. Body parsed by
\texttt{core::parseTunerPlayJson()}.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"service_id":12345,"component_id":1}
\end{drcode}
\end{drnote}
Success response: \texttt{\{"status":"playing"\}}. HTTP status:
\textbf{200 OK}; \textbf{409} when playback cannot start.
\apiendpoint{POST}{/api/tuner/seek}
\label{sec:api-tuner-seek}
Seeks FM in the requested direction. Optional JSON body parsed by
\texttt{core::parseTunerSeekJson()}; an empty body defaults to upward seek.
Returns \texttt{\{"frequency\_khz":...\}} on success. HTTP status:
\textbf{200 OK}; \textbf{400} for invalid \texttt{direction}; \textbf{409} on
seek failure.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"direction":"up"}
\end{drcode}
Use \texttt{"down"} for downward seek. Omit the body or send an empty JSON
object (\texttt{\{\}}) for upward seek (backward compatible).
\end{drnote}
\apiendpoint{POST}{/api/tuner/scan}
\label{sec:api-tuner-scan}
Automatic search for a single station: repeats hardware seeks (FM) or
walks ensemble indices (DAB) via \texttt{TunerService::scanForStation()},
stopping early on the first usable hit, optionally filtered by name. This
blocks the HTTP connection for the whole search (up to tens of seconds).
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"band":"fm","max_steps":45,"name":"BBC"}
\end{drcode}
\texttt{band} is required (\texttt{"fm"} or \texttt{"dab"}).
\texttt{max\_steps} is optional, 1--255 (default 45 for FM, 38 for DAB).
\texttt{name} is an optional case-insensitive substring match against the
RDS PS name (FM) or DAB service label.
\end{drnote}
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"status":"found","band":"fm","steps":12,"frequency_khz":97500,
"station_name":"BBC R1"}
\end{drcode}
\texttt{status} is \texttt{"found"} or \texttt{"not\_found"}. On a DAB hit,
\texttt{freq\_index}, \texttt{service\_id}, and \texttt{component\_id}
appear instead of \texttt{frequency\_khz}.
\end{drnote}
HTTP status: \textbf{200 OK} (including \texttt{"not\_found"} outcomes);
\textbf{400} for invalid JSON; \textbf{409} on a hardware/driver failure
mid-scan.
\apiendpoint{POST}{/api/tuner/scan/full}
\label{sec:api-tuner-scan-full}
Sweeps the entire FM band with repeated hardware seeks
(\texttt{TunerService::scanFullFmBand()}) and returns every usable station
found, in ascending frequency order. Does \emph{not} save results as
presets --- this only returns the list; use
\texttt{POST /api/stations} (Section~\ref{sec:api-stations-post}) to save
individual hits. Empty request body; blocks for the whole sweep (tens of
seconds).
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"stations":[{"frequency_khz":97500,"rssi_dbuv":58,"snr_db":21,
"station_name":"BBC R1"},
{"frequency_khz":101500,"rssi_dbuv":44,"snr_db":15}]}
\end{drcode}
\texttt{station\_name} (RDS PS) is present only when decoded before the
per-station poll budget ran out.
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{409} on a hardware/driver failure
mid-sweep; \textbf{503} when the tuner service is unavailable.
\apiendpoint{POST}{/api/tuner/calibrate-antenna}
\label{sec:api-tuner-calibrate-antenna}
Commits an FM ANTCAP value (AN851 Appendix A front-end calibration) as the
board's permanent default, found by sweeping \texttt{antcap} on
\texttt{POST /api/tuner/tune} (Section~\ref{sec:api-tuner-tune}) across
0--128 and comparing \texttt{rssi\_dbuv}/\texttt{snr\_db}. Persists to the
24AA025E48 EEPROM's user-writable region (separate from the factory-locked
EUI-48) and takes effect immediately on the live tuner --- no reboot
required, though it also survives one since \texttt{HardwareBootstrap::boot()}
reloads it at every boot. Once saved, every ordinary FM tune (seek, scan,
station recall, live UI) uses this value automatically instead of the
chip's own front-end auto-tune, unless a request explicitly overrides
\texttt{antcap} for that one call.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"antcap":102}
\end{drcode}
\texttt{antcap} is required, 0--128.
\end{drnote}
Success response: \texttt{\{"status":"saved","antcap":102\}}. HTTP status:
\textbf{200 OK}; \textbf{400} for invalid/missing \texttt{antcap};
\textbf{500} on an EEPROM write failure; \textbf{503} when the tuner
service is unavailable.
\apiendpoint{GET}{/api/audio/profile}
\label{sec:api-audio-profile-get}
Returns the current ADAU1701 audio snapshot serialised by
\texttt{core::serializeAudioProfileJson()} from \texttt{core::AudioProfile}.
The handler reads \texttt{audio::AudioService::currentProfile()}. Driver
behaviour, domain types, and persistence are documented in
Chapter~\ref{ch:adau1701}.
\begin{drnote}[Response schema (excerpt)]
\begin{drcode}[JSON]
{"mixer":{"si4684_left_db":0,"si4684_right_db":0,
"esp32_left_db":0,"esp32_right_db":0,
"mix_left_db":0,"mix_right_db":0},
"master":{"left_db":0,"right_db":0},
"eq":[{"gain_db":0,"center_hz":40,"q":1.414}, ...],
"enhancements":{"stereo_level":0,"bass_level":0}}
\end{drcode}
Six EQ bands are always present (\texttt{eq} array length~6).
Enhancement levels are 0--100; at 0 the base EQ band settings apply.
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{503} when the audio service is
unavailable.
\apiendpoint{PUT}{/api/audio/profile}
\label{sec:api-audio-profile-put}
Applies a full audio profile. Body parsed by
\texttt{core::parseAudioProfileJson()}; on success
\texttt{audio::AudioService::applyProfile(..., persist=true)} safeloads
the ADAU1701 and writes NVS key \texttt{audio\_profile\_json}.
\begin{drnote}[Success response]
\begin{drcode}[JSON]
{"status":"saved"}
\end{drcode}
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{400} for parse/validation failures;
\textbf{500} when safeload or NVS persistence fails.
\apiendpoint{POST}{/api/dsp/program}
\label{sec:api-dsp-program}
Uploads a framed ADAU1701 RAM download blob (\texttt{DRAD} v1, see
\texttt{core::parseDspProgramBlob()}). The body is raw bytes
(\texttt{application/octet-stream} or any content type). On success the blob
is written to the \texttt{dsp} flash partition and the device reboots; the
next boot replays the new program before \texttt{loadAndApply()}.
\begin{drnote}[Success response]
\begin{drcode}[JSON]
{"status":"stored","reboot_sec":3}
\end{drcode}
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{400} with \texttt{\{"error":"..."\}}
for invalid/truncated/CRC failures; \textbf{413} when the body exceeds
200\,KiB; \textbf{500} on flash write failure. Pack blobs with
\texttt{tools/pack\_dsp\_program.py} or \texttt{core::serializeDspProgramBlob()}.
\apiendpoint{POST}{/api/system/ota}
\label{sec:api-system-ota}
Streams a raw ESP-IDF application \texttt{.bin} into the inactive OTA slot
(\texttt{ota\_0}/\texttt{ota\_1}). The body is raw bytes; the server validates
the embedded \texttt{esp\_app\_desc\_t} at offset~0x20 (magic and
\texttt{project\_name == "digiradio"}) while streaming. On success the new
slot is selected and the device reboots; the first healthy boot after Wi-Fi
and HTTP are up calls
\texttt{ota::OtaService::confirmBoot()} to cancel rollback.
\begin{drnote}[Success response]
\begin{drcode}[JSON]
{"status":"stored","reboot_sec":3}
\end{drcode}
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{400} with \texttt{\{"error":"..."\}}
for invalid project/magic or flash write failures;
\textbf{413} when the body exceeds 1.7\,MiB (\texttt{0x1B0000});
\textbf{500} on \texttt{esp\_ota\_end}/set-boot failures. Push with
\texttt{curl --data-binary @build/digiradio.bin}.
\apiendpoint{POST}{/api/audio/reset}
\label{sec:api-audio-reset}
Restores \texttt{AudioProfile::factoryDefault()} (flat EQ, 0\,dB gains),
applies it to the DSP, and persists to NVS. Success response:
\texttt{\{"status":"saved"\}}. HTTP status: \textbf{200 OK}; \textbf{500}
on apply/persist failure.
\apiendpoint{POST}{/api/audio/stereo-enhance}
\label{sec:api-audio-stereo-enhance}
Adjusts stereo depth via a psychoacoustic PEQ overlay on bands 3--5
(1\,kHz / 3\,kHz / 8\,kHz). This is \emph{not} true M/S widening---there
is no dedicated SigmaStudio widener block; see
Section~\ref{sec:ss-enhancements}.
\begin{drnote}[Request body]
\begin{drcode}[JSON]
{"level":50}
\end{drcode}
\texttt{level} is an integer 0--100 (0 = off, 100 = maximum).
\end{drnote}
Success response: \texttt{\{"status":"saved"\}}. HTTP status:
\textbf{200 OK}; \textbf{400} for invalid JSON or level; \textbf{500}
when safeload or NVS persistence fails.
\apiendpoint{POST}{/api/audio/bass-enhance}
\label{sec:api-audio-bass-enhance}
Adjusts bass emphasis via a PEQ overlay on bands 1--2 (100\,Hz /
400\,Hz). Request and response schema match
\texttt{POST /api/audio/stereo-enhance} (Section~\ref{sec:api-audio-stereo-enhance}).
\apiendpoint{POST}{/api/audio/beep}
\label{sec:api-audio-beep}
Toggles the ADAU1701's own Beep1 tone generator cell directly (live
safeload only --- not part of \texttt{AudioProfile}, never persisted to
NVS). Used for signal-path bring-up/diagnostics without an ESP32-side
tone source.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"enabled":true}
\end{drcode}
\end{drnote}
Success response: \texttt{\{"status":"saved"\}}. HTTP status:
\textbf{200 OK}; \textbf{400} for invalid/missing \texttt{enabled};
\textbf{500} on safeload failure; \textbf{503} when the audio service is
unavailable.
\apiendpoint{GET}{/api/dsp/params}
\label{sec:api-dsp-params}
Lists every named Parameter RAM cell in the compiled SigmaStudio program
(\texttt{adau1701::kAdau1701ParamTable}, generated from
\texttt{DigiRadio\_IC\_1\_PARAM.h}). Read-only, no I/O with the chip.
\begin{drnote}[Response schema (excerpt)]
\begin{drcode}[JSON]
{"params":[{"name":"DspGain1algorithm0dB1","address":0},
{"name":"Beep1osc1algorithm0freq","address":16}]}
\end{drcode}
\end{drnote}
HTTP status: \textbf{200 OK}.
\apiendpoint{PUT}{/api/dsp/param}
\label{sec:api-dsp-param}
Writes one Parameter RAM cell by name, looked up against the same table as
\texttt{GET /api/dsp/params} and safeloaded via
\texttt{audio::AudioService::writeRawParam()}. This is a live-only escape
hatch onto \emph{any} SigmaStudio-exported cell --- the same trust level as
SigmaStudio's own Remote Connection panel: no domain validation on the
value, and it is never persisted or folded into \texttt{AudioProfile}. A
reboot or \texttt{POST /api/audio/reset} discards the change.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"name":"DspGain1algorithm0dB1","value":-6.0}
\end{drcode}
\texttt{value} is the SigmaStudio floating coefficient, converted to
8.23 fixed-point by \texttt{core::floatToFixpoint823()} before the
safeload write.
\end{drnote}
Success response: \texttt{\{"status":"saved"\}}. HTTP status:
\textbf{200 OK}; \textbf{400} for invalid JSON; \textbf{404} when
\texttt{name} is not in the compiled program's cell table; \textbf{500}
on safeload failure; \textbf{503} when the audio service is unavailable.
\apiendpoint{PUT}{/api/stream/phone}
\label{sec:api-stream-phone}
Accepts a live audio push from a phone app and writes it straight to the
shared I2S sink for as long as the connection stays open --- the same
physical wire \texttt{POST /api/streaming}'s web radio player uses, guarded
so only one producer holds it at a time. The body is raw interleaved
16-bit little-endian PCM, stereo, 48\,kHz, with \emph{no} header or
framing --- just samples. Send with chunked transfer encoding (no
\texttt{Content-Length} required) and close the connection to stop
streaming; the format is deliberately unencoded so the firmware side stays
minimal and the encoding choice lives entirely in the app.
HTTP status: \textbf{503} if the sink is unavailable; \textbf{409 Conflict}
if the web radio stream (or another phone stream) currently owns the I2S
sink; otherwise the connection is held open and a \textbf{200 OK} (empty
body) is sent once the client closes it, or \textbf{500} if a write to the
sink fails mid-stream.
\apiendpoint{GET}{/api/streaming}
\label{sec:api-streaming-get}
Returns the current internet radio streaming configuration
(\texttt{webradio::WebRadioService::config()}).
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"enabled":true,"url":"http://stream.example.com/radio.mp3"}
\end{drcode}
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{503} when the web radio service is
unavailable.
\apiendpoint{POST}{/api/streaming}
\label{sec:api-streaming-post}
Sets the internet radio stream URL and enables/disables playback. Applied
immediately to the live streaming task and persisted to NVS --- no reboot
required. The stream is MP3 over HTTP, decoded on-device via libhelix and
written to the same shared I2S sink as \texttt{PUT /api/stream/phone}.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"enabled":true,"url":"http://stream.example.com/radio.mp3"}
\end{drcode}
\texttt{url} must be \texttt{http://}, non-empty, and at most 200 bytes.
\end{drnote}
On success, returns the same shape as \texttt{GET /api/streaming}. HTTP
status: \textbf{200 OK}; \textbf{400} for invalid JSON or URL;
\textbf{500} when NVS persistence fails; \textbf{503} when the web radio
service is unavailable.
% ------------------------------------------------------------------
% Bluetooth (Slice 7)
% ------------------------------------------------------------------
\apiendpoint{GET}{/api/bluetooth/status}
\label{sec:api-bluetooth-status}
Returns BT1035 boot flag, whether discoverable mode was requested, the
last read A2DP state, module friendly name, and auto-reconnect count.
Serialised by \texttt{core::serializeBluetoothStatusJson()}.
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"booted":true,"pairing":false,"a2dp":"standby",
"device_name":"DigiRadio-A1B2","auto_reconnect":3}
\end{drcode}
\end{drnote}
\apiendpoint{POST}{/api/bluetooth/pair}
\label{sec:api-bluetooth-pair}
Enters discoverable mode (\texttt{AT+PAIR=1}). Success:
\texttt{\{"status":"pairing"\}}.
\apiendpoint{POST}{/api/bluetooth/pair/stop}
\label{sec:api-bluetooth-pair-stop}
Leaves discoverable mode (\texttt{AT+PAIR=0}). Success:
\texttt{\{"status":"idle"\}}.
\apiendpoint{POST}{/api/bluetooth/disconnect}
\label{sec:api-bluetooth-disconnect}
Releases the current A2DP session (\texttt{AT+A2DPDISC}).
\apiendpoint{GET}{/api/bluetooth/paired}
\label{sec:api-bluetooth-paired}
Returns paired remotes from \texttt{AT+PLIST}, serialised by
\texttt{core::serializeBluetoothPairedJson()}.
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"devices":[{"index":1,"mac":"001122334455","name":"Phone"}]}
\end{drcode}
\end{drnote}
\apiendpoint{POST}{/api/bluetooth/scan}
\label{sec:api-bluetooth-scan}
Scans for nearby Bluetooth devices via \texttt{AT+DISC} on the BT1035
(\texttt{bluetooth::BluetoothService::scanNearby()}). Blocks for the scan
window.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"seconds":45}
\end{drcode}
\texttt{seconds} is optional, 1--255 (default 45); malformed or
out-of-range values fall back to the default rather than failing.
\end{drnote}
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"devices":[{"index":1,"mac":"001122334455","name":"Bose SoundLink",
"rssi_dbm":-58}]}
\end{drcode}
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{500} on a BT1035 driver error;
\textbf{503} when the Bluetooth service is unavailable.
\apiendpoint{POST}{/api/bluetooth/connect}
\label{sec:api-bluetooth-connect}
Connects A2DP to a specific remote MAC, optionally saving it as the
default speaker for auto-reconnect on boot
(\texttt{BluetoothService::connectTo()}).
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"mac":"001122334455","name":"Bose SoundLink","save":true}
\end{drcode}
\texttt{mac} is required (12 hex characters, no separators). \texttt{name}
and \texttt{save} are optional; when \texttt{save} is true, \texttt{mac}
and \texttt{name} are persisted the same way as
\texttt{POST /api/bluetooth/speaker}.
\end{drnote}
Success response: \texttt{\{"status":"connected"\}}. HTTP status:
\textbf{200 OK}; \textbf{400} when \texttt{mac} is missing/invalid;
\textbf{500} on a BT1035 driver error; \textbf{503} when the Bluetooth
service is unavailable.
\apiendpoint{GET}{/api/bluetooth/speaker}
\label{sec:api-bluetooth-speaker-get}
Returns the saved default-speaker target used for boot-time auto-reconnect,
if any.
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"configured":true,"mac":"001122334455","name":"Bose SoundLink"}
\end{drcode}
\texttt{\{"configured":false\}} when nothing is saved.
\end{drnote}
HTTP status: \textbf{200 OK}; \textbf{500} on an NVS read failure;
\textbf{503} when the Bluetooth service is unavailable.
\apiendpoint{POST}{/api/bluetooth/speaker}
\label{sec:api-bluetooth-speaker-post}
Saves (or replaces) the default-speaker target, without connecting
immediately. Request schema matches
\texttt{POST /api/bluetooth/connect} minus \texttt{save} (\texttt{mac}
required, \texttt{name} optional).
Success response: \texttt{\{"status":"saved"\}}. HTTP status:
\textbf{200 OK}; \textbf{400} for invalid JSON; \textbf{500} on an NVS
write failure; \textbf{503} when the Bluetooth service is unavailable.
\apiendpoint{DELETE}{/api/bluetooth/speaker}
\label{sec:api-bluetooth-speaker-delete}
Clears the saved default-speaker target; boot-time auto-reconnect is
skipped until a new one is saved. Success response:
\texttt{\{"status":"cleared"\}}. HTTP status: \textbf{200 OK};
\textbf{503} when the Bluetooth service is unavailable.
\apiendpoint{POST}{/api/bluetooth/reconnect}
\label{sec:api-bluetooth-reconnect}
Manually retries A2DP connect to the saved default speaker
(\texttt{BluetoothService::reconnectSavedSpeaker()}) --- the same call the
firmware makes once at boot. Empty request body.
Success response: \texttt{\{"status":"connected"\}}. HTTP status:
\textbf{200 OK}; \textbf{500} on a BT1035 driver error or when no speaker
is saved; \textbf{503} when the Bluetooth service is unavailable.
\apiendpoint{POST}{/api/bluetooth/auto-reconnect}
\label{sec:api-bluetooth-auto-reconnect}
Sets the module auto-reconnect retry count (\texttt{AT+AUTOCONN=0..15}).
Body parsed by \texttt{core::parseBluetoothAutoReconnectJson()}.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"times":3}
\end{drcode}
Success: \texttt{\{"status":"saved"\}}.
\end{drnote}
% ------------------------------------------------------------------
% Station presets (Slice 4)
% ------------------------------------------------------------------
\apiendpoint{GET}{/api/stations}
\label{sec:api-stations-get}
Returns presets serialised by \texttt{core::serializeStationListJson()}.
\apiendpoint{POST}{/api/stations}
\label{sec:api-stations-post}
Adds one preset (\texttt{core::parseStationJson()}); persists via
\texttt{ISecureStore::saveStationListJson()}.
\apiendpoint{POST}{/api/stations/remove}
\label{sec:api-stations-remove}
Removes a preset by list index (\texttt{\{"index":0\}}).
\apiendpoint{POST}{/api/stations/reorder}
\label{sec:api-stations-reorder}
Reorders presets using \texttt{StationList::move()}. Body:
\texttt{\{"from":1,"to":0\}}. Success: \texttt{\{"status":"reordered"\}}.
\apiendpoint{POST}{/api/stations/tune}
\label{sec:api-stations-tune}
Recalls a preset via \texttt{integration::IntegrationService::recallPreset()}
(tune, re-apply saved audio profile, persist last index).
\section{Boot and network state machine}
\label{sec:api-boot-flow}
At boot, \texttt{integration::IntegrationService::startup()} loads presets
and best-effort recalls the last station. Then
\texttt{net::NetBootstrap::start(store, tuner, audio, bluetooth, stations,
integration)} consults \texttt{ISecureStore::hasWifiCredentials()}:
\begin{enumerate}
\item \textbf{Credentials present} --- create STA netif, connect via
\texttt{net::StaClient} (30\,s timeout). On success:
\texttt{NetState::StaConnected} and HTTP on the LAN address.
\item \textbf{No credentials, or STA join fails} --- fall back to
SoftAP setup mode (\texttt{NetState::SoftApSetup}).
\end{enumerate}
This explicit \texttt{enum class NetState} replaces ad-hoc flags; see
\texttt{net/NetState.hpp} in the source tree.
\subsection{BLE provisioning (additive)}
\label{sec:api-boot-ble-provisioning}
Alongside the SoftAP, setup mode also starts ESP-IDF's own
\texttt{wifi\_provisioning} manager over BLE
(\texttt{net::ble\_provisioning::start()}), using the ESP32-S3's onboard
BLE radio --- independent of the BT1035, which stays a separate UART-attached
classic-Bluetooth A2DP module. This is the standard Espressif provisioning
protocol (protocomm, Security1), so any generic ``ESP BLE Provisioning''
app (iOS/Android) can join the device to Wi-Fi without first connecting to
\texttt{DigiRadio-setup}; the proof-of-possession string is the device's
own serial number (same source as the SoftAP SSID and
\texttt{GET /api/health}'s \texttt{serialNumber}). It is not an HTTP
endpoint --- there is no REST call here --- and it is strictly additive:
if it fails to start, SoftAP setup keeps working exactly as before. On a
successful join the received credentials are converted to the same
\texttt{core::WifiCredentials} type and saved through the same
\texttt{ISecureStore} that \texttt{POST /api/wifi} uses, then the device
reboots into STA mode.
\section{Credential storage (Slice~2)}
\label{sec:api-storage}
Wi-Fi credentials are stored in NVS namespace \texttt{digiradio}, keys
\texttt{wifi\_ssid} and \texttt{wifi\_pwd}. Audio profiles (non-secret) use
the same namespace, key \texttt{audio\_profile\_json}, via
\texttt{secure\_store::NvsAudioProfileStore}. Station presets use key
\texttt{station\_list} (JSON blob). The last recalled preset index is stored
as \texttt{last\_preset} (u8). Passwords are wrapped in
\texttt{core::Secret} in RAM and are never logged or returned by the API.
\begin{drcaution}[Encryption at rest]
Firmware~0.8.3+ enables NVS encryption
(\texttt{CONFIG\_NVS\_ENCRYPTION}) and flash encryption in development mode
(\texttt{sdkconfig.defaults}). Keys live in the \texttt{nvs\_keys} partition;
Wi-Fi passwords remain wrapped in \texttt{core::Secret} in RAM and are never
logged. First upgrade from plain NVS requires \texttt{idf.py erase-flash}.
HIL checklist: \drpath{Software/docs/security-flash-nvs.md}.
\end{drcaution}