Files
DigiRadio/Software/docs/manual/ch-api.tex
T
micheleandCursor a8f2fd1c6c Enable NVS and flash encryption at rest (fw 0.8.3).
Add initEncryptedStorage, development-mode Kconfig defaults, production overlay, and security HIL docs; wire NetBootstrap through encrypted NVS bring-up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 07:46:59 +02:00

364 lines
13 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.5.0 (Slices~1--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}
\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.3",
"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{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 upward (no request body). Returns
\texttt{\{"frequency\_khz":...\}} on success. HTTP status: \textbf{200 OK};
\textbf{409} on seek failure.
\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/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, and the
last read A2DP state. Serialised by
\texttt{core::serializeBluetoothStatusJson()}.
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"booted":true,"pairing":false,"a2dp":"standby"}
\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}).
% ------------------------------------------------------------------
% 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}