Add internet radio streaming with runtime API, modernize web UI, remove auto-tune/beep at boot

Streaming (main feature this session):
- New WebRadioConfig/WebRadioJson core types, ISecureStore-backed persistence
- New webradio::WebRadioService (thread-safe live config) + GET/POST /api/streaming
- web_radio_stream task now runtime-toggleable (no reboot), no hardcoded URL
- Content-Type diagnostic: warns clearly when a URL is a webpage, not an audio stream

Boot cleanup:
- Removed boot-time auto FM/DAB tune, auto-beep, and the (now-concluded) Si4684
  crystal IBIAS/CTUN empirical sweep from main.cpp — tuning/beep are on-demand
  via the existing REST API only

Web UI:
- Modernized styling (cards, gradients, toggle switches, light/dark theme)
- New Stream tab wired to /api/streaming

Fixes found via real idf.py build (not just clangd):
- Restored wrongly-removed si4684/Si4684Tuner.hpp include in main.cpp
- Fixed MP3Decode() argument types in web_radio_stream.cpp (unsigned char**/int*)

Quality-gate fixes:
- Host-test stub headers (esp_log.h, freertos/*) so TunerService.cpp's
  scanForStation logging/pacing compiles for station_service_test /
  integration_service_test instead of running stale binaries
- Added WifiScanner and WebRadioService manual sections; filled in missing
  Doxygen docs on BluetoothService, i2s_sdata_probe, test_firmware, Bt1035At
- Ignore clangd's .cache/ index directory

Also includes prior uncommitted work carried in the tree: Wi-Fi/Bluetooth
device scan REST API and UI (WifiScanner, BT scan), SigmaStudio TCP bridge,
and the current ADAU1701 SigmaStudio DSP program export.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 21:15:22 +02:00
co-authored by Claude Sonnet 5
parent 8a8523515d
commit 6f7b6dd12c
131 changed files with 20309 additions and 1702 deletions
+2
View File
@@ -9,6 +9,7 @@ idf_component_register(
"src/WifiSsid.cpp"
"src/WifiCredentials.cpp"
"src/WifiProvisionJson.cpp"
"src/WifiScanJson.cpp"
"src/EmbeddedBlobReader.cpp"
"src/TunerJson.cpp"
"src/FrequencyKHz.cpp"
@@ -38,6 +39,7 @@ idf_component_register(
"src/RegisterWrite.cpp"
"src/DspProgramBlob.cpp"
"src/OtaAppDescriptor.cpp"
"src/WebRadioJson.cpp"
INCLUDE_DIRS "include"
)
@@ -90,4 +90,18 @@ namespace core {
[[nodiscard]] std::expected<EnhanceLevel, ParseError> parseEnhanceLevelJson(
std::string_view json);
/**
* @brief parseBeepEnabledJson — parse POST body {"enabled":true|false}.
*
* @dname parseBeepEnabledJson
* @param json Untrusted request body.
* @return Parsed enabled flag, or ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::expected<bool, ParseError> parseBeepEnabledJson(
std::string_view json);
} // namespace core
@@ -14,6 +14,8 @@
#include "core/Bt1035At.hpp"
#include "core/Bt1035PairedDevice.hpp"
#include "core/Bt1035ScannedDevice.hpp"
#include "core/BtSpeakerTarget.hpp"
#include <cstdint>
#include <string>
@@ -39,6 +41,22 @@ struct BluetoothStatus {
std::uint8_t autoReconnect; ///< Power-on reconnect count (0 = off).
};
/**
* @brief BluetoothConnectRequest — POST /api/bluetooth/connect body.
*
* @dname BluetoothConnectRequest
* @return n/a (type)
* @pubstate Parsed by parseBluetoothConnectJson().
*
* @author Michele Bigi
* @date 2026-08-05
*/
struct BluetoothConnectRequest {
std::string mac; ///< 12-char module MAC.
std::string name; ///< Optional label stored when save is true.
bool save; ///< Persist as default speaker on success.
};
/**
* @brief serializeBluetoothStatusJson — serialise BluetoothStatus for HTTP.
*
@@ -94,4 +112,74 @@ struct BluetoothStatus {
[[nodiscard]] std::expected<std::uint8_t, ParseError>
parseBluetoothAutoReconnectJson(std::string_view json);
/**
* @brief serializeBluetoothScanJson — serialise scan result list.
*
* @dname serializeBluetoothScanJson
* @param devices Parsed +SCAN entries.
* @return JSON object with a devices array.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string serializeBluetoothScanJson(
const std::vector<Bt1035ScannedDevice>& devices);
/**
* @brief parseBluetoothConnectJson — validate POST /api/bluetooth/connect.
*
* @dname parseBluetoothConnectJson
* @param json Request body with 12-char \c mac field.
* @return Normalised uppercase MAC on success, or ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<std::string, ParseError>
parseBluetoothConnectJson(std::string_view json);
/**
* @brief parseBluetoothConnectRequest — mac, optional name, save flag.
*
* @dname parseBluetoothConnectRequest
* @param json POST /api/bluetooth/connect body.
* @return Parsed connect request (mac may be empty if invalid).
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] BluetoothConnectRequest parseBluetoothConnectRequest(
std::string_view json);
/**
* @brief serializeBluetoothSpeakerJson — saved default speaker for HTTP.
*
* @dname serializeBluetoothSpeakerJson
* @param target Stored speaker, or nullptr when not configured.
* @return JSON object with configured flag.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string serializeBluetoothSpeakerJson(
const BtSpeakerTarget* target);
/**
* @brief parseBluetoothSpeakerJson — validate POST /api/bluetooth/speaker.
*
* @dname parseBluetoothSpeakerJson
* @param json Request body with mac and optional name.
* @return BtSpeakerTarget on success, or ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<BtSpeakerTarget, ParseError>
parseBluetoothSpeakerJson(std::string_view json);
} // namespace core
@@ -13,6 +13,7 @@
#pragma once
#include "core/Bt1035PairedDevice.hpp"
#include "core/Bt1035ScannedDevice.hpp"
#include "core/ParseError.hpp"
#include <array>
@@ -36,12 +37,14 @@ namespace core {
* @date 2026-07-06
*/
enum class Bt1035AtCommand {
Reset, ///< AT+RESET — software reset (best-effort, not gated on OK).
Ping, ///< AT — link check.
I2sMode, ///< AT+AUXCFG=3 — I2S input from ADAU1701 (mandatory).
I2sSlave48k32, ///< AT+I2SCFG=67 — I2S slave 48 kHz 32-bit (§5.1.4).
I2sSlave48k24, ///< AT+I2SCFG=35 — I2S slave 48 kHz 24-bit (§5.1.4).
PairDiscoverable, ///< AT+PAIR=1 — enter BR/EDR/BLE discoverable mode.
PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
A2dpEncoder, ///< AT+A2DPENC — read negotiated A2DP codec (§5.3.5).
A2dpDisconnect, ///< AT+A2DPDISC — release current A2DP connection.
QueryName, ///< AT+NAME — read BR/EDR local name (+NAME=).
QueryAutoConn, ///< AT+AUTOCONN — read power-on auto-reconnect count.
@@ -67,6 +70,24 @@ enum class Bt1035A2dpState : std::uint8_t {
Paused = 5,
};
/**
* @brief Bt1035A2dpCodec — negotiated codec from +A2DPENC=Param (§5.3.5).
*
* @dname Bt1035A2dpCodec
* @return n/a (type)
* @pubstate BT1035 chip variant codes (BT806 differs; board uses BT1035).
*
* @author Michele Bigi
* @date 2026-08-07
*/
enum class Bt1035A2dpCodec : std::uint8_t {
Sbc = 1,
Aptx = 2,
AptxHd = 3,
AptxLl = 4,
AptxAdaptive = 5,
};
/**
* @brief Bt1035AtResponseKind — parsed module reply class.
*
@@ -86,8 +107,14 @@ enum class Bt1035AtResponseKind {
/** Number of commands in bootInitSequence(). */
inline constexpr std::size_t kBt1035BootInitCommandCount = 3U;
/** Feasycom programming guide §5.1.4: I2S slave, 48 kHz, 32-bit. */
inline constexpr std::uint8_t kBt1035I2sSlave48k32Param = 67U;
/**
* Feasycom programming guide §5.1.4: I2S slave, 48 kHz, 24-bit — matches the
* ADAU1701 serial output word length (SigmaStudio Hardware Configuration).
* Bit field: BIT[0]=enable(1), BIT[1]=slave(1), BIT[2]=FS 48kHz(0),
* BIT[3]=left justified(0), BIT[4]=data 1-bit delay(0), BIT[5:6]=24-bit(01)
* -> 1 + 2 + 32 = 35.
*/
inline constexpr std::uint8_t kBt1035I2sSlave48k24Param = 35U;
/**
* @brief buildBt1035AtLine — serialise a command with CRLF terminator.
@@ -106,7 +133,7 @@ inline constexpr std::uint8_t kBt1035I2sSlave48k32Param = 67U;
* @brief bootInitSequence — mandatory bring-up commands in order.
*
* @dname bootInitSequence
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k32 (I2SCFG=67).
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k24 (I2SCFG=35).
* @pubstate none
*
* @author Michele Bigi
@@ -156,6 +183,33 @@ parseBt1035A2dpStatResponse(std::string_view response);
*/
[[nodiscard]] const char* a2dpStateToken(Bt1035A2dpState state) noexcept;
/**
* @brief parseBt1035A2dpEncoderResponse — extract negotiated codec.
*
* @dname parseBt1035A2dpEncoderResponse
* @param response Full module reply (may include +A2DPENC and OK lines).
* @return Bt1035A2dpCodec on success, or ParseError::MissingField.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::expected<Bt1035A2dpCodec, ParseError>
parseBt1035A2dpEncoderResponse(std::string_view response);
/**
* @brief a2dpCodecToken — serialise negotiated codec for logs/JSON.
*
* @dname a2dpCodecToken
* @param codec Parsed negotiated codec.
* @return Short stable string (e.g. "sbc").
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] const char* a2dpCodecToken(Bt1035A2dpCodec codec) noexcept;
/**
* @brief buildBt1035SetAutoConnLine — AT+AUTOCONN with reconnect count.
*
@@ -226,4 +280,149 @@ parseBt1035AutoConnResponse(std::string_view response);
[[nodiscard]] std::expected<std::vector<Bt1035PairedDevice>, ParseError>
parseBt1035PairedListResponse(std::string_view response);
/**
* @brief buildBt1035StartScanLine — AT+SCAN for nearby BR/EDR devices.
*
* @dname buildBt1035StartScanLine
* @param scanType AT+SCAN Param1; clamped to 1 or 2 (2 only when
* passed exactly, otherwise 1).
* @param scanSeconds BR/EDR inquiry time 1255 (Feasycom Param2).
* @return Full AT line including CRLF.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string buildBt1035StartScanLine(std::uint8_t scanType,
std::uint8_t scanSeconds = 0U);
/**
* @brief buildBt1035StopScanLine — stop an active device scan.
*
* @dname buildBt1035StopScanLine
* @return AT+SCAN=0 line with CRLF.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string buildBt1035StopScanLine();
/**
* @brief buildBt1035EnablePrintLine — ensure +SCAN events are reported.
*
* @dname buildBt1035EnablePrintLine
* @return AT+PRINT=1 line with CRLF.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string buildBt1035EnablePrintLine();
/**
* @brief buildBt1035DisconnectAllLine — release all BT connections.
*
* @dname buildBt1035DisconnectAllLine
* @return AT+DSCA line with CRLF.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string buildBt1035DisconnectAllLine();
/**
* @brief buildBt1035DisableAutoLinkLine — disable auto-link on power-up.
*
* @dname buildBt1035DisableAutoLinkLine
* @return AT+LINKCFG=0,0 line with CRLF.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string buildBt1035DisableAutoLinkLine();
/**
* @brief buildBt1035ClearPairedListLine — erase all paired records.
*
* @dname buildBt1035ClearPairedListLine
* @return AT+PLIST=0 line with CRLF.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string buildBt1035ClearPairedListLine();
/**
* @brief buildBt1035A2dpConnectLine — connect A2DP to a remote MAC.
*
* @dname buildBt1035A2dpConnectLine
* @param mac 12-char ASCII MAC address (no colons).
* @return AT+A2DPCONN line, or empty when mac invalid.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string buildBt1035A2dpConnectLine(std::string_view mac);
/**
* @brief buildBt1035A2dpAudioLine — start/stop A2DP audio (§5.3.6).
*
* @dname buildBt1035A2dpAudioLine
* @param establish true → AT+A2DPAUDIO=1, false → release audio.
* @return Full AT line including CRLF.
* @pubstate none
*
* Feasycom BT1035: link state Connected (3) is not enough for I2S→sink
* playback; audio starts after A2DPAUDIO=1 and +A2DPSTAT=4 (Streaming).
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string buildBt1035A2dpAudioLine(bool establish);
/**
* @brief isValidBt1035Mac — true for 12 hex digits.
*
* @dname isValidBt1035Mac
* @param mac Candidate MAC from scan or UI.
* @return true when exactly 12 hexadecimal characters.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] bool isValidBt1035Mac(std::string_view mac) noexcept;
/**
* @brief normalizeBt1035Mac — strip separators and uppercase MAC.
*
* @dname normalizeBt1035Mac
* @param mac MAC with optional colons or dashes.
* @return 12 uppercase hex digits, or empty when invalid length/chars.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string normalizeBt1035Mac(std::string_view mac);
/**
* @brief parseBt1035ScanResponse — parse +SCAN= inquiry results.
*
* @dname parseBt1035ScanResponse
* @param response Full UART payload until +SCAN=E.
* @return Discovered devices, or ParseError when payload malformed.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<std::vector<Bt1035ScannedDevice>, ParseError>
parseBt1035ScanResponse(std::string_view response);
} // namespace core
@@ -0,0 +1,39 @@
/**
* @file Bt1035ScannedDevice.hpp
* @brief One entry from FSC-BT1035 AT+SCAN (+SCAN= lines).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#pragma once
#include <cstdint>
#include <string>
namespace core {
/**
* @brief Bt1035ScannedDevice — nearby remote from +SCAN= events.
*
* @dname Bt1035ScannedDevice
* @return n/a (type)
* @pubstate Plain DTO parsed in the pure core.
*
* @author Michele Bigi
* @date 2026-08-05
*/
struct Bt1035ScannedDevice {
std::uint8_t index; ///< Scan result index from the module.
std::uint8_t addressType; ///< 0/1 LE, 2 BR/EDR (Feasycom manual).
std::string mac; ///< 12-char ASCII MAC without separators.
std::int16_t rssiDbm; ///< RSSI in dBm (-127..-1).
std::string name; ///< Remote friendly name when advertised.
std::string deviceClass; ///< Class of device hex string when present.
};
} // namespace core
@@ -0,0 +1,34 @@
/**
* @file BtSpeakerTarget.hpp
* @brief Saved A2DP speaker target (MAC + optional friendly name).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#pragma once
#include <string>
namespace core {
/**
* @brief BtSpeakerTarget — default BR/EDR speaker for AT+A2DPCONN.
*
* @dname BtSpeakerTarget
* @return n/a (type)
* @pubstate Persisted in NVS via ISecureStore; MAC is 12-char ASCII hex.
*
* @author Michele Bigi
* @date 2026-08-05
*/
struct BtSpeakerTarget {
std::string mac; ///< 12-char BR/EDR MAC (uppercase, no separators).
std::string name; ///< Optional label shown in the web UI.
};
} // namespace core
@@ -27,7 +27,7 @@ namespace core {
struct CompanionChipStatus {
bool si4684Ready; ///< Si4684 HOST_LOAD completed.
bool adau1701Ready; ///< ADAU1701 SigmaStudio download completed.
bool bt1035Ready; ///< BT1035 I2S init (AUXCFG=3 + I2SCFG=67) completed.
bool bt1035Ready; ///< BT1035 I2S init (AUXCFG=3 + I2SCFG=35) completed.
};
} // namespace core
@@ -130,6 +130,21 @@ public:
*/
[[nodiscard]] virtual std::expected<void, DspError> setEqBand(
EqBandIndex band, GainDb gain, FrequencyHz center, float q) = 0;
/**
* @brief setBeepEnabled — gate the SigmaStudio Beep1 tone generator.
*
* @dname setBeepEnabled
* @param enabled true unmutes Beep1, false mutes it.
* @return Ok on success, or DspError.
* @pubstate writes ADAU1701 parameter RAM via safeload. Not part of
* AudioProfile — live-only, never persisted.
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] virtual std::expected<void, DspError> setBeepEnabled(
bool enabled) = 0;
};
} // namespace core
@@ -18,6 +18,7 @@
#pragma once
#include "core/StoreError.hpp"
#include "core/BtSpeakerTarget.hpp"
#include "core/WifiCredentials.hpp"
#include <cstdint>
@@ -210,6 +211,97 @@ public:
*/
[[nodiscard]] virtual std::expected<void, StoreError>
clearLastPresetIndex() = 0;
/**
* @brief hasBtSpeakerTarget — check whether a default speaker MAC is stored.
*
* @dname hasBtSpeakerTarget
* @return true when loadBtSpeakerTarget would succeed.
* @pubstate reads backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] virtual bool hasBtSpeakerTarget() const = 0;
/**
* @brief saveBtSpeakerTarget — persist default A2DP speaker MAC/name.
*
* @dname saveBtSpeakerTarget
* @param target Validated MAC and optional display name.
* @return Ok on success, or StoreError::IoFailed.
* @pubstate writes backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] virtual std::expected<void, StoreError>
saveBtSpeakerTarget(const BtSpeakerTarget& target) = 0;
/**
* @brief loadBtSpeakerTarget — read stored default speaker.
*
* @dname loadBtSpeakerTarget
* @return BtSpeakerTarget on success, or StoreError.
* @pubstate reads backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] virtual std::expected<BtSpeakerTarget, StoreError>
loadBtSpeakerTarget() const = 0;
/**
* @brief clearBtSpeakerTarget — erase stored default speaker.
*
* @dname clearBtSpeakerTarget
* @return Ok on success, or StoreError::IoFailed.
* @pubstate clears backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] virtual std::expected<void, StoreError>
clearBtSpeakerTarget() = 0;
/**
* @brief hasWebRadioConfig — check whether a streaming config is stored.
*
* @dname hasWebRadioConfig
* @return true when loadWebRadioConfigJson would succeed.
* @pubstate reads backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] virtual bool hasWebRadioConfig() const = 0;
/**
* @brief saveWebRadioConfigJson — persist serialised streaming config.
*
* @dname saveWebRadioConfigJson
* @param json Output of core::serializeWebRadioConfigJson().
* @return Ok on success, or StoreError::IoFailed.
* @pubstate writes backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] virtual std::expected<void, StoreError>
saveWebRadioConfigJson(std::string_view json) = 0;
/**
* @brief loadWebRadioConfigJson — read stored streaming config.
*
* @dname loadWebRadioConfigJson
* @return JSON blob on success, or StoreError.
* @pubstate reads backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] virtual std::expected<std::string, StoreError>
loadWebRadioConfigJson() const = 0;
};
} // namespace core
@@ -33,11 +33,11 @@ struct MixerState {
GainDb si4684Right; ///< Si4674 volume control, right channel.
GainDb esp32Left; ///< ESP32 volume control, left channel.
GainDb esp32Right; ///< ESP32 volume control, right channel.
GainDb mixLeft; ///< St Mixer1 left blend level.
GainDb mixRight; ///< St Mixer1 right blend level.
GainDb mixLeft; ///< St Mixer1 ST0 (Si4684) input level.
GainDb mixRight; ///< St Mixer1 ST1 (ESP32) input level.
/**
* @brief factoryDefault — unity gains on all paths (radio-first mix).
* @brief factoryDefault — unity gains on all paths.
*
* @dname factoryDefault
* @return MixerState with 0 dB on every control.
@@ -47,6 +47,18 @@ struct MixerState {
* @date 2026-07-06
*/
[[nodiscard]] static MixerState factoryDefault() noexcept;
/**
* @brief radioFirst — Si4684 path open, ESP32 path muted.
*
* @dname radioFirst
* @return MixerState for FM/DAB tuner audio to the Bose I2S chain.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] static MixerState radioFirst() noexcept;
};
} // namespace core
@@ -58,6 +58,43 @@ struct TunerPlayRequest {
std::uint32_t componentId; ///< Audio component within the service.
};
/**
* @brief TunerScanRequest — parsed POST /api/tuner/scan body.
*
* @dname TunerScanRequest
* @return n/a (type)
* @pubstate Plain DTO filled by parseTunerScanJson at the HTTP boundary.
*
* @author Michele Bigi
* @date 2026-08-05
*/
struct TunerScanRequest {
TunerBand band; ///< FM seek scan or DAB ensemble scan.
std::uint8_t maxSteps; ///< Max FM seeks or DAB ensemble indices to try.
std::string nameFilter; ///< Optional case-insensitive substring (PS/DAB label).
};
/**
* @brief TunerScanResult — outcome of an automatic station search.
*
* @dname TunerScanResult
* @return n/a (type)
* @pubstate Built by TunerService::scanForStation().
*
* @author Michele Bigi
* @date 2026-08-05
*/
struct TunerScanResult {
bool found; ///< True when a station matched criteria.
TunerBand band; ///< Band that was scanned.
std::uint16_t stepsTried; ///< Seek or ensemble attempts performed.
std::optional<FrequencyKHz> fmFrequency; ///< FM centre when found on FM.
std::optional<std::uint8_t> dabFreqIndex; ///< Ensemble index when found on DAB.
std::optional<std::uint32_t> dabServiceId; ///< Started DAB service id.
std::optional<std::uint32_t> dabComponentId; ///< Started DAB component id.
std::optional<BroadcastLabel> stationName; ///< RDS PS or DAB label when known.
};
/**
* @brief serializeTunerStatusJson — serialise a tuner snapshot for GET status.
*
@@ -143,4 +180,31 @@ struct TunerPlayRequest {
[[nodiscard]] std::expected<SeekDirection, ParseError> parseTunerSeekJson(
std::string_view json);
/**
* @brief parseTunerScanJson — validate POST /api/tuner/scan body.
*
* @dname parseTunerScanJson
* @param json Untrusted request body from the HTTP handler.
* @return TunerScanRequest on success, or a ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<TunerScanRequest, ParseError> parseTunerScanJson(
std::string_view json);
/**
* @brief serializeTunerScanJson — serialise automatic scan outcome.
*
* @dname serializeTunerScanJson
* @param result Scan result from TunerService::scanForStation().
* @return JSON object for the HTTP response body.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string serializeTunerScanJson(const TunerScanResult& result);
} // namespace core
@@ -60,6 +60,7 @@ struct TunerStatus {
std::optional<std::uint32_t> dabPlayingServiceId; ///< Last played DAB service.
std::optional<std::uint32_t> dabPlayingComponentId; ///< Last played DAB component.
std::optional<FrequencyKHz> fmFrequency; ///< Tuned FM centre frequency.
std::optional<FrequencyKHz> fmChipReadFrequency; ///< FM_RSQ READFREQ (may lag commanded).
std::optional<std::int8_t> fmRssiDbuV; ///< FM RSSI in dBµV.
std::optional<std::int8_t> fmSnrDb; ///< FM SNR in dB.
std::optional<bool> fmStereo; ///< FM stereo pilot detected.
@@ -0,0 +1,52 @@
/**
* @file WebRadioConfig.hpp
* @brief Internet radio stream configuration (URL + enable flag).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-08
*/
#pragma once
#include <string>
namespace core {
/**
* @brief WebRadioConfig — HTTP MP3 stream URL and on/off state.
*
* @dname WebRadioConfig
* @return n/a (type)
* @pubstate Plain value type; url is a plaintext http:// URL (no secrets).
*
* @author Michele Bigi
* @date 2026-08-08
*/
struct WebRadioConfig {
bool enabled = false; ///< Whether the streaming task should be playing.
std::string url; ///< Plaintext http:// MP3 stream URL.
/**
* @brief factoryDefault — disabled, with a known-good sample URL.
*
* @dname factoryDefault
* @return WebRadioConfig with enabled=false and a placeholder URL.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] static WebRadioConfig factoryDefault()
{
return WebRadioConfig{
.enabled = false,
.url = "http://edge.radiomontecarlo.net/RMC.mp3",
};
}
};
} // namespace core
@@ -0,0 +1,66 @@
/**
* @file WebRadioJson.hpp
* @brief JSON parse/serialise for the web radio streaming API (pure core).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-08
*/
#pragma once
#include "core/ParseError.hpp"
#include "core/WebRadioConfig.hpp"
#include <expected>
#include <string>
#include <string_view>
namespace core {
/**
* @brief parseWebRadioConfigJson — parse POST /api/streaming body.
*
* @dname parseWebRadioConfigJson
* @param json Untrusted request body: {"enabled":bool,"url":string}.
* @return WebRadioConfig on success, or ParseError. Rejects URLs that
* are missing, longer than 200 bytes, or not http://.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] std::expected<WebRadioConfig, ParseError> parseWebRadioConfigJson(
std::string_view json);
/**
* @brief serializeWebRadioConfigJson — serialise for GET /api/streaming.
*
* @dname serializeWebRadioConfigJson
* @param config Current streaming configuration.
* @return JSON object string for the HTTP response body.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] std::string serializeWebRadioConfigJson(
const WebRadioConfig& config);
/**
* @brief serializeWebRadioErrorJson — error response for streaming routes.
*
* @dname serializeWebRadioErrorJson
* @param reason Safe token (never includes secrets).
* @return JSON object with status and reason fields.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] std::string serializeWebRadioErrorJson(std::string_view reason);
} // namespace core
@@ -0,0 +1,50 @@
/**
* @file WifiScanJson.hpp
* @brief JSON serialisation for Wi-Fi scan REST API (pure core).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#pragma once
#include "core/WifiScannedNetwork.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace core {
/**
* @brief serializeWifiScanJson — serialise scan result list.
*
* @dname serializeWifiScanJson
* @param networks Deduped AP records sorted by signal strength.
* @return JSON object with a networks array.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string serializeWifiScanJson(
const std::vector<WifiScannedNetwork>& networks);
/**
* @brief serializeWifiScanErrorJson — serialise a Wi-Fi scan API error.
*
* @dname serializeWifiScanErrorJson
* @param reason Short machine-readable cause.
* @return JSON error object.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string serializeWifiScanErrorJson(std::string_view reason);
} // namespace core
@@ -0,0 +1,37 @@
/**
* @file WifiScannedNetwork.hpp
* @brief One entry from a nearby Wi-Fi access-point scan.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#pragma once
#include <cstdint>
#include <string>
namespace core {
/**
* @brief WifiScannedNetwork — nearby AP from a Wi-Fi scan.
*
* @dname WifiScannedNetwork
* @return n/a (type)
* @pubstate Plain DTO assembled by the net shell after esp_wifi scan.
*
* @author Michele Bigi
* @date 2026-08-05
*/
struct WifiScannedNetwork {
std::string ssid; ///< Broadcast SSID (may be empty when hidden).
std::int16_t rssiDbm; ///< RSSI in dBm.
std::string auth; ///< Short auth token (open, wpa2, wpa3, …).
std::uint8_t channel; ///< Primary channel number.
};
} // namespace core
@@ -54,6 +54,25 @@ namespace {
return end != json.data() + valueStart;
}
[[nodiscard]] bool extractJsonBool(std::string_view json,
std::string_view key,
bool& out)
{
const std::string trueNeedle =
std::string("\"") + std::string(key) + "\":true";
const std::string falseNeedle =
std::string("\"") + std::string(key) + "\":false";
if (json.find(trueNeedle) != std::string_view::npos) {
out = true;
return true;
}
if (json.find(falseNeedle) != std::string_view::npos) {
out = false;
return true;
}
return false;
}
[[nodiscard]] std::expected<GainDb, ParseError> parseGainField(
std::string_view json, std::string_view key)
{
@@ -277,4 +296,17 @@ std::expected<EnhanceLevel, ParseError> parseEnhanceLevelJson(
return EnhanceLevel::tryFromLevel(level);
}
std::expected<bool, ParseError> parseBeepEnabledJson(std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
bool enabled = false;
if (!extractJsonBool(json, "enabled", enabled)) {
return std::unexpected(ParseError::MissingField);
}
return enabled;
}
} // namespace core
@@ -14,6 +14,7 @@
#include "core/BluetoothJson.hpp"
#include <cstdlib>
#include <cctype>
#include <sstream>
namespace core {
@@ -95,4 +96,112 @@ parseBluetoothAutoReconnectJson(std::string_view json)
return static_cast<std::uint8_t>(raw);
}
std::string serializeBluetoothScanJson(
const std::vector<Bt1035ScannedDevice>& devices)
{
std::ostringstream out;
out << "{\"devices\":[";
for (std::size_t i = 0; i < devices.size(); ++i) {
if (i > 0U) {
out << ',';
}
const Bt1035ScannedDevice& device = devices[i];
out << "{\"index\":" << static_cast<unsigned>(device.index)
<< ",\"mac\":";
appendJsonString(out, device.mac);
out << ",\"name\":";
appendJsonString(out, device.name);
out << ",\"rssi_dbm\":" << device.rssiDbm << "}";
}
out << "]}";
return out.str();
}
std::expected<std::string, ParseError>
parseBluetoothConnectJson(std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const std::string needle = "\"mac\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::size_t valueStart = start + needle.size();
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const std::string_view mac = json.substr(valueStart, valueEnd - valueStart);
if (!isValidBt1035Mac(mac)) {
return std::unexpected(ParseError::InvalidJson);
}
std::string normalized;
normalized.reserve(12U);
for (const char ch : mac) {
normalized.push_back(
static_cast<char>(std::toupper(static_cast<unsigned char>(ch))));
}
return normalized;
}
BluetoothConnectRequest parseBluetoothConnectRequest(std::string_view json)
{
BluetoothConnectRequest request{
.mac = {},
.name = {},
.save = false,
};
if (auto mac = parseBluetoothConnectJson(json); mac) {
request.mac = std::move(*mac);
}
const std::string nameNeedle = "\"name\":\"";
const std::size_t nameStart = json.find(nameNeedle);
if (nameStart != std::string_view::npos) {
const std::size_t valueStart = nameStart + nameNeedle.size();
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd != std::string_view::npos) {
request.name.assign(json.substr(valueStart, valueEnd - valueStart));
}
}
request.save = json.find("\"save\":true") != std::string_view::npos
|| json.find("\"save\": true") != std::string_view::npos;
return request;
}
std::string serializeBluetoothSpeakerJson(const BtSpeakerTarget* target)
{
if (target == nullptr) {
return "{\"configured\":false}";
}
std::ostringstream out;
out << "{\"configured\":true,\"mac\":";
appendJsonString(out, target->mac);
out << ",\"name\":";
appendJsonString(out, target->name);
out << '}';
return out.str();
}
std::expected<BtSpeakerTarget, ParseError>
parseBluetoothSpeakerJson(std::string_view json)
{
const auto mac = parseBluetoothConnectJson(json);
if (!mac) {
return std::unexpected(mac.error());
}
BtSpeakerTarget target{.mac = *mac, .name = {}};
const std::string nameNeedle = "\"name\":\"";
const std::size_t nameStart = json.find(nameNeedle);
if (nameStart != std::string_view::npos) {
const std::size_t valueStart = nameStart + nameNeedle.size();
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd != std::string_view::npos) {
target.name.assign(json.substr(valueStart, valueEnd - valueStart));
}
}
return target;
}
} // namespace core
+220 -3
View File
@@ -13,6 +13,7 @@
#include "core/Bt1035At.hpp"
#include <cctype>
#include <cstdlib>
#include <vector>
@@ -99,18 +100,22 @@ namespace {
std::string buildBt1035AtLine(Bt1035AtCommand command)
{
switch (command) {
case Bt1035AtCommand::Reset:
return "AT+RESET\r\n";
case Bt1035AtCommand::Ping:
return "AT\r\n";
case Bt1035AtCommand::I2sMode:
return "AT+AUXCFG=3\r\n";
case Bt1035AtCommand::I2sSlave48k32:
return "AT+I2SCFG=67\r\n";
case Bt1035AtCommand::I2sSlave48k24:
return "AT+I2SCFG=35\r\n";
case Bt1035AtCommand::PairDiscoverable:
return "AT+PAIR=1\r\n";
case Bt1035AtCommand::PairHidden:
return "AT+PAIR=0\r\n";
case Bt1035AtCommand::A2dpStat:
return "AT+A2DPSTAT\r\n";
case Bt1035AtCommand::A2dpEncoder:
return "AT+A2DPENC\r\n";
case Bt1035AtCommand::A2dpDisconnect:
return "AT+A2DPDISC\r\n";
case Bt1035AtCommand::QueryName:
@@ -145,7 +150,7 @@ std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noex
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
Bt1035AtCommand::Ping,
Bt1035AtCommand::I2sMode,
Bt1035AtCommand::I2sSlave48k32,
Bt1035AtCommand::I2sSlave48k24,
};
}
@@ -206,6 +211,43 @@ const char* a2dpStateToken(Bt1035A2dpState state) noexcept
return "unknown";
}
std::expected<Bt1035A2dpCodec, ParseError>
parseBt1035A2dpEncoderResponse(std::string_view response)
{
constexpr std::string_view kPrefix = "+A2DPENC=";
const std::size_t pos = response.find(kPrefix);
if (pos == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::size_t valueStart = pos + kPrefix.size();
char* end = nullptr;
const unsigned long raw =
std::strtoul(response.data() + valueStart, &end, 10);
if (end == response.data() + valueStart || raw < 1U || raw > 5U) {
return std::unexpected(ParseError::MissingField);
}
return static_cast<Bt1035A2dpCodec>(raw);
}
const char* a2dpCodecToken(Bt1035A2dpCodec codec) noexcept
{
switch (codec) {
case Bt1035A2dpCodec::Sbc:
return "sbc";
case Bt1035A2dpCodec::Aptx:
return "aptx";
case Bt1035A2dpCodec::AptxHd:
return "aptx-hd";
case Bt1035A2dpCodec::AptxLl:
return "aptx-ll";
case Bt1035A2dpCodec::AptxAdaptive:
return "aptx-adaptive";
}
return "unknown";
}
std::expected<std::string, ParseError>
parseBt1035NameResponse(std::string_view response)
{
@@ -286,4 +328,179 @@ parseBt1035PairedListResponse(std::string_view response)
return devices;
}
std::string buildBt1035StartScanLine(std::uint8_t scanType, std::uint8_t scanSeconds)
{
const std::uint8_t type = (scanType == 2U) ? 2U : 1U;
if (scanSeconds == 0U) {
return "AT+SCAN=" + std::to_string(type) + "\r\n";
}
return "AT+SCAN=" + std::to_string(type) + ","
+ std::to_string(scanSeconds) + "\r\n";
}
std::string buildBt1035StopScanLine()
{
return "AT+SCAN=0\r\n";
}
std::string buildBt1035EnablePrintLine()
{
return "AT+PRINT=1\r\n";
}
std::string buildBt1035DisconnectAllLine()
{
return "AT+DSCA\r\n";
}
std::string buildBt1035DisableAutoLinkLine()
{
return "AT+LINKCFG=0,0\r\n";
}
std::string buildBt1035ClearPairedListLine()
{
return "AT+PLIST=0\r\n";
}
bool isValidBt1035Mac(std::string_view mac) noexcept
{
if (mac.size() != 12U) {
return false;
}
for (const char ch : mac) {
if (!std::isxdigit(static_cast<unsigned char>(ch))) {
return false;
}
}
return true;
}
[[nodiscard]] std::string normalizeBt1035Mac(std::string_view mac)
{
std::string normalized;
normalized.reserve(12U);
for (const char ch : mac) {
if (ch == ':' || ch == '-') {
continue;
}
normalized.push_back(
static_cast<char>(std::toupper(static_cast<unsigned char>(ch))));
}
return normalized;
}
std::string buildBt1035A2dpConnectLine(std::string_view mac)
{
if (!isValidBt1035Mac(mac)) {
return {};
}
return std::string("AT+A2DPCONN=") + std::string(mac) + "\r\n";
}
std::string buildBt1035A2dpAudioLine(bool establish)
{
return establish ? "AT+A2DPAUDIO=1\r\n" : "AT+A2DPAUDIO=0\r\n";
}
std::expected<std::vector<Bt1035ScannedDevice>, ParseError>
parseBt1035ScanResponse(std::string_view response)
{
std::vector<Bt1035ScannedDevice> devices;
std::size_t pos = 0;
while ((pos = response.find("+SCAN", pos)) != std::string_view::npos) {
std::size_t start = pos + 5U;
if (start < response.size() && response[start] == '=') {
++start;
}
while (start < response.size()
&& (response[start] == ' ' || response[start] == '\t')) {
++start;
}
const std::size_t end = response.find_first_of("\r\n", start);
const std::string_view line =
end == std::string_view::npos ? response.substr(start)
: response.substr(start, end - start);
pos = end == std::string_view::npos ? response.size() : end + 1U;
if (line.empty() || line == "E" || line.front() == 'E') {
continue;
}
const std::string lineStr(line);
const std::size_t comma1 = lineStr.find(',');
const std::size_t comma2 =
comma1 == std::string_view::npos ? std::string_view::npos
: lineStr.find(',', comma1 + 1U);
const std::size_t comma3 =
comma2 == std::string_view::npos ? std::string_view::npos
: lineStr.find(',', comma2 + 1U);
const std::size_t comma4 =
comma3 == std::string_view::npos ? std::string_view::npos
: lineStr.find(',', comma3 + 1U);
if (comma4 == std::string_view::npos) {
continue;
}
char* endIdx = nullptr;
const unsigned long index =
std::strtoul(lineStr.c_str(), &endIdx, 10);
if (endIdx == lineStr.c_str() || index == 0U) {
continue;
}
const unsigned long addrType =
std::strtoul(lineStr.c_str() + comma1 + 1U, &endIdx, 10);
const std::string mac =
normalizeBt1035Mac(lineStr.substr(comma2 + 1U, comma3 - comma2 - 1U));
if (!isValidBt1035Mac(mac)) {
continue;
}
const long rssi =
std::strtol(lineStr.c_str() + comma3 + 1U, &endIdx, 10);
const unsigned long nameLen =
std::strtoul(lineStr.c_str() + comma4 + 1U, &endIdx, 10);
const std::size_t afterNameLen =
static_cast<std::size_t>(endIdx - lineStr.c_str());
std::string name;
std::string deviceClass;
if (nameLen > 0U) {
if (afterNameLen >= lineStr.size() || lineStr[afterNameLen] != ',') {
continue;
}
const std::size_t nameStart = afterNameLen + 1U;
if (nameStart + nameLen > lineStr.size()) {
continue;
}
name.assign(lineStr.substr(nameStart, nameLen));
const std::size_t afterName = nameStart + nameLen;
if (afterName < lineStr.size() && lineStr[afterName] == ',') {
deviceClass.assign(lineStr.substr(afterName + 1U));
}
} else if (afterNameLen < lineStr.size()
&& lineStr[afterNameLen] == ',') {
const std::size_t classStart = afterNameLen + 1U;
if (classStart < lineStr.size()) {
if (lineStr[classStart] == ',') {
deviceClass.assign(lineStr.substr(classStart + 1U));
} else {
deviceClass.assign(lineStr.substr(classStart));
}
}
}
Bt1035ScannedDevice entry = {};
entry.index = static_cast<std::uint8_t>(index);
entry.addressType = static_cast<std::uint8_t>(addrType);
entry.mac = mac;
entry.rssiDbm = static_cast<std::int16_t>(rssi);
entry.name = std::move(name);
entry.deviceClass = std::move(deviceClass);
devices.push_back(std::move(entry));
}
return devices;
}
} // namespace core
@@ -28,4 +28,18 @@ MixerState MixerState::factoryDefault() noexcept
};
}
MixerState MixerState::radioFirst() noexcept
{
const GainDb unity = GainDb::zero();
const GainDb muted = *GainDb::tryFromDb(GainDb::kMinDb);
return MixerState{
.si4684Left = unity,
.si4684Right = unity,
.esp32Left = muted,
.esp32Right = muted,
.mixLeft = unity,
.mixRight = muted,
};
}
} // namespace core
@@ -246,4 +246,74 @@ std::expected<SeekDirection, ParseError> parseTunerSeekJson(
return std::unexpected(ParseError::InvalidJson);
}
std::expected<TunerScanRequest, ParseError> parseTunerScanJson(
std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const std::string_view band = extractJsonString(json, "band");
if (band.empty()) {
return std::unexpected(ParseError::MissingField);
}
TunerScanRequest req = {};
unsigned long maxSteps = 0U;
if (band == "fm") {
req.band = TunerBand::Fm;
req.maxSteps = 45U;
} else if (band == "dab") {
req.band = TunerBand::Dab;
req.maxSteps = 38U;
} else {
return std::unexpected(ParseError::InvalidJson);
}
if (extractJsonUint(json, "max_steps", maxSteps)) {
if (maxSteps == 0U || maxSteps > 255U) {
return std::unexpected(ParseError::InvalidJson);
}
req.maxSteps = static_cast<std::uint8_t>(maxSteps);
}
const std::string_view name = extractJsonString(json, "name");
if (!name.empty()) {
req.nameFilter.assign(name.begin(), name.end());
for (char& ch : req.nameFilter) {
if (ch >= 'A' && ch <= 'Z') {
ch = static_cast<char>(ch - 'A' + 'a');
}
}
}
return req;
}
std::string serializeTunerScanJson(const TunerScanResult& result)
{
std::ostringstream out;
out << "{\"status\":\"" << (result.found ? "found" : "not_found") << '"'
<< ",\"band\":\"" << bandToken(result.band) << '"'
<< ",\"steps\":" << result.stepsTried;
if (result.fmFrequency) {
out << ",\"frequency_khz\":" << result.fmFrequency->value();
}
if (result.dabFreqIndex) {
out << ",\"freq_index\":" << static_cast<unsigned>(*result.dabFreqIndex);
}
if (result.dabServiceId) {
out << ",\"service_id\":" << *result.dabServiceId;
}
if (result.dabComponentId) {
out << ",\"component_id\":" << *result.dabComponentId;
}
if (result.stationName) {
out << ",\"station_name\":";
appendJsonString(out, result.stationName->value());
}
out << '}';
return out.str();
}
} // namespace core
@@ -0,0 +1,89 @@
/**
* @file WebRadioJson.cpp
* @brief WebRadioJson implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-08
*/
#include "core/WebRadioJson.hpp"
namespace core {
namespace {
constexpr std::size_t kMaxUrlLength = 200U;
constexpr std::string_view kHttpPrefix = "http://";
[[nodiscard]] std::string_view extractJsonString(std::string_view json,
std::string_view key)
{
const std::string needle =
std::string("\"") + std::string(key) + "\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
return {};
}
const std::size_t valueStart = start + needle.size();
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return {};
}
return json.substr(valueStart, valueEnd - valueStart);
}
[[nodiscard]] bool extractJsonBool(std::string_view json,
std::string_view key, bool& out)
{
const std::string trueNeedle =
std::string("\"") + std::string(key) + "\":true";
const std::string falseNeedle =
std::string("\"") + std::string(key) + "\":false";
if (json.find(trueNeedle) != std::string_view::npos) {
out = true;
return true;
}
if (json.find(falseNeedle) != std::string_view::npos) {
out = false;
return true;
}
return false;
}
} // namespace
std::expected<WebRadioConfig, ParseError> parseWebRadioConfigJson(
std::string_view json)
{
bool enabled = false;
if (!extractJsonBool(json, "enabled", enabled)) {
return std::unexpected(ParseError::MissingField);
}
const std::string_view url = extractJsonString(json, "url");
if (url.empty() || url.size() > kMaxUrlLength
|| url.substr(0, kHttpPrefix.size()) != kHttpPrefix) {
return std::unexpected(ParseError::InvalidJson);
}
return WebRadioConfig{.enabled = enabled, .url = std::string(url)};
}
std::string serializeWebRadioConfigJson(const WebRadioConfig& config)
{
return std::string(R"({"enabled":)") + (config.enabled ? "true" : "false")
+ R"(,"url":")" + config.url + "\"}";
}
std::string serializeWebRadioErrorJson(std::string_view reason)
{
return std::string(R"({"status":"error","reason":")")
+ std::string(reason) + "\"}";
}
} // namespace core
@@ -0,0 +1,73 @@
/**
* @file WifiScanJson.cpp
* @brief Wi-Fi scan JSON serialisation implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#include "core/WifiScanJson.hpp"
#include <sstream>
namespace core {
namespace {
/**
* @brief appendJsonString — emit a JSON string literal.
*
* @dname appendJsonString
* @param out Output stream.
* @param text Raw UTF-8 text.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
void appendJsonString(std::ostringstream& out, std::string_view text)
{
out << '"';
for (const char ch : text) {
if (ch == '"' || ch == '\\') {
out << '\\';
}
out << ch;
}
out << '"';
}
} // namespace
std::string serializeWifiScanJson(
const std::vector<WifiScannedNetwork>& networks)
{
std::ostringstream out;
out << "{\"networks\":[";
for (std::size_t i = 0; i < networks.size(); ++i) {
if (i > 0U) {
out << ',';
}
const WifiScannedNetwork& network = networks[i];
out << "{\"ssid\":";
appendJsonString(out, network.ssid);
out << ",\"rssi_dbm\":" << network.rssiDbm << ",\"auth\":";
appendJsonString(out, network.auth);
out << ",\"channel\":" << static_cast<unsigned>(network.channel) << '}';
}
out << "]}";
return out.str();
}
std::string serializeWifiScanErrorJson(std::string_view reason)
{
return std::string("{\"status\":\"error\",\"reason\":\"") + std::string(reason)
+ "\"}";
}
} // namespace core
@@ -21,6 +21,7 @@ add_library(digiradio_core STATIC
"${CORE_SRC_DIR}/WifiSsid.cpp"
"${CORE_SRC_DIR}/WifiCredentials.cpp"
"${CORE_SRC_DIR}/WifiProvisionJson.cpp"
"${CORE_SRC_DIR}/WifiScanJson.cpp"
"${CORE_SRC_DIR}/EmbeddedBlobReader.cpp"
"${CORE_SRC_DIR}/TunerJson.cpp"
"${CORE_SRC_DIR}/FrequencyKHz.cpp"
@@ -50,6 +51,7 @@ add_library(digiradio_core STATIC
"${CORE_SRC_DIR}/RegisterWrite.cpp"
"${CORE_SRC_DIR}/DspProgramBlob.cpp"
"${CORE_SRC_DIR}/OtaAppDescriptor.cpp"
"${CORE_SRC_DIR}/WebRadioJson.cpp"
)
target_include_directories(digiradio_core PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/../include"
@@ -75,6 +77,10 @@ add_executable(wifi_provision_test wifi_provision_test.cpp)
target_link_libraries(wifi_provision_test PRIVATE digiradio_core)
add_test(NAME wifi_provision_test COMMAND wifi_provision_test)
add_executable(wifi_scan_test wifi_scan_test.cpp)
target_link_libraries(wifi_scan_test PRIVATE digiradio_core)
add_test(NAME wifi_scan_test COMMAND wifi_scan_test)
add_executable(embedded_blob_reader_test embedded_blob_reader_test.cpp)
target_link_libraries(embedded_blob_reader_test PRIVATE digiradio_core)
add_test(NAME embedded_blob_reader_test COMMAND embedded_blob_reader_test)
@@ -101,6 +107,7 @@ add_test(NAME enhancements_design_test COMMAND enhancements_design_test)
add_executable(station_service_test station_service_test.cpp)
target_include_directories(station_service_test PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/host_stubs"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/station/include"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/tuner/include"
)
@@ -123,8 +130,13 @@ add_executable(broadcast_metadata_test broadcast_metadata_test.cpp)
target_link_libraries(broadcast_metadata_test PRIVATE digiradio_core)
add_test(NAME broadcast_metadata_test COMMAND broadcast_metadata_test)
add_executable(web_radio_json_test web_radio_json_test.cpp)
target_link_libraries(web_radio_json_test PRIVATE digiradio_core)
add_test(NAME web_radio_json_test COMMAND web_radio_json_test)
add_executable(integration_service_test integration_service_test.cpp)
target_include_directories(integration_service_test PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/host_stubs"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/integration/include"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/station/include"
"${CMAKE_CURRENT_SOURCE_DIR}/../../services/tuner/include"
@@ -72,6 +72,31 @@ namespace {
return EXIT_SUCCESS;
}
[[nodiscard]] int runBeepEnabledJsonTest()
{
const auto onParsed = core::parseBeepEnabledJson(R"({"enabled":true})");
if (!onParsed || *onParsed != true) {
std::cerr << "beep enabled=true parse mismatch\n";
return EXIT_FAILURE;
}
const auto offParsed = core::parseBeepEnabledJson(R"({"enabled":false})");
if (!offParsed || *offParsed != false) {
std::cerr << "beep enabled=false parse mismatch\n";
return EXIT_FAILURE;
}
const auto missing = core::parseBeepEnabledJson(R"({})");
if (missing) {
std::cerr << "beep missing field should fail\n";
return EXIT_FAILURE;
}
const auto malformed = core::parseBeepEnabledJson("not json");
if (malformed) {
std::cerr << "beep malformed body should fail\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
@@ -79,5 +104,8 @@ int main()
if (runRoundTripTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runBeepEnabledJsonTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
@@ -13,6 +13,7 @@
#include "core/Bt1035At.hpp"
#include "core/Bt1035PairedDevice.hpp"
#include "core/Bt1035ScannedDevice.hpp"
#include "core/BluetoothJson.hpp"
#include <cstdlib>
@@ -32,7 +33,7 @@ namespace {
std::cerr << "I2S mode must be in init sequence\n";
return EXIT_FAILURE;
}
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k32) {
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k24) {
std::cerr << "I2SCFG must be in init sequence\n";
return EXIT_FAILURE;
}
@@ -43,9 +44,15 @@ namespace {
return EXIT_FAILURE;
}
const std::string i2sCfg =
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k32);
if (i2sCfg != "AT+I2SCFG=67\r\n") {
std::cerr << "I2SCFG=67 command line mismatch\n";
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k24);
if (i2sCfg != "AT+I2SCFG=35\r\n") {
std::cerr << "I2SCFG=35 command line mismatch\n";
return EXIT_FAILURE;
}
const std::string reset =
core::buildBt1035AtLine(core::Bt1035AtCommand::Reset);
if (reset != "AT+RESET\r\n") {
std::cerr << "AT+RESET command line mismatch\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
@@ -136,6 +143,115 @@ namespace {
return EXIT_SUCCESS;
}
[[nodiscard]] int runScanConnectTest()
{
if (core::buildBt1035StartScanLine(1U, 20U) != "AT+SCAN=1,20\r\n") {
std::cerr << "SCAN start line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035StartScanLine(1U, 0U) != "AT+SCAN=1\r\n") {
std::cerr << "SCAN default line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035StartScanLine(2U, 15U) != "AT+SCAN=2,15\r\n") {
std::cerr << "BLE SCAN line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035EnablePrintLine() != "AT+PRINT=1\r\n") {
std::cerr << "PRINT line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035DisableAutoLinkLine() != "AT+LINKCFG=0,0\r\n") {
std::cerr << "LINKCFG line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035ClearPairedListLine() != "AT+PLIST=0\r\n") {
std::cerr << "PLIST clear line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035DisconnectAllLine() != "AT+DSCA\r\n") {
std::cerr << "DSCA line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035StopScanLine() != "AT+SCAN=0\r\n") {
std::cerr << "SCAN stop line mismatch\n";
return EXIT_FAILURE;
}
if (!core::isValidBt1035Mac("AABBCCDDEEFF")
|| core::isValidBt1035Mac("bad")) {
std::cerr << "MAC validation failed\n";
return EXIT_FAILURE;
}
if (core::buildBt1035A2dpConnectLine("aabbccddeeff")
!= "AT+A2DPCONN=aabbccddeeff\r\n") {
std::cerr << "A2DPCONN line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035A2dpAudioLine(true) != "AT+A2DPAUDIO=1\r\n") {
std::cerr << "A2DPAUDIO=1 line mismatch\n";
return EXIT_FAILURE;
}
if (core::buildBt1035A2dpAudioLine(false) != "AT+A2DPAUDIO=0\r\n") {
std::cerr << "A2DPAUDIO=0 line mismatch\n";
return EXIT_FAILURE;
}
const auto scanned = core::parseBt1035ScanResponse(
"+SCAN=1,0,112233445566,-55,7,MyPhone,240404\r\n"
"+SCAN=2,0,FFEEDDCCBBAA,-70,0,,240404\r\n"
"+SCAN=E\r\n"
"OK\r\n");
if (!scanned || scanned->size() != 2U || (*scanned)[0U].mac != "112233445566"
|| (*scanned)[0U].name != "MyPhone" || (*scanned)[0U].rssiDbm != -55
|| !(*scanned)[1U].name.empty()) {
std::cerr << "SCAN parse failed\n";
return EXIT_FAILURE;
}
const auto colonMac = core::parseBt1035ScanResponse(
"+SCAN=1,2,AA:BB:CC:DD:EE:FF,-60,4,Test,240404\r\n+SCAN=E\r\n");
if (!colonMac || colonMac->size() != 1U
|| (*colonMac)[0U].mac != "AABBCCDDEEFF") {
std::cerr << "SCAN colon MAC parse failed\n";
return EXIT_FAILURE;
}
const auto userLogScan = core::parseBt1035ScanResponse(
"+DEVSTAT=9\r\n\r\nOK\r\n\r\n"
"+SCAN=1,2,6960BB7EC5AA,-71,8,HY300PRO,1A0114\r\n\r\n"
"+SCAN=2,2,BC87FAE69D6E,-49,0\r\n\r\n"
"+DEVSTAT=1\r\n");
if (!userLogScan || userLogScan->size() != 2U
|| (*userLogScan)[0U].name != "HY300PRO"
|| (*userLogScan)[0U].mac != "6960BB7EC5AA"
|| (*userLogScan)[1U].mac != "BC87FAE69D6E"
|| !(*userLogScan)[1U].name.empty()
|| (*userLogScan)[1U].rssiDbm != -49) {
std::cerr << "SCAN user-log payload parse failed\n";
return EXIT_FAILURE;
}
const auto bareEmptyName = core::parseBt1035ScanResponse(
"+SCAN=2,2,BC87FAE69D6E,-49,0\r\n");
if (!bareEmptyName || bareEmptyName->size() != 1U
|| (*bareEmptyName)[0U].mac != "BC87FAE69D6E"
|| !(*bareEmptyName)[0U].name.empty()
|| !(*bareEmptyName)[0U].deviceClass.empty()) {
std::cerr << "SCAN bare empty-name line parse failed\n";
return EXIT_FAILURE;
}
const std::string scanJson =
core::serializeBluetoothScanJson(*scanned);
if (scanJson.find("\"mac\":\"112233445566\"") == std::string::npos
|| scanJson.find("\"rssi_dbm\":-55") == std::string::npos) {
std::cerr << "scan JSON serialise failed: " << scanJson << '\n';
return EXIT_FAILURE;
}
const auto mac =
core::parseBluetoothConnectJson(R"({"mac":"112233445566"})");
if (!mac || *mac != "112233445566") {
std::cerr << "connect JSON parse failed\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
@@ -149,5 +265,8 @@ int main()
if (runNameAutoConnPairedParseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runScanConnectTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
@@ -0,0 +1,21 @@
/**
* @file esp_log.h
* @brief Host-test stand-in for ESP-IDF logging macros.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Not shipped to device firmware: idf.py builds resolve the real ESP-IDF
* esp_log.h first. Only used so services/tuner/src/TunerService.cpp — which
* logs scan progress on real hardware — can also compile against
* components/core/test host tests without pulling in ESP-IDF.
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#define ESP_LOGE(tag, fmt, ...) ((void)0)
#define ESP_LOGW(tag, fmt, ...) ((void)0)
#define ESP_LOGI(tag, fmt, ...) ((void)0)
#define ESP_LOGD(tag, fmt, ...) ((void)0)
#define ESP_LOGV(tag, fmt, ...) ((void)0)
@@ -0,0 +1,19 @@
/**
* @file FreeRTOS.h
* @brief Host-test stand-in for the FreeRTOS core header.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Not shipped to device firmware: idf.py builds resolve the real FreeRTOS
* headers first. Only used so services/tuner/src/TunerService.cpp — which
* paces scan steps with vTaskDelay on real hardware — can also compile
* against components/core/test host tests without pulling in FreeRTOS.
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
using TickType_t = unsigned int;
#define pdMS_TO_TICKS(ms) (static_cast<TickType_t>(ms))
@@ -0,0 +1,18 @@
/**
* @file task.h
* @brief Host-test stand-in for the FreeRTOS task API.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Not shipped to device firmware: idf.py builds resolve the real FreeRTOS
* headers first. vTaskDelay is a no-op here — host tests assert scan logic
* against a FakeTuner, not real hardware settle timing.
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include "freertos/FreeRTOS.h"
inline void vTaskDelay(TickType_t) {}
@@ -15,6 +15,7 @@
#include "core/FrequencyKHz.hpp"
#include "core/IDsp.hpp"
#include "core/ISecureStore.hpp"
#include "core/BtSpeakerTarget.hpp"
#include "core/StationName.hpp"
#include "core/TunerBand.hpp"
#include "integration/IntegrationService.hpp"
@@ -68,6 +69,12 @@ public:
{
return {};
}
[[nodiscard]] std::expected<void, core::DspError> setBeepEnabled(
bool) override
{
return {};
}
};
class TrackingTuner final : public core::ITuner {
@@ -217,9 +224,60 @@ public:
return {};
}
[[nodiscard]] bool hasBtSpeakerTarget() const override
{
return btSpeaker_.has_value();
}
[[nodiscard]] std::expected<void, core::StoreError> saveBtSpeakerTarget(
const core::BtSpeakerTarget& target) override
{
btSpeaker_ = target;
return {};
}
[[nodiscard]] std::expected<core::BtSpeakerTarget, core::StoreError>
loadBtSpeakerTarget() const override
{
if (!btSpeaker_) {
return std::unexpected(core::StoreError::NotFound);
}
return *btSpeaker_;
}
[[nodiscard]] std::expected<void, core::StoreError> clearBtSpeakerTarget()
override
{
btSpeaker_.reset();
return {};
}
[[nodiscard]] bool hasWebRadioConfig() const override
{
return webRadioJson_.has_value();
}
[[nodiscard]] std::expected<void, core::StoreError>
saveWebRadioConfigJson(std::string_view json) override
{
webRadioJson_ = std::string(json);
return {};
}
[[nodiscard]] std::expected<std::string, core::StoreError>
loadWebRadioConfigJson() const override
{
if (!webRadioJson_) {
return std::unexpected(core::StoreError::NotFound);
}
return *webRadioJson_;
}
private:
std::optional<std::string> stationJson_;
std::optional<std::uint8_t> lastPresetIndex_;
std::optional<core::BtSpeakerTarget> btSpeaker_;
std::optional<std::string> webRadioJson_;
};
[[nodiscard]] core::Station makeFmStation(const char* name, std::uint32_t khz)
@@ -171,9 +171,54 @@ public:
return {};
}
[[nodiscard]] bool hasBtSpeakerTarget() const override
{
return false;
}
[[nodiscard]] std::expected<void, core::StoreError> saveBtSpeakerTarget(
const core::BtSpeakerTarget&) override
{
return {};
}
[[nodiscard]] std::expected<core::BtSpeakerTarget, core::StoreError>
loadBtSpeakerTarget() const override
{
return std::unexpected(core::StoreError::NotFound);
}
[[nodiscard]] std::expected<void, core::StoreError> clearBtSpeakerTarget()
override
{
return {};
}
[[nodiscard]] bool hasWebRadioConfig() const override
{
return webRadioJson_.has_value();
}
[[nodiscard]] std::expected<void, core::StoreError>
saveWebRadioConfigJson(std::string_view json) override
{
webRadioJson_ = std::string(json);
return {};
}
[[nodiscard]] std::expected<std::string, core::StoreError>
loadWebRadioConfigJson() const override
{
if (!webRadioJson_) {
return std::unexpected(core::StoreError::NotFound);
}
return *webRadioJson_;
}
private:
std::optional<std::string> stationJson_;
std::optional<std::uint8_t> lastPresetIndex_;
std::optional<std::string> webRadioJson_;
};
[[nodiscard]] core::Station makeFmStation(const char* name, std::uint32_t khz)
@@ -0,0 +1,73 @@
/**
* @file web_radio_json_test.cpp
* @brief Host tests for WebRadioConfig JSON round-trip.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-08
*/
#include "core/WebRadioJson.hpp"
#include <cstdlib>
#include <iostream>
namespace {
[[nodiscard]] int runRoundTripTest()
{
const core::WebRadioConfig config{
.enabled = true, .url = "http://edge.radiomontecarlo.net/RMC.mp3"};
const std::string json = core::serializeWebRadioConfigJson(config);
const auto parsed = core::parseWebRadioConfigJson(json);
if (!parsed || parsed->enabled != true || parsed->url != config.url) {
std::cerr << "round-trip mismatch\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runValidationTest()
{
const auto missingUrl =
core::parseWebRadioConfigJson(R"({"enabled":false})");
if (missingUrl) {
std::cerr << "missing url should fail\n";
return EXIT_FAILURE;
}
const auto badScheme = core::parseWebRadioConfigJson(
R"({"enabled":true,"url":"https://example.com/x.mp3"})");
if (badScheme) {
std::cerr << "non-http scheme should fail\n";
return EXIT_FAILURE;
}
const auto malformed = core::parseWebRadioConfigJson("not json");
if (malformed) {
std::cerr << "malformed body should fail\n";
return EXIT_FAILURE;
}
const auto ok = core::parseWebRadioConfigJson(
R"({"enabled":false,"url":"http://example.com/x.mp3"})");
if (!ok || ok->enabled != false) {
std::cerr << "valid disabled config should parse\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
{
if (runRoundTripTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runValidationTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
@@ -0,0 +1,121 @@
/**
* @file wifi_scan_test.cpp
* @brief Host tests for Wi-Fi scan JSON serialisation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-08-05
*/
#include "core/WifiScanJson.hpp"
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
namespace {
/**
* @brief expectEqual — assert two strings match.
*
* @dname expectEqual
* @param actual Observed value.
* @param expected Expected value.
* @return true when equal.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] bool expectEqual(const std::string& actual,
const std::string& expected)
{
if (actual == expected) {
return true;
}
std::cerr << "expected: " << expected << "\nactual: " << actual << '\n';
return false;
}
/**
* @brief runWifiScanSerialiseTest — verify scan list JSON shape.
*
* @dname runWifiScanSerialiseTest
* @return EXIT_SUCCESS or EXIT_FAILURE.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] int runWifiScanSerialiseTest()
{
const std::vector<core::WifiScannedNetwork> networks = {
{
.ssid = "HomeNet",
.rssiDbm = -42,
.auth = "wpa2",
.channel = 6,
},
{
.ssid = "Guest\"WiFi",
.rssiDbm = -71,
.auth = "open",
.channel = 11,
},
};
const std::string json = core::serializeWifiScanJson(networks);
const std::string expected =
R"({"networks":[{"ssid":"HomeNet","rssi_dbm":-42,"auth":"wpa2","channel":6},{"ssid":"Guest\"WiFi","rssi_dbm":-71,"auth":"open","channel":11}]})";
if (!expectEqual(json, expected)) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
/**
* @brief runWifiScanErrorSerialiseTest — verify error JSON.
*
* @dname runWifiScanErrorSerialiseTest
* @return EXIT_SUCCESS or EXIT_FAILURE.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] int runWifiScanErrorSerialiseTest()
{
if (!expectEqual(core::serializeWifiScanErrorJson("scan_failed"),
R"({"status":"error","reason":"scan_failed"})")) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
/**
* @brief main — host test entry point.
*
* @dname main
* @return EXIT_SUCCESS when all tests pass.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
int main()
{
if (runWifiScanSerialiseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runWifiScanErrorSerialiseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}