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;
}
@@ -220,6 +220,20 @@ public:
core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center,
float q);
/**
* @brief setBeepEnabled — gate the SigmaStudio Beep1 tone generator.
*
* @dname setBeepEnabled
* @param enabled true unmutes Beep1, false mutes it.
* @return Ok on success, or Adau1701Error.
* @pubstate writes parameter RAM via safeload (ADDR_BEEP1_ENABLE).
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::expected<void, Adau1701Error> setBeepEnabled(
bool enabled);
private:
[[nodiscard]] std::expected<void, Adau1701Error> ensureBooted() const;
[[nodiscard]] std::expected<void, Adau1701Error> safeloadGain(
@@ -61,6 +61,9 @@ public:
core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center,
float q) override;
[[nodiscard]] std::expected<void, core::DspError> setBeepEnabled(
bool enabled) override;
private:
[[nodiscard]] static core::DspError mapError(Adau1701Error error) noexcept;
@@ -27,273 +27,345 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
extern "C" {
extern "C"
{
} // extern "C"
namespace adau1701 {
namespace {
constexpr char kTag[] = "Adau1701";
constexpr int kI2cPort = 0;
/** Index 0 is the fixed high-pass band (SigmaStudio band 1); not safeloaded. */
constexpr std::uint8_t kFixedHighPassBandIndex = 0U;
} // namespace
Adau1701Driver::Adau1701Driver(Adau1701Pins pins,
core::IDspProgramSource& programSource)
: pins_(pins)
, programSource_(programSource)
, booted_(false)
, i2cBus_(nullptr)
, i2cDev_(nullptr)
namespace adau1701
{
}
Adau1701Driver::~Adau1701Driver()
{
auto* dev = static_cast<i2c_master_dev_handle_t>(i2cDev_);
auto* bus = static_cast<i2c_master_bus_handle_t>(i2cBus_);
if (dev != nullptr) {
i2c_master_bus_rm_device(dev);
namespace
{
constexpr char kTag[] = "Adau1701";
constexpr int kI2cPort = 0;
/** Index 0 is the fixed high-pass band (SigmaStudio band 1); not safeloaded. */
constexpr std::uint8_t kFixedHighPassBandIndex = 0U;
} // namespace
Adau1701Driver::Adau1701Driver(Adau1701Pins pins,
core::IDspProgramSource &programSource)
: pins_(pins), programSource_(programSource), booted_(false), i2cBus_(nullptr), i2cDev_(nullptr)
{
}
if (bus != nullptr) {
i2c_del_master_bus(bus);
}
}
std::expected<void, Adau1701Error> Adau1701Driver::replayProgram(
const core::DspProgram& program)
{
const unsigned char deviceAddr =
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
for (const core::RegisterWrite& write : program.writes()) {
const auto data = write.data();
if (data.empty()) {
return std::unexpected(Adau1701Error::DownloadFailed);
Adau1701Driver::~Adau1701Driver()
{
auto *dev = static_cast<i2c_master_dev_handle_t>(i2cDev_);
auto *bus = static_cast<i2c_master_bus_handle_t>(i2cBus_);
if (dev != nullptr)
{
i2c_master_bus_rm_device(dev);
}
if (bus != nullptr)
{
i2c_del_master_bus(bus);
}
SIGMA_WRITE_REGISTER_BLOCK(
deviceAddr,
write.address(),
static_cast<unsigned int>(data.size()),
const_cast<ADI_REG_TYPE*>(
reinterpret_cast<const ADI_REG_TYPE*>(data.data())));
}
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::boot()
{
if (booted_) {
std::expected<void, Adau1701Error> Adau1701Driver::replayProgram(
const core::DspProgram &program)
{
const unsigned char deviceAddr =
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
for (const core::RegisterWrite &write : program.writes())
{
const auto data = write.data();
if (data.empty())
{
return std::unexpected(Adau1701Error::DownloadFailed);
}
SIGMA_WRITE_REGISTER_BLOCK(
deviceAddr,
write.address(),
static_cast<unsigned int>(data.size()),
const_cast<ADI_REG_TYPE *>(
reinterpret_cast<const ADI_REG_TYPE *>(data.data())));
}
return {};
}
gpio_config_t resetCfg = {};
resetCfg.pin_bit_mask = 1ULL << pins_.resetGpio;
resetCfg.mode = GPIO_MODE_OUTPUT;
if (gpio_config(&resetCfg) != ESP_OK) {
return std::unexpected(Adau1701Error::ResetFailed);
}
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
vTaskDelay(pdMS_TO_TICKS(10));
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
vTaskDelay(pdMS_TO_TICKS(10));
i2c_master_bus_config_t busCfg = {};
busCfg.i2c_port = static_cast<i2c_port_num_t>(kI2cPort);
busCfg.sda_io_num = static_cast<gpio_num_t>(pins_.i2cSda);
busCfg.scl_io_num = static_cast<gpio_num_t>(pins_.i2cScl);
busCfg.clk_source = I2C_CLK_SRC_DEFAULT;
busCfg.glitch_ignore_cnt = 7;
busCfg.flags.enable_internal_pullup = true;
i2c_master_bus_handle_t bus = nullptr;
if (i2c_new_master_bus(&busCfg, &bus) != ESP_OK) {
ESP_LOGE(kTag, "i2c_new_master_bus failed");
return std::unexpected(Adau1701Error::I2cInitFailed);
}
i2cBus_ = bus;
i2c_device_config_t devCfg = {};
devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7;
devCfg.device_address = static_cast<uint16_t>(pins_.i2cAddr7);
devCfg.scl_speed_hz = 100000;
i2c_master_dev_handle_t dev = nullptr;
if (i2c_master_bus_add_device(bus, &devCfg, &dev) != ESP_OK) {
ESP_LOGE(kTag, "i2c_master_bus_add_device failed");
return std::unexpected(Adau1701Error::I2cInitFailed);
}
i2cDev_ = dev;
sigma_studio_bind_i2c(kI2cPort, static_cast<unsigned char>(pins_.i2cAddr7));
sigma_studio_set_device(dev);
const auto program = programSource_.loadProgram();
if (!program) {
ESP_LOGE(kTag, "DSP program load failed");
return std::unexpected(Adau1701Error::DownloadFailed);
}
if (auto replay = replayProgram(*program); !replay) {
return replay;
}
booted_ = true;
ESP_LOGI(kTag, "SigmaStudio program loaded");
return {};
}
bool Adau1701Driver::isBooted() const noexcept
{
return booted_;
}
void* Adau1701Driver::i2cBusHandle() const noexcept
{
return i2cBus_;
}
std::expected<void, Adau1701Error> Adau1701Driver::ensureBooted() const
{
if (!booted_) {
return std::unexpected(Adau1701Error::NotBooted);
}
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::safeloadFixpoint(
unsigned paramAddr, std::int32_t fixpoint)
{
if (sigma_safeload_param(paramAddr, fixpoint) != 0) {
return std::unexpected(Adau1701Error::SafeloadFailed);
}
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::safeloadGain(
unsigned paramAddr, core::GainDb gain)
{
return safeloadFixpoint(paramAddr, core::gainDbToLinearFixpoint(gain));
}
std::expected<void, Adau1701Error> Adau1701Driver::setInputVolume(
core::MixSource source, core::GainDb left, core::GainDb right)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
if (auto result = safeloadGain(paramAddrInputLeft(source), left); !result) {
return result;
}
return safeloadGain(paramAddrInputRight(source), right);
}
std::expected<void, Adau1701Error> Adau1701Driver::setMasterVolume(
core::GainDb left, core::GainDb right)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
if (auto result = safeloadGain(static_cast<unsigned>(ADDR_MULTIPLE1), left);
!result) {
return result;
}
return safeloadGain(static_cast<unsigned>(ADDR_MULTIPLE1_1), right);
}
std::expected<void, Adau1701Error> Adau1701Driver::applyMixer(
const core::MixerState& mixer)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
if (auto result = setInputVolume(core::MixSource::Si4684, mixer.si4684Left,
mixer.si4684Right);
!result) {
return result;
}
if (auto result = setInputVolume(core::MixSource::Esp32, mixer.esp32Left,
mixer.esp32Right);
!result) {
return result;
}
if (auto result =
safeloadGain(static_cast<unsigned>(ADDR_STMIXER1_ST0_VOLUME),
mixer.mixLeft);
!result) {
return result;
}
return safeloadGain(static_cast<unsigned>(ADDR_STMIXER1_ST1_VOLUME),
mixer.mixRight);
}
std::expected<void, Adau1701Error> Adau1701Driver::setEqBand(
core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, float q)
{
if (band.value() == kFixedHighPassBandIndex) {
return std::unexpected(Adau1701Error::InvalidParameter);
}
if (auto ready = ensureBooted(); !ready) {
return ready;
}
const core::BiquadCoefficients coeffs =
core::designPeakingEq(center, gain, q);
const auto fixpoints = coeffs.toFixpoint823();
const unsigned baseAddr = paramAddrEqBandBase(band.value());
unsigned addrs[5U];
int values[5U];
for (unsigned i = 0U; i < 5U; ++i) {
addrs[i] = baseAddr + i;
values[i] = fixpoints[i];
}
if (sigma_safeload_block(5U, addrs, values) != 0) {
return std::unexpected(Adau1701Error::SafeloadFailed);
}
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::applyEq(
const core::EqProfile& eq)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
for (std::uint8_t i = 0; i < core::EqBandIndex::kBandCount; ++i) {
if (i == kFixedHighPassBandIndex) {
continue;
std::expected<void, Adau1701Error> Adau1701Driver::boot()
{
if (booted_)
{
return {};
}
const auto index = core::EqBandIndex::tryFromIndex(i);
if (!index) {
gpio_config_t resetCfg = {};
resetCfg.pin_bit_mask = 1ULL << pins_.resetGpio;
resetCfg.mode = GPIO_MODE_OUTPUT;
if (gpio_config(&resetCfg) != ESP_OK)
{
return std::unexpected(Adau1701Error::ResetFailed);
}
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
vTaskDelay(pdMS_TO_TICKS(10));
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
vTaskDelay(pdMS_TO_TICKS(10));
i2c_master_bus_config_t busCfg = {};
busCfg.i2c_port = static_cast<i2c_port_num_t>(kI2cPort);
busCfg.sda_io_num = static_cast<gpio_num_t>(pins_.i2cSda);
busCfg.scl_io_num = static_cast<gpio_num_t>(pins_.i2cScl);
busCfg.clk_source = I2C_CLK_SRC_DEFAULT;
busCfg.glitch_ignore_cnt = 7;
busCfg.flags.enable_internal_pullup = true;
i2c_master_bus_handle_t bus = nullptr;
if (i2c_new_master_bus(&busCfg, &bus) != ESP_OK)
{
ESP_LOGE(kTag, "i2c_new_master_bus failed");
return std::unexpected(Adau1701Error::I2cInitFailed);
}
i2cBus_ = bus;
i2c_device_config_t devCfg = {};
devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7;
devCfg.device_address = static_cast<uint16_t>(pins_.i2cAddr7);
devCfg.scl_speed_hz = 100000;
i2c_master_dev_handle_t dev = nullptr;
if (i2c_master_bus_add_device(bus, &devCfg, &dev) != ESP_OK)
{
ESP_LOGE(kTag, "i2c_master_bus_add_device failed");
return std::unexpected(Adau1701Error::I2cInitFailed);
}
i2cDev_ = dev;
sigma_studio_bind_i2c(kI2cPort, static_cast<unsigned char>(pins_.i2cAddr7));
sigma_studio_set_device(dev);
const auto program = programSource_.loadProgram();
if (!program)
{
ESP_LOGE(kTag, "DSP program load failed");
return std::unexpected(Adau1701Error::DownloadFailed);
}
ESP_LOGI(kTag,
"DSP program contains %u writes",
static_cast<unsigned>(program->writes().size()));
for (const auto &w : program->writes())
{
ESP_LOGI(kTag,
"ADDR=0x%04X LEN=%u",
static_cast<unsigned>(w.address()),
static_cast<unsigned>(w.data().size()));
}
if (auto replay = replayProgram(*program); !replay)
{
return replay;
}
booted_ = true;
ESP_LOGI(kTag, "SigmaStudio program loaded");
return {};
}
bool Adau1701Driver::isBooted() const noexcept
{
return booted_;
}
void *Adau1701Driver::i2cBusHandle() const noexcept
{
return i2cBus_;
}
std::expected<void, Adau1701Error> Adau1701Driver::ensureBooted() const
{
if (!booted_)
{
return std::unexpected(Adau1701Error::NotBooted);
}
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::safeloadFixpoint(
unsigned paramAddr, std::int32_t fixpoint)
{
sigma_studio_lock();
const int result = sigma_safeload_param(paramAddr, fixpoint);
sigma_studio_unlock();
if (result != 0)
{
return std::unexpected(Adau1701Error::SafeloadFailed);
}
const core::EqBandSettings& band = eq.band(*index);
if (auto result = setEqBand(*index, band.gain, band.center, band.q);
!result) {
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::safeloadGain(
unsigned paramAddr, core::GainDb gain)
{
return safeloadFixpoint(paramAddr, core::gainDbToLinearFixpoint(gain));
}
std::expected<void, Adau1701Error> Adau1701Driver::setInputVolume(
core::MixSource source, core::GainDb left, core::GainDb right)
{
if (auto ready = ensureBooted(); !ready)
{
return ready;
}
if (auto result = safeloadGain(paramAddrInputLeft(source), left); !result)
{
return result;
}
return safeloadGain(paramAddrInputRight(source), right);
}
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::applyProfile(
const core::AudioProfile& profile)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
std::expected<void, Adau1701Error> Adau1701Driver::setMasterVolume(
core::GainDb left, core::GainDb right)
{
if (auto ready = ensureBooted(); !ready)
{
return ready;
}
if (auto result = safeloadGain(static_cast<unsigned>(ADDR_MULTIPLE1), left);
!result)
{
return result;
}
return safeloadGain(static_cast<unsigned>(ADDR_MULTIPLE1_1), right);
}
if (auto result = applyMixer(profile.mixer); !result) {
return result;
std::expected<void, Adau1701Error> Adau1701Driver::applyMixer(
const core::MixerState &mixer)
{
if (auto ready = ensureBooted(); !ready)
{
return ready;
}
if (auto result = setInputVolume(core::MixSource::Si4684, mixer.si4684Left,
mixer.si4684Right);
!result)
{
return result;
}
if (auto result = setInputVolume(core::MixSource::Esp32, mixer.esp32Left,
mixer.esp32Right);
!result)
{
return result;
}
if (auto result =
safeloadGain(static_cast<unsigned>(ADDR_STMIXER1_ST0_VOLUME),
mixer.mixLeft);
!result)
{
return result;
}
return safeloadGain(static_cast<unsigned>(ADDR_STMIXER1_ST1_VOLUME),
mixer.mixRight);
}
if (auto result = applyEq(profile.eq); !result) {
return result;
std::expected<void, Adau1701Error> Adau1701Driver::setEqBand(
core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, float q)
{
if (band.value() == kFixedHighPassBandIndex)
{
return std::unexpected(Adau1701Error::InvalidParameter);
}
if (auto ready = ensureBooted(); !ready)
{
return ready;
}
const core::BiquadCoefficients coeffs =
core::designPeakingEq(center, gain, q);
const auto fixpoints = coeffs.toFixpoint823();
const unsigned baseAddr = paramAddrEqBandBase(band.value());
unsigned addrs[5U];
int values[5U];
for (unsigned i = 0U; i < 5U; ++i)
{
addrs[i] = baseAddr + i;
values[i] = fixpoints[i];
}
sigma_studio_lock();
const int result = sigma_safeload_block(5U, addrs, values);
sigma_studio_unlock();
if (result != 0)
{
return std::unexpected(Adau1701Error::SafeloadFailed);
}
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::setBeepEnabled(
bool enabled)
{
if (auto ready = ensureBooted(); !ready)
{
return ready;
}
// Beep1 ("Beep - variable gain", ADI Sound Generation toolbox,
// DigiRadio.params) ENABLE/KICK: unity fixpoint 0x00800000 = on,
// exact zero = off. Not continuous gains, so bypass
// safeloadGain/GainDb (whose quietest value is -96 dB, not true
// zero) and write the raw fixpoint. KICK is the cell's trigger
// input (per its own SigmaStudio parameter name); ENABLE alone
// may leave the generator gated shut without it.
const std::int32_t value =
enabled ? core::gainDbToLinearFixpoint(core::GainDb::zero()) : 0;
if (auto result = safeloadFixpoint(
static_cast<unsigned>(ADDR_BEEP1_ENABLE), value);
!result)
{
return result;
}
return safeloadFixpoint(static_cast<unsigned>(ADDR_BEEP1_KICK), value);
}
std::expected<void, Adau1701Error> Adau1701Driver::applyEq(
const core::EqProfile &eq)
{
if (auto ready = ensureBooted(); !ready)
{
return ready;
}
for (std::uint8_t i = 0; i < core::EqBandIndex::kBandCount; ++i)
{
if (i == kFixedHighPassBandIndex)
{
continue;
}
const auto index = core::EqBandIndex::tryFromIndex(i);
if (!index)
{
return std::unexpected(Adau1701Error::SafeloadFailed);
}
const core::EqBandSettings &band = eq.band(*index);
if (auto result = setEqBand(*index, band.gain, band.center, band.q);
!result)
{
return result;
}
}
return {};
}
std::expected<void, Adau1701Error> Adau1701Driver::applyProfile(
const core::AudioProfile &profile)
{
if (auto ready = ensureBooted(); !ready)
{
return ready;
}
if (auto result = applyMixer(profile.mixer); !result)
{
return result;
}
if (auto result = applyEq(profile.eq); !result)
{
return result;
}
return setMasterVolume(profile.masterLeft, profile.masterRight);
}
return setMasterVolume(profile.masterLeft, profile.masterRight);
}
} // namespace adau1701
@@ -89,4 +89,12 @@ std::expected<void, core::DspError> Adau1701Dsp::setEqBand(
return {};
}
std::expected<void, core::DspError> Adau1701Dsp::setBeepEnabled(bool enabled)
{
if (auto result = driver_.setBeepEnabled(enabled); !result) {
return std::unexpected(mapError(result.error()));
}
return {};
}
} // namespace adau1701
@@ -18,7 +18,7 @@
#include "esp_log.h"
#include "esp_partition.h"
#include <vector>
#include <cstdlib>
#include <memory>
namespace adau1701 {
@@ -75,26 +75,60 @@ FlashDspProgramSource::loadProgram()
return std::unexpected(core::DspProgramError::Empty);
}
// Partizione non vuota: alloca il backing store con new(nothrow), cosi'
// un OOM ritorna nullptr invece di abortire (nessuna eccezione).
auto* raw = new (std::nothrow) std::uint8_t[part->size];
// Determine exact blob size by scanning DRAD write records before
// allocating — the full partition (256 KB) exceeds the available heap
// on ESP32-S3, and new(nothrow) still invokes __cxa_allocate_exception
// when exceptions are disabled, causing abort().
// Read DRAD header (12 bytes) to get write_count.
constexpr std::size_t kHdrSize = 12U;
std::uint8_t hdr[kHdrSize] = {};
if (esp_partition_read(part, 0, hdr, kHdrSize) != ESP_OK) {
return std::unexpected(core::DspProgramError::FlashReadFailed);
}
// Quick magic/version check (full validation done by parseDspProgramBlob).
if (hdr[0] != 'D' || hdr[1] != 'R' || hdr[2] != 'A' || hdr[3] != 'D'
|| static_cast<std::uint16_t>(hdr[4] | (hdr[5] << 8)) != 1U) {
return std::unexpected(core::DspProgramError::FlashReadFailed);
}
const auto writeCount =
static_cast<std::uint16_t>(hdr[6] | (hdr[7] << 8));
if (writeCount == 0U || writeCount > 32U) {
return std::unexpected(core::DspProgramError::FlashReadFailed);
}
// Scan write record headers (4 bytes each) to compute total payload size.
std::size_t payloadSize = 0U;
for (std::uint16_t i = 0U; i < writeCount; ++i) {
std::uint8_t rec[4] = {};
if (esp_partition_read(part, kHdrSize + payloadSize, rec, 4U)
!= ESP_OK) {
return std::unexpected(core::DspProgramError::FlashReadFailed);
}
const auto dataLen =
static_cast<std::uint16_t>(rec[2] | (rec[3] << 8));
if (dataLen == 0U || dataLen > 16384U) {
return std::unexpected(core::DspProgramError::FlashReadFailed);
}
payloadSize += 4U + dataLen;
}
const std::size_t totalSize = kHdrSize + payloadSize;
// Use malloc — avoids C++ exception machinery entirely (no nothrow workaround).
auto* raw = static_cast<std::uint8_t*>(::malloc(totalSize));
if (raw == nullptr) {
ESP_LOGE(kTag, "dsp buffer alloc failed (%u byte)",
static_cast<unsigned>(part->size));
ESP_LOGE(kTag, "dsp buffer alloc failed (%u bytes)",
static_cast<unsigned>(totalSize));
return std::unexpected(core::DspProgramError::FlashReadFailed);
}
std::unique_ptr<std::uint8_t[]> guard(raw);
std::unique_ptr<std::uint8_t, decltype(&::free)> guard(raw, ::free);
if (esp_partition_read(part, 0, raw, part->size) != ESP_OK) {
if (esp_partition_read(part, 0, raw, totalSize) != ESP_OK) {
return std::unexpected(core::DspProgramError::FlashReadFailed);
}
std::span<const std::uint8_t> view(raw, part->size);
if (partitionLooksEmpty(view)) {
return std::unexpected(core::DspProgramError::Empty);
}
return core::parseDspProgramBlob(view);
return core::parseDspProgramBlob({raw, totalSize});
}
std::expected<void, core::DspProgramError>
@@ -14,10 +14,13 @@
#include "SigmaStudioFW.h"
#include "driver/i2c_master.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include <string.h>
static i2c_master_dev_handle_t s_dev = NULL;
static SemaphoreHandle_t s_lock = NULL;
void sigma_studio_bind_i2c(int port, unsigned char addr7)
{
@@ -28,6 +31,45 @@ void sigma_studio_bind_i2c(int port, unsigned char addr7)
void sigma_studio_set_device(void* i2cDevHandle)
{
s_dev = (i2c_master_dev_handle_t)i2cDevHandle;
if (s_lock == NULL) {
s_lock = xSemaphoreCreateMutex();
}
}
void sigma_studio_lock(void)
{
if (s_lock != NULL) {
xSemaphoreTake(s_lock, portMAX_DELAY);
}
}
void sigma_studio_unlock(void)
{
if (s_lock != NULL) {
xSemaphoreGive(s_lock);
}
}
/*
* The ADAU1701 memory map is word-indexed with a region-dependent word
* width (Param RAM = 4 bytes, Program RAM = 5 bytes, Control regs =
* 2 bytes) — confirmed by DigiRadio's own generated export
* (DigiRadio_IC_1.h: PROGRAM_ADDR_IC_1=1024/PROGRAM_SIZE_IC_1=5120,
* PARAM_ADDR_IC_1=0/PARAM_SIZE_IC_1=4096; DigiRadio_IC_1_REG.h:
* REG_COREREGISTER_IC_1_ADDR=0x81C) and independently by the
* ADAU1701-TCPi-ESP32 reference project's directWrite(). Chunk
* boundaries must land on whole words, or the address advance for the
* next I2C transaction (and the data itself, mid-word) is wrong.
*/
static unsigned int sigmaWordSize(unsigned int address)
{
if (address >= 0x0400U && address <= 0x07FFU) {
return 5U;
}
if (address >= 0x0800U) {
return 2U;
}
return 4U;
}
void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
@@ -40,25 +82,45 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
return;
}
enum { kChunk = 64U };
enum { kChunkBytesMax = 64U };
const unsigned int wordSize = sigmaWordSize(address);
const unsigned int wordsPerChunk = kChunkBytesMax / wordSize;
const unsigned int chunkBytes = wordsPerChunk * wordSize;
unsigned int addr = address;
unsigned int remaining = length;
ADI_REG_TYPE* cursor = pData;
while (remaining > 0U) {
const unsigned int chunk =
remaining > kChunk ? kChunk : remaining;
unsigned char buf[2U + 64U];
remaining > chunkBytes ? chunkBytes : remaining;
const unsigned int words = chunk / wordSize;
unsigned char buf[2U + kChunkBytesMax];
buf[0] = (unsigned char)((addr >> 8) & 0xFFU);
buf[1] = (unsigned char)(addr & 0xFFU);
memcpy(buf + 2U, cursor, chunk);
i2c_master_transmit(s_dev, buf, (size_t)(2U + chunk), 1000);
addr += chunk;
addr += words;
cursor += chunk;
remaining -= chunk;
}
}
int sigma_i2c_read(unsigned int reg, unsigned char* data, unsigned int length)
{
if (s_dev == NULL || data == NULL || length == 0U) {
return -1;
}
unsigned char addrBuf[2U];
addrBuf[0] = (unsigned char)((reg >> 8) & 0xFFU);
addrBuf[1] = (unsigned char)(reg & 0xFFU);
const esp_err_t err = i2c_master_transmit_receive(
s_dev, addrBuf, sizeof(addrBuf), data, (size_t)length, 1000);
return err == ESP_OK ? 0 : -1;
}
static int sigma_i2c_write(unsigned int reg, const unsigned char* data,
unsigned char length)
{
@@ -66,7 +128,7 @@ static int sigma_i2c_write(unsigned int reg, const unsigned char* data,
return -1;
}
unsigned char buf[2U + 4U];
unsigned char buf[2U + 5U];
if ((size_t)length + 2U > sizeof(buf)) {
return -1;
}
@@ -78,14 +140,25 @@ static int sigma_i2c_write(unsigned int reg, const unsigned char* data,
return err == ESP_OK ? 0 : -1;
}
/*
* Safeload Data registers are 5 bytes wide (fixed 0x00 qualifier byte +
* full sign-extended 32-bit value, MSB first) — confirmed against the
* ADAU1701-TCPi-ESP32 reference project's safeloadChunk(), which sends
* the identical 5-byte layout. `fixpoint` is a full-range Q8.23
* two's-complement value (core::floatToFixpoint823): the previous
* 4-byte payload here dropped the top (sign) byte entirely, silently
* corrupting every negative coefficient (e.g. biquad a1/b1, routinely
* negative for peaking/shelving EQ bands).
*/
static int sigma_write_fixpoint_reg(unsigned int reg, int fixpoint)
{
unsigned char payload[4U];
unsigned char payload[5U];
payload[0] = 0U;
payload[1] = (unsigned char)((fixpoint >> 16) & 0xFFU);
payload[2] = (unsigned char)((fixpoint >> 8) & 0xFFU);
payload[3] = (unsigned char)(fixpoint & 0xFFU);
return sigma_i2c_write(reg, payload, 4U);
payload[1] = (unsigned char)(((unsigned int)fixpoint >> 24) & 0xFFU);
payload[2] = (unsigned char)(((unsigned int)fixpoint >> 16) & 0xFFU);
payload[3] = (unsigned char)(((unsigned int)fixpoint >> 8) & 0xFFU);
payload[4] = (unsigned char)((unsigned int)fixpoint & 0xFFU);
return sigma_i2c_write(reg, payload, 5U);
}
static int sigma_write_param_addr(unsigned int reg, unsigned int paramAddr)
@@ -133,3 +206,30 @@ int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs,
return sigma_trigger_safeload();
}
int sigma_safeload_raw_block(unsigned char count, const unsigned int* paramAddrs,
const unsigned char* rawWords)
{
if (count == 0U || count > 5U || paramAddrs == NULL || rawWords == NULL) {
return -1;
}
for (unsigned char i = 0U; i < count; ++i) {
const unsigned int dataReg =
ADAU1701_SAFELOAD_DATA_BASE + (unsigned int)i;
const unsigned int addrReg =
ADAU1701_SAFELOAD_ADDR_BASE + (unsigned int)i;
unsigned char payload[5U];
payload[0] = 0U;
memcpy(payload + 1U, rawWords + ((unsigned int)i * 4U), 4U);
if (sigma_i2c_write(dataReg, payload, 5U) != 0) {
return -1;
}
if (sigma_write_param_addr(addrReg, paramAddrs[i]) != 0) {
return -1;
}
}
return sigma_trigger_safeload();
}
@@ -16,6 +16,7 @@
#include "core/Bt1035At.hpp"
#include <cstdint>
#include <expected>
#include <string>
#include <string_view>
@@ -36,8 +37,6 @@ namespace bt1035 {
struct Bt1035Pins {
int uartTx; ///< ESP32 TX -> module RX.
int uartRx; ///< ESP32 RX <- module TX.
int rtsGpio; ///< RTS (flow control).
int ctsGpio; ///< CTS (flow control).
int resetGpio; ///< Module RESET (active level per schematic).
int sysCtlGpio; ///< SYS_CTL (optional module enable).
};
@@ -48,7 +47,7 @@ struct Bt1035Pins {
* @dname Bt1035Driver
* @return n/a (type)
* @pubstate Owns UART port after boot(). booted_ true after init sequence
* including AT+AUXCFG=3 and AT+I2SCFG=67 (I2S slave from ADAU1701).
* including AT+AUXCFG=3 and AT+I2SCFG=35 (I2S slave from ADAU1701).
*
* @author Michele Bigi
* @date 2026-07-06
@@ -159,6 +158,22 @@ public:
[[nodiscard]] std::expected<core::Bt1035A2dpState, Bt1035Error>
queryA2dpState();
/**
* @brief queryA2dpEncoder — read negotiated A2DP codec (AT+A2DPENC).
*
* @dname queryA2dpEncoder
* @return Parsed codec on success, or Bt1035Error. Failing here while
* queryA2dpState() reports Streaming means the module has a
* link but is not actually encoding audio — a real fault,
* not just a quiet source.
* @pubstate writes UART; parses +A2DPENC from the reply.
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::expected<core::Bt1035A2dpCodec, Bt1035Error>
queryA2dpEncoder();
/**
* @brief disconnectA2dp — release the active A2DP session (AT+A2DPDISC).
*
@@ -236,15 +251,115 @@ public:
[[nodiscard]] std::expected<std::vector<core::Bt1035PairedDevice>, Bt1035Error>
queryPairedList();
/**
* @brief scanNearbyBrEdr — inquiry for nearby A2DP sink devices.
*
* @dname scanNearbyBrEdr
* @param scanSeconds BR/EDR scan duration 1255 (default 20 in service).
* @return Parsed +SCAN entries, or Bt1035Error.
* @pubstate sends AT+SCAN=1,n and collects until +SCAN=E.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<std::vector<core::Bt1035ScannedDevice>, Bt1035Error>
scanNearbyBrEdr(std::uint8_t scanSeconds = 20U);
/**
* @brief stopScan — abort an active AT+SCAN inquiry.
*
* @dname stopScan
* @return Ok on success, or Bt1035Error.
* @pubstate sends AT+SCAN=0.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<void, Bt1035Error> stopScan();
/**
* @brief prepareForOutgoingConnect — disable auto-link before A2DPCONN.
*
* @dname prepareForOutgoingConnect
* @pubstate sends LINKCFG/AUTOCONN off; does not clear PLIST.
*
* @author Michele Bigi
* @date 2026-08-05
*/
void prepareForOutgoingConnect();
/**
* @brief waitForA2dpConnected — poll until A2DP link is up.
*
* @dname waitForA2dpConnected
* @param timeoutMs Maximum wait in milliseconds.
* @return true when Connected/Streaming/Paused, false on timeout.
* @pubstate polls AT+A2DPSTAT.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] bool waitForA2dpConnected(int timeoutMs);
/**
* @brief startA2dpAudio — send AT+A2DPAUDIO=1 (Feasycom §5.3.6).
*
* @dname startA2dpAudio
* @return Ok on module OK, or Bt1035Error.
* @pubstate writes UART.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<void, Bt1035Error> startA2dpAudio();
/**
* @brief waitForA2dpStreaming — poll until +A2DPSTAT=4 (Streaming).
*
* @dname waitForA2dpStreaming
* @param timeoutMs Maximum wait in milliseconds.
* @return true when Streaming, false on timeout.
* @pubstate polls AT+A2DPSTAT; sends A2DPAUDIO=1 when stuck at Connected.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] bool waitForA2dpStreaming(int timeoutMs);
/**
* @brief connectA2dp — pair/connect to a remote by MAC (AT+A2DPCONN).
*
* @dname connectA2dp
* @param mac 12-char ASCII MAC from scan results.
* @return Ok on success, or Bt1035Error.
* @pubstate may take several seconds while the module pairs.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<void, Bt1035Error> connectA2dp(
std::string_view mac);
private:
[[nodiscard]] std::expected<void, Bt1035Error> ensureBooted() const;
[[nodiscard]] std::expected<void, Bt1035Error> runInitSequence();
[[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollect(
std::string_view commandLine, int timeoutMs = kResponseTimeoutMs);
[[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollectUntil(
std::string_view commandLine, std::string_view endMarker, int timeoutMs,
std::uint8_t minScanSeconds = 0U);
[[nodiscard]] std::expected<void, Bt1035Error> transmitAndExpectOk(
std::string_view commandLine);
[[nodiscard]] std::expected<void, Bt1035Error> transmitAndExpectOkLogged(
std::string_view label, std::string_view commandLine);
void prepareForInquiryScan();
[[nodiscard]] bool waitForA2dpIdle(int timeoutMs);
[[nodiscard]] std::expected<std::vector<core::Bt1035ScannedDevice>, Bt1035Error>
runInquiryScan(std::uint8_t scanType, std::uint8_t scanSeconds);
static constexpr int kResponseTimeoutMs = 2000;
static constexpr int kConnectTimeoutMs = 15000;
Bt1035Pins pins_;
bool booted_;
@@ -13,6 +13,7 @@
#include "bt1035/Bt1035Driver.hpp"
#include "core/Bt1035ScannedDevice.hpp"
#include "driver/gpio.h"
#include "driver/uart.h"
#include "esp_log.h"
@@ -20,6 +21,8 @@
#include "freertos/task.h"
#include <array>
#include <algorithm>
#include <cstdlib>
#include <string>
#include <string_view>
#include <vector>
@@ -30,11 +33,218 @@ namespace {
constexpr char kTag[] = "Bt1035";
constexpr int kUartPort = 2;
constexpr int kBaudRate = 115200;
constexpr int kUartRxBuffer = 512;
constexpr int kUartRxBuffer = 4096;
constexpr int kUartTxBuffer = 256;
constexpr int kResponseTimeoutMs = 2000;
constexpr int kPostResetMs = 500;
constexpr int kPostUartMs = 100;
void flushUartRx(int uartPort) noexcept
{
uart_flush_input(static_cast<uart_port_t>(uartPort));
std::array<char, 256> discard{};
while (uart_read_bytes(static_cast<uart_port_t>(uartPort), discard.data(),
discard.size(), 0)
> 0) {
}
}
constexpr int kBrEdrScanTimeoutMs = 90000;
constexpr int kScanProgressLogMs = 5000;
constexpr int kScanIdleCompleteMs = 4000;
constexpr int kDefaultScanListenSeconds = 25;
constexpr unsigned long kDevStatScanComplete = 1U;
void logAtLine(std::string_view label, std::string_view line) noexcept
{
std::string sanitized;
sanitized.reserve(line.size());
for (const char ch : line) {
if (ch == '\r') {
sanitized.append("\\r");
} else if (ch == '\n') {
sanitized.append("\\n");
} else if (ch >= 0x20 && ch < 0x7F) {
sanitized.push_back(ch);
} else {
sanitized.push_back('.');
}
}
ESP_LOGI(kTag, "%.*s: %s", static_cast<int>(label.size()), label.data(),
sanitized.c_str());
}
[[nodiscard]] bool responseHasScanEnd(std::string_view response) noexcept
{
return response.find("+SCAN=E") != std::string_view::npos
|| response.find("+SCAN= E") != std::string_view::npos
|| response.find("+SCAN=END") != std::string_view::npos
|| response.find("+SCAN= END") != std::string_view::npos;
}
[[nodiscard]] bool responseHasScanEntries(std::string_view response) noexcept
{
return response.find("+SCAN=") != std::string_view::npos
|| response.find("+SCAN =") != std::string_view::npos;
}
[[nodiscard]] bool responseHasDevStatScanComplete(
std::string_view response) noexcept
{
if (!responseHasScanEntries(response)) {
return false;
}
constexpr std::string_view kPrefix = "+DEVSTAT=";
std::size_t pos = 0;
unsigned long lastValue = 0U;
bool found = false;
while ((pos = response.find(kPrefix, pos)) != std::string_view::npos) {
const std::size_t start = pos + kPrefix.size();
char* end = nullptr;
const unsigned long raw =
std::strtoul(response.data() + start, &end, 10);
if (end != response.data() + start) {
lastValue = raw;
found = true;
}
pos = start + 1U;
}
return found && lastValue == kDevStatScanComplete;
}
[[nodiscard]] std::size_t countScanEntries(std::string_view response) noexcept
{
std::size_t count = 0U;
std::size_t pos = 0;
while ((pos = response.find("+SCAN=", pos)) != std::string_view::npos) {
const std::size_t valueStart = pos + 6U;
if (valueStart < response.size() && response[valueStart] != 'E') {
++count;
}
pos = valueStart + 1U;
}
return count;
}
enum class ScanCollectReason {
EndMarker,
DevStatComplete,
IdleAfterScan,
TimeoutPartial,
};
[[nodiscard]] bool scanCollectionShouldStop(std::string_view response,
std::size_t scanEntryCount,
TickType_t lastRxTick,
TickType_t now,
TickType_t started,
std::uint8_t minScanSeconds,
ScanCollectReason* reason) noexcept
{
if (!response.empty() && responseHasScanEnd(response)) {
if (reason != nullptr) {
*reason = ScanCollectReason::EndMarker;
}
return true;
}
const int elapsedMs = static_cast<int>(pdTICKS_TO_MS(now - started));
const int minListenMs =
minScanSeconds > 0U
? static_cast<int>(minScanSeconds) * 1000
: kDefaultScanListenSeconds * 1000;
const bool minListenElapsed = elapsedMs >= minListenMs;
if (minListenElapsed && responseHasDevStatScanComplete(response)) {
if (reason != nullptr) {
*reason = ScanCollectReason::DevStatComplete;
}
return true;
}
if (minListenElapsed && scanEntryCount > 0U && lastRxTick > 0U
&& (now - lastRxTick) >= pdMS_TO_TICKS(kScanIdleCompleteMs)) {
if (reason != nullptr) {
*reason = ScanCollectReason::IdleAfterScan;
}
return true;
}
return false;
}
void logScanRawPayload(std::string_view label, std::string_view response) noexcept
{
if (response.empty()) {
ESP_LOGI(kTag, "%.*s (0 bytes)", static_cast<int>(label.size()),
label.data());
return;
}
std::string sanitized;
sanitized.reserve(std::min(response.size(), std::size_t{512U}));
for (const char ch : response) {
if (ch == '\r') {
sanitized.append("\\r");
} else if (ch == '\n') {
sanitized.append("\\n");
} else if (ch >= 0x20 && ch < 0x7F) {
sanitized.push_back(ch);
} else {
sanitized.push_back('.');
}
}
constexpr std::size_t kChunk = 480U;
if (sanitized.size() <= kChunk) {
ESP_LOGI(kTag, "%.*s (%d bytes): %s",
static_cast<int>(label.size()), label.data(),
static_cast<int>(response.size()), sanitized.c_str());
return;
}
ESP_LOGI(kTag, "%.*s (%d bytes, part 1/%u): %.*s",
static_cast<int>(label.size()), label.data(),
static_cast<int>(response.size()),
static_cast<unsigned>(
(sanitized.size() + kChunk - 1U) / kChunk),
static_cast<int>(kChunk), sanitized.c_str());
for (std::size_t off = kChunk; off < sanitized.size(); off += kChunk) {
const std::size_t len = std::min(kChunk, sanitized.size() - off);
ESP_LOGI(kTag, "%.*s (cont %u): %.*s",
static_cast<int>(label.size()), label.data(),
static_cast<unsigned>(off / kChunk + 1U),
static_cast<int>(len), sanitized.c_str() + off);
}
}
void logScanUartChunk(int received, std::string_view chunk) noexcept
{
const std::string label = "scan UART RX +" + std::to_string(received);
logScanRawPayload(label, chunk);
}
[[nodiscard]] const char* scanCollectReasonLabel(
ScanCollectReason reason) noexcept
{
switch (reason) {
case ScanCollectReason::EndMarker:
return "+SCAN=E/end marker";
case ScanCollectReason::DevStatComplete:
return "+DEVSTAT=1";
case ScanCollectReason::IdleAfterScan:
return "UART idle after +SCAN";
case ScanCollectReason::TimeoutPartial:
return "timeout";
}
return "unknown";
}
void logScanParsedDevice(const core::Bt1035ScannedDevice& device) noexcept
{
ESP_LOGI(kTag,
"scan device[%u]: mac=%s name=%s rssi=%d dBm class=%s addrType=%u",
static_cast<unsigned>(device.index), device.mac.c_str(),
device.name.empty() ? "(no name)" : device.name.c_str(),
static_cast<int>(device.rssiDbm),
device.deviceClass.empty() ? "-" : device.deviceClass.c_str(),
static_cast<unsigned>(device.addressType));
}
} // namespace
Bt1035Driver::Bt1035Driver(Bt1035Pins pins)
@@ -102,6 +312,284 @@ std::expected<std::string, Bt1035Error> Bt1035Driver::transmitAndCollect(
return std::unexpected(Bt1035Error::AtTimeout);
}
std::expected<std::string, Bt1035Error> Bt1035Driver::transmitAndCollectUntil(
std::string_view commandLine, std::string_view endMarker, int timeoutMs,
std::uint8_t minScanSeconds)
{
logAtLine("scan UART TX", commandLine);
const int written = uart_write_bytes(static_cast<uart_port_t>(uartPort_),
commandLine.data(),
commandLine.size());
if (written < 0
|| static_cast<std::size_t>(written) != commandLine.size()) {
ESP_LOGE(kTag, "scan UART TX failed (%d)", written);
return std::unexpected(Bt1035Error::UartInitFailed);
}
std::array<char, 512> buffer{};
std::string accumulated;
accumulated.reserve(8192U);
const TickType_t started = xTaskGetTickCount();
const TickType_t deadline = started + pdMS_TO_TICKS(timeoutMs);
TickType_t lastProgressLog = started;
TickType_t lastRxTick = 0;
std::size_t scanEntryCount = 0U;
ScanCollectReason stopReason = ScanCollectReason::TimeoutPartial;
bool stoppedEarly = false;
ESP_LOGI(kTag, "======== BT scan inquiry begin (min %u s, timeout %d ms) ========",
static_cast<unsigned>(minScanSeconds), timeoutMs);
while (xTaskGetTickCount() < deadline) {
const int received = uart_read_bytes(static_cast<uart_port_t>(uartPort_),
buffer.data(), buffer.size(),
pdMS_TO_TICKS(200));
const TickType_t now = xTaskGetTickCount();
if (received > 0) {
const std::string_view chunk(buffer.data(),
static_cast<std::size_t>(received));
logScanUartChunk(received, chunk);
accumulated.append(buffer.data(), static_cast<std::size_t>(received));
lastRxTick = now;
const std::size_t updatedScanCount =
countScanEntries(accumulated);
if (updatedScanCount > scanEntryCount) {
ESP_LOGI(kTag, "scan +SCAN entry #%u (total %u)",
static_cast<unsigned>(updatedScanCount),
static_cast<unsigned>(updatedScanCount));
scanEntryCount = updatedScanCount;
}
if (!endMarker.empty()
&& accumulated.find(endMarker) != std::string::npos) {
stopReason = ScanCollectReason::EndMarker;
stoppedEarly = true;
ESP_LOGI(kTag, "scan end marker %.*s seen",
static_cast<int>(endMarker.size()), endMarker.data());
break;
}
if (scanCollectionShouldStop(accumulated, scanEntryCount, lastRxTick,
now, started, minScanSeconds,
&stopReason)) {
stoppedEarly = true;
if (stopReason == ScanCollectReason::EndMarker) {
ESP_LOGI(kTag, "scan end marker +SCAN=E seen");
} else if (stopReason == ScanCollectReason::DevStatComplete) {
ESP_LOGI(kTag,
"scan complete: +DEVSTAT=1 after %u +SCAN entr(ies)",
static_cast<unsigned>(scanEntryCount));
}
break;
}
if (core::parseBt1035AtResponse(accumulated)
== core::Bt1035AtResponseKind::Error) {
logScanRawPayload("scan ERROR response", accumulated);
return std::unexpected(Bt1035Error::AtError);
}
} else if (scanEntryCount > 0U
&& scanCollectionShouldStop(accumulated, scanEntryCount,
lastRxTick, now, started,
minScanSeconds, &stopReason)) {
stoppedEarly = true;
if (stopReason == ScanCollectReason::IdleAfterScan) {
ESP_LOGI(kTag,
"scan complete: idle %d ms after last +SCAN (%u entr(ies))",
kScanIdleCompleteMs,
static_cast<unsigned>(scanEntryCount));
}
break;
}
if ((now - lastProgressLog) >= pdMS_TO_TICKS(kScanProgressLogMs)) {
lastProgressLog = now;
const int elapsedMs =
static_cast<int>(pdTICKS_TO_MS(now - started));
const int minListenMs =
minScanSeconds > 0U
? static_cast<int>(minScanSeconds) * 1000
: kDefaultScanListenSeconds * 1000;
ESP_LOGI(kTag,
"scan progress: %d ms, %d bytes rx, %u +SCAN, min listen %d ms",
elapsedMs, static_cast<int>(accumulated.size()),
static_cast<unsigned>(scanEntryCount), minListenMs);
}
}
const int elapsedMs =
static_cast<int>(pdTICKS_TO_MS(xTaskGetTickCount() - started));
if (stoppedEarly) {
ESP_LOGI(kTag, "scan stopped: reason=%s, elapsed=%d ms, %u +SCAN entr(ies)",
scanCollectReasonLabel(stopReason), elapsedMs,
static_cast<unsigned>(scanEntryCount));
} else {
stopReason = ScanCollectReason::TimeoutPartial;
ESP_LOGW(kTag, "scan stopped: reason=%s, elapsed=%d ms, %u +SCAN entr(ies)",
scanCollectReasonLabel(stopReason), elapsedMs,
static_cast<unsigned>(scanEntryCount));
}
logScanRawPayload("scan UART full RX", accumulated);
const bool hasEnd = stoppedEarly
|| (!endMarker.empty()
? accumulated.find(endMarker)
!= std::string::npos
: responseHasScanEnd(accumulated));
if (!hasEnd) {
if (!responseHasScanEntries(accumulated)) {
if (accumulated.find("+A2DPSTAT=2") != std::string_view::npos) {
ESP_LOGW(kTag,
"scan blocked: module auto-connecting A2DP (disable LINKCFG)");
}
if (accumulated.find("+DEVSTAT=") != std::string_view::npos) {
ESP_LOGW(kTag, "scan saw DEVSTAT events but no +SCAN= lines");
}
ESP_LOGW(kTag, "scan timeout (%d bytes rx, no +SCAN entries)",
static_cast<int>(accumulated.size()));
(void)transmitAndCollect(core::buildBt1035StopScanLine(), 1000);
return std::unexpected(Bt1035Error::AtTimeout);
}
ESP_LOGW(kTag, "scan timeout but %d bytes contain +SCAN entries — parsing partial",
static_cast<int>(accumulated.size()));
}
ESP_LOGI(kTag, "======== BT scan inquiry end ========");
return accumulated;
}
std::expected<void, Bt1035Error> Bt1035Driver::transmitAndExpectOkLogged(
std::string_view label, std::string_view commandLine)
{
logAtLine(label, commandLine);
auto collected = transmitAndCollect(commandLine);
if (collected) {
if (!collected->empty()) {
const std::string rxLabel = std::string(label) + " RX";
logScanRawPayload(rxLabel, *collected);
}
ESP_LOGI(kTag, "%.*s: OK", static_cast<int>(label.size()), label.data());
return {};
}
if (collected.error() == Bt1035Error::AtTimeout) {
ESP_LOGW(kTag, "%.*s: timeout (no response)", static_cast<int>(label.size()),
label.data());
} else {
ESP_LOGW(kTag, "%.*s: failed (%d)", static_cast<int>(label.size()),
label.data(), static_cast<int>(collected.error()));
}
return std::unexpected(collected.error());
}
void Bt1035Driver::prepareForInquiryScan()
{
ESP_LOGI(kTag, "======== BT scan prep begin ========");
flushUartRx(uartPort_);
(void)transmitAndExpectOkLogged("scan prep PRINT",
core::buildBt1035EnablePrintLine());
(void)transmitAndExpectOkLogged("scan prep LINKCFG off",
core::buildBt1035DisableAutoLinkLine());
(void)transmitAndExpectOkLogged("scan prep AUTOCONN off",
core::buildBt1035SetAutoConnLine(0U));
(void)transmitAndExpectOkLogged("scan prep PLIST clear",
core::buildBt1035ClearPairedListLine());
(void)transmitAndExpectOkLogged(
"scan prep A2DPDISC",
core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpDisconnect));
(void)transmitAndExpectOkLogged("scan prep DSCA",
core::buildBt1035DisconnectAllLine());
(void)transmitAndExpectOkLogged(
"scan prep PAIR=0",
core::buildBt1035AtLine(core::Bt1035AtCommand::PairHidden));
auto stopScan = transmitAndCollect(core::buildBt1035StopScanLine(), 1000);
if (stopScan) {
logScanRawPayload("scan prep SCAN=0 RX", *stopScan);
ESP_LOGI(kTag, "scan prep SCAN=0: OK");
} else {
ESP_LOGI(kTag, "scan prep SCAN=0: skipped (no active scan)");
}
flushUartRx(uartPort_);
vTaskDelay(pdMS_TO_TICKS(500));
if (!waitForA2dpIdle(10000)) {
ESP_LOGW(kTag, "scan prep: A2DP still busy — continuing anyway");
}
if (auto paired = queryPairedList(); paired && !paired->empty()) {
ESP_LOGI(kTag, "scan prep: %u paired device(s) on module",
static_cast<unsigned>(paired->size()));
for (const core::Bt1035PairedDevice& entry : *paired) {
ESP_LOGI(kTag, "scan prep paired: %s (%s)",
entry.mac.c_str(),
entry.name.empty() ? "(no name)" : entry.name.c_str());
}
}
flushUartRx(uartPort_);
ESP_LOGI(kTag, "======== BT scan prep end ========");
}
bool Bt1035Driver::waitForA2dpIdle(int timeoutMs)
{
const TickType_t deadline =
xTaskGetTickCount() + pdMS_TO_TICKS(timeoutMs);
TickType_t lastDisconnectAttempt = 0;
while (xTaskGetTickCount() < deadline) {
auto state = queryA2dpState();
if (!state) {
ESP_LOGW(kTag, "scan prep: A2DPSTAT query failed");
return false;
}
ESP_LOGI(kTag, "scan prep: A2DPSTAT=%u",
static_cast<unsigned>(static_cast<std::uint8_t>(*state)));
if (*state == core::Bt1035A2dpState::Standby
|| *state == core::Bt1035A2dpState::Unsupported) {
return true;
}
const TickType_t now = xTaskGetTickCount();
if ((now - lastDisconnectAttempt) >= pdMS_TO_TICKS(1500)) {
lastDisconnectAttempt = now;
ESP_LOGW(kTag, "scan prep: A2DP busy — sending A2DPDISC+DSCA");
(void)transmitAndExpectOk(
core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpDisconnect));
(void)transmitAndExpectOk(core::buildBt1035DisconnectAllLine());
flushUartRx(uartPort_);
}
vTaskDelay(pdMS_TO_TICKS(400));
}
return false;
}
std::expected<std::vector<core::Bt1035ScannedDevice>, Bt1035Error>
Bt1035Driver::runInquiryScan(std::uint8_t scanType, std::uint8_t scanSeconds)
{
const std::string startLine =
core::buildBt1035StartScanLine(scanType, scanSeconds);
const int timeoutMs = scanSeconds == 0U
? kBrEdrScanTimeoutMs
: static_cast<int>(scanSeconds) * 1000 + 30000;
flushUartRx(uartPort_);
ESP_LOGI(kTag, "scan inquiry: type=%u seconds=%u timeout=%d ms",
static_cast<unsigned>(scanType),
static_cast<unsigned>(scanSeconds), timeoutMs);
auto response = transmitAndCollectUntil(startLine, {}, timeoutMs, scanSeconds);
if (!response) {
ESP_LOGW(kTag, "scan inquiry failed (type %u)",
static_cast<unsigned>(scanType));
return std::unexpected(response.error());
}
auto parsed = core::parseBt1035ScanResponse(*response);
if (!parsed) {
ESP_LOGW(kTag, "scan parse failed (type %u)",
static_cast<unsigned>(scanType));
return std::unexpected(Bt1035Error::UnexpectedResponse);
}
ESP_LOGI(kTag, "scan parsed %u device(s)",
static_cast<unsigned>(parsed->size()));
for (const core::Bt1035ScannedDevice& device : *parsed) {
logScanParsedDevice(device);
}
return *parsed;
}
std::expected<void, Bt1035Error> Bt1035Driver::transmitAndExpectOk(
std::string_view commandLine)
{
@@ -156,6 +644,25 @@ std::expected<core::Bt1035A2dpState, Bt1035Error> Bt1035Driver::queryA2dpState()
return *parsed;
}
std::expected<core::Bt1035A2dpCodec, Bt1035Error> Bt1035Driver::queryA2dpEncoder()
{
if (auto ready = ensureBooted(); !ready) {
return std::unexpected(ready.error());
}
auto response = transmitAndCollect(
core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpEncoder));
if (!response) {
return std::unexpected(response.error());
}
auto parsed = core::parseBt1035A2dpEncoderResponse(*response);
if (!parsed) {
return std::unexpected(Bt1035Error::UnexpectedResponse);
}
return *parsed;
}
std::expected<void, Bt1035Error> Bt1035Driver::disconnectA2dp()
{
if (auto ready = ensureBooted(); !ready) {
@@ -243,6 +750,133 @@ Bt1035Driver::queryPairedList()
return *parsed;
}
std::expected<std::vector<core::Bt1035ScannedDevice>, Bt1035Error>
Bt1035Driver::scanNearbyBrEdr(std::uint8_t scanSeconds)
{
if (auto ready = ensureBooted(); !ready) {
return std::unexpected(ready.error());
}
ESP_LOGI(kTag, "======== BT scan session begin (%u s) ========",
static_cast<unsigned>(scanSeconds));
prepareForInquiryScan();
auto result = runInquiryScan(1U, scanSeconds);
ESP_LOGI(kTag, "scan post-cleanup: A2DPDISC+DSCA+SCAN=0");
(void)transmitAndExpectOkLogged(
"scan post A2DPDISC",
core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpDisconnect));
(void)transmitAndExpectOkLogged("scan post DSCA",
core::buildBt1035DisconnectAllLine());
(void)transmitAndExpectOkLogged("scan post SCAN=0",
core::buildBt1035StopScanLine());
if (result) {
ESP_LOGI(kTag, "======== BT scan session OK: %u device(s) ========",
static_cast<unsigned>(result->size()));
} else {
ESP_LOGW(kTag, "======== BT scan session FAILED ========");
}
return result;
}
std::expected<void, Bt1035Error> Bt1035Driver::stopScan()
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
return transmitAndExpectOk(core::buildBt1035StopScanLine());
}
void Bt1035Driver::prepareForOutgoingConnect()
{
ESP_LOGI(kTag, "connect prep: LINKCFG/AUTOCONN off");
(void)transmitAndExpectOk(core::buildBt1035DisableAutoLinkLine());
(void)transmitAndExpectOk(core::buildBt1035SetAutoConnLine(0U));
flushUartRx(uartPort_);
}
bool Bt1035Driver::waitForA2dpConnected(int timeoutMs)
{
const TickType_t deadline =
xTaskGetTickCount() + pdMS_TO_TICKS(timeoutMs);
while (xTaskGetTickCount() < deadline) {
auto state = queryA2dpState();
if (!state) {
return false;
}
ESP_LOGI(kTag, "connect wait: A2DPSTAT=%u",
static_cast<unsigned>(static_cast<std::uint8_t>(*state)));
if (*state == core::Bt1035A2dpState::Connected
|| *state == core::Bt1035A2dpState::Streaming
|| *state == core::Bt1035A2dpState::Paused) {
return true;
}
vTaskDelay(pdMS_TO_TICKS(500));
}
return false;
}
std::expected<void, Bt1035Error> Bt1035Driver::startA2dpAudio()
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
ESP_LOGI(kTag, "A2DP audio start (AT+A2DPAUDIO=1)");
return transmitAndExpectOk(core::buildBt1035A2dpAudioLine(true));
}
bool Bt1035Driver::waitForA2dpStreaming(int timeoutMs)
{
const TickType_t deadline =
xTaskGetTickCount() + pdMS_TO_TICKS(timeoutMs);
TickType_t lastAudioStartAttempt = 0;
while (xTaskGetTickCount() < deadline) {
auto state = queryA2dpState();
if (state) {
ESP_LOGI(kTag, "stream wait: A2DPSTAT=%u",
static_cast<unsigned>(static_cast<std::uint8_t>(*state)));
if (*state == core::Bt1035A2dpState::Streaming) {
return true;
}
const TickType_t now = xTaskGetTickCount();
if ((*state == core::Bt1035A2dpState::Connected
|| *state == core::Bt1035A2dpState::Paused)
&& (lastAudioStartAttempt == 0U
|| (now - lastAudioStartAttempt) >= pdMS_TO_TICKS(3000))) {
lastAudioStartAttempt = now;
if (auto started = startA2dpAudio(); !started) {
ESP_LOGW(kTag, "A2DPAUDIO=1 failed (%d)",
static_cast<int>(started.error()));
}
}
}
vTaskDelay(pdMS_TO_TICKS(500));
}
return false;
}
std::expected<void, Bt1035Error> Bt1035Driver::connectA2dp(std::string_view mac)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
prepareForOutgoingConnect();
const std::string line = core::buildBt1035A2dpConnectLine(mac);
if (line.empty()) {
return std::unexpected(Bt1035Error::UnexpectedResponse);
}
(void)stopScan();
auto response = transmitAndCollect(line, kConnectTimeoutMs);
if (!response) {
return std::unexpected(response.error());
}
return {};
}
std::expected<void, Bt1035Error> Bt1035Driver::runInitSequence()
{
for (const core::Bt1035AtCommand command : core::bootInitSequence()) {
@@ -314,13 +948,24 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
uart_flush_input(static_cast<uart_port_t>(uartPort_));
vTaskDelay(pdMS_TO_TICKS(kPostUartMs));
// Best-effort: not gated on OK — some Feasycom firmware acks before
// rebooting, some resets silently. Either way, settle and flush before
// the mandatory init sequence below, which IS gated.
(void)transmitAndExpectOk(core::buildBt1035AtLine(core::Bt1035AtCommand::Reset));
vTaskDelay(pdMS_TO_TICKS(kPostResetMs));
uart_flush_input(static_cast<uart_port_t>(uartPort_));
ESP_LOGI(kTag, "AT+RESET sent");
if (auto init = runInitSequence(); !init) {
ESP_LOGE(kTag, "AT init failed");
return init;
}
(void)transmitAndExpectOk(core::buildBt1035DisableAutoLinkLine());
ESP_LOGI(kTag, "auto-link disabled (AT+LINKCFG=0,0)");
booted_ = true;
ESP_LOGI(kTag, "I2S slave mode enabled (AT+AUXCFG=3, AT+I2SCFG=67)");
ESP_LOGI(kTag, "I2S slave mode enabled (AT+AUXCFG=3, AT+I2SCFG=35)");
return {};
}
@@ -106,14 +106,21 @@ public:
* rail ramp before app_main runs.
*
* @dname boot
* @param band DAB or FM application to load.
* @param band DAB or FM application to load.
* @param xtalIbias POWER_UP ARG3 IBIAS, 10 uA steps (AN649 §Command
* 0x01); default matches the values already
* verified live (72 = 720 uA startup bias).
* @param xtalCtun POWER_UP ARG8 CTUN, 0-63 (AN649 §Command 0x01);
* default matches the values already verified live.
* @return Ok on success, or Si4684Error.
* @pubstate writes booted_ and loadedBand_ on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, Si4684Error> boot(Si4684Band band);
[[nodiscard]] std::expected<void, Si4684Error> boot(
Si4684Band band, std::uint8_t xtalIbias = 72U,
std::uint8_t xtalCtun = 31U);
/**
* @brief isBooted — query whether boot completed successfully.
@@ -387,7 +394,10 @@ private:
[[nodiscard]] std::expected<void, Si4684Error> ensureBand(
Si4684Band band) const;
[[nodiscard]] std::expected<void, Si4684Error> waitCts();
[[nodiscard]] std::expected<void, Si4684Error> waitStc();
[[nodiscard]] std::expected<void, Si4684Error> waitStc(
int maxRetries = 250);
[[nodiscard]] std::expected<bool, Si4684Error> pollStc();
[[nodiscard]] std::expected<void, Si4684Error> clearFmStc();
[[nodiscard]] std::expected<void, Si4684Error> sendCommand(
std::span<const std::uint8_t> bytes);
[[nodiscard]] std::expected<void, Si4684Error> readRaw(
@@ -190,10 +190,25 @@ private:
*/
[[nodiscard]] static core::TunerError mapError(Si4684Error error) noexcept;
/**
* @brief ensureBandLoaded — HOST_LOAD FM/DAB image when band differs.
*
* @dname ensureBandLoaded
* @param band Required application band for the next operation.
* @return Ok when the chip runs the requested image.
* @pubstate may reload firmware (~1 s) and stop an active DAB service.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<void, core::TunerError> ensureBandLoaded(
core::TunerBand band);
Si4684Driver& driver_;
std::uint8_t dabIndex_;
core::FrequencyKHz fmFrequency_;
std::uint8_t volume_;
bool fmBandReady_;
core::RdsMetadataAccumulator rdsMetadata_;
core::DabDynamicLabelAccumulator dabDynamicLabel_;
std::optional<std::uint32_t> lastDabServiceId_;
@@ -18,6 +18,7 @@
#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <vector>
namespace si4684 {
@@ -91,11 +92,11 @@ struct Si4684SysState {
* @date 2026-07-06
*/
struct Si4684FmRsq {
core::FrequencyKHz frequency; ///< Tuned centre frequency in kHz.
std::int8_t rssiDbuV; ///< RSSI in dBµV.
std::int8_t snrDb; ///< SNR in dB.
bool valid; ///< RSQ valid flag from the chip.
bool stereo; ///< Stereo pilot detected.
std::optional<core::FrequencyKHz> frequency; ///< READFREQ when in FM band.
std::int8_t rssiDbuV; ///< RSSI in dBµV.
std::int8_t snrDb; ///< SNR in dB.
bool valid; ///< RSQ valid flag from the chip.
bool stereo; ///< Stereo pilot detected.
};
/**
@@ -35,15 +35,58 @@ constexpr int kCtsPollMs = 2;
constexpr int kCtsRetries = 5000;
constexpr int kStcRetries = 250;
constexpr int kStcPollMs = 20;
/** SPI readRaw: byte 0 is a lead-in; STATUS0 is at index 1 (AN649). */
constexpr std::size_t kSpiReplyLeadIn = 1U;
/** FM_RSQ_STATUS field indices with kSpiReplyLeadIn (AN649 RESP510). */
constexpr std::size_t kFmRsqOffValid = 6U;
constexpr std::size_t kFmRsqOffReadFreq = 7U;
constexpr std::size_t kFmRsqOffRssi = 10U;
constexpr std::size_t kFmRsqOffSnr = 11U;
constexpr std::uint16_t kPropDigitalIoOutputSelect = 0x0200U;
constexpr std::uint16_t kPropDigitalIoOutputFormat = 0x0202U;
constexpr std::uint16_t kPropDigitalIoSampleRate = 0x0201U;
/** I2S slave — ADAU1701 is bus master (AN649 property 0x0200, bit15=0). */
constexpr std::uint16_t kSi4684I2sSlaveSelect = 0x0000U;
/** 24-bit samples in 32-bit I2S slots (SAMPL=0x18, SLOT=0x7, I2S mode). */
constexpr std::uint16_t kSi4684I2sOutputFormat = 0x1870U;
/** 48000 Hz — matches ADAU1701 48 kHz master clock domain. */
constexpr std::uint16_t kSi4684I2sSampleRateHz = 0xBB80U;
constexpr std::uint16_t kPropPinConfigEnable = 0x0800U;
constexpr std::uint16_t kPropAudioVolume = 0x0300U;
constexpr std::uint16_t kPropAudioMute = 0x0301U;
constexpr std::uint16_t kPropAudioOutputConfig = 0x0302U;
/** AN649 AUDIO_OUTPUT_CONFIG bit1 I2SOUTEN — required for I2S to ADAU1701. */
constexpr std::uint16_t kSi4684I2sOutEnable = 0x0002U;
/** Si4684 volume: 0=mute, 63=max (AN649 AUDIO_ANALOG_VOLUME). */
constexpr std::uint8_t kSi4684VolumeMax = 63U;
constexpr std::uint16_t kPropFmRdsConfig = 0x3C02U;
/** AN649 FM valid tune properties (defaults RSSI 17 dBµV, SNR 10 dB). */
constexpr std::uint16_t kPropFmValidRssiThreshold = 0x3202U;
constexpr std::uint16_t kPropFmValidSnrThreshold = 0x3204U;
constexpr std::uint16_t kFmValidRssiThresholdDbuV = 0x0005U;
constexpr std::uint16_t kFmValidSnrThresholdDb = 0x0003U;
/** AN649 FM seek band/spacing (10 kHz units): 87.5107.9 MHz, 100 kHz steps. */
constexpr std::uint16_t kPropFmSeekBandBottom = 0x3100U;
constexpr std::uint16_t kPropFmSeekBandTop = 0x3101U;
constexpr std::uint16_t kPropFmSeekSpacing = 0x3102U;
constexpr std::uint16_t kFmSeekBandBottomChip = 8750U;
constexpr std::uint16_t kFmSeekBandTopChip = 10790U;
constexpr std::uint16_t kFmSeekSpacingChip = 10U;
/** AN851 §"Varactor Tuning Properties" recommended-network table: FM slope/
* intercept for 0x1710/0x1711 (Table, "FM" row: 0xEDB5 / 0x01E3). */
constexpr std::uint16_t kFmTuneFeVarm = 0xEDB5U;
constexpr std::uint16_t kFmTuneFeVarb = 0x01E3U;
constexpr std::uint16_t kFmTuneFeCfgEnable = 0x0001U;
constexpr std::uint16_t kPropDabTuneFeCfg = 0x1712U;
constexpr std::uint16_t kPropFmTuneFeCfg = 0x1712U;
constexpr std::uint16_t kPropDabXpadEnable = 0xB400U;
constexpr std::uint16_t kPropDigitalServiceIntSource = 0x8100U;
/** AN649 INT_CTL_ENABLE / INT_CTL_REPEAT — route STC to INTB until STCACK. */
constexpr std::uint16_t kPropIntCtlEnable = 0x0000U;
constexpr std::uint16_t kPropIntCtlRepeat = 0x0001U;
constexpr std::uint16_t kIntCtlStcEnable = 0x0001U; ///< STCIEN
constexpr std::uint16_t kIntCtlStcRepeat = 0x0001U; ///< STCREP
std::uint16_t readLe16(const std::uint8_t* p)
{
@@ -140,28 +183,83 @@ std::expected<void, Si4684Error> Si4684Driver::waitCts()
return std::unexpected(Si4684Error::CtsTimeout);
}
std::expected<void, Si4684Error> Si4684Driver::waitStc()
std::expected<bool, Si4684Error> Si4684Driver::pollStc()
{
std::array<std::uint8_t, 5> pollTx = {};
std::array<std::uint8_t, 5> pollRx = {};
for (int attempt = 0; attempt < kStcRetries; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(kStcPollMs));
spi_transaction_t txn = {};
txn.length = pollTx.size() * 8U;
txn.tx_buffer = pollTx.data();
txn.rx_buffer = pollRx.data();
if (spi_device_transmit(static_cast<spi_device_handle_t>(spiDevice_),
&txn) != ESP_OK) {
return std::unexpected(Si4684Error::SpiInitFailed);
spi_transaction_t txn = {};
txn.length = pollTx.size() * 8U;
txn.tx_buffer = pollTx.data();
txn.rx_buffer = pollRx.data();
if (spi_device_transmit(static_cast<spi_device_handle_t>(spiDevice_),
&txn) != ESP_OK) {
return std::unexpected(Si4684Error::SpiInitFailed);
}
// STATUS0 STCINT (AN649 D0) is at pollRx[1] after the SPI lead-in byte.
if ((pollRx[1] & 0x01U) != 0U) {
return true;
}
if (pins_.intbGpio >= 0) {
const gpio_num_t intb = static_cast<gpio_num_t>(pins_.intbGpio);
if (gpio_get_level(intb) == 0) {
return true;
}
if ((pollRx[1] & 0x01U) != 0U) {
}
return false;
}
std::expected<void, Si4684Error> Si4684Driver::waitStc(int maxRetries)
{
for (int attempt = 0; attempt < maxRetries; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(kStcPollMs));
auto stc = pollStc();
if (!stc) {
return std::unexpected(stc.error());
}
if (*stc) {
return {};
}
}
// Diagnostic: dump the final poll so a STC timeout is distinguishable
// between "chip replies but STCINT never sets" (register/offset bug)
// and "chip stopped replying" (SPI/CTS problem) without guessing.
std::array<std::uint8_t, 5> pollTx = {};
std::array<std::uint8_t, 5> pollRx = {};
spi_transaction_t txn = {};
txn.length = pollTx.size() * 8U;
txn.tx_buffer = pollTx.data();
txn.rx_buffer = pollRx.data();
const esp_err_t spiResult = spi_device_transmit(
static_cast<spi_device_handle_t>(spiDevice_), &txn);
const int intbLevel = pins_.intbGpio >= 0
? gpio_get_level(static_cast<gpio_num_t>(pins_.intbGpio))
: -1;
ESP_LOGW(kTag,
"STC timeout: last poll spi_err=%d status=%02x %02x %02x %02x "
"%02x INTB=%d",
static_cast<int>(spiResult), pollRx[0], pollRx[1], pollRx[2],
pollRx[3], pollRx[4], intbLevel);
return std::unexpected(Si4684Error::StcTimeout);
}
std::expected<void, Si4684Error> Si4684Driver::clearFmStc()
{
if (auto band = ensureBand(Si4684Band::Fm); !band) {
return band;
}
// AN649 FM_RSQ_STATUS ARG1 STCACK=1 clears a latched STCINT.
const std::uint8_t args[] = {0x01U};
if (auto cmd = writeCommand(Command::FmRsqStatus, args, sizeof(args));
!cmd) {
return cmd;
}
std::array<std::uint8_t, 23> raw = {};
if (auto rd = readRaw(raw); !rd) {
return rd;
}
return {};
}
std::expected<void, Si4684Error> Si4684Driver::sendCommand(
std::span<const std::uint8_t> bytes)
{
@@ -314,7 +412,7 @@ std::expected<Si4684PartInfo, Si4684Error> Si4684Driver::getPartInfo()
}
Si4684PartInfo info = {};
info.chipId = readLe16(raw.data() + 9);
info.chipId = readLe16(raw.data() + 9U);
info.firmwareMajor = fnRaw[5];
info.firmwareMinor = fnRaw[6];
info.firmwareBuild = fnRaw[7];
@@ -341,14 +439,21 @@ std::expected<Si4684SysState, Si4684Error> Si4684Driver::getSysState()
std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
Si4684Band band)
{
if (auto stcEn = setProperty(kPropIntCtlEnable, kIntCtlStcEnable); !stcEn) {
return stcEn;
}
if (auto stcRep = setProperty(kPropIntCtlRepeat, kIntCtlStcRepeat); !stcRep) {
return stcRep;
}
if (band == Si4684Band::Dab) {
if (auto plan = installDefaultDabFrequencyPlan(); !plan) {
return plan;
}
// AN851 recommended-network table, "DAB" row: 0xF8A9 / 0x01C6.
static constexpr std::uint16_t kDabProps[][2] = {
{0x0202U, 0x1600U},
{0x1710U, 0xFC4AU},
{0x1711U, 0x00F8U},
{0x1710U, 0xF8A9U},
{0x1711U, 0x01C6U},
{0x8101U, 0x0064U},
{0xB200U, 0x0000U},
{0xB201U, 0x0080U},
@@ -366,32 +471,92 @@ std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
if (auto xpad = setProperty(kPropDabXpadEnable, 0x0097U); !xpad) {
return xpad;
}
if (auto dabFe = setProperty(kPropDabTuneFeCfg, 0x0001U); !dabFe) {
ESP_LOGW(kTag, "DAB TUNE_FE_CFG (0x1712) failed");
return dabFe;
}
if (auto dsrv = setProperty(kPropDigitalServiceIntSource, 0x0001U);
!dsrv) {
ESP_LOGW(kTag, "DIGITAL_SERVICE_INT_SOURCE (0x8100) failed");
return dsrv;
}
} else {
// FM varactor cal per hitech95/uGreen DTS (not DAB PE5PVB values).
static constexpr std::uint16_t kFmFeProps[][2] = {
{0x1710U, kFmTuneFeVarm},
{0x1711U, kFmTuneFeVarb},
};
for (const auto& prop : kFmFeProps) {
if (auto set = setProperty(prop[0], prop[1]); !set) {
return set;
}
}
if (auto feCfg = setProperty(kPropFmTuneFeCfg, kFmTuneFeCfgEnable);
!feCfg) {
return feCfg;
}
static constexpr std::uint16_t kFmSeekProps[][2] = {
{kPropFmSeekBandBottom, kFmSeekBandBottomChip},
{kPropFmSeekBandTop, kFmSeekBandTopChip},
{kPropFmSeekSpacing, kFmSeekSpacingChip},
};
for (const auto& prop : kFmSeekProps) {
if (auto set = setProperty(prop[0], prop[1]); !set) {
return set;
}
}
if (auto rds = setProperty(kPropFmRdsConfig, 0x0001U); !rds) {
return rds;
}
// AN649 §0x3202/0x3204: lower seek/tune validity for weak lab antennas.
if (auto rssi = setProperty(kPropFmValidRssiThreshold,
kFmValidRssiThresholdDbuV);
!rssi) {
return rssi;
}
if (auto snr = setProperty(kPropFmValidSnrThreshold,
kFmValidSnrThresholdDb);
!snr) {
return snr;
}
ESP_LOGI(kTag, "FM valid tune: RSSI>=%u dBuV SNR>=%u dB",
static_cast<unsigned>(kFmValidRssiThresholdDbuV),
static_cast<unsigned>(kFmValidSnrThresholdDb));
}
if (auto i2s = setProperty(kPropDigitalIoOutputSelect, 0x8000U); !i2s) {
return i2s;
if (auto i2sRole =
setProperty(kPropDigitalIoOutputSelect, kSi4684I2sSlaveSelect);
!i2sRole) {
return i2sRole;
}
if (auto rate = setProperty(kPropDigitalIoSampleRate, 0xAC44U); !rate) {
if (auto i2sFmt =
setProperty(kPropDigitalIoOutputFormat, kSi4684I2sOutputFormat);
!i2sFmt) {
return i2sFmt;
}
if (auto rate =
setProperty(kPropDigitalIoSampleRate, kSi4684I2sSampleRateHz);
!rate) {
return rate;
}
if (auto pins = setProperty(kPropPinConfigEnable, 0x0003U); !pins) {
return pins;
}
if (auto dabFe = setProperty(kPropDabTuneFeCfg, 0x0001U); !dabFe) {
return dabFe;
if (auto mute = setProperty(kPropAudioMute, 0x0000U); !mute) {
return mute;
}
if (auto dsrv = setProperty(kPropDigitalServiceIntSource, 0x0001U);
!dsrv) {
return dsrv;
if (auto outCfg = setProperty(kPropAudioOutputConfig, kSi4684I2sOutEnable);
!outCfg) {
return outCfg;
}
if (auto vol = setProperty(kPropAudioVolume, kSi4684VolumeMax); !vol) {
return vol;
}
return {};
}
std::expected<void, Si4684Error> Si4684Driver::boot(Si4684Band band)
std::expected<void, Si4684Error> Si4684Driver::boot(
Si4684Band band, std::uint8_t xtalIbias, std::uint8_t xtalCtun)
{
if (booted_ && loadedBand_ == band) {
return {};
@@ -451,12 +616,25 @@ std::expected<void, Si4684Error> Si4684Driver::boot(Si4684Band band)
spiDevice_ = dev;
}
if (pins_.intbGpio >= 0) {
gpio_config_t intCfg = {};
intCfg.pin_bit_mask = 1ULL << pins_.intbGpio;
intCfg.mode = GPIO_MODE_INPUT;
intCfg.pull_up_en = GPIO_PULLUP_ENABLE;
if (gpio_config(&intCfg) != ESP_OK) {
return std::unexpected(Si4684Error::SpiInitFailed);
}
}
if (auto st = writeCommand(Command::GetSysState, nullptr, 0U); !st) {
return st;
}
const std::uint8_t powerUp[] = {
0x17, 0x48, 0x00, 0xf8, 0x24, 0x01, 0x1F, 0x10,
// ARG2=0x17(CLK_MODE=crystal,TR_SIZE), ARG3=IBIAS, ARG4-7=XTAL_FREQ
// 19.2 MHz (0x0124F800), ARG8=CTUN, ARG9=0x10 (fixed bit4=1 per AN649
// §Command 0x01), ARG10-15=0 (AN649 POWER_UP argument table).
std::uint8_t powerUp[] = {
0x17, xtalIbias, 0x00, 0xf8, 0x24, 0x01, xtalCtun, 0x10,
0x00, 0x00, 0x00, 0x18, 0x00, 0x00,
};
if (auto pu = writeCommand(Command::PowerUp, powerUp, sizeof(powerUp));
@@ -517,6 +695,9 @@ std::expected<void, Si4684Error> Si4684Driver::tuneFm(
if (auto band = ensureBand(Si4684Band::Fm); !band) {
return band;
}
if (auto cleared = clearFmStc(); !cleared) {
return cleared;
}
const std::uint16_t chipFreq = kHzToChipFmFreq(frequency.value());
const std::uint8_t args[] = {
0x00U,
@@ -524,13 +705,38 @@ std::expected<void, Si4684Error> Si4684Driver::tuneFm(
static_cast<std::uint8_t>(chipFreq >> 8),
0x00U,
0x00U,
0x00U, // PROG_ID (AN649 ARG6; ignored when DIR_TUNE=0)
};
if (auto cmd = writeCommand(Command::FmTuneFreq, args, sizeof(args));
!cmd) {
return std::unexpected(Si4684Error::TuneFailed);
}
if (auto stc = waitStc(); !stc) {
return stc;
if (auto stc = waitStc(kStcRetries); !stc) {
ESP_LOGW(kTag, "FM tune STC timeout at %u kHz — settling 150 ms",
static_cast<unsigned>(frequency.value()));
vTaskDelay(pdMS_TO_TICKS(150));
} else {
(void)clearFmStc();
}
if (auto rsq = readFmRsq(); rsq) {
const std::uint32_t readKhz =
rsq->frequency ? rsq->frequency->value() : 0U;
if (readKhz != 0U && readKhz != frequency.value()) {
ESP_LOGW(kTag,
"FM tune READFREQ mismatch: want %u kHz got %u kHz",
static_cast<unsigned>(frequency.value()),
static_cast<unsigned>(readKhz));
}
ESP_LOGI(kTag,
"FM tuned %u kHz rssi=%d dBuV snr=%d dB valid=%d readfreq=%u",
static_cast<unsigned>(frequency.value()),
static_cast<int>(rsq->rssiDbuV),
static_cast<int>(rsq->snrDb),
static_cast<int>(rsq->valid),
static_cast<unsigned>(readKhz));
} else {
ESP_LOGI(kTag, "FM tuned %u kHz (RSQ read failed)",
static_cast<unsigned>(frequency.value()));
}
return {};
}
@@ -541,27 +747,54 @@ std::expected<core::FrequencyKHz, Si4684Error> Si4684Driver::seekFm(
if (auto band = ensureBand(Si4684Band::Fm); !band) {
return std::unexpected(band.error());
}
if (auto cleared = clearFmStc(); !cleared) {
return std::unexpected(cleared.error());
}
std::optional<std::uint32_t> prevKhz;
if (auto before = readFmRsq(); before && before->frequency) {
prevKhz = before->frequency->value();
}
const bool seekUp = direction == core::SeekDirection::Up;
const bool wrapBand = wrap == SeekBandWrap::Wrap;
// AN649 FM_SEEK_START: ARG1=tune/injection, ARG2=SEEKUP|WRAP.
const std::uint8_t seekFlags =
static_cast<std::uint8_t>(((seekUp ? 1U : 0U) << 1U)
| (wrapBand ? 1U : 0U));
const std::uint8_t args[] = {
0x10U,
static_cast<std::uint8_t>(((seekUp ? 1U : 0U) << 1U) | (wrapBand ? 1U : 0U)),
0x00U,
seekFlags,
0x00U,
0x00U,
0x00U,
};
if (auto cmd = writeCommand(Command::FmSeekStart, args, sizeof(args));
!cmd) {
ESP_LOGW(kTag, "FM seek command failed (flags=0x%02x)",
static_cast<unsigned>(seekFlags));
return std::unexpected(Si4684Error::TuneFailed);
}
if (auto stc = waitStc(); !stc) {
if (auto stc = waitStc(kStcRetries); !stc) {
ESP_LOGW(kTag, "FM seek STC timeout (flags=0x%02x)",
static_cast<unsigned>(seekFlags));
return std::unexpected(stc.error());
}
(void)clearFmStc();
auto rsq = readFmRsq();
if (!rsq) {
ESP_LOGW(kTag, "FM seek RSQ read failed");
return std::unexpected(rsq.error());
}
return rsq->frequency;
if (!rsq->frequency) {
ESP_LOGW(kTag, "FM seek READFREQ out of band (valid=%d)",
static_cast<int>(rsq->valid));
return std::unexpected(Si4684Error::TuneFailed);
}
if (prevKhz && rsq->frequency->value() == *prevKhz) {
ESP_LOGW(kTag, "FM seek READFREQ unchanged at %u kHz",
static_cast<unsigned>(*prevKhz));
return std::unexpected(Si4684Error::TuneFailed);
}
return *rsq->frequency;
}
std::expected<Si4684FmRsq, Si4684Error> Si4684Driver::readFmRsq()
@@ -579,18 +812,42 @@ std::expected<Si4684FmRsq, Si4684Error> Si4684Driver::readFmRsq()
return std::unexpected(rd.error());
}
const auto khz = chipFmFreqToKHz(readLe16(raw.data() + 6));
if (auto freq = core::FrequencyKHz::tryFromKhz(khz); freq) {
Si4684FmRsq rsq{
*freq,
static_cast<std::int8_t>(raw[8]),
static_cast<std::int8_t>(raw[9]),
(raw[4] & 0x01U) != 0U,
(raw[4] & 0x02U) != 0U,
};
return rsq;
if (raw.size() < kFmRsqOffSnr + 1U) {
return std::unexpected(Si4684Error::ReplyTooShort);
}
return std::unexpected(Si4684Error::CommandFailed);
ESP_LOGI(kTag,
"FM RSQ raw: %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x "
"%02x %02x",
raw[0], raw[1], raw[2], raw[3], raw[4], raw[5], raw[6], raw[7],
raw[8], raw[9], raw[10], raw[11]);
const auto khz =
chipFmFreqToKHz(readLe16(raw.data() + kFmRsqOffReadFreq));
const auto freq = core::FrequencyKHz::tryFromKhz(khz);
const bool freqInBand = static_cast<bool>(freq);
const bool chipValid = (raw[kFmRsqOffValid] & 0x01U) != 0U;
if (!freqInBand && khz != 0U) {
ESP_LOGW(kTag,
"FM RSQ out-of-band freq %u kHz (st=%02x %02x %02x %02x "
"freq=%02x %02x rssi=%02x snr=%02x)",
static_cast<unsigned>(khz), raw[kSpiReplyLeadIn],
raw[kSpiReplyLeadIn + 1U],
raw[kSpiReplyLeadIn + 2U], raw[kSpiReplyLeadIn + 3U],
raw[kFmRsqOffReadFreq], raw[kFmRsqOffReadFreq + 1U],
raw[kFmRsqOffRssi], raw[kFmRsqOffSnr]);
} else if (khz == 0U && raw[kSpiReplyLeadIn] == 0U
&& raw[kSpiReplyLeadIn + 1U] == 0U) {
ESP_LOGW(kTag, "FM RSQ empty reply (st=%02x %02x %02x %02x)",
raw[kSpiReplyLeadIn], raw[kSpiReplyLeadIn + 1U],
raw[kSpiReplyLeadIn + 2U], raw[kSpiReplyLeadIn + 3U]);
}
Si4684FmRsq rsq{
freqInBand ? std::optional<core::FrequencyKHz>{*freq} : std::nullopt,
static_cast<std::int8_t>(raw[kFmRsqOffRssi]),
static_cast<std::int8_t>(raw[kFmRsqOffSnr]),
freqInBand && chipValid,
false,
};
return rsq;
}
std::expected<Si4684FmRdsStatus, Si4684Error> Si4684Driver::readFmRds()
@@ -611,10 +868,10 @@ std::expected<Si4684FmRdsStatus, Si4684Error> Si4684Driver::readFmRds()
Si4684FmRdsStatus rds = {};
rds.received = (raw[4] & 0x01U) != 0U;
rds.fifoUsed = raw[10];
rds.blockA = readLe16(raw.data() + 12);
rds.blockB = readLe16(raw.data() + 14);
rds.blockC = readLe16(raw.data() + 16);
rds.blockD = readLe16(raw.data() + 18);
rds.blockA = readLe16(raw.data() + 12U);
rds.blockB = readLe16(raw.data() + 14U);
rds.blockC = readLe16(raw.data() + 16U);
rds.blockD = readLe16(raw.data() + 18U);
return rds;
}
@@ -708,7 +965,7 @@ std::expected<void, Si4684Error> Si4684Driver::tuneDab(std::uint8_t freqIndex)
!cmd) {
return std::unexpected(Si4684Error::TuneFailed);
}
if (auto stc = waitStc(); !stc) {
if (auto stc = waitStc(kStcRetries); !stc) {
return stc;
}
return {};
@@ -13,11 +13,16 @@
#include "si4684/Si4684Tuner.hpp"
#include "esp_log.h"
#include "si4684/Si4684Band.hpp"
namespace si4684 {
namespace {
constexpr char kTag[] = "Si4684Tuner";
constexpr std::uint32_t kFmSeekStepKhz = 100U;
constexpr std::uint32_t kFmBandBottomKhz = 87500U;
constexpr std::uint32_t kFmBandTopKhz = 107900U;
[[nodiscard]] core::FrequencyKHz defaultFmFrequency()
{
@@ -30,7 +35,8 @@ Si4684Tuner::Si4684Tuner(Si4684Driver& driver)
: driver_(driver)
, dabIndex_(0U)
, fmFrequency_(defaultFmFrequency())
, volume_(40U)
, volume_(63U)
, fmBandReady_(false)
{
}
@@ -44,6 +50,9 @@ core::TunerError Si4684Tuner::mapError(Si4684Error error) noexcept
case Si4684Error::TuneFailed:
case Si4684Error::StcTimeout:
return core::TunerError::TuneFailed;
case Si4684Error::CtsTimeout:
case Si4684Error::CommandFailed:
return core::TunerError::HardwareFailed;
default:
return core::TunerError::HardwareFailed;
}
@@ -68,6 +77,58 @@ std::expected<core::TunerBand, core::TunerError> Si4684Tuner::currentBand() cons
: core::TunerBand::Dab;
}
std::expected<void, core::TunerError> Si4684Tuner::ensureBandLoaded(
core::TunerBand band)
{
const Si4684Band hwTarget =
(band == core::TunerBand::Fm) ? Si4684Band::Fm : Si4684Band::Dab;
if (driver_.isBooted() && driver_.loadedBand() == hwTarget) {
if (hwTarget != Si4684Band::Fm || fmBandReady_) {
return {};
}
}
const bool reloading =
!(driver_.isBooted() && driver_.loadedBand() == hwTarget);
if (reloading) {
ESP_LOGI(kTag, "band switch -> %s",
band == core::TunerBand::Fm ? "FM" : "DAB");
}
if (driver_.isBooted() && driver_.loadedBand() == Si4684Band::Dab
&& hwTarget == Si4684Band::Fm && lastDabServiceId_
&& lastDabComponentId_) {
(void)driver_.stopDabService(*lastDabServiceId_, *lastDabComponentId_);
lastDabServiceId_.reset();
lastDabComponentId_.reset();
dabDynamicLabel_.reset();
}
if (auto loaded = boot(band); !loaded) {
return loaded;
}
if (auto vol = driver_.setVolume(volume_); !vol) {
return std::unexpected(mapError(vol.error()));
}
if (band == core::TunerBand::Fm) {
rdsMetadata_.reset();
fmFrequency_ = defaultFmFrequency();
fmBandReady_ = false;
if (auto tuned = driver_.tuneFm(fmFrequency_); !tuned) {
return std::unexpected(mapError(tuned.error()));
}
fmBandReady_ = true;
} else {
fmBandReady_ = false;
dabDynamicLabel_.reset();
lastDabServiceId_.reset();
lastDabComponentId_.reset();
}
return {};
}
std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
{
if (!driver_.isBooted()) {
@@ -106,11 +167,20 @@ std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
status.fmFrequency = fmFrequency_;
if (auto rsq = driver_.readFmRsq(); rsq) {
status.locked = rsq->valid;
status.fmFrequency = rsq->frequency;
status.fmRssiDbuV = rsq->rssiDbuV;
status.fmSnrDb = rsq->snrDb;
status.fmStereo = rsq->stereo;
fmFrequency_ = rsq->frequency;
status.fmChipReadFrequency = rsq->frequency;
// Keep commanded frequency when chip READFREQ is stale (stuck at band
// bottom); adopt READFREQ only when valid or it matches our target.
status.fmFrequency = fmFrequency_;
if (rsq->frequency) {
const std::uint32_t chipKhz = rsq->frequency->value();
if (rsq->valid || chipKhz == fmFrequency_.value()) {
status.fmFrequency = *rsq->frequency;
fmFrequency_ = *rsq->frequency;
}
}
} else {
return std::unexpected(mapError(rsq.error()));
}
@@ -118,7 +188,8 @@ std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
for (int attempt = 0; attempt < 8; ++attempt) {
auto rds = driver_.readFmRds();
if (!rds) {
return std::unexpected(mapError(rds.error()));
ESP_LOGW(kTag, "FM RDS read failed (attempt %d)", attempt);
break;
}
if (!rds->received) {
break;
@@ -140,6 +211,9 @@ std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
std::expected<void, core::TunerError> Si4684Tuner::tuneDab(
std::uint8_t freqIndex)
{
if (auto ready = ensureBandLoaded(core::TunerBand::Dab); !ready) {
return ready;
}
if (auto result = driver_.tuneDab(freqIndex); !result) {
return std::unexpected(mapError(result.error()));
}
@@ -153,14 +227,9 @@ std::expected<void, core::TunerError> Si4684Tuner::tuneDab(
std::expected<void, core::TunerError> Si4684Tuner::tuneFm(
core::FrequencyKHz frequency)
{
if (driver_.loadedBand() == Si4684Band::Dab && lastDabServiceId_
&& lastDabComponentId_) {
(void)driver_.stopDabService(*lastDabServiceId_, *lastDabComponentId_);
lastDabServiceId_.reset();
lastDabComponentId_.reset();
dabDynamicLabel_.reset();
if (auto ready = ensureBandLoaded(core::TunerBand::Fm); !ready) {
return ready;
}
if (auto result = driver_.tuneFm(frequency); !result) {
return std::unexpected(mapError(result.error()));
}
@@ -172,18 +241,55 @@ std::expected<void, core::TunerError> Si4684Tuner::tuneFm(
std::expected<core::FrequencyKHz, core::TunerError> Si4684Tuner::seekFm(
core::SeekDirection direction)
{
const auto result = driver_.seekFm(direction, SeekBandWrap::Wrap);
if (!result) {
return std::unexpected(mapError(result.error()));
if (auto ready = ensureBandLoaded(core::TunerBand::Fm); !ready) {
return std::unexpected(ready.error());
}
fmFrequency_ = *result;
rdsMetadata_.reset();
return *result;
const core::FrequencyKHz before = fmFrequency_;
const auto hw = driver_.seekFm(direction, SeekBandWrap::Wrap);
if (hw && hw->value() != before.value()) {
fmFrequency_ = *hw;
rdsMetadata_.reset();
return *hw;
}
// hitech95/si468x_dab_receiver uses HW seek + INTB STC; when READFREQ does
// not move, step by FM_SEEK_FREQUENCY_SPACING (100 kHz) via FM_TUNE_FREQ.
std::uint32_t nextKhz = before.value();
if (direction == core::SeekDirection::Up) {
nextKhz += kFmSeekStepKhz;
if (nextKhz > kFmBandTopKhz) {
nextKhz = kFmBandBottomKhz;
}
} else {
nextKhz = (nextKhz > kFmBandBottomKhz + kFmSeekStepKhz)
? nextKhz - kFmSeekStepKhz
: kFmBandTopKhz;
}
const auto next = core::FrequencyKHz::tryFromKhz(nextKhz);
if (!next) {
return std::unexpected(core::TunerError::TuneFailed);
}
if (auto stepped = tuneFm(*next); !stepped) {
return std::unexpected(stepped.error());
}
if (auto rsq = driver_.readFmRsq(); rsq && rsq->frequency) {
ESP_LOGI(kTag, "FM software seek %s -> %u kHz (chip READFREQ)",
direction == core::SeekDirection::Up ? "UP" : "DOWN",
static_cast<unsigned>(rsq->frequency->value()));
return *rsq->frequency;
}
ESP_LOGI(kTag, "FM software seek %s -> %u kHz",
direction == core::SeekDirection::Up ? "UP" : "DOWN",
static_cast<unsigned>(nextKhz));
return *next;
}
std::expected<std::vector<core::TunerServiceEntry>, core::TunerError>
Si4684Tuner::listDabServices()
{
if (auto ready = ensureBandLoaded(core::TunerBand::Dab); !ready) {
return std::unexpected(ready.error());
}
if (auto events = driver_.readDabEventStatus(); events) {
if (!events->serviceListReady) {
return std::unexpected(core::TunerError::ServiceListEmpty);
@@ -215,6 +321,9 @@ std::expected<void, core::TunerError> Si4684Tuner::playDabService(
std::uint32_t serviceId,
std::uint32_t componentId)
{
if (auto ready = ensureBandLoaded(core::TunerBand::Dab); !ready) {
return ready;
}
if (auto result = driver_.startDabService(serviceId, componentId); !result) {
return std::unexpected(mapError(result.error()));
}
+3 -1
View File
@@ -4,10 +4,12 @@ idf_component_register(
"src/SoftApHost.cpp"
"src/StaClient.cpp"
"src/SetupWebServer.cpp"
"src/SigmaStudioTcpServer.cpp"
"src/WifiScanner.cpp"
"src/NetBootstrap.cpp"
INCLUDE_DIRS "include"
EMBED_FILES "www/index.html.gz"
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns secure_store tuner audio bluetooth station integration ota bt1035 adau1701
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns lwip secure_store tuner audio bluetooth station integration ota bt1035 adau1701 webradio
)
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -23,6 +23,7 @@
#include "net/NetError.hpp"
#include "net/NetState.hpp"
#include "net/SetupWebServer.hpp"
#include "net/SigmaStudioTcpServer.hpp"
#include "net/SoftApHost.hpp"
#include "net/StaClient.hpp"
@@ -53,6 +54,10 @@ namespace tuner {
class TunerService;
} // namespace tuner
namespace webradio {
class WebRadioService;
} // namespace webradio
namespace net {
/**
@@ -60,8 +65,9 @@ namespace net {
*
* @dname NetBootstrap
* @return n/a (type)
* @pubstate Owns optional softAp_, optional sta_, and webServer_. Must
* outlive app_main; keep one instance alive for process lifetime.
* @pubstate Owns optional softAp_, optional sta_, webServer_, and
* sigmaStudio_. Must outlive app_main; keep one instance alive
* for process lifetime.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -79,6 +85,7 @@ public:
* @param stations Station preset service for REST routes.
* @param integration Application orchestration for preset recall.
* @param ota Firmware OTA service for POST /api/system/ota.
* @param webRadio Streaming config for GET/POST /api/streaming.
* @param companionChips Boot flags exposed on GET /api/health.
* @param deviceIdentity EEPROM-derived SSID, hostname, and serial.
* @return NetBootstrap on success, or a NetError.
@@ -93,6 +100,7 @@ public:
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity);
@@ -106,12 +114,14 @@ public:
* @param softAp Optional SoftAP mode host.
* @param sta Optional STA client instance.
* @param webServer HTTP server instance.
* @param sigmaStudio SigmaStudio TCP:8086 bridge instance.
* @param state Initial network state.
* @pubstate Transfers ownership of optional network resources.
*/
NetBootstrap(std::optional<SoftApHost> softAp,
std::optional<StaClient> sta,
SetupWebServer webServer,
SigmaStudioTcpServer sigmaStudio,
NetState state);
/**
@@ -119,7 +129,7 @@ public:
*
* @dname NetBootstrap
* @param other Source instance; left empty after the move.
* @pubstate transfers softAp_, sta_, and webServer_ from other.
* @pubstate transfers softAp_, sta_, webServer_, and sigmaStudio_ from other.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -132,7 +142,7 @@ public:
* @dname operator=
* @param other Source instance; left empty after the move.
* @return Reference to this instance.
* @pubstate transfers softAp_, sta_, and webServer_ from other.
* @pubstate transfers softAp_, sta_, webServer_, and sigmaStudio_ from other.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -143,7 +153,7 @@ public:
* @brief ~NetBootstrap — tear down network subsystems.
*
* @dname ~NetBootstrap
* @pubstate destroys softAp_, sta_, and webServer_.
* @pubstate destroys softAp_, sta_, webServer_, and sigmaStudio_.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -166,6 +176,7 @@ private:
std::optional<SoftApHost> softAp_;
std::optional<StaClient> sta_;
SetupWebServer webServer_;
SigmaStudioTcpServer sigmaStudio_;
NetState state_;
};
@@ -37,10 +37,12 @@ enum class NetError {
WifiConfigFailed,
WifiStartFailed,
HttpServerStartFailed,
TcpServerStartFailed,
StaConnectTimeout,
StaConnectFailed,
StoreSaveFailed,
CredentialsNotFound,
WifiScanFailed,
};
} // namespace net
@@ -50,6 +50,10 @@ namespace tuner {
class TunerService;
} // namespace tuner
namespace webradio {
class WebRadioService;
} // namespace webradio
struct httpd_handle;
namespace net {
@@ -73,6 +77,7 @@ struct HttpRouteContext {
station::StationService* stations; ///< Preset list REST routes.
integration::IntegrationService* integration; ///< Preset recall orchestration.
ota::OtaService* ota; ///< Firmware OTA streaming.
webradio::WebRadioService* webRadio; ///< Streaming config REST routes.
core::CompanionChipStatus companionChips; ///< Boot flags for /api/health.
core::DeviceIdentity deviceIdentity; ///< EEPROM-derived unit identity.
};
@@ -152,6 +157,7 @@ public:
* @param stations Station preset service for list REST routes.
* @param integration Application orchestration for preset recall.
* @param ota Firmware OTA service for POST /api/system/ota.
* @param webRadio Streaming config for GET/POST /api/streaming.
* @param companionChips Boot flags for GET /api/health.
* @param deviceIdentity Unit identity for /api/health serialNumber.
* @return Ok on success, or NetError::HttpServerStartFailed.
@@ -167,6 +173,7 @@ public:
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity);
@@ -179,7 +186,6 @@ private:
bluetooth::BluetoothService* bluetooth_;
station::StationService* stations_;
integration::IntegrationService* integration_;
HttpRouteContext routeContext_;
};
} // namespace net
@@ -0,0 +1,133 @@
/**
* @file SigmaStudioTcpServer.hpp
* @brief TCP bridge exposing the ADAU1701 to SigmaStudio's Remote Connection.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* Implements the subset of SigmaStudio's TCPi wire protocol needed for
* Connect, Link Compile Download, register read/write, and runtime
* safeload parameter updates, ported from the ADAU1701-TCPi-ESP32
* reference project (https://github.com/rarranzb/ADAU1701-TCPi-ESP32,
* MIT) onto DigiRadio's existing I2C plumbing
* (components/drivers/adau1701/src/SigmaStudioFW.c). A completed Link
* Compile Download is persisted to the `dsp` flash partition via
* adau1701::FlashDspProgramSource::storeBlob(), so it becomes the
* program DigiRadio boots with next time — same mechanism as
* POST /api/dsp/program.
*
* @author Michele Bigi
* @date 2026-08-07
*/
#pragma once
#include "net/NetError.hpp"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <expected>
namespace net {
/**
* @brief SigmaStudioTcpServer — port 8086 bridge to the ADAU1701.
*
* @dname SigmaStudioTcpServer
* @return n/a (type)
* @pubstate Owns a listening socket and a FreeRTOS task for the process
* lifetime once started(); stop()/destructor tear both down.
* Requires adau1701::Adau1701Driver::boot() to have already
* bound the I2C device handle via sigma_studio_set_device().
* Only one instance may be started at a time: the accept task
* reads the listen fd from a process-lifetime singleton (see
* activeListenFd() in the .cpp) rather than a captured `this`,
* since instances are constructed as locals and move-relocated
* into NetBootstrap — same rationale as SetupWebServer's
* routeContextStorage().
*
* @author Michele Bigi
* @date 2026-08-07
*/
class SigmaStudioTcpServer {
public:
/**
* @brief SigmaStudioTcpServer — construct an unstarted server.
*
* @dname SigmaStudioTcpServer
* @pubstate clears listenFd_ and task_.
*
* @author Michele Bigi
* @date 2026-08-07
*/
SigmaStudioTcpServer();
/**
* @brief ~SigmaStudioTcpServer — stop the server if running.
*
* @dname ~SigmaStudioTcpServer
* @pubstate deletes task_ and closes listenFd_ when started.
*
* @author Michele Bigi
* @date 2026-08-07
*/
~SigmaStudioTcpServer();
SigmaStudioTcpServer(const SigmaStudioTcpServer&) = delete;
SigmaStudioTcpServer& operator=(const SigmaStudioTcpServer&) = delete;
/**
* @brief SigmaStudioTcpServer — move-construct, transferring ownership.
*
* @dname SigmaStudioTcpServer
* @param other Source server; left stopped after the move.
* @pubstate takes ownership of other.listenFd_ and other.task_.
*
* @author Michele Bigi
* @date 2026-08-07
*/
SigmaStudioTcpServer(SigmaStudioTcpServer&& other) noexcept;
/**
* @brief operator= — move-assign, transferring ownership.
*
* @dname operator=
* @param other Source server; left stopped after the move.
* @return Reference to this instance.
* @pubstate takes ownership of other.listenFd_ and other.task_.
*
* @author Michele Bigi
* @date 2026-08-07
*/
SigmaStudioTcpServer& operator=(SigmaStudioTcpServer&& other) noexcept;
/**
* @brief start — open the listening socket and spawn the server task.
*
* @dname start
* @return Ok on success, or NetError::TcpServerStartFailed.
* @pubstate writes listenFd_ and task_ on success.
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::expected<void, NetError> start();
private:
void stop() noexcept;
/** @brief FreeRTOS task entry: accepts and serves one client at a time. */
static void acceptLoopTask(void* arg);
int listenFd_;
TaskHandle_t task_;
};
} // namespace net
@@ -20,6 +20,8 @@
#include "core/WifiCredentials.hpp"
#include "net/NetError.hpp"
#include "esp_event.h"
#include <expected>
#include <string_view>
@@ -107,6 +109,8 @@ public:
private:
bool connected_;
esp_event_handler_instance_t wifiHandler_;
esp_event_handler_instance_t ipHandler_;
};
} // namespace net
@@ -0,0 +1,50 @@
/**
* @file WifiScanner.hpp
* @brief Blocking Wi-Fi scan wrapper for the setup web UI.
*
* 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 "net/NetError.hpp"
#include <expected>
#include <vector>
namespace net {
/**
* @brief WifiScanner — runs esp_wifi scan and maps results to core DTOs.
*
* @dname WifiScanner
* @return n/a (type)
* @pubstate Stateless shell helper; assumes esp_wifi_init() already ran.
*
* @author Michele Bigi
* @date 2026-08-05
*/
class WifiScanner {
public:
/**
* @brief scanNearby — list visible access points sorted by RSSI.
*
* @dname scanNearby
* @return Deduped networks on success, or NetError::WifiScanFailed.
* @pubstate Temporarily switches AP-only mode to APSTA when required.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] static std::expected<std::vector<core::WifiScannedNetwork>,
NetError>
scanNearby();
};
} // namespace net
+26 -7
View File
@@ -27,6 +27,7 @@
#include "secure_store/NvsPlatformInit.hpp"
#include "station/StationService.hpp"
#include "tuner/TunerService.hpp"
#include "webradio/WebRadioService.hpp"
namespace net {
@@ -102,10 +103,12 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity)
{
esp_netif_create_default_wifi_ap();
esp_netif_create_default_wifi_sta();
SoftApHost softAp(SoftApConfig::forSsid(deviceIdentity.softApSsid()));
if (auto apResult = softAp.start(); !apResult) {
@@ -115,17 +118,23 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
SetupWebServer webServer;
if (auto webResult =
webServer.start(store, NetState::SoftApSetup, tuner, audio,
bluetooth, stations, integration, ota,
bluetooth, stations, integration, ota, webRadio,
companionChips, deviceIdentity);
!webResult) {
return std::unexpected(webResult.error());
}
SigmaStudioTcpServer sigmaStudio;
if (auto sigmaResult = sigmaStudio.start(); !sigmaResult) {
ESP_LOGW(kTag, "SigmaStudio TCP bridge failed to start — continuing "
"without it");
}
ESP_LOGI(kTag, "setup mode ready — SSID %.*s",
static_cast<int>(deviceIdentity.softApSsid().size()),
deviceIdentity.softApSsid().data());
return NetBootstrap(std::move(softAp), std::nullopt, std::move(webServer),
NetState::SoftApSetup);
std::move(sigmaStudio), NetState::SoftApSetup);
}
/**
@@ -146,6 +155,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity)
{
@@ -175,17 +185,23 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
SetupWebServer webServer;
if (auto webResult =
webServer.start(store, NetState::StaConnected, tuner, audio,
bluetooth, stations, integration, ota,
bluetooth, stations, integration, ota, webRadio,
companionChips, deviceIdentity);
!webResult) {
return std::unexpected(webResult.error());
}
SigmaStudioTcpServer sigmaStudio;
if (auto sigmaResult = sigmaStudio.start(); !sigmaResult) {
ESP_LOGW(kTag, "SigmaStudio TCP bridge failed to start — continuing "
"without it");
}
ESP_LOGI(kTag, "STA mode ready — hostname %.*s.local",
static_cast<int>(deviceIdentity.hostname().size()),
deviceIdentity.hostname().data());
return NetBootstrap(std::nullopt, std::move(sta), std::move(webServer),
NetState::StaConnected);
std::move(sigmaStudio), NetState::StaConnected);
}
} // namespace
@@ -197,6 +213,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity)
{
@@ -210,8 +227,8 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
if (store.hasWifiCredentials()) {
auto staResult = startStaMode(store, tuner, audio, bluetooth, stations,
integration, ota, companionChips,
deviceIdentity);
integration, ota, webRadio,
companionChips, deviceIdentity);
if (staResult) {
return staResult;
}
@@ -223,16 +240,18 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
}
return startSetupMode(store, tuner, audio, bluetooth, stations, integration,
ota, companionChips, deviceIdentity);
ota, webRadio, companionChips, deviceIdentity);
}
NetBootstrap::NetBootstrap(std::optional<SoftApHost> softAp,
std::optional<StaClient> sta,
SetupWebServer webServer,
SigmaStudioTcpServer sigmaStudio,
NetState state)
: softAp_(std::move(softAp))
, sta_(std::move(sta))
, webServer_(std::move(webServer))
, sigmaStudio_(std::move(sigmaStudio))
, state_(state)
{
}
+606 -44
View File
@@ -21,11 +21,9 @@
#include "core/AudioProfile.hpp"
#include "core/AudioProfileJson.hpp"
#include "core/BluetoothJson.hpp"
#include "core/Bt1035ScannedDevice.hpp"
#include "core/CompanionChipStatus.hpp"
#include "core/DspProgramBlob.hpp"
#include "core/FirmwareVersion.hpp"
#include "core/HealthStatus.hpp"
#include "core/HealthStatusJson.hpp"
#include "core/IntegrationError.hpp"
#include "core/ParseError.hpp"
#include "core/SeekDirection.hpp"
@@ -33,6 +31,8 @@
#include "core/StoreError.hpp"
#include "core/TunerJson.hpp"
#include "core/WifiProvisionJson.hpp"
#include "core/WifiScanJson.hpp"
#include "net/WifiScanner.hpp"
#include "tuner/TunerService.hpp"
#include "audio/AudioService.hpp"
#include "bluetooth/BluetoothService.hpp"
@@ -42,10 +42,13 @@
#include "ota/OtaService.hpp"
#include "ota/OtaError.hpp"
#include "core/OtaAppDescriptor.hpp"
#include "core/WebRadioJson.hpp"
#include "bt1035/Bt1035Error.hpp"
#include "webradio/WebRadioService.hpp"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -53,6 +56,8 @@
#include <array>
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
@@ -68,6 +73,49 @@ extern const uint8_t index_html_gz_start[] asm(
extern const uint8_t index_html_gz_end[] asm(
"_binary_index_html_gz_end");
/**
* @brief routeContextStorage — app-lifetime HTTP handler dependencies.
*
* @dname routeContextStorage
* @return Reference to the singleton route context.
* @pubstate Populated in SetupWebServer::start(); stable across moves so
* esp_http_server user_ctx never goes stale.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] HttpRouteContext& routeContextStorage() noexcept
{
static HttpRouteContext ctx{
.store = nullptr,
.tuner = nullptr,
.audio = nullptr,
.bluetooth = nullptr,
.stations = nullptr,
.integration = nullptr,
.ota = nullptr,
.webRadio = nullptr,
.companionChips = {},
.deviceIdentity = core::DeviceIdentity::unknown(),
};
return ctx;
}
/**
* @brief routeContextReady — whether start() populated route dependencies.
*
* @dname routeContextReady
* @return true after SetupWebServer::start() succeeds.
* @pubstate reads routeContextStorage().store.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] bool routeContextReady() noexcept
{
return routeContextStorage().store != nullptr;
}
/**
* @brief routeContextFrom — read handler dependencies from user_ctx.
*
@@ -81,7 +129,10 @@ extern const uint8_t index_html_gz_end[] asm(
*/
[[nodiscard]] HttpRouteContext* routeContextFrom(httpd_req_t* req) noexcept
{
return static_cast<HttpRouteContext*>(req->user_ctx);
if (req != nullptr && req->user_ctx != nullptr) {
return static_cast<HttpRouteContext*>(req->user_ctx);
}
return routeContextReady() ? &routeContextStorage() : nullptr;
}
/**
@@ -216,21 +267,64 @@ template <std::size_t N>
esp_err_t healthGetHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
const core::CompanionChipStatus chips =
ctx != nullptr
? ctx->companionChips
: core::CompanionChipStatus{
.si4684Ready = false,
.adau1701Ready = false,
.bt1035Ready = false,
};
const core::HealthStatus status = core::HealthStatus::ok(
core::FirmwareVersion(kFirmwareVersion), chips,
ctx != nullptr ? ctx->deviceIdentity.serialNumber()
: std::string_view("unknown"));
const std::string json = core::serializeHealthStatusJson(status);
bool si4684Ready = false;
bool adau1701Ready = false;
bool bt1035Ready = false;
const char* serialNumber = "unknown";
if (ctx != nullptr) {
si4684Ready = ctx->companionChips.si4684Ready;
adau1701Ready = ctx->companionChips.adau1701Ready;
bt1035Ready = ctx->companionChips.bt1035Ready;
const std::string_view serial = ctx->deviceIdentity.serialNumber();
if (!serial.empty() && serial.size() <= 32U) {
serialNumber = serial.data();
}
}
char ipAddr[16] = {};
esp_netif_t* staNetif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (staNetif != nullptr) {
esp_netif_ip_info_t ipInfo{};
if (esp_netif_get_ip_info(staNetif, &ipInfo) == ESP_OK
&& ipInfo.ip.addr != 0U) {
std::snprintf(ipAddr, sizeof(ipAddr), IPSTR, IP2STR(&ipInfo.ip));
}
}
if (ipAddr[0] == '\0') {
esp_netif_t* apNetif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF");
if (apNetif != nullptr) {
esp_netif_ip_info_t ipInfo{};
if (esp_netif_get_ip_info(apNetif, &ipInfo) == ESP_OK
&& ipInfo.ip.addr != 0U) {
std::snprintf(ipAddr, sizeof(ipAddr), IPSTR, IP2STR(&ipInfo.ip));
}
}
}
char json[320];
int jsonLen = 0;
if (ipAddr[0] != '\0') {
jsonLen = std::snprintf(
json, sizeof(json),
R"({"status":"ok","fw":"%s","serialNumber":"%s","ip":"%s","chips":{"si4684":%s,"adau1701":%s,"bt1035":%s}})",
kFirmwareVersion, serialNumber, ipAddr,
si4684Ready ? "true" : "false", adau1701Ready ? "true" : "false",
bt1035Ready ? "true" : "false");
} else {
jsonLen = std::snprintf(
json, sizeof(json),
R"({"status":"ok","fw":"%s","serialNumber":"%s","chips":{"si4684":%s,"adau1701":%s,"bt1035":%s}})",
kFirmwareVersion, serialNumber, si4684Ready ? "true" : "false",
adau1701Ready ? "true" : "false", bt1035Ready ? "true" : "false");
}
if (jsonLen <= 0 || jsonLen >= static_cast<int>(sizeof(json))) {
httpd_resp_set_status(req, "500 Internal Server Error");
return httpd_resp_send(req, nullptr, 0);
}
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
return httpd_resp_send(req, json, static_cast<std::size_t>(jsonLen));
}
/**
@@ -446,6 +540,61 @@ esp_err_t tunerSeekPostHandler(httpd_req_t* req)
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief tunerScanPostHandler — automatic FM/DAB station search (test mode).
*
* @dname tunerScanPostHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate uses route context tuner service; may block tens of seconds.
*
* @author Michele Bigi
* @date 2026-08-05
*/
esp_err_t tunerScanPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->tuner == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 512> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto parsed =
core::parseTunerScanJson(std::string_view(body.data()));
if (!parsed) {
const std::string json =
core::serializeTunerErrorJson(parseErrorToken(parsed.error()));
httpd_resp_set_status(req, "400 Bad Request");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
ESP_LOGI(kTag, "tuner scan HTTP band=%s max_steps=%u name='%.*s'",
parsed->band == core::TunerBand::Fm ? "fm" : "dab",
static_cast<unsigned>(parsed->maxSteps),
static_cast<int>(parsed->nameFilter.size()),
parsed->nameFilter.c_str());
auto result = ctx->tuner->scanForStation(*parsed);
if (!result) {
const std::string json =
core::serializeTunerErrorJson(tunerErrorToken(result.error()));
httpd_resp_set_status(req, "409 Conflict");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
const std::string json = core::serializeTunerScanJson(*result);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief audioProfileGetHandler — serve GET /api/audio/profile JSON.
*
@@ -608,6 +757,125 @@ esp_err_t audioBassEnhancePostHandler(httpd_req_t* req)
return audioEnhancePostHandler(req, false);
}
/**
* @brief audioBeepPostHandler — toggle the ADAU1701 Beep1 tone generator.
*
* @dname audioBeepPostHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate live-only safeload; does not touch AudioProfile or NVS.
*
* @author Michele Bigi
* @date 2026-08-07
*/
esp_err_t audioBeepPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->audio == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 128> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto parsed = core::parseBeepEnabledJson(std::string_view(body.data()));
if (!parsed) {
const std::string json =
core::serializeAudioErrorJson(parseErrorToken(parsed.error()));
httpd_resp_set_status(req, "400 Bad Request");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
if (auto applied = ctx->audio->setBeepEnabled(*parsed); !applied) {
const std::string json = core::serializeAudioErrorJson("dsp_failed");
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
const std::string json = core::serializeAudioSavedJson();
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief streamingGetHandler — serve GET /api/streaming as JSON.
*
* @dname streamingGetHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate reads route context web radio service snapshot.
*
* @author Michele Bigi
* @date 2026-08-08
*/
esp_err_t streamingGetHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->webRadio == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
const std::string json =
core::serializeWebRadioConfigJson(ctx->webRadio->config());
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief streamingPostHandler — accept POST /api/streaming JSON.
*
* @dname streamingPostHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate persists config to NVS and updates the live streaming task
* config; takes effect without a reboot.
*
* @author Michele Bigi
* @date 2026-08-08
*/
esp_err_t streamingPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->webRadio == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 512> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto parsed =
core::parseWebRadioConfigJson(std::string_view(body.data()));
if (!parsed) {
const std::string json =
core::serializeWebRadioErrorJson(parseErrorToken(parsed.error()));
httpd_resp_set_status(req, "400 Bad Request");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
if (auto applied = ctx->webRadio->setConfig(*parsed); !applied) {
const std::string json =
core::serializeWebRadioErrorJson("store_failed");
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
const std::string json = core::serializeWebRadioConfigJson(*parsed);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief indexGetHandler — serve gzipped setup page from flash.
* @param req HTTP request handle from esp_http_server.
@@ -699,6 +967,35 @@ esp_err_t wifiPostHandler(httpd_req_t* req)
return ESP_OK;
}
/**
* @brief wifiScanPostHandler — list nearby Wi-Fi access points.
*
* @dname wifiScanPostHandler
* @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code.
* @pubstate runs esp_wifi scan via WifiScanner.
*
* @author Michele Bigi
* @date 2026-08-05
*/
esp_err_t wifiScanPostHandler(httpd_req_t* req)
{
(void)req;
auto networks = WifiScanner::scanNearby();
if (!networks) {
const std::string json =
core::serializeWifiScanErrorJson("scan_failed");
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
const std::string json = core::serializeWifiScanJson(*networks);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief dspProgramPostHandler — store validated ADAU program blob to flash.
*
@@ -939,6 +1236,191 @@ esp_err_t bluetoothPairedGetHandler(httpd_req_t* req)
return httpd_resp_send(req, json.c_str(), json.size());
}
esp_err_t bluetoothScanPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->bluetooth == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
ESP_LOGI(kTag, "bluetooth scan HTTP request");
std::uint8_t scanSeconds = 45U;
std::array<char, 128> body{};
if (readRequestBody(req, body)) {
const std::string_view payload(body.data());
const std::string needle = "\"seconds\":";
const std::size_t start = payload.find(needle);
if (start != std::string_view::npos) {
char* end = nullptr;
const unsigned long raw = std::strtoul(
payload.data() + start + needle.size(), &end, 10);
if (end != payload.data() + start + needle.size() && raw >= 1U
&& raw <= 255U) {
scanSeconds = static_cast<std::uint8_t>(raw);
}
}
}
ESP_LOGI(kTag, "bluetooth scan HTTP seconds=%u",
static_cast<unsigned>(scanSeconds));
auto devices = ctx->bluetooth->scanNearby(scanSeconds);
if (!devices) {
ESP_LOGW(kTag, "bluetooth scan failed: %s",
bt1035ErrorToken(devices.error()));
const std::string json =
core::serializeBluetoothErrorJson(bt1035ErrorToken(devices.error()));
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
ESP_LOGI(kTag, "bluetooth scan HTTP OK: %u device(s)",
static_cast<unsigned>(devices->size()));
for (const core::Bt1035ScannedDevice& device : *devices) {
ESP_LOGI(kTag, "bluetooth scan HTTP result: mac=%s name=%s rssi=%d",
device.mac.c_str(),
device.name.empty() ? "(no name)" : device.name.c_str(),
static_cast<int>(device.rssiDbm));
}
const std::string json = core::serializeBluetoothScanJson(*devices);
ESP_LOGI(kTag, "bluetooth scan JSON length=%u",
static_cast<unsigned>(json.size()));
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
esp_err_t bluetoothConnectPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->bluetooth == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 256> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const core::BluetoothConnectRequest request =
core::parseBluetoothConnectRequest(std::string_view(body.data()));
if (request.mac.empty()) {
const std::string json =
core::serializeBluetoothErrorJson("invalid_mac");
httpd_resp_set_status(req, "400 Bad Request");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
ESP_LOGI(kTag, "bluetooth connect HTTP mac=%s save=%s",
request.mac.c_str(), request.save ? "yes" : "no");
if (auto result = ctx->bluetooth->connectTo(request); !result) {
const std::string json =
core::serializeBluetoothErrorJson(bt1035ErrorToken(result.error()));
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, "{\"status\":\"connected\"}", 24);
}
esp_err_t bluetoothSpeakerGetHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->bluetooth == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
if (!ctx->bluetooth->hasSavedSpeaker()) {
const std::string json = core::serializeBluetoothSpeakerJson(nullptr);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
const auto target = ctx->bluetooth->loadSavedSpeaker();
if (!target) {
httpd_resp_set_status(req, "500 Internal Server Error");
return httpd_resp_send(req, nullptr, 0);
}
const std::string json = core::serializeBluetoothSpeakerJson(&(*target));
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
esp_err_t bluetoothSpeakerPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->bluetooth == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 256> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto target = core::parseBluetoothSpeakerJson(std::string_view(body.data()));
if (!target) {
const std::string json =
core::serializeBluetoothErrorJson(parseErrorToken(target.error()));
httpd_resp_set_status(req, "400 Bad Request");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
if (auto saved = ctx->bluetooth->saveSpeaker(*target); !saved) {
httpd_resp_set_status(req, "500 Internal Server Error");
return httpd_resp_send(req, nullptr, 0);
}
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
}
esp_err_t bluetoothSpeakerDeleteHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->bluetooth == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
(void)ctx->bluetooth->clearSavedSpeaker();
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, "{\"status\":\"cleared\"}", 20);
}
esp_err_t bluetoothReconnectPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->bluetooth == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
ESP_LOGI(kTag, "bluetooth reconnect HTTP request");
if (auto result = ctx->bluetooth->reconnectSavedSpeaker(true); !result) {
const std::string json =
core::serializeBluetoothErrorJson(bt1035ErrorToken(result.error()));
httpd_resp_set_status(req, "500 Internal Server Error");
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, "{\"status\":\"connected\"}", 24);
}
esp_err_t bluetoothAutoReconnectPostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
@@ -1124,9 +1606,6 @@ SetupWebServer::SetupWebServer()
, bluetooth_(nullptr)
, stations_(nullptr)
, integration_(nullptr)
, routeContext_{nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, core::CompanionChipStatus{false, false, false},
core::DeviceIdentity::unknown()}
{
}
@@ -1139,7 +1618,6 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept
, bluetooth_(other.bluetooth_)
, stations_(other.stations_)
, integration_(other.integration_)
, routeContext_(other.routeContext_)
{
other.server_ = nullptr;
other.store_ = nullptr;
@@ -1149,8 +1627,6 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept
other.bluetooth_ = nullptr;
other.stations_ = nullptr;
other.integration_ = nullptr;
other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, nullptr, {}, core::DeviceIdentity::unknown()};
}
SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
@@ -1167,7 +1643,6 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
bluetooth_ = other.bluetooth_;
stations_ = other.stations_;
integration_ = other.integration_;
routeContext_ = other.routeContext_;
other.server_ = nullptr;
other.store_ = nullptr;
other.netState_ = NetState::Uninitialized;
@@ -1176,9 +1651,6 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
other.bluetooth_ = nullptr;
other.stations_ = nullptr;
other.integration_ = nullptr;
other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, nullptr, core::CompanionChipStatus{false, false, false},
core::DeviceIdentity::unknown()};
}
return *this;
}
@@ -1189,9 +1661,6 @@ SetupWebServer::~SetupWebServer()
httpd_stop(server_);
server_ = nullptr;
}
routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, core::CompanionChipStatus{false, false, false},
core::DeviceIdentity::unknown()};
}
std::expected<void, NetError> SetupWebServer::start(
@@ -1203,6 +1672,7 @@ std::expected<void, NetError> SetupWebServer::start(
station::StationService& stations,
integration::IntegrationService& integration,
ota::OtaService& ota,
webradio::WebRadioService& webRadio,
core::CompanionChipStatus companionChips,
const core::DeviceIdentity& deviceIdentity)
{
@@ -1217,30 +1687,34 @@ std::expected<void, NetError> SetupWebServer::start(
bluetooth_ = &bluetooth;
stations_ = &stations;
integration_ = &integration;
routeContext_.store = &store;
routeContext_.tuner = &tuner;
routeContext_.audio = &audio;
routeContext_.bluetooth = &bluetooth;
routeContext_.stations = &stations;
routeContext_.integration = &integration;
routeContext_.ota = &ota;
routeContext_.companionChips = companionChips;
routeContext_.deviceIdentity = deviceIdentity;
auto& routeContext = routeContextStorage();
routeContext.store = &store;
routeContext.tuner = &tuner;
routeContext.audio = &audio;
routeContext.bluetooth = &bluetooth;
routeContext.stations = &stations;
routeContext.integration = &integration;
routeContext.ota = &ota;
routeContext.webRadio = &webRadio;
routeContext.companionChips = companionChips;
routeContext.deviceIdentity = deviceIdentity;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.stack_size = 12288;
config.max_open_sockets = 3;
config.max_uri_handlers = 40;
config.server_port = 80;
config.lru_purge_enable = true;
config.recv_wait_timeout = 60;
config.send_wait_timeout = 60;
if (httpd_start(&server_, &config) != ESP_OK) {
ESP_LOGE(kTag, "httpd_start failed");
routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr,
nullptr, core::CompanionChipStatus{false, false, false},
core::DeviceIdentity::unknown()};
routeContext.store = nullptr;
return std::unexpected(NetError::HttpServerStartFailed);
}
void* routeCtx = &routeContext_;
void* routeCtx = &routeContext;
const httpd_uri_t healthUri = {
.uri = "/api/health",
@@ -1266,6 +1740,14 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &wifiUri);
const httpd_uri_t wifiScanUri = {
.uri = "/api/wifi/scan",
.method = HTTP_POST,
.handler = wifiScanPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &wifiScanUri);
const httpd_uri_t tunerStatusUri = {
.uri = "/api/tuner/status",
.method = HTTP_GET,
@@ -1306,6 +1788,14 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &tunerSeekUri);
const httpd_uri_t tunerScanUri = {
.uri = "/api/tuner/scan",
.method = HTTP_POST,
.handler = tunerScanPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &tunerScanUri);
const httpd_uri_t audioProfileGetUri = {
.uri = "/api/audio/profile",
.method = HTTP_GET,
@@ -1346,6 +1836,30 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &audioBassEnhanceUri);
const httpd_uri_t audioBeepUri = {
.uri = "/api/audio/beep",
.method = HTTP_POST,
.handler = audioBeepPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &audioBeepUri);
const httpd_uri_t streamingGetUri = {
.uri = "/api/streaming",
.method = HTTP_GET,
.handler = streamingGetHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &streamingGetUri);
const httpd_uri_t streamingPostUri = {
.uri = "/api/streaming",
.method = HTTP_POST,
.handler = streamingPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &streamingPostUri);
const httpd_uri_t dspProgramUri = {
.uri = "/api/dsp/program",
.method = HTTP_POST,
@@ -1402,6 +1916,54 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &bluetoothPairedUri);
const httpd_uri_t bluetoothScanUri = {
.uri = "/api/bluetooth/scan",
.method = HTTP_POST,
.handler = bluetoothScanPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothScanUri);
const httpd_uri_t bluetoothConnectUri = {
.uri = "/api/bluetooth/connect",
.method = HTTP_POST,
.handler = bluetoothConnectPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothConnectUri);
const httpd_uri_t bluetoothSpeakerGetUri = {
.uri = "/api/bluetooth/speaker",
.method = HTTP_GET,
.handler = bluetoothSpeakerGetHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothSpeakerGetUri);
const httpd_uri_t bluetoothSpeakerPostUri = {
.uri = "/api/bluetooth/speaker",
.method = HTTP_POST,
.handler = bluetoothSpeakerPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothSpeakerPostUri);
const httpd_uri_t bluetoothSpeakerDeleteUri = {
.uri = "/api/bluetooth/speaker",
.method = HTTP_DELETE,
.handler = bluetoothSpeakerDeleteHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothSpeakerDeleteUri);
const httpd_uri_t bluetoothReconnectUri = {
.uri = "/api/bluetooth/reconnect",
.method = HTTP_POST,
.handler = bluetoothReconnectPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothReconnectUri);
const httpd_uri_t bluetoothAutoReconnectUri = {
.uri = "/api/bluetooth/auto-reconnect",
.method = HTTP_POST,
@@ -0,0 +1,570 @@
/**
* @file SigmaStudioTcpServer.cpp
* @brief SigmaStudioTcpServer implementation.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
*
* @author Michele Bigi
* @date 2026-08-07
*/
#include "net/SigmaStudioTcpServer.hpp"
#include "adau1701/FlashDspProgramSource.hpp"
#include "core/DspProgram.hpp"
#include "core/DspProgramBlob.hpp"
#include "core/RegisterWrite.hpp"
#include "SigmaStudioFW.h"
#include "esp_log.h"
#include "lwip/sockets.h"
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <span>
#include <vector>
namespace net {
namespace {
constexpr char kTag[] = "SigmaTcp";
constexpr std::uint16_t kPort = 8086U;
constexpr std::size_t kRecvBufSize = 16U * 1024U;
constexpr std::uint32_t kTaskStackBytes = 8192U;
constexpr UBaseType_t kTaskPriority = 5U;
constexpr std::uint8_t kCtrlWrite = 0x09U;
constexpr std::uint8_t kCtrlReadReq = 0x0AU;
constexpr std::uint8_t kCtrlReadResp = 0x0BU;
constexpr std::uint8_t kChipAddrDsp = 0x01U;
// board::pins::Adau1701Addr (main/board_pins.hpp), duplicated as a literal
// to avoid a net -> main include dependency; keep in sync if the board's
// I2C address ever changes. The reference project accepts both the IC
// index (0x01) and the raw address as chipAddr since it's unclear which
// convention real SigmaStudio uses for a single-IC project — mirrored here.
constexpr std::uint8_t kDspI2cAddr7 = 0x34U;
[[nodiscard]] bool isDspChipAddr(std::uint8_t chipAddr) noexcept
{
return chipAddr == kChipAddrDsp || chipAddr == kDspI2cAddr7;
}
/**
* @brief activeListenFd — process-lifetime storage for the listen fd.
*
* @dname activeListenFd
* @return Reference to the singleton listen-fd slot.
* @pubstate Written by SigmaStudioTcpServer::start()/stop(); read by
* acceptLoopTask(). SigmaStudioTcpServer is constructed as a
* local and move-relocated into NetBootstrap (see
* NetBootstrap.cpp), so a `this` pointer captured at start()
* time would go stale once that local's stack frame returns —
* same problem SetupWebServer's routeContextStorage() solves.
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::atomic<int>& activeListenFd() noexcept
{
static std::atomic<int> fd{-1};
return fd;
}
constexpr std::uint16_t kProgRamStart = 0x0400U;
constexpr std::uint16_t kProgRamEnd = 0x07FFU;
constexpr std::uint16_t kCtrlRegStart = 0x0800U;
constexpr std::uint16_t kCoreControlReg = 0x081CU;
constexpr std::uint8_t kDspRunBit = 0x04U;
constexpr unsigned kWordBytesParam = 4U;
constexpr unsigned kWordsPerSafeload = 5U;
constexpr std::size_t kWriteHeaderSize = 10U;
constexpr std::size_t kReadReqHeaderSize = 8U;
constexpr std::size_t kReadRespHeaderSize = 9U;
constexpr std::uint16_t kMaxReadBytes = 256U;
constexpr std::size_t kMaxCaptureRegions = 32U; // core::DspProgramBlob kMaxWriteCount
constexpr std::size_t kMaxRegionPayload = 16U * 1024U; // kMaxWritePayload
[[nodiscard]] unsigned wordSizeForAddress(std::uint16_t address) noexcept
{
if (address >= kProgRamStart && address <= kProgRamEnd) {
return 5U;
}
if (address >= kCtrlRegStart) {
return 2U;
}
return 4U;
}
[[nodiscard]] std::uint16_t readBe16(const std::uint8_t* p) noexcept
{
return static_cast<std::uint16_t>((static_cast<std::uint16_t>(p[0]) << 8)
| p[1]);
}
void writeBe16(std::uint8_t* p, std::uint16_t value) noexcept
{
p[0] = static_cast<std::uint8_t>((value >> 8) & 0xFFU);
p[1] = static_cast<std::uint8_t>(value & 0xFFU);
}
/**
* @brief PendingRegion — one coalesced contiguous write during a Download.
*/
struct PendingRegion {
std::uint16_t address;
std::vector<std::uint8_t> data;
};
/**
* @brief DownloadCapture — coalesces a Link Compile Download for persistence.
*
* Merges contiguous direct (non-safeload) DSP writes into a handful of
* regions (mirroring the ~5-block shape of EmbeddedDspProgramSource), then
* on finish() serialises and stores them via FlashDspProgramSource so the
* downloaded program becomes what DigiRadio boots with next time. Bails
* out (does not persist) if the session doesn't fit the DRAD blob's own
* caps — the DSP still runs fine from what was already written live.
*/
class DownloadCapture {
public:
void addWrite(std::uint16_t address, std::span<const std::uint8_t> data)
{
if (overflowed_ || data.empty()) {
return;
}
if (tryExtendLast(address, data)) {
return;
}
if (regions_.size() >= kMaxCaptureRegions) {
abandon();
return;
}
regions_.push_back(PendingRegion{
address, std::vector<std::uint8_t>(data.begin(), data.end())});
}
void finish()
{
if (!overflowed_ && !regions_.empty()) {
persist();
}
regions_.clear();
overflowed_ = false;
}
private:
[[nodiscard]] bool tryExtendLast(std::uint16_t address,
std::span<const std::uint8_t> data)
{
if (regions_.empty()) {
return false;
}
PendingRegion& last = regions_.back();
const unsigned wordSize = wordSizeForAddress(last.address);
const auto lastWords =
static_cast<std::uint32_t>(last.data.size() / wordSize);
const std::uint32_t lastEnd =
static_cast<std::uint32_t>(last.address) + lastWords;
if (lastEnd != address
|| last.data.size() + data.size() > kMaxRegionPayload) {
return false;
}
last.data.insert(last.data.end(), data.begin(), data.end());
return true;
}
void abandon()
{
overflowed_ = true;
regions_.clear();
ESP_LOGW(kTag,
"Download too fragmented to persist as boot program — "
"DSP still runs live, only reboot-persistence is skipped");
}
void persist()
{
std::vector<core::RegisterWrite> writes;
writes.reserve(regions_.size());
for (auto& region : regions_) {
writes.emplace_back(region.address, std::move(region.data));
}
const core::DspProgram program(std::move(writes));
const std::vector<std::uint8_t> blob =
core::serializeDspProgramBlob(program);
if (auto stored = adau1701::FlashDspProgramSource::storeBlob(blob);
!stored) {
ESP_LOGW(kTag, "SigmaStudio download not persisted (flash store failed)");
return;
}
ESP_LOGI(kTag,
"SigmaStudio download persisted as boot program (%u bytes)",
static_cast<unsigned>(blob.size()));
}
std::vector<PendingRegion> regions_;
bool overflowed_ = false;
};
/**
* @brief ConnectionState — per-TCP-connection Download tracking.
*/
struct ConnectionState {
DownloadCapture capture;
bool dspRunning = false;
};
void directWrite(std::uint16_t address, std::span<const std::uint8_t> data)
{
if (data.empty()) {
return;
}
sigma_studio_lock();
SIGMA_WRITE_REGISTER_BLOCK(
0U, address, static_cast<unsigned int>(data.size()),
const_cast<ADI_REG_TYPE*>(
reinterpret_cast<const ADI_REG_TYPE*>(data.data())));
sigma_studio_unlock();
}
void safeloadWrite(std::uint16_t address, std::span<const std::uint8_t> data)
{
const auto totalWords =
static_cast<unsigned>(data.size() / kWordBytesParam);
unsigned offset = 0U;
sigma_studio_lock();
while (offset < totalWords) {
const unsigned words =
std::min(totalWords - offset, kWordsPerSafeload);
unsigned addrs[kWordsPerSafeload];
for (unsigned i = 0U; i < words; ++i) {
addrs[i] = static_cast<unsigned>(address) + offset + i;
}
sigma_safeload_raw_block(
static_cast<unsigned char>(words), addrs,
data.data() + static_cast<std::size_t>(offset) * kWordBytesParam);
offset += words;
}
sigma_studio_unlock();
}
void trackDownloadCompletion(std::uint16_t address,
std::span<const std::uint8_t> payload,
ConnectionState& state)
{
if (address != kCoreControlReg || payload.size() < 2U) {
return;
}
const bool wasRunning = state.dspRunning;
state.dspRunning = (payload.back() & kDspRunBit) != 0U;
if (!wasRunning && state.dspRunning) {
state.capture.finish();
}
}
void dispatchWrite(std::uint16_t address, std::span<const std::uint8_t> payload,
std::uint8_t safeload, ConnectionState& state)
{
if (safeload != 0U) {
safeloadWrite(address, payload);
return;
}
if (!state.dspRunning) {
state.capture.addWrite(address, payload);
}
directWrite(address, payload);
trackDownloadCompletion(address, payload, state);
}
[[nodiscard]] std::size_t tryConsumeWrite(const std::uint8_t* p,
std::size_t avail,
ConnectionState& state)
{
if (avail < kWriteHeaderSize) {
return 0U;
}
const std::uint16_t totalLen = readBe16(p + 3U);
if (totalLen < kWriteHeaderSize) {
return 1U; // malformed frame — resync by one byte
}
if (avail < totalLen) {
return 0U;
}
const std::uint8_t safeload = p[1];
const std::uint8_t chipAddr = p[5];
const std::uint16_t dataLen = readBe16(p + 6U);
const std::uint16_t address = readBe16(p + 8U);
const std::uint16_t maxPayload =
static_cast<std::uint16_t>(totalLen - kWriteHeaderSize);
const std::uint16_t safeLen = std::min(dataLen, maxPayload);
if (isDspChipAddr(chipAddr)) {
const std::span<const std::uint8_t> payload(p + kWriteHeaderSize,
safeLen);
dispatchWrite(address, payload, safeload, state);
}
return totalLen;
}
void sendReadResponse(int clientFd, std::uint8_t chipAddr,
std::uint16_t address, std::uint16_t requested)
{
const std::uint16_t length = std::min(requested, kMaxReadBytes);
std::vector<std::uint8_t> data(length);
sigma_studio_lock();
const int result =
length == 0U ? 0 : sigma_i2c_read(address, data.data(), length);
sigma_studio_unlock();
if (result != 0) {
ESP_LOGW(kTag,
"read 0x%04x len=%u chipAddr=0x%02x: sigma_i2c_read failed",
static_cast<unsigned>(address),
static_cast<unsigned>(length),
static_cast<unsigned>(chipAddr));
return;
}
std::vector<std::uint8_t> resp(kReadRespHeaderSize + length);
resp[0] = kCtrlReadResp;
writeBe16(resp.data() + 1U,
static_cast<std::uint16_t>(kReadRespHeaderSize + length));
resp[3] = chipAddr;
writeBe16(resp.data() + 4U, length);
writeBe16(resp.data() + 6U, address);
resp[8] = 0x01U;
std::memcpy(resp.data() + kReadRespHeaderSize, data.data(), length);
const ssize_t sent = send(clientFd, resp.data(), resp.size(), 0);
ESP_LOGI(kTag,
"read 0x%04x len=%u chipAddr=0x%02x: sent %d/%u resp bytes",
static_cast<unsigned>(address), static_cast<unsigned>(length),
static_cast<unsigned>(chipAddr), static_cast<int>(sent),
static_cast<unsigned>(resp.size()));
}
[[nodiscard]] std::size_t tryConsumeRead(const std::uint8_t* p,
std::size_t avail, int clientFd)
{
if (avail < kReadReqHeaderSize) {
return 0U;
}
const std::uint16_t totalLen = readBe16(p + 1U);
if (totalLen < kReadReqHeaderSize) {
return 1U; // malformed frame — resync by one byte
}
if (avail < totalLen) {
return 0U;
}
const std::uint8_t chipAddr = p[3];
const std::uint16_t dataLen = readBe16(p + 4U);
const std::uint16_t address = readBe16(p + 6U);
if (isDspChipAddr(chipAddr)) {
sendReadResponse(clientFd, chipAddr, address, dataLen);
} else {
ESP_LOGW(kTag,
"read req dropped: chipAddr=0x%02x not DSP (want 0x%02x or "
"0x%02x), addr=0x%04x len=%u",
static_cast<unsigned>(chipAddr),
static_cast<unsigned>(kChipAddrDsp),
static_cast<unsigned>(kDspI2cAddr7),
static_cast<unsigned>(address),
static_cast<unsigned>(dataLen));
}
return totalLen;
}
[[nodiscard]] std::size_t processBuffer(std::uint8_t* buf, std::size_t len,
int clientFd, ConnectionState& state)
{
std::size_t pos = 0U;
while (pos < len) {
const std::uint8_t ctrl = buf[pos];
std::size_t consumed = 0U;
if (ctrl == kCtrlWrite) {
consumed = tryConsumeWrite(buf + pos, len - pos, state);
} else if (ctrl == kCtrlReadReq) {
consumed = tryConsumeRead(buf + pos, len - pos, clientFd);
} else {
ESP_LOGW(kTag, "unrecognized ctrl byte 0x%02x — resyncing",
static_cast<unsigned>(ctrl));
consumed = 1U;
}
if (consumed == 0U) {
break;
}
pos += consumed;
}
return pos;
}
/** @brief Diagnostic: hex-dump up to the first 48 bytes of a receive. */
void logRxHexDump(const std::uint8_t* p, std::size_t len)
{
constexpr std::size_t kMaxDump = 48U;
char hex[3U * kMaxDump + 1U];
const std::size_t n = std::min(len, kMaxDump);
for (std::size_t i = 0U; i < n; ++i) {
std::snprintf(hex + i * 3U, 4U, "%02x ", static_cast<unsigned>(p[i]));
}
ESP_LOGI(kTag, "rx %u bytes: %s%s", static_cast<unsigned>(len), hex,
len > kMaxDump ? "..." : "");
}
void serveClient(int clientFd)
{
ConnectionState state;
std::vector<std::uint8_t> buf(kRecvBufSize);
std::size_t len = 0U;
while (true) {
const ssize_t received =
recv(clientFd, buf.data() + len, buf.size() - len, 0);
if (received <= 0) {
break;
}
logRxHexDump(buf.data() + len, static_cast<std::size_t>(received));
len += static_cast<std::size_t>(received);
const std::size_t consumed = processBuffer(buf.data(), len, clientFd, state);
if (consumed > 0U && consumed < len) {
std::memmove(buf.data(), buf.data() + consumed, len - consumed);
}
len = consumed <= len ? len - consumed : 0U;
}
}
} // namespace
SigmaStudioTcpServer::SigmaStudioTcpServer()
: listenFd_(-1)
, task_(nullptr)
{
}
SigmaStudioTcpServer::~SigmaStudioTcpServer()
{
stop();
}
SigmaStudioTcpServer::SigmaStudioTcpServer(SigmaStudioTcpServer&& other) noexcept
: listenFd_(other.listenFd_)
, task_(other.task_)
{
other.listenFd_ = -1;
other.task_ = nullptr;
}
SigmaStudioTcpServer&
SigmaStudioTcpServer::operator=(SigmaStudioTcpServer&& other) noexcept
{
if (this != &other) {
stop();
listenFd_ = other.listenFd_;
task_ = other.task_;
other.listenFd_ = -1;
other.task_ = nullptr;
}
return *this;
}
void SigmaStudioTcpServer::stop() noexcept
{
if (task_ != nullptr) {
vTaskDelete(task_);
task_ = nullptr;
}
activeListenFd().store(-1, std::memory_order_release);
if (listenFd_ >= 0) {
close(listenFd_);
listenFd_ = -1;
}
}
std::expected<void, NetError> SigmaStudioTcpServer::start()
{
if (task_ != nullptr) {
return {};
}
const int fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (fd < 0) {
ESP_LOGE(kTag, "socket() failed");
return std::unexpected(NetError::TcpServerStartFailed);
}
const int reuse = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(kPort);
if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0
|| listen(fd, 1) != 0) {
ESP_LOGE(kTag, "bind/listen failed");
close(fd);
return std::unexpected(NetError::TcpServerStartFailed);
}
listenFd_ = fd;
activeListenFd().store(fd, std::memory_order_release);
const BaseType_t created =
xTaskCreate(&SigmaStudioTcpServer::acceptLoopTask, "sigma_tcp",
kTaskStackBytes, nullptr, kTaskPriority, &task_);
if (created != pdPASS) {
ESP_LOGE(kTag, "xTaskCreate failed");
activeListenFd().store(-1, std::memory_order_release);
close(listenFd_);
listenFd_ = -1;
task_ = nullptr;
return std::unexpected(NetError::TcpServerStartFailed);
}
ESP_LOGI(kTag, "SigmaStudio TCP bridge listening on port %u",
static_cast<unsigned>(kPort));
return {};
}
void SigmaStudioTcpServer::acceptLoopTask(void* /*arg*/)
{
while (true) {
const int listenFd = activeListenFd().load(std::memory_order_acquire);
sockaddr_in clientAddr{};
socklen_t clientLen = sizeof(clientAddr);
const int clientFd = accept(
listenFd, reinterpret_cast<sockaddr*>(&clientAddr), &clientLen);
if (clientFd < 0) {
ESP_LOGW(kTag, "accept() failed: errno=%d", errno);
vTaskDelay(pdMS_TO_TICKS(100));
continue;
}
ESP_LOGI(kTag, "SigmaStudio client connected");
serveClient(clientFd);
ESP_LOGI(kTag, "SigmaStudio client disconnected");
close(clientFd);
}
}
} // namespace net
+8 -1
View File
@@ -18,6 +18,7 @@
#include "net/SoftApHost.hpp"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "esp_log.h"
@@ -69,11 +70,17 @@ std::expected<void, NetError> SoftApHost::start()
return {};
}
if (esp_wifi_set_mode(WIFI_MODE_AP) != ESP_OK) {
if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_set_mode failed");
return std::unexpected(NetError::WifiConfigFailed);
}
wifi_config_t staCfg = {};
if (esp_wifi_set_config(WIFI_IF_STA, &staCfg) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_set_config STA failed");
return std::unexpected(NetError::WifiConfigFailed);
}
wifi_config_t wifiCfg = {};
const std::string_view ssid = config_.ssid();
std::memcpy(wifiCfg.ap.ssid, ssid.data(), ssid.size());
+137 -12
View File
@@ -36,9 +36,62 @@ namespace {
constexpr char kTag[] = "StaClient";
constexpr int kConnectedBit = BIT0;
constexpr int kFailedBit = BIT1;
constexpr TickType_t kConnectTimeout = pdMS_TO_TICKS(30000);
constexpr int kMaxConnectRetries = 10;
constexpr TickType_t kConnectTimeout = pdMS_TO_TICKS(45000);
constexpr TickType_t kRetryDelay = pdMS_TO_TICKS(800);
EventGroupHandle_t s_wifiEventGroup = nullptr;
int s_connectRetries = 0;
/**
* @brief disconnectReasonString — map ESP-IDF Wi-Fi disconnect reason codes.
*
* @dname disconnectReasonString
* @param reason wifi_event_sta_disconnected_t::reason value.
* @return Short English label for logs.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] const char* disconnectReasonString(uint8_t reason) noexcept
{
switch (reason) {
case WIFI_REASON_AUTH_EXPIRE:
return "auth expired (wrong password?)";
case WIFI_REASON_NO_AP_FOUND:
return "no AP found (check SSID / 2.4 GHz)";
case WIFI_REASON_AUTH_FAIL:
return "auth failed (check password)";
case WIFI_REASON_ASSOC_FAIL:
return "association failed";
case WIFI_REASON_HANDSHAKE_TIMEOUT:
return "handshake timeout";
case WIFI_REASON_BEACON_TIMEOUT:
return "beacon timeout (weak signal / PS)";
case WIFI_REASON_CONNECTION_FAIL:
return "connection failed";
default:
return "unknown";
}
}
/**
* @brief applyStaLinkTuning — stabilise STA link after connect.
*
* @dname applyStaLinkTuning
* @pubstate Disables PS, forces 20 MHz, disables inactive disconnect.
*
* @author Michele Bigi
* @date 2026-08-05
*/
void applyStaLinkTuning() noexcept
{
esp_wifi_set_ps(WIFI_PS_NONE);
esp_wifi_set_bandwidth(WIFI_IF_STA, WIFI_BW_HT20);
esp_wifi_set_inactive_time(WIFI_IF_STA, 0);
ESP_LOGI(kTag, "STA link tuning applied (PS off, HT20, inactive off)");
}
/**
* @brief wifiEventHandler — signal connect success or failure.
@@ -59,29 +112,72 @@ void wifiEventHandler(void* arg,
void* eventData)
{
(void)arg;
(void)eventData;
if (eventBase == WIFI_EVENT && eventId == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (eventBase == WIFI_EVENT
&& eventId == WIFI_EVENT_STA_DISCONNECTED) {
const auto* disc =
static_cast<const wifi_event_sta_disconnected_t*>(eventData);
const uint8_t reason = disc != nullptr ? disc->reason : 0U;
ESP_LOGW(kTag, "STA disconnected (reason %u: %s)",
static_cast<unsigned>(reason),
disconnectReasonString(reason));
if (s_wifiEventGroup != nullptr) {
if (s_connectRetries < kMaxConnectRetries) {
++s_connectRetries;
ESP_LOGI(kTag, "retrying STA connect (%d/%d)",
s_connectRetries, kMaxConnectRetries);
vTaskDelay(kRetryDelay);
esp_wifi_connect();
return;
}
xEventGroupSetBits(s_wifiEventGroup, kFailedBit);
return;
}
ESP_LOGW(kTag, "STA link lost — reconnecting");
esp_wifi_connect();
} else if (eventBase == IP_EVENT && eventId == IP_EVENT_STA_GOT_IP) {
const auto* event =
static_cast<const ip_event_got_ip_t*>(eventData);
if (event != nullptr) {
ESP_LOGI(kTag, "STA IP " IPSTR, IP2STR(&event->ip_info.ip));
}
applyStaLinkTuning();
if (s_wifiEventGroup != nullptr) {
xEventGroupSetBits(s_wifiEventGroup, kConnectedBit);
}
}
}
void unregisterStaHandlers(esp_event_handler_instance_t wifiHandler,
esp_event_handler_instance_t ipHandler) noexcept
{
if (wifiHandler != nullptr) {
esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID,
wifiHandler);
}
if (ipHandler != nullptr) {
esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP,
ipHandler);
}
}
} // namespace
StaClient::StaClient()
: connected_(false)
, wifiHandler_(nullptr)
, ipHandler_(nullptr)
{
}
StaClient::~StaClient()
{
unregisterStaHandlers(wifiHandler_, ipHandler_);
wifiHandler_ = nullptr;
ipHandler_ = nullptr;
if (connected_) {
esp_wifi_stop();
connected_ = false;
@@ -90,18 +186,27 @@ StaClient::~StaClient()
StaClient::StaClient(StaClient&& other) noexcept
: connected_(other.connected_)
, wifiHandler_(other.wifiHandler_)
, ipHandler_(other.ipHandler_)
{
other.connected_ = false;
other.wifiHandler_ = nullptr;
other.ipHandler_ = nullptr;
}
StaClient& StaClient::operator=(StaClient&& other) noexcept
{
if (this != &other) {
unregisterStaHandlers(wifiHandler_, ipHandler_);
if (connected_) {
esp_wifi_stop();
}
connected_ = other.connected_;
wifiHandler_ = other.wifiHandler_;
ipHandler_ = other.ipHandler_;
other.connected_ = false;
other.wifiHandler_ = nullptr;
other.ipHandler_ = nullptr;
}
return *this;
}
@@ -122,19 +227,18 @@ StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname
if (s_wifiEventGroup == nullptr) {
return std::unexpected(NetError::StaConnectFailed);
}
s_connectRetries = 0;
esp_event_handler_instance_t instanceAnyId = nullptr;
esp_event_handler_instance_t instanceGotIp = nullptr;
esp_event_handler_instance_register(WIFI_EVENT,
ESP_EVENT_ANY_ID,
&wifiEventHandler,
nullptr,
&instanceAnyId);
&wifiHandler_);
esp_event_handler_instance_register(IP_EVENT,
IP_EVENT_STA_GOT_IP,
&wifiEventHandler,
nullptr,
&instanceGotIp);
&ipHandler_);
if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) {
return std::unexpected(NetError::WifiConfigFailed);
@@ -150,15 +254,31 @@ StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname
wifi_config_t wifiCfg = {};
const std::string_view ssid = creds.ssid().value();
const std::size_t ssidCopy =
std::min(ssid.size(), sizeof(wifiCfg.sta.ssid) - 1);
std::min(ssid.size(), sizeof(wifiCfg.sta.ssid) - 1U);
std::memcpy(wifiCfg.sta.ssid, ssid.data(), ssidCopy);
wifiCfg.sta.ssid[ssidCopy] = '\0';
std::size_t pwdLen = 0U;
creds.password().usePlaintext([&](std::string_view pwd) {
pwdLen = pwd.size();
const std::size_t pwdCopy =
std::min(pwd.size(), sizeof(wifiCfg.sta.password) - 1);
std::min(pwd.size(), sizeof(wifiCfg.sta.password) - 1U);
std::memcpy(wifiCfg.sta.password, pwd.data(), pwdCopy);
wifiCfg.sta.password[pwdCopy] = '\0';
});
if (pwdLen == 0U) {
wifiCfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
} else {
wifiCfg.sta.threshold.authmode = WIFI_AUTH_WPA2_WPA3_PSK;
}
wifiCfg.sta.pmf_cfg.capable = true;
wifiCfg.sta.pmf_cfg.required = false;
wifiCfg.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
wifiCfg.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
wifiCfg.sta.failure_retry_cnt = 3;
wifiCfg.sta.listen_interval = 1;
if (esp_wifi_set_config(WIFI_IF_STA, &wifiCfg) != ESP_OK) {
return std::unexpected(NetError::WifiConfigFailed);
}
@@ -167,20 +287,22 @@ StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname
return std::unexpected(NetError::WifiStartFailed);
}
esp_wifi_set_protocol(WIFI_IF_STA,
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G
| WIFI_PROTOCOL_11N);
esp_wifi_set_ps(WIFI_PS_NONE);
const EventBits_t bits = xEventGroupWaitBits(s_wifiEventGroup,
kConnectedBit | kFailedBit,
pdTRUE,
pdFALSE,
kConnectTimeout);
esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP,
instanceGotIp);
esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID,
instanceAnyId);
vEventGroupDelete(s_wifiEventGroup);
s_wifiEventGroup = nullptr;
if ((bits & kConnectedBit) != 0) {
applyStaLinkTuning();
connected_ = true;
if (!hostLabel.empty()) {
if (mdns_init() == ESP_OK) {
@@ -192,6 +314,9 @@ StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname
return {};
}
unregisterStaHandlers(wifiHandler_, ipHandler_);
wifiHandler_ = nullptr;
ipHandler_ = nullptr;
esp_wifi_stop();
ESP_LOGW(kTag, "STA connect timed out or failed");
if ((bits & kFailedBit) != 0) {
+220
View File
@@ -0,0 +1,220 @@
/**
* @file WifiScanner.cpp
* @brief WifiScanner 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 "net/WifiScanner.hpp"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <algorithm>
#include <cstring>
#include <string>
#include <unordered_map>
namespace net {
namespace {
constexpr char kTag[] = "WifiScanner";
/**
* @brief authToken — map ESP-IDF auth mode to a short API token.
*
* @dname authToken
* @param auth wifi_ap_record_t::authmode value.
* @return Stable lowercase token for JSON.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] const char* authToken(wifi_auth_mode_t auth) noexcept
{
switch (auth) {
case WIFI_AUTH_OPEN:
return "open";
case WIFI_AUTH_WEP:
return "wep";
case WIFI_AUTH_WPA_PSK:
return "wpa";
case WIFI_AUTH_WPA2_PSK:
return "wpa2";
case WIFI_AUTH_WPA_WPA2_PSK:
return "wpa_wpa2";
case WIFI_AUTH_WPA2_ENTERPRISE:
return "wpa2_enterprise";
case WIFI_AUTH_WPA3_PSK:
return "wpa3";
case WIFI_AUTH_WPA2_WPA3_PSK:
return "wpa2_wpa3";
case WIFI_AUTH_WAPI_PSK:
return "wapi";
default:
return "unknown";
}
}
/**
* @brief ssidFromRecord — read a possibly unterminated SSID field.
*
* @dname ssidFromRecord
* @param record Raw ESP-IDF scan entry.
* @return SSID string (empty when hidden).
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::string ssidFromRecord(const wifi_ap_record_t& record)
{
const std::size_t len =
strnlen(reinterpret_cast<const char*>(record.ssid), sizeof(record.ssid));
return std::string(reinterpret_cast<const char*>(record.ssid), len);
}
/**
* @brief ensureStaNetif — create default STA netif when missing.
*
* @dname ensureStaNetif
* @return true when STA netif exists or was created.
* @pubstate may call esp_netif_create_default_wifi_sta().
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] bool ensureStaNetif() noexcept
{
if (esp_netif_get_handle_from_ifkey("WIFI_STA_DEF") != nullptr) {
return true;
}
return esp_netif_create_default_wifi_sta() != nullptr;
}
/**
* @brief dedupeBySsid — keep strongest RSSI per SSID.
*
* @dname dedupeBySsid
* @param records Raw scan rows from esp_wifi.
* @return Sorted networks, strongest first.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::vector<core::WifiScannedNetwork>
dedupeBySsid(const std::vector<wifi_ap_record_t>& records)
{
std::unordered_map<std::string, core::WifiScannedNetwork> best;
best.reserve(records.size());
for (const wifi_ap_record_t& record : records) {
const std::string ssid = ssidFromRecord(record);
if (ssid.empty()) {
continue;
}
core::WifiScannedNetwork entry = {
.ssid = ssid,
.rssiDbm = record.rssi,
.auth = authToken(record.authmode),
.channel = record.primary,
};
const auto existing = best.find(ssid);
if (existing == best.end() || entry.rssiDbm > existing->second.rssiDbm) {
best.emplace(ssid, std::move(entry));
}
}
std::vector<core::WifiScannedNetwork> networks;
networks.reserve(best.size());
for (auto& item : best) {
networks.push_back(std::move(item.second));
}
std::sort(networks.begin(), networks.end(),
[](const core::WifiScannedNetwork& left,
const core::WifiScannedNetwork& right) {
return left.rssiDbm > right.rssiDbm;
});
return networks;
}
} // namespace
std::expected<std::vector<core::WifiScannedNetwork>, NetError>
WifiScanner::scanNearby()
{
wifi_mode_t mode = WIFI_MODE_NULL;
if (esp_wifi_get_mode(&mode) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_get_mode failed");
return std::unexpected(NetError::WifiScanFailed);
}
if (mode == WIFI_MODE_AP) {
if (!ensureStaNetif()) {
ESP_LOGE(kTag, "STA netif creation failed");
return std::unexpected(NetError::WifiScanFailed);
}
wifi_config_t staCfg = {};
if (esp_wifi_set_config(WIFI_IF_STA, &staCfg) != ESP_OK) {
ESP_LOGE(kTag, "STA config for scan failed");
return std::unexpected(NetError::WifiScanFailed);
}
if (esp_wifi_set_mode(WIFI_MODE_APSTA) != ESP_OK) {
ESP_LOGE(kTag, "APSTA mode switch failed");
return std::unexpected(NetError::WifiScanFailed);
}
vTaskDelay(pdMS_TO_TICKS(300));
}
(void)esp_wifi_scan_stop();
(void)esp_wifi_clear_ap_list();
wifi_scan_config_t scanCfg = {};
scanCfg.channel = 0;
scanCfg.show_hidden = true;
scanCfg.scan_type = WIFI_SCAN_TYPE_ACTIVE;
scanCfg.scan_time.active.min = 300U;
scanCfg.scan_time.active.max = 1200U;
const esp_err_t scanErr = esp_wifi_scan_start(&scanCfg, true);
if (scanErr != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_scan_start failed (%d)", static_cast<int>(scanErr));
return std::unexpected(NetError::WifiScanFailed);
}
std::uint16_t count = 0U;
if (esp_wifi_scan_get_ap_num(&count) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_scan_get_ap_num failed");
return std::unexpected(NetError::WifiScanFailed);
}
std::vector<wifi_ap_record_t> records(count);
if (count > 0U
&& esp_wifi_scan_get_ap_records(&count, records.data()) != ESP_OK) {
ESP_LOGE(kTag, "esp_wifi_scan_get_ap_records failed");
return std::unexpected(NetError::WifiScanFailed);
}
records.resize(count);
ESP_LOGI(kTag, "raw AP count %u", static_cast<unsigned>(count));
const auto networks = dedupeBySsid(records);
ESP_LOGI(kTag, "scan found %u unique network(s)",
static_cast<unsigned>(networks.size()));
return networks;
}
} // namespace net
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -122,6 +122,25 @@ public:
[[nodiscard]] std::expected<void, core::StoreError>
clearLastPresetIndex() override;
[[nodiscard]] bool hasBtSpeakerTarget() const override;
[[nodiscard]] std::expected<void, core::StoreError>
saveBtSpeakerTarget(const core::BtSpeakerTarget& target) override;
[[nodiscard]] std::expected<core::BtSpeakerTarget, core::StoreError>
loadBtSpeakerTarget() const override;
[[nodiscard]] std::expected<void, core::StoreError>
clearBtSpeakerTarget() override;
[[nodiscard]] bool hasWebRadioConfig() const override;
[[nodiscard]] std::expected<void, core::StoreError>
saveWebRadioConfigJson(std::string_view json) override;
[[nodiscard]] std::expected<std::string, core::StoreError>
loadWebRadioConfigJson() const override;
};
} // namespace secure_store
@@ -18,6 +18,7 @@
#include "secure_store/NvsSecureStore.hpp"
#include "core/Bt1035At.hpp"
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"
@@ -33,6 +34,9 @@ constexpr char kSsidKey[] = "wifi_ssid";
constexpr char kPasswordKey[] = "wifi_pwd";
constexpr char kStationListKey[] = "station_list";
constexpr char kLastPresetKey[] = "last_preset";
constexpr char kBtSpeakerMacKey[] = "bt_spk_mac";
constexpr char kBtSpeakerNameKey[] = "bt_spk_name";
constexpr char kWebRadioConfigKey[] = "web_radio_cfg";
constexpr char kTag[] = "NvsSecureStore";
} // namespace
@@ -41,8 +45,15 @@ bool NvsSecureStore::hasWifiCredentials() const
nvs_handle_t handle = 0;
const esp_err_t openErr = nvs_open(kNamespace, NVS_READONLY, &handle);
if (openErr != ESP_OK) {
ESP_LOGW(kTag, "nvs_open failed in hasWifiCredentials (0x%x)",
static_cast<unsigned>(openErr));
if (openErr == ESP_ERR_NVS_NOT_FOUND) {
ESP_LOGD(kTag,
"no wifi credentials namespace yet in hasWifiCredentials "
"(0x%x)",
static_cast<unsigned>(openErr));
} else {
ESP_LOGW(kTag, "nvs_open failed in hasWifiCredentials (0x%x)",
static_cast<unsigned>(openErr));
}
return false;
}
@@ -313,4 +324,170 @@ std::expected<void, core::StoreError> NvsSecureStore::clearLastPresetIndex()
return {};
}
bool NvsSecureStore::hasBtSpeakerTarget() const
{
nvs_handle_t handle = 0;
if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) {
return false;
}
std::size_t macLen = 0;
const esp_err_t err = nvs_get_str(handle, kBtSpeakerMacKey, nullptr, &macLen);
nvs_close(handle);
return err == ESP_OK && macLen > 1U;
}
std::expected<void, core::StoreError>
NvsSecureStore::saveBtSpeakerTarget(const core::BtSpeakerTarget& target)
{
if (!core::isValidBt1035Mac(target.mac)) {
return std::unexpected(core::StoreError::InvalidData);
}
nvs_handle_t handle = 0;
if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) {
return std::unexpected(core::StoreError::IoFailed);
}
esp_err_t err = nvs_set_str(handle, kBtSpeakerMacKey, target.mac.c_str());
if (err == ESP_OK) {
err = nvs_set_str(handle, kBtSpeakerNameKey, target.name.c_str());
}
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
return std::unexpected(core::StoreError::IoFailed);
}
ESP_LOGI(kTag, "BT speaker target saved (%s)", target.mac.c_str());
return {};
}
std::expected<core::BtSpeakerTarget, core::StoreError>
NvsSecureStore::loadBtSpeakerTarget() const
{
nvs_handle_t handle = 0;
if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) {
return std::unexpected(core::StoreError::NotFound);
}
std::size_t macLen = 0;
if (nvs_get_str(handle, kBtSpeakerMacKey, nullptr, &macLen) != ESP_OK
|| macLen == 0U) {
nvs_close(handle);
return std::unexpected(core::StoreError::NotFound);
}
std::vector<char> macBuf(macLen);
if (nvs_get_str(handle, kBtSpeakerMacKey, macBuf.data(), &macLen) != ESP_OK) {
nvs_close(handle);
return std::unexpected(core::StoreError::IoFailed);
}
std::string name;
std::size_t nameLen = 0;
if (nvs_get_str(handle, kBtSpeakerNameKey, nullptr, &nameLen) == ESP_OK
&& nameLen > 0U) {
std::vector<char> nameBuf(nameLen);
if (nvs_get_str(handle, kBtSpeakerNameKey, nameBuf.data(), &nameLen)
== ESP_OK) {
name.assign(nameBuf.data());
}
}
nvs_close(handle);
const std::string mac = core::normalizeBt1035Mac(macBuf.data());
if (!core::isValidBt1035Mac(mac)) {
return std::unexpected(core::StoreError::InvalidData);
}
return core::BtSpeakerTarget{.mac = mac, .name = std::move(name)};
}
std::expected<void, core::StoreError> NvsSecureStore::clearBtSpeakerTarget()
{
nvs_handle_t handle = 0;
if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) {
return std::unexpected(core::StoreError::IoFailed);
}
esp_err_t err = nvs_erase_key(handle, kBtSpeakerMacKey);
if (err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND) {
err = nvs_erase_key(handle, kBtSpeakerNameKey);
}
if (err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) {
return std::unexpected(core::StoreError::IoFailed);
}
ESP_LOGI(kTag, "BT speaker target cleared");
return {};
}
bool NvsSecureStore::hasWebRadioConfig() const
{
nvs_handle_t handle = 0;
if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) {
return false;
}
std::size_t len = 0;
const esp_err_t err =
nvs_get_str(handle, kWebRadioConfigKey, nullptr, &len);
nvs_close(handle);
return err == ESP_OK && len > 1;
}
std::expected<void, core::StoreError>
NvsSecureStore::saveWebRadioConfigJson(std::string_view json)
{
nvs_handle_t handle = 0;
if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) {
return std::unexpected(core::StoreError::IoFailed);
}
const std::string payload(json);
esp_err_t err = nvs_set_str(handle, kWebRadioConfigKey, payload.c_str());
if (err == ESP_OK) {
err = nvs_commit(handle);
}
nvs_close(handle);
if (err != ESP_OK) {
return std::unexpected(core::StoreError::IoFailed);
}
return {};
}
std::expected<std::string, core::StoreError>
NvsSecureStore::loadWebRadioConfigJson() const
{
nvs_handle_t handle = 0;
if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) {
return std::unexpected(core::StoreError::NotFound);
}
std::size_t len = 0;
if (nvs_get_str(handle, kWebRadioConfigKey, nullptr, &len) != ESP_OK
|| len == 0) {
nvs_close(handle);
return std::unexpected(core::StoreError::NotFound);
}
std::vector<char> buffer(len);
if (nvs_get_str(handle, kWebRadioConfigKey, buffer.data(), &len)
!= ESP_OK) {
nvs_close(handle);
return std::unexpected(core::StoreError::IoFailed);
}
nvs_close(handle);
return std::string(buffer.data());
}
} // namespace secure_store
@@ -130,6 +130,20 @@ public:
[[nodiscard]] std::expected<void, core::StoreError> setMasterVolume(
core::GainDb left, core::GainDb right, bool persist);
/**
* @brief applyRadioFirstMix — route Si4684 to output, mute ESP32 path.
*
* @dname applyRadioFirstMix
* @param persist When true and store is set, write NVS.
* @return Ok on success, or StoreError.
* @pubstate updates profile_.mixer and master; safeloads ADAU1701.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<void, core::StoreError> applyRadioFirstMix(
bool persist);
/**
* @brief setEqBand — update one PEQ band and apply live.
*
@@ -179,6 +193,21 @@ public:
[[nodiscard]] std::expected<void, core::StoreError> setBassEnhance(
core::EnhanceLevel level, bool persist);
/**
* @brief setBeepEnabled — toggle the ADAU1701 Beep1 tone generator.
*
* @dname setBeepEnabled
* @param enabled true unmutes Beep1, false mutes it.
* @return Ok on success, or DspError.
* @pubstate live-only: not part of AudioProfile, never persisted, does
* not touch profile_.
*
* @author Michele Bigi
* @date 2026-08-07
*/
[[nodiscard]] std::expected<void, core::DspError> setBeepEnabled(
bool enabled);
private:
[[nodiscard]] std::expected<void, core::StoreError> persistProfile() const;
@@ -134,6 +134,25 @@ std::expected<void, core::StoreError> AudioService::setMasterVolume(
return {};
}
std::expected<void, core::StoreError> AudioService::applyRadioFirstMix(
bool persist)
{
const core::GainDb unity = core::GainDb::zero();
profile_.mixer = core::MixerState::radioFirst();
profile_.masterLeft = unity;
profile_.masterRight = unity;
if (auto applied = dsp_.applyMixer(profile_.mixer); !applied) {
return std::unexpected(core::StoreError::IoFailed);
}
if (auto master = dsp_.setMasterVolume(unity, unity); !master) {
return std::unexpected(core::StoreError::IoFailed);
}
if (persist) {
return persistProfile();
}
return {};
}
std::expected<void, core::StoreError> AudioService::setEqBand(
core::EqBandIndex band, core::GainDb gain, core::FrequencyHz center, float q,
bool persist)
@@ -160,4 +179,9 @@ std::expected<void, core::StoreError> AudioService::setBassEnhance(
return applyEffectiveEq(persist);
}
std::expected<void, core::DspError> AudioService::setBeepEnabled(bool enabled)
{
return dsp_.setBeepEnabled(enabled);
}
} // namespace audio
@@ -2,7 +2,7 @@ idf_component_register(
SRCS
"src/BluetoothService.cpp"
INCLUDE_DIRS "include"
REQUIRES core bt1035
REQUIRES core bt1035 freertos
)
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -15,6 +15,9 @@
#include "bt1035/Bt1035Driver.hpp"
#include "bt1035/Bt1035Error.hpp"
#include "core/BluetoothJson.hpp"
#include "core/BtSpeakerTarget.hpp"
#include "core/ISecureStore.hpp"
#include "core/StoreError.hpp"
#include <expected>
#include <vector>
@@ -22,12 +25,11 @@
namespace bluetooth {
/**
* @brief BluetoothService — pairing and A2DP status for the web API.
* @brief BluetoothService — pairing, scan, and saved-speaker reconnect.
*
* @dname BluetoothService
* @return n/a (type)
* @pubstate Borrows bt1035::Bt1035Driver for the process lifetime. Tracks
* whether discoverable mode was requested via startPairing().
* @pubstate Borrows bt1035::Bt1035Driver and core::ISecureStore for life.
*
* @author Michele Bigi
* @date 2026-07-06
@@ -35,96 +37,173 @@ namespace bluetooth {
class BluetoothService {
public:
/**
* @brief BluetoothService — bind to the BT1035 driver.
* @brief BluetoothService — bind driver and store for the process life.
*
* @dname BluetoothService
* @param driver Booted BT1035 driver (must outlive this service).
* @pubstate stores driver reference; pairing inactive initially.
*
* @author Michele Bigi
* @date 2026-07-06
* @param driver BT1035 driver (must outlive this service).
* @param store Secure store for the saved default speaker.
* @pubstate initialises pairingActive_ to false.
*/
explicit BluetoothService(bt1035::Bt1035Driver& driver);
explicit BluetoothService(bt1035::Bt1035Driver& driver,
core::ISecureStore& store);
/**
* @brief refreshStatus — read module boot, pairing, and A2DP state.
* @brief refreshStatus — read boot/pairing/name/reconnect/A2DP state.
*
* @dname refreshStatus
* @return BluetoothStatus on success, or Bt1035Error from the driver.
* @pubstate queries driver for A2DP state when booted.
*
* @author Michele Bigi
* @date 2026-07-06
* @return BluetoothStatus (booted=false if the module never booted),
* or a Bt1035Error from the first failing query.
* @pubstate queries driver_; reads pairingActive_.
*/
[[nodiscard]] std::expected<core::BluetoothStatus, bt1035::Bt1035Error>
refreshStatus();
/**
* @brief startPairing — enter discoverable mode (AT+PAIR=1).
* @brief startPairing — enter BT1035 discoverable mode.
*
* @dname startPairing
* @return Ok on success, or Bt1035Error.
* @pubstate sets pairingActive_ on success.
*
* @author Michele Bigi
* @date 2026-07-06
* @return Ok on success, or a Bt1035Error.
* @pubstate sets pairingActive_ true on success.
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> startPairing();
/**
* @brief stopPairing — leave discoverable mode (AT+PAIR=0).
* @brief stopPairing — leave BT1035 discoverable mode.
*
* @dname stopPairing
* @return Ok on success, or Bt1035Error.
* @pubstate clears pairingActive_ on success.
*
* @author Michele Bigi
* @date 2026-07-06
* @return Ok on success, or a Bt1035Error.
* @pubstate sets pairingActive_ false on success.
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> stopPairing();
/**
* @brief disconnect — release the active A2DP link (AT+A2DPDISC).
* @brief disconnect — tear down the current A2DP link.
*
* @dname disconnect
* @return Ok on success, or Bt1035Error.
* @pubstate delegates to driver.
*
* @author Michele Bigi
* @date 2026-07-06
* @return Ok on success, or a Bt1035Error.
* @pubstate none
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> disconnect();
/**
* @brief listPaired — read paired remotes from the module.
* @brief listPaired — read the BT1035's paired-device list.
*
* @dname listPaired
* @return Device list on success, or Bt1035Error.
* @pubstate queries AT+PLIST via the driver.
*
* @author Michele Bigi
* @date 2026-07-07
* @return Paired devices, or a Bt1035Error.
* @pubstate none
*/
[[nodiscard]] std::expected<std::vector<core::Bt1035PairedDevice>,
bt1035::Bt1035Error>
listPaired();
/**
* @brief setAutoReconnect — configure power-on reconnect attempts.
* @brief setAutoReconnect — set the module's auto-reconnect attempts.
*
* @dname setAutoReconnect
* @param times 0 off, 115 per Feasycom manual.
* @return Ok on success, or Bt1035Error.
* @pubstate writes AT+AUTOCONN via the driver.
*
* @author Michele Bigi
* @date 2026-07-07
* @param times Retry count passed to the BT1035 firmware.
* @return Ok on success, or a Bt1035Error.
* @pubstate none
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setAutoReconnect(
std::uint8_t times);
/**
* @brief scanNearby — classic BT/EDR discovery, cancelling pairing.
*
* @dname scanNearby
* @param scanSeconds Scan duration in seconds.
* @return Discovered devices, or a Bt1035Error.
* @pubstate leaves pairing mode and stops any active scan first; sets
* pairingActive_ false.
*/
[[nodiscard]] std::expected<std::vector<core::Bt1035ScannedDevice>,
bt1035::Bt1035Error>
scanNearby(std::uint8_t scanSeconds = 20U);
/**
* @brief connectTo — direct A2DPCONN to a MAC, wait for streaming.
*
* @dname connectTo
* @param mac Target BT1035 MAC (12 hex chars, no separators).
* @return Ok once A2DP reaches Streaming, or a Bt1035Error.
* @pubstate sets pairingActive_ false; blocks up to the connect and
* stream settle timeouts.
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> connectTo(
std::string_view mac);
/**
* @brief connectTo — connect by request, optionally saving the target.
*
* @dname connectTo
* @param request Validated MAC/name plus a save flag.
* @return Ok once A2DP reaches Streaming, or a Bt1035Error
* (UnexpectedResponse for an invalid MAC).
* @pubstate sets pairingActive_ false; persists via saveSpeaker() when
* request.save is true (a failed save does not fail connect).
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> connectTo(
const core::BluetoothConnectRequest& request);
/**
* @brief hasSavedSpeaker — check whether a default speaker is stored.
*
* @dname hasSavedSpeaker
* @return true when loadSavedSpeaker() would succeed.
* @pubstate reads store_.
*/
[[nodiscard]] bool hasSavedSpeaker() const;
/**
* @brief loadSavedSpeaker — read the stored default speaker.
*
* @dname loadSavedSpeaker
* @return BtSpeakerTarget on success, or a StoreError.
* @pubstate reads store_.
*/
[[nodiscard]] std::expected<core::BtSpeakerTarget, core::StoreError>
loadSavedSpeaker() const;
/**
* @brief saveSpeaker — persist the default A2DP speaker target.
*
* @dname saveSpeaker
* @param target MAC and optional display name.
* @return Ok on success, or a StoreError.
* @pubstate writes store_.
*/
[[nodiscard]] std::expected<void, core::StoreError> saveSpeaker(
const core::BtSpeakerTarget& target);
/**
* @brief clearSavedSpeaker — erase the stored default speaker.
*
* @dname clearSavedSpeaker
* @return Ok on success, or a StoreError.
* @pubstate writes store_.
*/
[[nodiscard]] std::expected<void, core::StoreError> clearSavedSpeaker();
/** @brief Launch background task: A2DPCONN saved MAC, scan fallback. */
void startupReconnect();
/**
* @brief reconnectSavedSpeaker — direct MAC connect, optional scan retry.
*
* @dname reconnectSavedSpeaker
* @param scanFallback Run AT+SCAN when direct connect fails.
* @return Ok when A2DP reaches Connected, or Bt1035Error.
* @pubstate may block tens of seconds when scanFallback is true.
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> reconnectSavedSpeaker(
bool scanFallback);
private:
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> connectDirect(
std::string_view mac);
bt1035::Bt1035Driver& driver_;
core::ISecureStore& store_;
bool pairingActive_;
};
@@ -13,10 +13,67 @@
#include "bluetooth/BluetoothService.hpp"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
namespace bluetooth {
BluetoothService::BluetoothService(bt1035::Bt1035Driver& driver)
namespace {
constexpr char kTag[] = "BluetoothSvc";
constexpr int kConnectSettleMs = 30000;
constexpr int kStreamSettleMs = 20000;
constexpr int kAutoReconnectWaitMs = 45000;
[[nodiscard]] bool isA2dpLinked(core::Bt1035A2dpState state) noexcept
{
using core::Bt1035A2dpState;
return state == Bt1035A2dpState::Connected
|| state == Bt1035A2dpState::Streaming
|| state == Bt1035A2dpState::Paused;
}
[[nodiscard]] bool isA2dpLinked(bt1035::Bt1035Driver& driver)
{
if (auto state = driver.queryA2dpState(); state) {
return isA2dpLinked(*state);
}
return false;
}
[[nodiscard]] bool ensureA2dpStreaming(bt1035::Bt1035Driver& driver)
{
if (auto state = driver.queryA2dpState(); state
&& *state == core::Bt1035A2dpState::Streaming) {
ESP_LOGI(kTag, "A2DP already streaming");
return true;
}
if (!driver.waitForA2dpStreaming(kStreamSettleMs)) {
ESP_LOGW(kTag, "A2DP not streaming within %d ms", kStreamSettleMs);
return false;
}
ESP_LOGI(kTag, "A2DP streaming OK");
return true;
}
void savedSpeakerReconnectTask(void* arg)
{
auto* service = static_cast<BluetoothService*>(arg);
ESP_LOGI(kTag, "boot reconnect task started");
if (auto result = service->reconnectSavedSpeaker(true); !result) {
ESP_LOGW(kTag, "boot reconnect failed (%d)",
static_cast<int>(result.error()));
} else {
ESP_LOGI(kTag, "boot reconnect OK");
}
vTaskDelete(nullptr);
}
} // namespace
BluetoothService::BluetoothService(bt1035::Bt1035Driver& driver,
core::ISecureStore& store)
: driver_(driver)
, store_(store)
, pairingActive_(false)
{
}
@@ -91,4 +148,152 @@ std::expected<void, bt1035::Bt1035Error> BluetoothService::setAutoReconnect(
return driver_.setAutoReconnect(times);
}
std::expected<std::vector<core::Bt1035ScannedDevice>, bt1035::Bt1035Error>
BluetoothService::scanNearby(std::uint8_t scanSeconds)
{
pairingActive_ = false;
(void)driver_.leavePairingMode();
(void)driver_.stopScan();
return driver_.scanNearbyBrEdr(scanSeconds);
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::connectDirect(
std::string_view mac)
{
if (auto sent = driver_.connectA2dp(mac); !sent) {
return sent;
}
if (!driver_.waitForA2dpConnected(kConnectSettleMs)) {
ESP_LOGW(kTag, "A2DP connect sent but link not up in %d ms",
kConnectSettleMs);
return std::unexpected(bt1035::Bt1035Error::AtTimeout);
}
if (!ensureA2dpStreaming(driver_)) {
return std::unexpected(bt1035::Bt1035Error::AtTimeout);
}
return {};
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::connectTo(
std::string_view mac)
{
pairingActive_ = false;
return connectDirect(mac);
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::connectTo(
const core::BluetoothConnectRequest& request)
{
if (request.mac.empty() || !core::isValidBt1035Mac(request.mac)) {
return std::unexpected(bt1035::Bt1035Error::UnexpectedResponse);
}
pairingActive_ = false;
if (auto connected = connectDirect(request.mac); !connected) {
return connected;
}
if (request.save) {
core::BtSpeakerTarget target{
.mac = request.mac,
.name = request.name,
};
if (auto saved = saveSpeaker(target); !saved) {
ESP_LOGW(kTag, "connect OK but save speaker failed");
}
}
return {};
}
bool BluetoothService::hasSavedSpeaker() const
{
return store_.hasBtSpeakerTarget();
}
std::expected<core::BtSpeakerTarget, core::StoreError>
BluetoothService::loadSavedSpeaker() const
{
return store_.loadBtSpeakerTarget();
}
std::expected<void, core::StoreError> BluetoothService::saveSpeaker(
const core::BtSpeakerTarget& target)
{
return store_.saveBtSpeakerTarget(target);
}
std::expected<void, core::StoreError> BluetoothService::clearSavedSpeaker()
{
return store_.clearBtSpeakerTarget();
}
void BluetoothService::startupReconnect()
{
if (!hasSavedSpeaker()) {
ESP_LOGI(kTag, "no saved BT speaker — skip boot reconnect");
return;
}
if (xTaskCreate(savedSpeakerReconnectTask, "bt_reconn", 8192, this, 4,
nullptr)
!= pdPASS) {
ESP_LOGW(kTag, "boot reconnect task create failed");
}
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::reconnectSavedSpeaker(
bool scanFallback)
{
const auto target = loadSavedSpeaker();
if (!target) {
return {};
}
ESP_LOGI(kTag, "reconnect saved speaker %s (%s)", target->mac.c_str(),
target->name.empty() ? "(no name)" : target->name.c_str());
if (isA2dpLinked(driver_)) {
ESP_LOGI(kTag, "A2DP already linked — ensure streaming");
if (!ensureA2dpStreaming(driver_)) {
return std::unexpected(bt1035::Bt1035Error::AtTimeout);
}
return {};
}
if (auto connected = connectDirect(target->mac); connected) {
return connected;
}
if (isA2dpLinked(driver_)) {
ESP_LOGI(kTag, "A2DP linked after connect attempt");
if (!ensureA2dpStreaming(driver_)) {
return std::unexpected(bt1035::Bt1035Error::AtTimeout);
}
return {};
}
ESP_LOGI(kTag, "waiting for module auto-reconnect (%d ms)",
kAutoReconnectWaitMs);
if (driver_.waitForA2dpConnected(kAutoReconnectWaitMs)) {
ESP_LOGI(kTag, "A2DP auto-reconnect linked");
if (!ensureA2dpStreaming(driver_)) {
return std::unexpected(bt1035::Bt1035Error::AtTimeout);
}
return {};
}
if (!scanFallback) {
return std::unexpected(bt1035::Bt1035Error::AtTimeout);
}
ESP_LOGW(kTag, "direct A2DPCONN failed — retry connect (no scan)");
if (auto retry = connectDirect(target->mac); retry) {
return retry;
}
if (isA2dpLinked(driver_)) {
if (!ensureA2dpStreaming(driver_)) {
return std::unexpected(bt1035::Bt1035Error::AtTimeout);
}
return {};
}
return std::unexpected(bt1035::Bt1035Error::AtTimeout);
}
} // namespace bluetooth
@@ -2,7 +2,7 @@ idf_component_register(
SRCS
"src/TunerService.cpp"
INCLUDE_DIRS "include"
REQUIRES core
REQUIRES core freertos
)
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -16,6 +16,7 @@
#include "core/ITuner.hpp"
#include "core/SeekDirection.hpp"
#include "core/TunerError.hpp"
#include "core/TunerJson.hpp"
#include "core/TunerStatus.hpp"
#include <cstdint>
@@ -109,6 +110,20 @@ public:
[[nodiscard]] std::expected<core::FrequencyKHz, core::TunerError> seekFm(
core::SeekDirection direction);
/**
* @brief scanForStation — FM seek or DAB ensemble search with optional name.
*
* @dname scanForStation
* @param request Band, step limit, and optional label substring.
* @return Scan outcome; TunerError only on driver failure.
* @pubstate tunes/plays first matching station; updates last tune caches.
*
* @author Michele Bigi
* @date 2026-08-05
*/
[[nodiscard]] std::expected<core::TunerScanResult, core::TunerError>
scanForStation(const core::TunerScanRequest& request);
/**
* @brief listDabServices — programmes available on the current ensemble.
*
@@ -13,15 +13,124 @@
#include "tuner/TunerService.hpp"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <algorithm>
#include <cctype>
#include <string>
namespace tuner {
namespace {
constexpr char kTag[] = "TunerSvc";
constexpr int kDabTuneSettleMs = 600;
/** FM scan step size (100 kHz spacing). */
constexpr std::uint32_t kFmScanStepKhz = 100U;
constexpr int kFmTuneSettleMs = 120;
constexpr int kFmNamePollMs = 250;
constexpr int kFmNamePollAttempts = 6;
constexpr std::int8_t kMinFmScanRssiDbuV = 5;
constexpr std::int8_t kMinFmScanSnrDb = 10;
/** Max |READFREQ commanded| for scan hit without RDS lock (kHz). */
constexpr std::uint32_t kFmScanChipFreqSlackKhz = 150U;
constexpr std::uint8_t kMinDabFicQuality = 35U;
/** European FM band top used for scan/seek wrap (107.9 MHz). */
constexpr std::uint32_t kFmScanMaxKhz = 107900U;
/** European FM band bottom used for full-band scan (87.5 MHz). */
constexpr std::uint32_t kFmScanMinKhz = 87500U;
[[nodiscard]] core::FrequencyKHz fmScanStartFrequency(
core::FrequencyKHz current,
std::string_view nameFilter)
{
if (!nameFilter.empty()) {
return current;
}
const std::uint32_t khz = current.value();
if (khz > kFmScanMaxKhz || khz < kFmScanMinKhz) {
return *core::FrequencyKHz::tryFromKhz(kFmScanMinKhz);
}
return current;
}
[[nodiscard]] core::FrequencyKHz defaultFmFrequency()
{
return *core::FrequencyKHz::tryFromKhz(101500U);
}
[[nodiscard]] std::string toLowerAscii(std::string_view text)
{
std::string lowered;
lowered.reserve(text.size());
for (const char ch : text) {
lowered.push_back(static_cast<char>(std::tolower(
static_cast<unsigned char>(ch))));
}
return lowered;
}
[[nodiscard]] bool nameMatchesFilter(const std::optional<core::BroadcastLabel>& label,
std::string_view filterLower)
{
if (filterLower.empty()) {
return true;
}
if (!label) {
return false;
}
const std::string haystack = toLowerAscii(label->value());
return haystack.find(filterLower) != std::string::npos;
}
[[nodiscard]] bool nameMatchesFilter(std::string_view label,
std::string_view filterLower)
{
if (filterLower.empty()) {
return true;
}
const std::string haystack = toLowerAscii(label);
return haystack.find(filterLower) != std::string::npos;
}
[[nodiscard]] bool fmStatusUsableForScan(const core::TunerStatus& status,
std::uint32_t commandedKhz,
bool requireLocked)
{
if (!status.fmRssiDbuV || *status.fmRssiDbuV < kMinFmScanRssiDbuV) {
return false;
}
if (requireLocked) {
return status.locked;
}
if (!status.fmSnrDb || *status.fmSnrDb < kMinFmScanSnrDb) {
return false;
}
if (status.fmChipReadFrequency) {
const std::uint32_t chipKhz = status.fmChipReadFrequency->value();
const std::uint32_t diff =
chipKhz > commandedKhz ? chipKhz - commandedKhz
: commandedKhz - chipKhz;
if (diff > kFmScanChipFreqSlackKhz) {
return false;
}
}
return true;
}
[[nodiscard]] core::TunerScanResult makeScanResult(
const core::TunerScanRequest& request,
std::uint16_t steps,
bool found)
{
core::TunerScanResult result = {};
result.found = found;
result.band = request.band;
result.stepsTried = steps;
return result;
}
} // namespace
TunerService::TunerService(core::ITuner& tuner)
@@ -77,6 +186,160 @@ std::expected<core::FrequencyKHz, core::TunerError> TunerService::seekFm(
return result;
}
std::expected<core::TunerScanResult, core::TunerError>
TunerService::scanForStation(const core::TunerScanRequest& request)
{
const std::string_view nameFilter = request.nameFilter;
if (request.band == core::TunerBand::Fm) {
const core::FrequencyKHz scanStart =
fmScanStartFrequency(lastFmFrequency_, nameFilter);
if (auto tuned = tuneFm(scanStart); !tuned) {
return std::unexpected(tuned.error());
}
const std::uint32_t startFreq = lastFmFrequency_.value();
const bool requireLocked = !nameFilter.empty();
std::uint32_t freqKhz = startFreq;
ESP_LOGI(kTag, "FM scan start from %u kHz (max %u steps, name='%.*s')",
static_cast<unsigned>(startFreq), static_cast<unsigned>(request.maxSteps),
static_cast<int>(nameFilter.size()), nameFilter.data());
for (std::uint16_t step = 1U; step <= request.maxSteps; ++step) {
freqKhz += kFmScanStepKhz;
if (freqKhz > kFmScanMaxKhz) {
freqKhz = kFmScanMinKhz;
}
if (freqKhz == startFreq) {
ESP_LOGI(kTag, "FM scan wrapped to start frequency");
break;
}
const auto next = core::FrequencyKHz::tryFromKhz(freqKhz);
if (!next) {
return std::unexpected(core::TunerError::TuneFailed);
}
if (auto tuned = tuneFm(*next); !tuned) {
ESP_LOGW(kTag, "FM scan step %u tune failed at %u kHz (err=%d)",
static_cast<unsigned>(step), static_cast<unsigned>(freqKhz),
static_cast<int>(tuned.error()));
return std::unexpected(tuned.error());
}
vTaskDelay(pdMS_TO_TICKS(kFmTuneSettleMs));
std::optional<core::BroadcastLabel> stationName;
bool usable = false;
for (int attempt = 0; attempt < kFmNamePollAttempts; ++attempt) {
auto status = refreshStatus();
if (!status) {
return std::unexpected(status.error());
}
if (attempt == 0) {
ESP_LOGI(kTag,
"FM scan step %u at %u kHz: valid=%d rssi=%d dBuV "
"snr=%d dB",
static_cast<unsigned>(step),
static_cast<unsigned>(lastFmFrequency_.value()),
static_cast<int>(status->locked),
status->fmRssiDbuV ? *status->fmRssiDbuV : -128,
status->fmSnrDb ? *status->fmSnrDb : -128);
}
if (!fmStatusUsableForScan(*status, freqKhz, requireLocked)) {
break;
}
usable = true;
stationName = status->fmStationName;
if (nameFilter.empty()
|| nameMatchesFilter(stationName, nameFilter)) {
break;
}
if (attempt + 1 < kFmNamePollAttempts) {
vTaskDelay(pdMS_TO_TICKS(kFmNamePollMs));
}
}
if (!usable) {
continue;
}
if (nameFilter.empty() || nameMatchesFilter(stationName, nameFilter)) {
auto status = refreshStatus();
if (!status) {
return std::unexpected(status.error());
}
core::TunerScanResult result = makeScanResult(request, step, true);
result.fmFrequency = status->fmFrequency;
result.stationName = status->fmStationName;
ESP_LOGI(kTag, "FM scan found %u kHz after %u steps",
static_cast<unsigned>(result.fmFrequency ? result.fmFrequency->value() : 0U),
static_cast<unsigned>(step));
return result;
}
}
ESP_LOGW(kTag, "FM scan: no station found");
return makeScanResult(request, request.maxSteps, false);
}
ESP_LOGI(kTag, "DAB scan start (max %u ensembles, name='%.*s')",
static_cast<unsigned>(request.maxSteps),
static_cast<int>(nameFilter.size()), nameFilter.data());
for (std::uint16_t step = 0U; step < request.maxSteps; ++step) {
const std::uint8_t freqIndex = static_cast<std::uint8_t>(step);
if (auto tuned = tuneDab(freqIndex); !tuned) {
return std::unexpected(tuned.error());
}
vTaskDelay(pdMS_TO_TICKS(kDabTuneSettleMs));
auto status = refreshStatus();
if (!status) {
return std::unexpected(status.error());
}
if (!status->locked || !status->dabFicQuality
|| *status->dabFicQuality < kMinDabFicQuality) {
continue;
}
auto services = listDabServices();
if (!services || services->empty()) {
continue;
}
for (const core::TunerServiceEntry& service : *services) {
const std::string_view label(service.label.data());
if (!nameMatchesFilter(label, nameFilter)) {
continue;
}
if (auto played =
playDabService(service.serviceId, service.componentId);
!played) {
continue;
}
core::TunerScanResult result =
makeScanResult(request, static_cast<std::uint16_t>(step + 1U), true);
result.dabFreqIndex = freqIndex;
result.dabServiceId = service.serviceId;
result.dabComponentId = service.componentId;
if (auto parsed = core::BroadcastLabel::tryFromChipBytes(label);
parsed) {
result.stationName = std::move(*parsed);
}
ESP_LOGI(kTag, "DAB scan found idx=%u svc=%u after %u steps",
static_cast<unsigned>(freqIndex),
static_cast<unsigned>(service.serviceId),
static_cast<unsigned>(step + 1U));
return result;
}
}
ESP_LOGW(kTag, "DAB scan: no station found");
return makeScanResult(request, request.maxSteps, false);
}
std::expected<std::vector<core::TunerServiceEntry>, core::TunerError>
TunerService::listDabServices()
{
@@ -0,0 +1,8 @@
idf_component_register(
SRCS
"src/WebRadioService.cpp"
INCLUDE_DIRS "include"
REQUIRES core freertos
)
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -0,0 +1,91 @@
/**
* @file WebRadioService.hpp
* @brief Live, persisted config for the internet radio streaming task.
*
* 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/ISecureStore.hpp"
#include "core/StoreError.hpp"
#include "core/WebRadioConfig.hpp"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include <expected>
namespace webradio {
/**
* @brief WebRadioService — thread-safe streaming config for HTTP + task.
*
* @dname WebRadioService
* @return n/a (type)
* @pubstate Borrows core::ISecureStore for life. Owns a FreeRTOS mutex
* guarding an in-RAM copy of the config; the streaming task in
* main/web_radio_stream.cpp polls config() to react to changes
* without a reboot.
*
* @author Michele Bigi
* @date 2026-08-08
*/
class WebRadioService {
public:
/**
* @brief WebRadioService — load persisted config or factory default.
*
* @dname WebRadioService
* @param store Secure store for persistence (must outlive this).
* @pubstate reads store via loadWebRadioConfigJson(); creates mutex_.
*
* @author Michele Bigi
* @date 2026-08-08
*/
explicit WebRadioService(core::ISecureStore& store);
~WebRadioService();
WebRadioService(const WebRadioService&) = delete;
WebRadioService& operator=(const WebRadioService&) = delete;
/**
* @brief config — read a thread-safe snapshot of the current config.
*
* @dname config
* @return Copy of the live WebRadioConfig.
* @pubstate locks mutex_ for the copy.
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] core::WebRadioConfig config() const;
/**
* @brief setConfig — persist and apply a new streaming config.
*
* @dname setConfig
* @param config Validated config from the HTTP layer.
* @return Ok on success, or a StoreError.
* @pubstate persists via store_ first, then updates the live copy under
* mutex_ so a failed save never desyncs the running task.
*
* @author Michele Bigi
* @date 2026-08-08
*/
[[nodiscard]] std::expected<void, core::StoreError> setConfig(
const core::WebRadioConfig& config);
private:
core::ISecureStore& store_;
SemaphoreHandle_t mutex_;
core::WebRadioConfig config_;
};
} // namespace webradio
@@ -0,0 +1,72 @@
/**
* @file WebRadioService.cpp
* @brief WebRadioService 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 "webradio/WebRadioService.hpp"
#include "core/WebRadioJson.hpp"
namespace webradio {
namespace {
[[nodiscard]] core::WebRadioConfig loadInitialConfig(core::ISecureStore& store)
{
if (store.hasWebRadioConfig()) {
auto json = store.loadWebRadioConfigJson();
if (json) {
auto parsed = core::parseWebRadioConfigJson(*json);
if (parsed) {
return *parsed;
}
}
}
return core::WebRadioConfig::factoryDefault();
}
} // namespace
WebRadioService::WebRadioService(core::ISecureStore& store)
: store_(store)
, mutex_(xSemaphoreCreateMutex())
, config_(loadInitialConfig(store))
{
}
WebRadioService::~WebRadioService()
{
vSemaphoreDelete(mutex_);
}
core::WebRadioConfig WebRadioService::config() const
{
xSemaphoreTake(mutex_, portMAX_DELAY);
const core::WebRadioConfig snapshot = config_;
xSemaphoreGive(mutex_);
return snapshot;
}
std::expected<void, core::StoreError> WebRadioService::setConfig(
const core::WebRadioConfig& config)
{
const std::string json = core::serializeWebRadioConfigJson(config);
if (auto saved = store_.saveWebRadioConfigJson(json); !saved) {
return saved;
}
xSemaphoreTake(mutex_, portMAX_DELAY);
config_ = config;
xSemaphoreGive(mutex_);
return {};
}
} // namespace webradio