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:
@@ -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 1–255 (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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user