Files
DigiRadio/Software/docs/manual/ch-api.tex
T
micheleandClaude Sonnet 5 3a58d33aad Add DAB ANTCAP calibration and fix BT1035 boot banner timing
Extend the FM-only ANTCAP antenna-varactor override to DAB, mirroring the
existing mechanism end to end (driver, tuner, service, EEPROM storage,
HTTP API). Live sweep on real hardware found no ANTCAP value beating
auto-tune on the ensembles tested, so DAB stays on auto-tune by default.

Also fix BT1035 boot: the module's real boot banner doesn't appear until
~18-24s after RESET# releases, not the 3.5s previously waited; add a
2-attempt retry and a baud-rate probe fallback for diagnostics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:12:08 +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}