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