\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.8.5. \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.8.5","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. \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{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}