Rename conflicting TikZ step style to drstep, add missing ch:firmware label, and use TeX Gyre Heros font fallback instead of Linux Biolinum O. Co-authored-by: Cursor <cursoragent@cursor.com>
445 lines
16 KiB
TeX
445 lines
16 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.8.4.
|
|
|
|
\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}
|
|
|
|
\subsection{\texttt{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.4","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.
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\subsection{\texttt{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).
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\subsection{\texttt{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}
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\subsection{\texttt{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()}.
|
|
|
|
\subsection{\texttt{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}.
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\subsection{\texttt{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}).
|
|
|
|
% ------------------------------------------------------------------
|
|
% Bluetooth (Slice 7)
|
|
% ------------------------------------------------------------------
|
|
|
|
\subsection{\texttt{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}
|
|
|
|
\subsection{\texttt{POST /api/bluetooth/pair}}
|
|
\label{sec:api-bluetooth-pair}
|
|
|
|
Enters discoverable mode (\texttt{AT+PAIR=1}). Success:
|
|
\texttt{\{"status":"pairing"\}}.
|
|
|
|
\subsection{\texttt{POST /api/bluetooth/pair/stop}}
|
|
\label{sec:api-bluetooth-pair-stop}
|
|
|
|
Leaves discoverable mode (\texttt{AT+PAIR=0}). Success:
|
|
\texttt{\{"status":"idle"\}}.
|
|
|
|
\subsection{\texttt{POST /api/bluetooth/disconnect}}
|
|
\label{sec:api-bluetooth-disconnect}
|
|
|
|
Releases the current A2DP session (\texttt{AT+A2DPDISC}).
|
|
|
|
\subsection{\texttt{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}
|
|
|
|
\subsection{\texttt{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)
|
|
% ------------------------------------------------------------------
|
|
|
|
\subsection{\texttt{GET /api/stations}}
|
|
\label{sec:api-stations-get}
|
|
|
|
Returns presets serialised by \texttt{core::serializeStationListJson()}.
|
|
|
|
\subsection{\texttt{POST /api/stations}}
|
|
\label{sec:api-stations-post}
|
|
|
|
Adds one preset (\texttt{core::parseStationJson()}); persists via
|
|
\texttt{ISecureStore::saveStationListJson()}.
|
|
|
|
\subsection{\texttt{POST /api/stations/remove}}
|
|
\label{sec:api-stations-remove}
|
|
|
|
Removes a preset by list index (\texttt{\{"index":0\}}).
|
|
|
|
\subsection{\texttt{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"\}}.
|
|
|
|
\subsection{\texttt{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.
|
|
|
|
\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:
|
|
\texttt{Software/docs/security-flash-nvs.md}.
|
|
\end{drcaution}
|