diff --git a/Software/components/core/CMakeLists.txt b/Software/components/core/CMakeLists.txt index 219a11e..e77856b 100644 --- a/Software/components/core/CMakeLists.txt +++ b/Software/components/core/CMakeLists.txt @@ -23,6 +23,12 @@ idf_component_register( "src/AudioProfile.cpp" "src/AudioProfileJson.cpp" "src/Bt1035At.cpp" + "src/StationName.cpp" + "src/PresetSlot.cpp" + "src/Station.cpp" + "src/StationList.cpp" + "src/StationListJson.cpp" + "src/BluetoothJson.cpp" INCLUDE_DIRS "include" ) diff --git a/Software/components/core/include/core/BluetoothJson.hpp b/Software/components/core/include/core/BluetoothJson.hpp new file mode 100644 index 0000000..b62b719 --- /dev/null +++ b/Software/components/core/include/core/BluetoothJson.hpp @@ -0,0 +1,64 @@ +/** + * @file BluetoothJson.hpp + * @brief JSON serialisation for Bluetooth 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-07-06 + */ +#pragma once + +#include "core/Bt1035At.hpp" + +#include + +namespace core { + +/** + * @brief BluetoothStatus — snapshot for GET /api/bluetooth/status. + * + * @dname BluetoothStatus + * @return n/a (type) + * @pubstate Plain DTO assembled by BluetoothService. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct BluetoothStatus { + bool booted; ///< BT1035 driver ready after boot(). + bool pairing; ///< Discoverable mode requested by firmware. + Bt1035A2dpState a2dpState; ///< Last read A2DP link state. +}; + +/** + * @brief serializeBluetoothStatusJson — serialise BluetoothStatus for HTTP. + * + * @dname serializeBluetoothStatusJson + * @param status Domain snapshot. + * @return JSON object string. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string serializeBluetoothStatusJson( + const BluetoothStatus& status); + +/** + * @brief serializeBluetoothErrorJson — serialise a Bluetooth API error. + * + * @dname serializeBluetoothErrorJson + * @param reason Short machine-readable cause. + * @return JSON error object. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string serializeBluetoothErrorJson(const char* reason); + +} // namespace core diff --git a/Software/components/core/include/core/Bt1035At.hpp b/Software/components/core/include/core/Bt1035At.hpp index bac0797..1f5665e 100644 --- a/Software/components/core/include/core/Bt1035At.hpp +++ b/Software/components/core/include/core/Bt1035At.hpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -33,8 +34,31 @@ namespace core { * @date 2026-07-06 */ enum class Bt1035AtCommand { - Ping, ///< AT — link check. - AuxLineIn, ///< AT+AUXCFG=1 — wired Line-In from ADAU1701 (mandatory). + Ping, ///< AT — link check. + AuxLineIn, ///< AT+AUXCFG=1 — wired Line-In from ADAU1701 (mandatory). + PairDiscoverable, ///< AT+PAIR=1 — enter BR/EDR/BLE discoverable mode. + PairHidden, ///< AT+PAIR=0 — leave discoverable mode. + A2dpStat, ///< AT+A2DPSTAT — read A2DP link state. + A2dpDisconnect, ///< AT+A2DPDISC — release current A2DP connection. +}; + +/** + * @brief Bt1035A2dpState — A2DP link state from +A2DPSTAT=Param (BT1035 manual). + * + * @dname Bt1035A2dpState + * @return n/a (type) + * @pubstate Numeric values match Feasycom firmware event codes. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class Bt1035A2dpState : std::uint8_t { + Unsupported = 0, + Standby = 1, + Connecting = 2, + Connected = 3, + Streaming = 4, + Paused = 5, }; /** @@ -96,4 +120,31 @@ bootInitSequence() noexcept; [[nodiscard]] Bt1035AtResponseKind parseBt1035AtResponse( std::string_view line) noexcept; +/** + * @brief parseBt1035A2dpStatResponse — extract A2DP state from UART payload. + * + * @dname parseBt1035A2dpStatResponse + * @param response Full module reply (may include +A2DPSTAT and OK lines). + * @return Bt1035A2dpState on success, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected +parseBt1035A2dpStatResponse(std::string_view response); + +/** + * @brief a2dpStateToken — serialise A2DP state for JSON APIs. + * + * @dname a2dpStateToken + * @param state Parsed A2DP link state. + * @return Short stable string (e.g. "streaming"). + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] const char* a2dpStateToken(Bt1035A2dpState state) noexcept; + } // namespace core diff --git a/Software/components/core/include/core/ISecureStore.hpp b/Software/components/core/include/core/ISecureStore.hpp index bf23f8d..7b49a8d 100644 --- a/Software/components/core/include/core/ISecureStore.hpp +++ b/Software/components/core/include/core/ISecureStore.hpp @@ -21,6 +21,8 @@ #include "core/WifiCredentials.hpp" #include +#include +#include namespace core { @@ -103,6 +105,58 @@ public: */ [[nodiscard]] virtual std::expected clearWifiCredentials() = 0; + + /** + * @brief hasStationList — check whether presets are stored. + * + * @dname hasStationList + * @return true when loadStationListJson would succeed. + * @pubstate reads backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual bool hasStationList() const = 0; + + /** + * @brief saveStationListJson — persist serialised preset list JSON. + * + * @dname saveStationListJson + * @param json Output of core::serializeStationListJson(). + * @return Ok on success, or StoreError::IoFailed. + * @pubstate writes backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + saveStationListJson(std::string_view json) = 0; + + /** + * @brief loadStationListJson — read stored preset list JSON. + * + * @dname loadStationListJson + * @return JSON blob on success, or StoreError. + * @pubstate reads backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + loadStationListJson() const = 0; + + /** + * @brief clearStationList — erase stored presets. + * + * @dname clearStationList + * @return Ok on success, or StoreError::IoFailed. + * @pubstate clears backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + clearStationList() = 0; }; } // namespace core diff --git a/Software/components/core/include/core/PresetSlot.hpp b/Software/components/core/include/core/PresetSlot.hpp new file mode 100644 index 0000000..5671555 --- /dev/null +++ b/Software/components/core/include/core/PresetSlot.hpp @@ -0,0 +1,69 @@ +/** + * @file PresetSlot.hpp + * @brief Strong type for a physical preset button slot (1–20). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ParseError.hpp" + +#include +#include + +namespace core { + +/** + * @brief PresetSlot — optional hardware preset index on the radio. + * + * @dname PresetSlot + * @return n/a (type) + * @pubstate Owns slot_ in [1, kMaxSlot]. Immutable after construction. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class PresetSlot { +public: + /** Highest preset button index supported by the list model. */ + static constexpr std::uint8_t kMaxSlot = 20U; + + /** + * @brief tryFrom — validate a preset slot at the boundary. + * + * @dname tryFrom + * @param slot Untrusted slot number from JSON (1–20). + * @return PresetSlot on success, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static std::expected + tryFrom(unsigned slot) noexcept; + + /** + * @brief value — read the slot number. + * + * @dname value + * @return Slot index 1–20. + * @pubstate reads slot_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::uint8_t value() const noexcept; + +private: + explicit PresetSlot(std::uint8_t slot) noexcept; + + std::uint8_t slot_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/Station.hpp b/Software/components/core/include/core/Station.hpp new file mode 100644 index 0000000..97db6be --- /dev/null +++ b/Software/components/core/include/core/Station.hpp @@ -0,0 +1,93 @@ +/** + * @file Station.hpp + * @brief Value type for one saved tuner preset (DAB or FM). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/FrequencyKHz.hpp" +#include "core/PresetSlot.hpp" +#include "core/StationName.hpp" +#include "core/TunerBand.hpp" + +#include +#include + +namespace core { + +/** + * @brief Station — immutable preset: label, band, tune target, optional slot. + * + * @dname Station + * @return n/a (type) + * @pubstate All fields fixed at construction. FM presets carry fmFrequency_; + * DAB presets carry dabFreqIndex_ and optional service/component ids. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class Station { +public: + /** + * @brief Station — construct a validated preset value. + * + * @dname Station + * @param name User-visible label. + * @param band DAB or FM target band. + * @param dabFreqIndex Ensemble index 0–37 when band is Dab. + * @param dabServiceId Optional DAB service id for play(). + * @param dabComponentId Optional DAB component id for play(). + * @param fmFrequency FM centre frequency when band is Fm. + * @param presetSlot Optional hardware preset button slot. + * @pubstate stores immutable tune target fields. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + Station(StationName name, + TunerBand band, + std::uint8_t dabFreqIndex, + std::optional dabServiceId, + std::optional dabComponentId, + std::optional fmFrequency, + std::optional presetSlot); + + [[nodiscard]] const StationName& name() const noexcept; + [[nodiscard]] TunerBand band() const noexcept; + [[nodiscard]] std::uint8_t dabFreqIndex() const noexcept; + [[nodiscard]] std::optional dabServiceId() const noexcept; + [[nodiscard]] std::optional dabComponentId() const noexcept; + [[nodiscard]] std::optional fmFrequency() const noexcept; + [[nodiscard]] std::optional presetSlot() const noexcept; + + /** + * @brief sameTuneTarget — compare band-specific tune coordinates. + * + * @dname sameTuneTarget + * @param other Candidate preset. + * @return true when both would tune/play the same source. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] bool sameTuneTarget(const Station& other) const noexcept; + +private: + StationName name_; + TunerBand band_; + std::uint8_t dabFreqIndex_; + std::optional dabServiceId_; + std::optional dabComponentId_; + std::optional fmFrequency_; + std::optional presetSlot_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/StationList.hpp b/Software/components/core/include/core/StationList.hpp new file mode 100644 index 0000000..42a10d6 --- /dev/null +++ b/Software/components/core/include/core/StationList.hpp @@ -0,0 +1,123 @@ +/** + * @file StationList.hpp + * @brief In-memory collection of tuner presets with CRUD validation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/Station.hpp" +#include "core/StationListError.hpp" + +#include +#include +#include + +namespace core { + +/** + * @brief StationList — ordered preset collection (pure domain). + * + * @dname StationList + * @return n/a (type) + * @pubstate Owns stations_ (max kMaxStations entries). Duplicate tune targets + * and preset slots are rejected on add(). + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class StationList { +public: + /** Maximum presets persisted in NVS. */ + static constexpr std::size_t kMaxStations = 20U; + + /** + * @brief StationList — construct an empty list. + * + * @dname StationList + * @pubstate clears stations_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + StationList(); + + /** + * @brief stations — read the ordered preset vector. + * + * @dname stations + * @return Const reference to internal storage. + * @pubstate reads stations_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] const std::vector& stations() const noexcept; + + /** + * @brief replaceAll — replace the entire list (e.g. after NVS load). + * + * @dname replaceAll + * @param stations Validated presets (caller must enforce limits). + * @pubstate moves stations into stations_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + void replaceAll(std::vector stations); + + /** + * @brief add — append a preset after duplicate and capacity checks. + * + * @dname add + * @param station New preset value. + * @return Ok on success, or StationListError. + * @pubstate appends to stations_ on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected add(Station station); + + /** + * @brief removeAt — delete preset by list index. + * + * @dname removeAt + * @param index Zero-based position in stations(). + * @return Ok on success, or StationListError::NotFound. + * @pubstate erases one element on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected removeAt(std::size_t index); + + /** + * @brief move — reorder a preset within the list. + * + * @dname move + * @param fromIndex Current index. + * @param toIndex Target index. + * @return Ok on success, or StationListError::NotFound. + * @pubstate reorders stations_ on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected move(std::size_t fromIndex, + std::size_t toIndex); + +private: + [[nodiscard]] bool hasDuplicateTuneTarget(const Station& candidate) const; + [[nodiscard]] bool slotTaken(const PresetSlot& slot) const; + + std::vector stations_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/StationListError.hpp b/Software/components/core/include/core/StationListError.hpp new file mode 100644 index 0000000..7011e44 --- /dev/null +++ b/Software/components/core/include/core/StationListError.hpp @@ -0,0 +1,34 @@ +/** + * @file StationListError.hpp + * @brief Domain errors for station preset list operations. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +namespace core { + +/** + * @brief StationListError — failure causes for preset list CRUD. + * + * @dname StationListError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class StationListError { + Duplicate, + Full, + NotFound, + SlotInUse, +}; + +} // namespace core diff --git a/Software/components/core/include/core/StationListJson.hpp b/Software/components/core/include/core/StationListJson.hpp new file mode 100644 index 0000000..55f4fa5 --- /dev/null +++ b/Software/components/core/include/core/StationListJson.hpp @@ -0,0 +1,122 @@ +/** + * @file StationListJson.hpp + * @brief JSON parse/serialise for station preset list (pure core). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ParseError.hpp" +#include "core/Station.hpp" +#include "core/StationList.hpp" +#include "core/StationListError.hpp" + +#include +#include +#include +#include + +namespace core { + +/** + * @brief StationRemoveRequest — parsed POST /api/stations/remove body. + * + * @dname StationRemoveRequest + * @return n/a (type) + * @pubstate Plain DTO filled by parseStationRemoveJson. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct StationRemoveRequest { + std::size_t index; ///< Zero-based list index to delete. +}; + +/** + * @brief serializeStationListJson — serialise all presets for GET /api/stations. + * + * @dname serializeStationListJson + * @param list Domain preset collection. + * @return JSON object with a stations array. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string serializeStationListJson(const StationList& list); + +/** + * @brief parseStationListJson — load presets from persisted NVS JSON. + * + * @dname parseStationListJson + * @param json Untrusted blob from secure storage. + * @return StationList on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected +parseStationListJson(std::string_view json); + +/** + * @brief parseStationJson — validate one POST /api/stations body. + * + * @dname parseStationJson + * @param json Untrusted request body. + * @return Station on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected parseStationJson( + std::string_view json); + +/** + * @brief parseStationRemoveJson — validate POST /api/stations/remove body. + * + * @dname parseStationRemoveJson + * @param json Untrusted request body with index field. + * @return StationRemoveRequest on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected +parseStationRemoveJson(std::string_view json); + +/** + * @brief serializeStationListErrorJson — serialise a station API error. + * + * @dname serializeStationListErrorJson + * @param reason Short machine-readable cause. + * @return JSON error object. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string serializeStationListErrorJson(const char* reason); + +/** + * @brief stationListErrorToken — map domain error to API reason string. + * + * @dname stationListErrorToken + * @param error StationListError from add/remove/move. + * @return Stable reason token. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] const char* stationListErrorToken(StationListError error) noexcept; + +} // namespace core diff --git a/Software/components/core/include/core/StationName.hpp b/Software/components/core/include/core/StationName.hpp new file mode 100644 index 0000000..b02dce9 --- /dev/null +++ b/Software/components/core/include/core/StationName.hpp @@ -0,0 +1,71 @@ +/** + * @file StationName.hpp + * @brief Strong type for a user-visible station preset label. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ParseError.hpp" + +#include +#include +#include +#include + +namespace core { + +/** + * @brief StationName — validated preset display name (1–32 UTF-8 bytes). + * + * @dname StationName + * @return n/a (type) + * @pubstate Owns name_ (immutable after construction). + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class StationName { +public: + /** Maximum label length stored in NVS. */ + static constexpr std::size_t kMaxLength = 32; + + /** + * @brief tryFrom — validate a station name at the HTTP boundary. + * + * @dname tryFrom + * @param raw Untrusted label from JSON input. + * @return StationName on success, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static std::expected + tryFrom(std::string_view raw); + + /** + * @brief value — read the stored label. + * + * @dname value + * @return Validated preset name. + * @pubstate reads name_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::string_view value() const noexcept; + +private: + explicit StationName(std::string name); + + std::string name_; +}; + +} // namespace core diff --git a/Software/components/core/src/BluetoothJson.cpp b/Software/components/core/src/BluetoothJson.cpp new file mode 100644 index 0000000..9bb33f7 --- /dev/null +++ b/Software/components/core/src/BluetoothJson.cpp @@ -0,0 +1,36 @@ +/** + * @file BluetoothJson.cpp + * @brief BluetoothJson implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/BluetoothJson.hpp" + +#include + +namespace core { + +std::string serializeBluetoothStatusJson(const BluetoothStatus& status) +{ + std::ostringstream out; + out << "{\"booted\":" << (status.booted ? "true" : "false") + << ",\"pairing\":" << (status.pairing ? "true" : "false") + << ",\"a2dp\":\"" << a2dpStateToken(status.a2dpState) << "\"}"; + return out.str(); +} + +std::string serializeBluetoothErrorJson(const char* reason) +{ + std::ostringstream out; + out << "{\"status\":\"error\",\"reason\":\"" << reason << "\"}"; + return out.str(); +} + +} // namespace core diff --git a/Software/components/core/src/Bt1035At.cpp b/Software/components/core/src/Bt1035At.cpp index d303db9..5f5eb9a 100644 --- a/Software/components/core/src/Bt1035At.cpp +++ b/Software/components/core/src/Bt1035At.cpp @@ -13,6 +13,8 @@ #include "core/Bt1035At.hpp" +#include + namespace core { namespace { @@ -32,6 +34,65 @@ namespace { return text; } +[[nodiscard]] bool lineEqualsOk(std::string_view line) noexcept +{ + return trimAscii(line) == "OK"; +} + +[[nodiscard]] bool lineIsError(std::string_view line) noexcept +{ + const std::string_view trimmed = trimAscii(line); + return trimmed == "ERROR" || trimmed.starts_with("ERROR"); +} + +[[nodiscard]] bool responseContainsOk(std::string_view response) noexcept +{ + std::size_t start = 0; + while (start < response.size()) { + 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); + if (lineEqualsOk(line)) { + return true; + } + if (end == std::string_view::npos) { + break; + } + start = end + 1U; + while (start < response.size() + && (response[start] == '\r' || response[start] == '\n')) { + ++start; + } + } + return lineEqualsOk(response); +} + +[[nodiscard]] bool responseContainsError(std::string_view response) noexcept +{ + std::size_t start = 0; + while (start < response.size()) { + 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); + if (lineIsError(line)) { + return true; + } + if (end == std::string_view::npos) { + break; + } + start = end + 1U; + while (start < response.size() + && (response[start] == '\r' || response[start] == '\n')) { + ++start; + } + } + return lineIsError(response); +} + } // namespace std::string buildBt1035AtLine(Bt1035AtCommand command) @@ -41,6 +102,14 @@ std::string buildBt1035AtLine(Bt1035AtCommand command) return "AT\r\n"; case Bt1035AtCommand::AuxLineIn: return "AT+AUXCFG=1\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::A2dpDisconnect: + return "AT+A2DPDISC\r\n"; } return "AT\r\n"; } @@ -55,6 +124,12 @@ std::array bootInitSequence() noex Bt1035AtResponseKind parseBt1035AtResponse(std::string_view line) noexcept { + if (responseContainsOk(line)) { + return Bt1035AtResponseKind::Ok; + } + if (responseContainsError(line)) { + return Bt1035AtResponseKind::Error; + } const std::string_view trimmed = trimAscii(line); if (trimmed == "OK") { return Bt1035AtResponseKind::Ok; @@ -65,4 +140,43 @@ Bt1035AtResponseKind parseBt1035AtResponse(std::string_view line) noexcept return Bt1035AtResponseKind::Unexpected; } +std::expected +parseBt1035A2dpStatResponse(std::string_view response) +{ + constexpr std::string_view kPrefix = "+A2DPSTAT="; + 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 > 5U) { + return std::unexpected(ParseError::MissingField); + } + + return static_cast(raw); +} + +const char* a2dpStateToken(Bt1035A2dpState state) noexcept +{ + switch (state) { + case Bt1035A2dpState::Unsupported: + return "unsupported"; + case Bt1035A2dpState::Standby: + return "standby"; + case Bt1035A2dpState::Connecting: + return "connecting"; + case Bt1035A2dpState::Connected: + return "connected"; + case Bt1035A2dpState::Streaming: + return "streaming"; + case Bt1035A2dpState::Paused: + return "paused"; + } + return "unknown"; +} + } // namespace core diff --git a/Software/components/core/src/PresetSlot.cpp b/Software/components/core/src/PresetSlot.cpp new file mode 100644 index 0000000..4c65110 --- /dev/null +++ b/Software/components/core/src/PresetSlot.cpp @@ -0,0 +1,36 @@ +/** + * @file PresetSlot.cpp + * @brief PresetSlot implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/PresetSlot.hpp" + +namespace core { + +std::expected PresetSlot::tryFrom(unsigned slot) noexcept +{ + if (slot < 1U || slot > kMaxSlot) { + return std::unexpected(ParseError::MissingField); + } + return PresetSlot(static_cast(slot)); +} + +PresetSlot::PresetSlot(std::uint8_t slot) noexcept + : slot_(slot) +{ +} + +std::uint8_t PresetSlot::value() const noexcept +{ + return slot_; +} + +} // namespace core diff --git a/Software/components/core/src/Station.cpp b/Software/components/core/src/Station.cpp new file mode 100644 index 0000000..2a2ea82 --- /dev/null +++ b/Software/components/core/src/Station.cpp @@ -0,0 +1,86 @@ +/** + * @file Station.cpp + * @brief Station implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/Station.hpp" + +namespace core { + +Station::Station(StationName name, + TunerBand band, + std::uint8_t dabFreqIndex, + std::optional dabServiceId, + std::optional dabComponentId, + std::optional fmFrequency, + std::optional presetSlot) + : name_(std::move(name)) + , band_(band) + , dabFreqIndex_(dabFreqIndex) + , dabServiceId_(dabServiceId) + , dabComponentId_(dabComponentId) + , fmFrequency_(std::move(fmFrequency)) + , presetSlot_(std::move(presetSlot)) +{ +} + +const StationName& Station::name() const noexcept +{ + return name_; +} + +TunerBand Station::band() const noexcept +{ + return band_; +} + +std::uint8_t Station::dabFreqIndex() const noexcept +{ + return dabFreqIndex_; +} + +std::optional Station::dabServiceId() const noexcept +{ + return dabServiceId_; +} + +std::optional Station::dabComponentId() const noexcept +{ + return dabComponentId_; +} + +std::optional Station::fmFrequency() const noexcept +{ + return fmFrequency_; +} + +std::optional Station::presetSlot() const noexcept +{ + return presetSlot_; +} + +bool Station::sameTuneTarget(const Station& other) const noexcept +{ + if (band_ != other.band_) { + return false; + } + if (band_ == TunerBand::Fm) { + return fmFrequency_ && other.fmFrequency_ + && fmFrequency_->value() == other.fmFrequency_->value(); + } + if (dabFreqIndex_ != other.dabFreqIndex_) { + return false; + } + return dabServiceId_ == other.dabServiceId_ + && dabComponentId_ == other.dabComponentId_; +} + +} // namespace core diff --git a/Software/components/core/src/StationList.cpp b/Software/components/core/src/StationList.cpp new file mode 100644 index 0000000..e928db6 --- /dev/null +++ b/Software/components/core/src/StationList.cpp @@ -0,0 +1,92 @@ +/** + * @file StationList.cpp + * @brief StationList implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/StationList.hpp" + +#include + +namespace core { + +StationList::StationList() = default; + +const std::vector& StationList::stations() const noexcept +{ + return stations_; +} + +void StationList::replaceAll(std::vector stations) +{ + stations_ = std::move(stations); +} + +bool StationList::hasDuplicateTuneTarget(const Station& candidate) const +{ + for (const Station& existing : stations_) { + if (existing.sameTuneTarget(candidate)) { + return true; + } + } + return false; +} + +bool StationList::slotTaken(const PresetSlot& slot) const +{ + for (const Station& existing : stations_) { + if (existing.presetSlot() && existing.presetSlot()->value() == slot.value()) { + return true; + } + } + return false; +} + +std::expected StationList::add(Station station) +{ + if (stations_.size() >= kMaxStations) { + return std::unexpected(StationListError::Full); + } + if (hasDuplicateTuneTarget(station)) { + return std::unexpected(StationListError::Duplicate); + } + if (station.presetSlot() && slotTaken(*station.presetSlot())) { + return std::unexpected(StationListError::SlotInUse); + } + stations_.push_back(std::move(station)); + return {}; +} + +std::expected StationList::removeAt(std::size_t index) +{ + if (index >= stations_.size()) { + return std::unexpected(StationListError::NotFound); + } + stations_.erase(stations_.begin() + static_cast(index)); + return {}; +} + +std::expected StationList::move(std::size_t fromIndex, + std::size_t toIndex) +{ + if (fromIndex >= stations_.size() || toIndex >= stations_.size()) { + return std::unexpected(StationListError::NotFound); + } + if (fromIndex == toIndex) { + return {}; + } + Station moving = std::move(stations_[fromIndex]); + stations_.erase(stations_.begin() + static_cast(fromIndex)); + stations_.insert(stations_.begin() + static_cast(toIndex), + std::move(moving)); + return {}; +} + +} // namespace core diff --git a/Software/components/core/src/StationListJson.cpp b/Software/components/core/src/StationListJson.cpp new file mode 100644 index 0000000..8e6b7ad --- /dev/null +++ b/Software/components/core/src/StationListJson.cpp @@ -0,0 +1,265 @@ +/** + * @file StationListJson.cpp + * @brief StationListJson implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/StationListJson.hpp" + +#include +#include + +namespace core { + +namespace { + +[[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 extractJsonUint(std::string_view json, + std::string_view key, + unsigned long& out) +{ + const std::string needle = + std::string("\"") + std::string(key) + "\":"; + const std::size_t start = json.find(needle); + if (start == std::string_view::npos) { + return false; + } + const std::size_t valueStart = start + needle.size(); + char* end = nullptr; + out = std::strtoul(json.data() + valueStart, &end, 10); + return end != json.data() + valueStart; +} + +[[nodiscard]] const char* bandToken(TunerBand band) noexcept +{ + return band == TunerBand::Fm ? "fm" : "dab"; +} + +[[nodiscard]] std::expected +parseBandField(std::string_view json) +{ + const std::string_view band = extractJsonString(json, "band"); + if (band == "fm") { + return TunerBand::Fm; + } + if (band == "dab") { + return TunerBand::Dab; + } + return std::unexpected(ParseError::MissingField); +} + +[[nodiscard]] std::expected +parseStationObject(std::string_view json) +{ + const std::string_view nameRaw = extractJsonString(json, "name"); + auto name = StationName::tryFrom(nameRaw); + if (!name) { + return std::unexpected(ParseError::MissingField); + } + + auto band = parseBandField(json); + if (!band) { + return std::unexpected(ParseError::MissingField); + } + + std::optional slot; + unsigned long slotRaw = 0; + if (extractJsonUint(json, "preset_slot", slotRaw)) { + auto parsedSlot = PresetSlot::tryFrom(slotRaw); + if (!parsedSlot) { + return std::unexpected(ParseError::MissingField); + } + slot = *parsedSlot; + } + + if (*band == TunerBand::Fm) { + unsigned long khz = 0; + if (!extractJsonUint(json, "fm_frequency_khz", khz)) { + return std::unexpected(ParseError::MissingField); + } + auto frequency = FrequencyKHz::tryFromKhz(static_cast(khz)); + if (!frequency) { + return std::unexpected(ParseError::MissingField); + } + return Station(*name, TunerBand::Fm, 0U, std::nullopt, std::nullopt, + *frequency, slot); + } + + unsigned long dabIndex = 0; + if (!extractJsonUint(json, "dab_freq_index", dabIndex) || dabIndex > 37U) { + return std::unexpected(ParseError::MissingField); + } + + std::optional serviceId; + unsigned long serviceRaw = 0; + if (extractJsonUint(json, "dab_service_id", serviceRaw)) { + serviceId = static_cast(serviceRaw); + } + + std::optional componentId; + unsigned long componentRaw = 0; + if (extractJsonUint(json, "dab_component_id", componentRaw)) { + componentId = static_cast(componentRaw); + } + + return Station(*name, TunerBand::Dab, static_cast(dabIndex), + serviceId, componentId, std::nullopt, slot); +} + +void appendStationJson(std::ostringstream& out, const Station& station) +{ + out << "{\"name\":\"" << station.name().value() << "\",\"band\":\"" + << bandToken(station.band()) << "\""; + + if (station.band() == TunerBand::Fm && station.fmFrequency()) { + out << ",\"fm_frequency_khz\":" << station.fmFrequency()->value(); + } else { + out << ",\"dab_freq_index\":" + << static_cast(station.dabFreqIndex()); + if (station.dabServiceId()) { + out << ",\"dab_service_id\":" << *station.dabServiceId(); + } + if (station.dabComponentId()) { + out << ",\"dab_component_id\":" << *station.dabComponentId(); + } + } + + if (station.presetSlot()) { + out << ",\"preset_slot\":" + << static_cast(station.presetSlot()->value()); + } + out << '}'; +} + +} // namespace + +std::string serializeStationListJson(const StationList& list) +{ + std::ostringstream out; + out << "{\"stations\":["; + bool first = true; + for (const Station& station : list.stations()) { + if (!first) { + out << ','; + } + first = false; + appendStationJson(out, station); + } + out << "]}"; + return out.str(); +} + +std::expected parseStationListJson(std::string_view json) +{ + StationList list; + const std::size_t arrayStart = json.find("\"stations\""); + if (arrayStart == std::string_view::npos) { + return list; + } + + std::size_t pos = json.find('[', arrayStart); + if (pos == std::string_view::npos) { + return std::unexpected(ParseError::InvalidJson); + } + ++pos; + + while (pos < json.size()) { + while (pos < json.size() + && (json[pos] == ' ' || json[pos] == ',' || json[pos] == '\n' + || json[pos] == '\r')) { + ++pos; + } + if (pos >= json.size() || json[pos] == ']') { + break; + } + if (json[pos] != '{') { + return std::unexpected(ParseError::InvalidJson); + } + const std::size_t objectStart = pos; + int depth = 0; + for (; pos < json.size(); ++pos) { + if (json[pos] == '{') { + ++depth; + } else if (json[pos] == '}') { + --depth; + if (depth == 0) { + ++pos; + break; + } + } + } + const std::string_view object = + json.substr(objectStart, pos - objectStart); + auto station = parseStationObject(object); + if (!station) { + return std::unexpected(station.error()); + } + if (auto added = list.add(std::move(*station)); !added) { + return std::unexpected(ParseError::InvalidJson); + } + } + + return list; +} + +std::expected parseStationJson(std::string_view json) +{ + return parseStationObject(json); +} + +std::expected +parseStationRemoveJson(std::string_view json) +{ + unsigned long index = 0; + if (!extractJsonUint(json, "index", index)) { + return std::unexpected(ParseError::MissingField); + } + return StationRemoveRequest{.index = static_cast(index)}; +} + +std::string serializeStationListErrorJson(const char* reason) +{ + std::ostringstream out; + out << "{\"status\":\"error\",\"reason\":\"" << reason << "\"}"; + return out.str(); +} + +const char* stationListErrorToken(StationListError error) noexcept +{ + switch (error) { + case StationListError::Duplicate: + return "duplicate"; + case StationListError::Full: + return "full"; + case StationListError::NotFound: + return "not_found"; + case StationListError::SlotInUse: + return "slot_in_use"; + } + return "station_error"; +} + +} // namespace core diff --git a/Software/components/core/src/StationName.cpp b/Software/components/core/src/StationName.cpp new file mode 100644 index 0000000..0099fff --- /dev/null +++ b/Software/components/core/src/StationName.cpp @@ -0,0 +1,36 @@ +/** + * @file StationName.cpp + * @brief StationName implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/StationName.hpp" + +namespace core { + +std::expected StationName::tryFrom(std::string_view raw) +{ + if (raw.empty() || raw.size() > kMaxLength) { + return std::unexpected(ParseError::MissingField); + } + return StationName(std::string(raw)); +} + +StationName::StationName(std::string name) + : name_(std::move(name)) +{ +} + +std::string_view StationName::value() const noexcept +{ + return name_; +} + +} // namespace core diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index 34074ab..0ba5eaa 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -35,6 +35,12 @@ add_library(digiradio_core STATIC "${CORE_SRC_DIR}/AudioProfile.cpp" "${CORE_SRC_DIR}/AudioProfileJson.cpp" "${CORE_SRC_DIR}/Bt1035At.cpp" + "${CORE_SRC_DIR}/StationName.cpp" + "${CORE_SRC_DIR}/PresetSlot.cpp" + "${CORE_SRC_DIR}/Station.cpp" + "${CORE_SRC_DIR}/StationList.cpp" + "${CORE_SRC_DIR}/StationListJson.cpp" + "${CORE_SRC_DIR}/BluetoothJson.cpp" ) target_include_directories(digiradio_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/../include" @@ -72,6 +78,10 @@ add_executable(enhancements_design_test enhancements_design_test.cpp) target_link_libraries(enhancements_design_test PRIVATE digiradio_core) add_test(NAME enhancements_design_test COMMAND enhancements_design_test) +add_executable(station_list_test station_list_test.cpp) +target_link_libraries(station_list_test PRIVATE digiradio_core) +add_test(NAME station_list_test COMMAND station_list_test) + add_executable(bt1035_at_test bt1035_at_test.cpp) target_link_libraries(bt1035_at_test PRIVATE digiradio_core) add_test(NAME bt1035_at_test COMMAND bt1035_at_test) diff --git a/Software/components/core/test/bt1035_at_test.cpp b/Software/components/core/test/bt1035_at_test.cpp index ac17a40..b6b80fd 100644 --- a/Software/components/core/test/bt1035_at_test.cpp +++ b/Software/components/core/test/bt1035_at_test.cpp @@ -54,6 +54,23 @@ namespace { std::cerr << "unexpected parse failed\n"; return EXIT_FAILURE; } + if (core::parseBt1035AtResponse("+A2DPSTAT=3\r\nOK\r\n") + != core::Bt1035AtResponseKind::Ok) { + std::cerr << "expected multiline OK detection\n"; + return EXIT_FAILURE; + } + const std::string pairOn = + core::buildBt1035AtLine(core::Bt1035AtCommand::PairDiscoverable); + if (pairOn != "AT+PAIR=1\r\n") { + std::cerr << "expected AT+PAIR=1 line\n"; + return EXIT_FAILURE; + } + const auto a2dp = + core::parseBt1035A2dpStatResponse("+A2DPSTAT=4\r\nOK\r\n"); + if (!a2dp || *a2dp != core::Bt1035A2dpState::Streaming) { + std::cerr << "expected streaming A2DP state\n"; + return EXIT_FAILURE; + } return EXIT_SUCCESS; } diff --git a/Software/components/core/test/station_list_test.cpp b/Software/components/core/test/station_list_test.cpp new file mode 100644 index 0000000..6a5584a --- /dev/null +++ b/Software/components/core/test/station_list_test.cpp @@ -0,0 +1,98 @@ +/** + * @file station_list_test.cpp + * @brief Host tests for station preset list domain and JSON. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#include "core/FrequencyKHz.hpp" +#include "core/PresetSlot.hpp" +#include "core/Station.hpp" +#include "core/StationList.hpp" +#include "core/StationListError.hpp" +#include "core/StationListJson.hpp" +#include "core/StationName.hpp" +#include "core/TunerBand.hpp" + +#include +#include + +namespace { + +[[nodiscard]] core::Station makeFmStation(const char* name, std::uint32_t khz, + unsigned slot) +{ + auto label = core::StationName::tryFrom(name); + auto frequency = core::FrequencyKHz::tryFromKhz(khz); + auto preset = core::PresetSlot::tryFrom(slot); + return core::Station(*label, core::TunerBand::Fm, 0U, std::nullopt, + std::nullopt, *frequency, *preset); +} + +[[nodiscard]] int runDuplicateRejectionTest() +{ + core::StationList list; + if (auto added = list.add(makeFmStation("Radio 2", 88500U, 1U)); !added) { + std::cerr << "first add failed\n"; + return EXIT_FAILURE; + } + if (list.add(makeFmStation("Radio 2 dup", 88500U, 2U))) { + std::cerr << "expected duplicate tune target rejection\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runJsonRoundTripTest() +{ + core::StationList list; + if (auto added = list.add(makeFmStation("Jazz FM", 101500U, 3U)); !added) { + std::cerr << "add failed\n"; + return EXIT_FAILURE; + } + + const std::string json = core::serializeStationListJson(list); + auto parsed = core::parseStationListJson(json); + if (!parsed || parsed->stations().size() != 1U) { + std::cerr << "round-trip parse failed\n"; + return EXIT_FAILURE; + } + if (parsed->stations()[0U].name().value() != "Jazz FM") { + std::cerr << "name mismatch after round-trip\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runParseStationJsonTest() +{ + const auto station = core::parseStationJson( + R"({"name":"BBC","band":"dab","dab_freq_index":12,"dab_service_id":42,"dab_component_id":1})"); + if (!station || station->band() != core::TunerBand::Dab + || station->dabFreqIndex() != 12U) { + std::cerr << "dab station parse failed\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runDuplicateRejectionTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runJsonRoundTripTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runParseStationJsonTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp index d742ccc..518ae6d 100644 --- a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp +++ b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp @@ -119,9 +119,60 @@ public: [[nodiscard]] std::expected sendCommand( core::Bt1035AtCommand command); + /** + * @brief enterPairingMode — make module discoverable (AT+PAIR=1). + * + * @dname enterPairingMode + * @return Ok on OK response, or Bt1035Error. + * @pubstate writes UART; module advertises until paired or leavePairingMode(). + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected enterPairingMode(); + + /** + * @brief leavePairingMode — stop discoverable advertising (AT+PAIR=0). + * + * @dname leavePairingMode + * @return Ok on OK response, or Bt1035Error. + * @pubstate writes UART. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected leavePairingMode(); + + /** + * @brief queryA2dpState — read current A2DP link state (AT+A2DPSTAT). + * + * @dname queryA2dpState + * @return Parsed A2DP state on success, or Bt1035Error. + * @pubstate writes UART; parses +A2DPSTAT from the reply. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected + queryA2dpState(); + + /** + * @brief disconnectA2dp — release the active A2DP session (AT+A2DPDISC). + * + * @dname disconnectA2dp + * @return Ok on OK response, or Bt1035Error. + * @pubstate writes UART. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected disconnectA2dp(); + private: [[nodiscard]] std::expected ensureBooted() const; [[nodiscard]] std::expected runInitSequence(); + [[nodiscard]] std::expected transmitAndCollect( + std::string_view commandLine); [[nodiscard]] std::expected transmitAndExpectOk( std::string_view commandLine); diff --git a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp index d7e05e7..eca32d8 100644 --- a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp +++ b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp @@ -64,7 +64,7 @@ std::expected Bt1035Driver::ensureBooted() const return {}; } -std::expected Bt1035Driver::transmitAndExpectOk( +std::expected Bt1035Driver::transmitAndCollect( std::string_view commandLine) { const int written = uart_write_bytes(static_cast(uartPort_), @@ -89,7 +89,7 @@ std::expected Bt1035Driver::transmitAndExpectOk( const core::Bt1035AtResponseKind kind = core::parseBt1035AtResponse(accumulated); if (kind == core::Bt1035AtResponseKind::Ok) { - return {}; + return accumulated; } if (kind == core::Bt1035AtResponseKind::Error) { return std::unexpected(Bt1035Error::AtError); @@ -100,6 +100,15 @@ std::expected Bt1035Driver::transmitAndExpectOk( return std::unexpected(Bt1035Error::AtTimeout); } +std::expected Bt1035Driver::transmitAndExpectOk( + std::string_view commandLine) +{ + if (auto collected = transmitAndCollect(commandLine); collected) { + return {}; + } + return std::unexpected(collected.error()); +} + std::expected Bt1035Driver::sendCommand( core::Bt1035AtCommand command) { @@ -109,6 +118,49 @@ std::expected Bt1035Driver::sendCommand( return transmitAndExpectOk(core::buildBt1035AtLine(command)); } +std::expected Bt1035Driver::enterPairingMode() +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + return sendCommand(core::Bt1035AtCommand::PairDiscoverable); +} + +std::expected Bt1035Driver::leavePairingMode() +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + return sendCommand(core::Bt1035AtCommand::PairHidden); +} + +std::expected Bt1035Driver::queryA2dpState() +{ + if (auto ready = ensureBooted(); !ready) { + return std::unexpected(ready.error()); + } + + auto response = + transmitAndCollect(core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpStat)); + if (!response) { + return std::unexpected(response.error()); + } + + auto parsed = core::parseBt1035A2dpStatResponse(*response); + if (!parsed) { + return std::unexpected(Bt1035Error::UnexpectedResponse); + } + return *parsed; +} + +std::expected Bt1035Driver::disconnectA2dp() +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + return sendCommand(core::Bt1035AtCommand::A2dpDisconnect); +} + std::expected Bt1035Driver::runInitSequence() { for (const core::Bt1035AtCommand command : core::bootInitSequence()) { diff --git a/Software/components/net/CMakeLists.txt b/Software/components/net/CMakeLists.txt index 6e67f3d..c88ccad 100644 --- a/Software/components/net/CMakeLists.txt +++ b/Software/components/net/CMakeLists.txt @@ -7,7 +7,7 @@ idf_component_register( "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 tuner audio + REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server tuner audio bluetooth station bt1035 ) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/net/include/net/NetBootstrap.hpp b/Software/components/net/include/net/NetBootstrap.hpp index 1cf473e..18893f9 100644 --- a/Software/components/net/include/net/NetBootstrap.hpp +++ b/Software/components/net/include/net/NetBootstrap.hpp @@ -32,6 +32,14 @@ namespace audio { class AudioService; } // namespace audio +namespace bluetooth { +class BluetoothService; +} // namespace bluetooth + +namespace station { +class StationService; +} // namespace station + namespace tuner { class TunerService; } // namespace tuner @@ -66,7 +74,8 @@ public: */ [[nodiscard]] static std::expected start(core::ISecureStore& store, tuner::TunerService& tuner, - audio::AudioService& audio, + audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, + station::StationService& stations, core::CompanionChipStatus companionChips); NetBootstrap(const NetBootstrap&) = delete; diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index 6d8c587..df66dcc 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -30,6 +30,14 @@ namespace audio { class AudioService; } // namespace audio +namespace bluetooth { +class BluetoothService; +} // namespace bluetooth + +namespace station { +class StationService; +} // namespace station + namespace tuner { class TunerService; } // namespace tuner @@ -53,6 +61,8 @@ struct HttpRouteContext { core::ISecureStore* store; ///< Secure store for Wi-Fi provisioning. tuner::TunerService* tuner; ///< Tuner service for tuner REST routes. audio::AudioService* audio; ///< Audio service for ADAU1701 REST routes. + bluetooth::BluetoothService* bluetooth; ///< Bluetooth pairing REST routes. + station::StationService* stations; ///< Preset list REST routes. core::CompanionChipStatus companionChips; ///< Boot flags for /api/health. }; @@ -127,8 +137,10 @@ public: * @param netState Active network phase exposed to handlers. * @param tuner Tuner service for the tuner REST routes. * @param audio Audio service for the audio REST routes. + * @param bluetooth Bluetooth service for pairing REST routes. + * @param stations Station preset service for list REST routes. * @return Ok on success, or NetError::HttpServerStartFailed. - * @pubstate writes server_, store_, netState_, tuner_, and audio_ on success. + * @pubstate writes server_, store_, netState_, and service pointers on success. * * @author Michele Bigi * @date 2026-07-06 @@ -136,6 +148,8 @@ public: [[nodiscard]] std::expected start( core::ISecureStore& store, NetState netState, tuner::TunerService& tuner, audio::AudioService& audio, + bluetooth::BluetoothService& bluetooth, + station::StationService& stations, core::CompanionChipStatus companionChips); private: @@ -144,6 +158,8 @@ private: NetState netState_; tuner::TunerService* tuner_; audio::AudioService* audio_; + bluetooth::BluetoothService* bluetooth_; + station::StationService* stations_; HttpRouteContext routeContext_; }; diff --git a/Software/components/net/src/NetBootstrap.cpp b/Software/components/net/src/NetBootstrap.cpp index dda0bf5..bab6b2f 100644 --- a/Software/components/net/src/NetBootstrap.cpp +++ b/Software/components/net/src/NetBootstrap.cpp @@ -24,6 +24,8 @@ #include "esp_wifi.h" #include "nvs_flash.h" #include "audio/AudioService.hpp" +#include "bluetooth/BluetoothService.hpp" +#include "station/StationService.hpp" #include "tuner/TunerService.hpp" namespace net { @@ -101,6 +103,8 @@ constexpr char kTag[] = "NetBootstrap"; [[nodiscard]] std::expected startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, audio::AudioService& audio, + bluetooth::BluetoothService& bluetooth, + station::StationService& stations, core::CompanionChipStatus companionChips) { esp_netif_create_default_wifi_ap(); @@ -113,7 +117,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, SetupWebServer webServer; if (auto webResult = webServer.start(store, NetState::SoftApSetup, tuner, audio, - companionChips); + bluetooth, stations, companionChips); !webResult) { return std::unexpected(webResult.error()); } @@ -137,6 +141,8 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, [[nodiscard]] std::expected startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, audio::AudioService& audio, + bluetooth::BluetoothService& bluetooth, + station::StationService& stations, core::CompanionChipStatus companionChips) { auto credsResult = store.loadWifiCredentials(); @@ -155,7 +161,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, SetupWebServer webServer; if (auto webResult = webServer.start(store, NetState::StaConnected, tuner, audio, - companionChips); + bluetooth, stations, companionChips); !webResult) { return std::unexpected(webResult.error()); } @@ -170,6 +176,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, std::expected NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, audio::AudioService& audio, + bluetooth::BluetoothService& bluetooth, + station::StationService& stations, core::CompanionChipStatus companionChips) { if (auto platform = initPlatform(); !platform) { @@ -181,14 +189,16 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, } if (store.hasWifiCredentials()) { - auto staResult = startStaMode(store, tuner, audio, companionChips); + auto staResult = startStaMode(store, tuner, audio, bluetooth, stations, + companionChips); if (staResult) { return staResult; } ESP_LOGW(kTag, "STA join failed — falling back to setup SoftAP"); } - return startSetupMode(store, tuner, audio, companionChips); + return startSetupMode(store, tuner, audio, bluetooth, stations, + companionChips); } NetBootstrap::NetBootstrap(std::optional softAp, diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 2cdbbca..8c13833 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -20,17 +20,22 @@ #include "core/AudioProfile.hpp" #include "core/AudioProfileJson.hpp" +#include "core/BluetoothJson.hpp" #include "core/CompanionChipStatus.hpp" #include "core/FirmwareVersion.hpp" #include "core/HealthStatus.hpp" #include "core/HealthStatusJson.hpp" #include "core/ParseError.hpp" #include "core/SeekDirection.hpp" +#include "core/StationListJson.hpp" #include "core/StoreError.hpp" #include "core/TunerJson.hpp" #include "core/WifiProvisionJson.hpp" #include "tuner/TunerService.hpp" #include "audio/AudioService.hpp" +#include "bluetooth/BluetoothService.hpp" +#include "station/StationService.hpp" +#include "bt1035/Bt1035Error.hpp" #include "esp_http_server.h" #include "esp_log.h" @@ -45,7 +50,7 @@ namespace net { namespace { constexpr char kTag[] = "SetupWebServer"; -constexpr char kFirmwareVersion[] = "0.6.0"; +constexpr char kFirmwareVersion[] = "0.7.0"; constexpr unsigned kRebootDelaySec = 3; extern const uint8_t www_index_html_gz_start[] asm( @@ -131,6 +136,24 @@ void rebootTask(void* arg) return "tuner_error"; } +[[nodiscard]] const char* bt1035ErrorToken(bt1035::Bt1035Error error) noexcept +{ + switch (error) { + case bt1035::Bt1035Error::NotBooted: + return "not_booted"; + case bt1035::Bt1035Error::AtTimeout: + return "at_timeout"; + case bt1035::Bt1035Error::AtError: + return "at_error"; + case bt1035::Bt1035Error::UnexpectedResponse: + return "unexpected_response"; + case bt1035::Bt1035Error::ResetFailed: + case bt1035::Bt1035Error::UartInitFailed: + return "driver_failed"; + } + return "bluetooth_error"; +} + template [[nodiscard]] bool readRequestBodyImpl(httpd_req_t* req, std::array& body) { @@ -624,6 +647,193 @@ esp_err_t wifiPostHandler(httpd_req_t* req) return ESP_OK; } +esp_err_t bluetoothStatusGetHandler(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); + } + auto status = ctx->bluetooth->refreshStatus(); + if (!status) { + const std::string json = + core::serializeBluetoothErrorJson(bt1035ErrorToken(status.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()); + } + const std::string json = core::serializeBluetoothStatusJson(*status); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + +esp_err_t bluetoothPairPostHandler(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 (auto result = ctx->bluetooth->startPairing(); !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\":\"pairing\"}", 20); +} + +esp_err_t bluetoothPairStopPostHandler(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 (auto result = ctx->bluetooth->stopPairing(); !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\":\"idle\"}", 17); +} + +esp_err_t bluetoothDisconnectPostHandler(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 (auto result = ctx->bluetooth->disconnect(); !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\":\"disconnected\"}", 24); +} + +esp_err_t stationsGetHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->stations == nullptr) { + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_send(req, nullptr, 0); + } + const std::string json = + core::serializeStationListJson(ctx->stations->list()); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + +esp_err_t stationsPostHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->stations == nullptr) { + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_send(req, nullptr, 0); + } + std::array body{}; + if (!readRequestBody(req, body)) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, nullptr, 0); + } + auto parsed = core::parseStationJson(body.data()); + if (!parsed) { + const std::string json = + core::serializeStationListErrorJson(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 added = ctx->stations->add(std::move(*parsed)); !added) { + const std::string json = core::serializeStationListErrorJson( + core::stationListErrorToken(added.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()); + } + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, "{\"status\":\"saved\"}", 18); +} + +esp_err_t stationsRemovePostHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->stations == nullptr) { + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_send(req, nullptr, 0); + } + std::array body{}; + if (!readRequestBody(req, body)) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, nullptr, 0); + } + auto parsed = core::parseStationRemoveJson(body.data()); + if (!parsed) { + const std::string json = + core::serializeStationListErrorJson(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 removed = ctx->stations->removeAt(parsed->index); !removed) { + const std::string json = core::serializeStationListErrorJson( + core::stationListErrorToken(removed.error())); + httpd_resp_set_status(req, "404 Not Found"); + 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\":\"removed\"}", 20); +} + +esp_err_t stationsTunePostHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->stations == nullptr || ctx->tuner == nullptr) { + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_send(req, nullptr, 0); + } + std::array body{}; + if (!readRequestBody(req, body)) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, nullptr, 0); + } + auto parsed = core::parseStationRemoveJson(body.data()); + if (!parsed) { + const std::string json = + core::serializeStationListErrorJson(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 (parsed->index >= ctx->stations->list().stations().size()) { + const std::string json = + core::serializeStationListErrorJson("not_found"); + httpd_resp_set_status(req, "404 Not Found"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + if (auto tuned = ctx->stations->tuneToIndex(parsed->index); !tuned) { + const std::string json = + core::serializeTunerErrorJson(tunerErrorToken(tuned.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\":\"tuned\"}", 18); +} + } // namespace SetupWebServer::SetupWebServer() @@ -632,7 +842,9 @@ SetupWebServer::SetupWebServer() , netState_(NetState::Uninitialized) , tuner_(nullptr) , audio_(nullptr) - , routeContext_{nullptr, nullptr, nullptr, {}} + , bluetooth_(nullptr) + , stations_(nullptr) + , routeContext_{nullptr, nullptr, nullptr, nullptr, nullptr, {}} { } @@ -642,6 +854,8 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept , netState_(other.netState_) , tuner_(other.tuner_) , audio_(other.audio_) + , bluetooth_(other.bluetooth_) + , stations_(other.stations_) , routeContext_(other.routeContext_) { other.server_ = nullptr; @@ -649,7 +863,9 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept other.netState_ = NetState::Uninitialized; other.tuner_ = nullptr; other.audio_ = nullptr; - other.routeContext_ = {nullptr, nullptr, nullptr, {}}; + other.bluetooth_ = nullptr; + other.stations_ = nullptr; + other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}}; } SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept @@ -663,13 +879,17 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept netState_ = other.netState_; tuner_ = other.tuner_; audio_ = other.audio_; + bluetooth_ = other.bluetooth_; + stations_ = other.stations_; routeContext_ = other.routeContext_; other.server_ = nullptr; other.store_ = nullptr; other.netState_ = NetState::Uninitialized; other.tuner_ = nullptr; other.audio_ = nullptr; - other.routeContext_ = {nullptr, nullptr, nullptr, {}}; + other.bluetooth_ = nullptr; + other.stations_ = nullptr; + other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}}; } return *this; } @@ -680,7 +900,7 @@ SetupWebServer::~SetupWebServer() httpd_stop(server_); server_ = nullptr; } - routeContext_ = {nullptr, nullptr, nullptr, {}}; + routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}}; } std::expected SetupWebServer::start( @@ -688,6 +908,8 @@ std::expected SetupWebServer::start( NetState netState, tuner::TunerService& tuner, audio::AudioService& audio, + bluetooth::BluetoothService& bluetooth, + station::StationService& stations, core::CompanionChipStatus companionChips) { if (server_ != nullptr) { @@ -698,9 +920,13 @@ std::expected SetupWebServer::start( netState_ = netState; tuner_ = &tuner; audio_ = &audio; + bluetooth_ = &bluetooth; + stations_ = &stations; routeContext_.store = &store; routeContext_.tuner = &tuner; routeContext_.audio = &audio; + routeContext_.bluetooth = &bluetooth; + routeContext_.stations = &stations; routeContext_.companionChips = companionChips; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); @@ -709,7 +935,7 @@ std::expected SetupWebServer::start( if (httpd_start(&server_, &config) != ESP_OK) { ESP_LOGE(kTag, "httpd_start failed"); - routeContext_ = {nullptr, nullptr, nullptr, {}}; + routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}}; return std::unexpected(NetError::HttpServerStartFailed); } @@ -819,6 +1045,70 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &audioBassEnhanceUri); + const httpd_uri_t bluetoothStatusUri = { + .uri = "/api/bluetooth/status", + .method = HTTP_GET, + .handler = bluetoothStatusGetHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &bluetoothStatusUri); + + const httpd_uri_t bluetoothPairUri = { + .uri = "/api/bluetooth/pair", + .method = HTTP_POST, + .handler = bluetoothPairPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &bluetoothPairUri); + + const httpd_uri_t bluetoothPairStopUri = { + .uri = "/api/bluetooth/pair/stop", + .method = HTTP_POST, + .handler = bluetoothPairStopPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &bluetoothPairStopUri); + + const httpd_uri_t bluetoothDisconnectUri = { + .uri = "/api/bluetooth/disconnect", + .method = HTTP_POST, + .handler = bluetoothDisconnectPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &bluetoothDisconnectUri); + + const httpd_uri_t stationsGetUri = { + .uri = "/api/stations", + .method = HTTP_GET, + .handler = stationsGetHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &stationsGetUri); + + const httpd_uri_t stationsPostUri = { + .uri = "/api/stations", + .method = HTTP_POST, + .handler = stationsPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &stationsPostUri); + + const httpd_uri_t stationsRemoveUri = { + .uri = "/api/stations/remove", + .method = HTTP_POST, + .handler = stationsRemovePostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &stationsRemoveUri); + + const httpd_uri_t stationsTuneUri = { + .uri = "/api/stations/tune", + .method = HTTP_POST, + .handler = stationsTunePostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &stationsTuneUri); + ESP_LOGI(kTag, "HTTP server listening on port 80"); return {}; } diff --git a/Software/components/net/www/index.html b/Software/components/net/www/index.html index 3162dde..94daa25 100644 --- a/Software/components/net/www/index.html +++ b/Software/components/net/www/index.html @@ -198,6 +198,33 @@

+
+

Presets

+

Saved DAB/FM stations (persisted in NVS).

+
    + + + + + +

    +
    + +
    +

    Bluetooth

    +

    FSC-BT1035 aptX transmitter — pair headphones or speakers.

    +
    No status yet.
    +
    + + +
    +
    + + +
    +

    +
    +

    Audio

    ADAU1701 mixer and master volume (safeload at runtime).

    @@ -521,6 +548,149 @@ .catch(function () { showMsg(msg, "Reset request failed.", false); }); }); + function formatBtStatus(s) { + return [ + "Booted: " + s.booted, + "Pairing: " + s.pairing, + "A2DP: " + s.a2dp + ].join("\n"); + } + + function refreshBluetooth() { + var msg = document.getElementById("bt-msg"); + return fetch("/api/bluetooth/status") + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (res) { + if (res.ok && res.d.booted != null) { + document.getElementById("bt-status").textContent = formatBtStatus(res.d); + showMsg(msg, "", true); + } else { + showMsg(msg, "BT error: " + (res.d.reason || "unknown"), false); + } + }) + .catch(function () { showMsg(msg, "BT request failed.", false); }); + } + + document.getElementById("bt-refresh").addEventListener("click", refreshBluetooth); + document.getElementById("bt-pair").addEventListener("click", function () { + var msg = document.getElementById("bt-msg"); + fetch("/api/bluetooth/pair", { method: "POST" }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (res) { + if (res.ok) { + showMsg(msg, "Discoverable — pair from your phone.", true); + refreshBluetooth(); + } else { + showMsg(msg, "Pair failed: " + (res.d.reason || "unknown"), false); + } + }) + .catch(function () { showMsg(msg, "Pair request failed.", false); }); + }); + document.getElementById("bt-stop-pair").addEventListener("click", function () { + var msg = document.getElementById("bt-msg"); + fetch("/api/bluetooth/pair/stop", { method: "POST" }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (res) { + if (res.ok) { + showMsg(msg, "Pairing stopped.", true); + refreshBluetooth(); + } else { + showMsg(msg, "Stop failed: " + (res.d.reason || "unknown"), false); + } + }) + .catch(function () { showMsg(msg, "Stop request failed.", false); }); + }); + document.getElementById("bt-disconnect").addEventListener("click", function () { + var msg = document.getElementById("bt-msg"); + fetch("/api/bluetooth/disconnect", { method: "POST" }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (res) { + if (res.ok) { + showMsg(msg, "A2DP disconnected.", true); + refreshBluetooth(); + } else { + showMsg(msg, "Disconnect failed: " + (res.d.reason || "unknown"), false); + } + }) + .catch(function () { showMsg(msg, "Disconnect request failed.", false); }); + }); + + function renderPresets(data) { + var list = document.getElementById("preset-list"); + list.innerHTML = ""; + (data.stations || []).forEach(function (s, idx) { + var li = document.createElement("li"); + var label = s.name + " (" + s.band + ")"; + if (s.fm_frequency_khz) label += " " + s.fm_frequency_khz + " kHz"; + if (s.dab_freq_index != null) label += " idx " + s.dab_freq_index; + li.textContent = label; + var tuneBtn = document.createElement("button"); + tuneBtn.textContent = "Tune"; + tuneBtn.addEventListener("click", function () { + fetch("/api/stations/tune", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index: idx }) + }).then(function () { refreshTunerStatus(); }); + }); + var delBtn = document.createElement("button"); + delBtn.textContent = "Del"; + delBtn.className = "secondary"; + delBtn.addEventListener("click", function () { + fetch("/api/stations/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index: idx }) + }).then(function () { loadPresets(); }); + }); + li.appendChild(tuneBtn); + li.appendChild(delBtn); + list.appendChild(li); + }); + } + + function loadPresets() { + return fetch("/api/stations") + .then(function (r) { return r.json(); }) + .then(function (d) { renderPresets(d); }) + .catch(function () {}); + } + + document.getElementById("save-preset").addEventListener("click", function () { + var msg = document.getElementById("preset-msg"); + var name = document.getElementById("preset-name").value.trim(); + if (!name) { + showMsg(msg, "Enter a name.", false); + return; + } + var band = document.getElementById("band").value; + var body = { name: name, band: band }; + if (band === "fm") { + body.fm_frequency_khz = parseInt(document.getElementById("fm-khz").value, 10); + } else { + body.dab_freq_index = parseInt(document.getElementById("dab-index").value, 10); + } + var slotVal = document.getElementById("preset-slot").value; + if (slotVal) body.preset_slot = parseInt(slotVal, 10); + fetch("/api/stations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (res) { + if (res.ok && res.d.status === "saved") { + showMsg(msg, "Preset saved.", true); + loadPresets(); + } else { + showMsg(msg, "Save failed: " + (res.d.reason || "unknown"), false); + } + }) + .catch(function () { showMsg(msg, "Save request failed.", false); }); + }); + + refreshBluetooth(); + loadPresets(); loadAudioProfile(); diff --git a/Software/components/net/www/index.html.gz b/Software/components/net/www/index.html.gz index fb5718e..63bef04 100644 Binary files a/Software/components/net/www/index.html.gz and b/Software/components/net/www/index.html.gz differ diff --git a/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp b/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp index 4b3e761..7bcb368 100644 --- a/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp +++ b/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp @@ -100,6 +100,17 @@ public: */ [[nodiscard]] std::expected clearWifiCredentials() override; + + [[nodiscard]] bool hasStationList() const override; + + [[nodiscard]] std::expected + saveStationListJson(std::string_view json) override; + + [[nodiscard]] std::expected + loadStationListJson() const override; + + [[nodiscard]] std::expected + clearStationList() override; }; } // namespace secure_store diff --git a/Software/components/secure_store/src/NvsSecureStore.cpp b/Software/components/secure_store/src/NvsSecureStore.cpp index 21ed219..db8d59d 100644 --- a/Software/components/secure_store/src/NvsSecureStore.cpp +++ b/Software/components/secure_store/src/NvsSecureStore.cpp @@ -30,6 +30,7 @@ namespace { constexpr char kNamespace[] = "digiradio"; constexpr char kSsidKey[] = "wifi_ssid"; constexpr char kPasswordKey[] = "wifi_pwd"; +constexpr char kStationListKey[] = "station_list"; } // namespace bool NvsSecureStore::hasWifiCredentials() const @@ -148,4 +149,84 @@ std::expected NvsSecureStore::clearWifiCredentials() return {}; } +bool NvsSecureStore::hasStationList() 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, kStationListKey, nullptr, &len); + nvs_close(handle); + + return err == ESP_OK && len > 1; +} + +std::expected +NvsSecureStore::saveStationListJson(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, kStationListKey, 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 +NvsSecureStore::loadStationListJson() 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, kStationListKey, nullptr, &len) != ESP_OK + || len == 0) { + nvs_close(handle); + return std::unexpected(core::StoreError::NotFound); + } + + std::vector buffer(len); + if (nvs_get_str(handle, kStationListKey, buffer.data(), &len) != ESP_OK) { + nvs_close(handle); + return std::unexpected(core::StoreError::IoFailed); + } + nvs_close(handle); + + return std::string(buffer.data()); +} + +std::expected NvsSecureStore::clearStationList() +{ + 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, kStationListKey); + 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); + } + return {}; +} + } // namespace secure_store diff --git a/Software/components/services/bluetooth/CMakeLists.txt b/Software/components/services/bluetooth/CMakeLists.txt new file mode 100644 index 0000000..9d22358 --- /dev/null +++ b/Software/components/services/bluetooth/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "src/BluetoothService.cpp" + INCLUDE_DIRS "include" + REQUIRES core bt1035 +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp b/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp new file mode 100644 index 0000000..1cc853d --- /dev/null +++ b/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp @@ -0,0 +1,102 @@ +/** + * @file BluetoothService.hpp + * @brief Intent-level Bluetooth API for HTTP and UI. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "bt1035/Bt1035Driver.hpp" +#include "bt1035/Bt1035Error.hpp" +#include "core/BluetoothJson.hpp" + +#include + +namespace bluetooth { + +/** + * @brief BluetoothService — pairing and A2DP status for the web API. + * + * @dname BluetoothService + * @return n/a (type) + * @pubstate Borrows bt1035::Bt1035Driver for the process lifetime. Tracks + * whether discoverable mode was requested via startPairing(). + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class BluetoothService { +public: + /** + * @brief BluetoothService — bind to the BT1035 driver. + * + * @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 + */ + explicit BluetoothService(bt1035::Bt1035Driver& driver); + + /** + * @brief refreshStatus — read module boot, pairing, and 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 + */ + [[nodiscard]] std::expected + refreshStatus(); + + /** + * @brief startPairing — enter discoverable mode (AT+PAIR=1). + * + * @dname startPairing + * @return Ok on success, or Bt1035Error. + * @pubstate sets pairingActive_ on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected startPairing(); + + /** + * @brief stopPairing — leave discoverable mode (AT+PAIR=0). + * + * @dname stopPairing + * @return Ok on success, or Bt1035Error. + * @pubstate clears pairingActive_ on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected stopPairing(); + + /** + * @brief disconnect — release the active A2DP link (AT+A2DPDISC). + * + * @dname disconnect + * @return Ok on success, or Bt1035Error. + * @pubstate delegates to driver. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected disconnect(); + +private: + bt1035::Bt1035Driver& driver_; + bool pairingActive_; +}; + +} // namespace bluetooth diff --git a/Software/components/services/bluetooth/src/BluetoothService.cpp b/Software/components/services/bluetooth/src/BluetoothService.cpp new file mode 100644 index 0000000..a81c5e2 --- /dev/null +++ b/Software/components/services/bluetooth/src/BluetoothService.cpp @@ -0,0 +1,68 @@ +/** + * @file BluetoothService.cpp + * @brief BluetoothService implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "bluetooth/BluetoothService.hpp" + +namespace bluetooth { + +BluetoothService::BluetoothService(bt1035::Bt1035Driver& driver) + : driver_(driver) + , pairingActive_(false) +{ +} + +std::expected +BluetoothService::refreshStatus() +{ + core::BluetoothStatus status{ + .booted = driver_.isBooted(), + .pairing = pairingActive_, + .a2dpState = core::Bt1035A2dpState::Standby, + }; + + if (!status.booted) { + return status; + } + + auto a2dp = driver_.queryA2dpState(); + if (!a2dp) { + return std::unexpected(a2dp.error()); + } + status.a2dpState = *a2dp; + return status; +} + +std::expected BluetoothService::startPairing() +{ + if (auto result = driver_.enterPairingMode(); !result) { + return result; + } + pairingActive_ = true; + return {}; +} + +std::expected BluetoothService::stopPairing() +{ + if (auto result = driver_.leavePairingMode(); !result) { + return result; + } + pairingActive_ = false; + return {}; +} + +std::expected BluetoothService::disconnect() +{ + return driver_.disconnectA2dp(); +} + +} // namespace bluetooth diff --git a/Software/components/services/station/CMakeLists.txt b/Software/components/services/station/CMakeLists.txt new file mode 100644 index 0000000..e67624a --- /dev/null +++ b/Software/components/services/station/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "src/StationService.cpp" + INCLUDE_DIRS "include" + REQUIRES core tuner +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/services/station/include/station/StationService.hpp b/Software/components/services/station/include/station/StationService.hpp new file mode 100644 index 0000000..045f4bd --- /dev/null +++ b/Software/components/services/station/include/station/StationService.hpp @@ -0,0 +1,127 @@ +/** + * @file StationService.hpp + * @brief Preset list orchestration with NVS persistence and tuner recall. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ISecureStore.hpp" +#include "core/Station.hpp" +#include "core/StationList.hpp" +#include "core/StationListError.hpp" +#include "core/StoreError.hpp" +#include "core/TunerError.hpp" +#include "tuner/TunerService.hpp" + +#include +#include + +namespace station { + +/** + * @brief StationService — CRUD presets and tune via TunerService. + * + * @dname StationService + * @return n/a (type) + * @pubstate Owns in-memory StationList mirrored to ISecureStore on mutation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class StationService { +public: + /** + * @brief StationService — bind store and tuner for the process lifetime. + * + * @dname StationService + * @param store Secure persistence for preset JSON. + * @param tuner Tuner orchestration for recall. + * @pubstate stores references; list empty until loadFromStore(). + * + * @author Michele Bigi + * @date 2026-07-06 + */ + StationService(core::ISecureStore& store, tuner::TunerService& tuner); + + /** + * @brief loadFromStore — hydrate list from NVS (empty when absent). + * + * @dname loadFromStore + * @return Ok on success, or StoreError / parse failure as IoFailed. + * @pubstate replaces list_ from NVS JSON when present. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected loadFromStore(); + + /** + * @brief list — read the in-memory preset collection. + * + * @dname list + * @return Const reference to list_. + * @pubstate reads list_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] const core::StationList& list() const noexcept; + + /** + * @brief add — append a preset and persist. + * + * @dname add + * @param station Validated preset from JSON boundary. + * @return Ok on success, or StationListError / StoreError. + * @pubstate mutates list_ and NVS on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected add( + core::Station station); + + /** + * @brief removeAt — delete preset by index and persist. + * + * @dname removeAt + * @param index Zero-based list position. + * @return Ok on success, or StationListError / StoreError. + * @pubstate mutates list_ and NVS on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected removeAt( + std::size_t index); + + /** + * @brief tuneToIndex — recall a saved preset on the tuner. + * + * @dname tuneToIndex + * @param index Zero-based list position. + * @return Ok on success, or StationListError / TunerError. + * @pubstate delegates tune/play to tuner_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected tuneToIndex( + std::size_t index); + +private: + [[nodiscard]] std::expected persist(); + + core::ISecureStore& store_; + tuner::TunerService& tuner_; + core::StationList list_; +}; + +} // namespace station diff --git a/Software/components/services/station/src/StationService.cpp b/Software/components/services/station/src/StationService.cpp new file mode 100644 index 0000000..4a82c10 --- /dev/null +++ b/Software/components/services/station/src/StationService.cpp @@ -0,0 +1,109 @@ +/** + * @file StationService.cpp + * @brief StationService implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "station/StationService.hpp" + +#include "core/StationListJson.hpp" +#include "core/TunerBand.hpp" + +namespace station { + +StationService::StationService(core::ISecureStore& store, + tuner::TunerService& tuner) + : store_(store) + , tuner_(tuner) +{ +} + +std::expected StationService::loadFromStore() +{ + if (!store_.hasStationList()) { + list_ = core::StationList{}; + return {}; + } + + auto blob = store_.loadStationListJson(); + if (!blob) { + return std::unexpected(blob.error()); + } + + auto parsed = core::parseStationListJson(*blob); + if (!parsed) { + return std::unexpected(core::StoreError::InvalidData); + } + list_ = std::move(*parsed); + return {}; +} + +const core::StationList& StationService::list() const noexcept +{ + return list_; +} + +std::expected StationService::persist() +{ + return store_.saveStationListJson(core::serializeStationListJson(list_)); +} + +std::expected StationService::add( + core::Station station) +{ + if (auto added = list_.add(std::move(station)); !added) { + return added; + } + if (auto saved = persist(); !saved) { + list_.removeAt(list_.stations().size() - 1U); + return std::unexpected(core::StationListError::Full); + } + return {}; +} + +std::expected StationService::removeAt( + std::size_t index) +{ + if (auto removed = list_.removeAt(index); !removed) { + return removed; + } + if (auto saved = persist(); !saved) { + return std::unexpected(core::StationListError::NotFound); + } + return {}; +} + +std::expected StationService::tuneToIndex( + std::size_t index) +{ + if (index >= list_.stations().size()) { + return std::unexpected(core::TunerError::NotBooted); + } + + const core::Station& station = list_.stations()[index]; + if (station.band() == core::TunerBand::Fm) { + if (!station.fmFrequency()) { + return std::unexpected(core::TunerError::WrongBand); + } + return tuner_.tuneFm(*station.fmFrequency()); + } + + if (auto tuned = tuner_.tuneDab(station.dabFreqIndex()); !tuned) { + return tuned; + } + + if (station.dabServiceId() && station.dabComponentId()) { + return tuner_.playDabService(*station.dabServiceId(), + *station.dabComponentId()); + } + return {}; +} + +} // namespace station diff --git a/Software/docs/TODO.md b/Software/docs/TODO.md new file mode 100644 index 0000000..abca173 --- /dev/null +++ b/Software/docs/TODO.md @@ -0,0 +1,62 @@ +# DigiRadio — consolidated TODO (audit 2026-07-06) + +Firmware **0.7.0** after BT1035 pairing + Slice 4 presets. This list +cross-checks code, manual, API, UI, and `instructions.md`. + +## Done in this slice + +| Area | Status | +|------|--------| +| BT1035 pairing AT (`AT+PAIR`, `AT+A2DPSTAT`, `AT+A2DPDISC`) | Done | +| `BluetoothService` + `/api/bluetooth/*` + UI | Done | +| `Station`, `StationList`, NVS persistence | Done | +| `StationService` + `/api/stations/*` + UI | Done | +| Host tests (10/10 green) | Done | +| Manual: `ch-api`, `ch-bt1035`, `ch-classes` | Done | + +## High priority (next) + +1. **Device flash / HIL** — verify Si4684, ADAU1701, BT1035 on hardware; pairing with real headphones; preset recall across reboot. +2. **DAB preset save from UI** — “Save current tune target” stores ensemble index only; capture `service_id` / `component_id` from the last played DAB service (needs UI state or tuner cache). +3. **FM band switch UX** — Si4684 boots DAB; FM tune may reload FM image; document/limit band changes in UI (auto-reload or explicit band selector). +4. **NVS encryption** — enable `nvs_keys` partition for production (Wi-Fi, presets, future user creds). +5. **Slice 8 integration** — unify tuner + audio + presets in a single “now playing” model; source selection (DAB / FM / BT Line-In). + +## Medium priority + +6. **IBtModule interface** — AGENTS mentions it; driver is used directly today. Add when a second BT module or host fake is needed. +7. **BT1035 extended AT** — `AT+NAME`, `AT+PLIST`, `AT+A2DPCONN`, event-driven `+PAIRED` / `+A2DPDEV` (UART listener task). +8. **Station list reorder API** — `StationList::move()` exists in core; no HTTP route yet. +9. **EQ UI completeness** — web UI exposes master/mixer/enhance; per-band EQ editing not in UI (API supports full profile PUT). +10. **Si4684 RSQ / scan UX** — driver + HTTP largely done; polish seek, service list refresh, signal display on UI. +11. **User credentials** — `ISecureStore` extension for login (out of scope until product needs it). + +## Documentation / tooling + +12. **Overleaf sync** — GitHub is source of truth; root `docs/` symlink can break Overleaf push; compile from `Software/docs/manual/manual.tex`. +13. **Firmware version single source** — `0.7.0` in `SetupWebServer.cpp`; align `FirmwareVersion` / health test constants if desired. +14. **Doxygen pass** — run `doxygen Doxyfile` on CI host with ESP-IDF toolchain. + +## Low priority / ideas + +15. Physical preset buttons → map GPIO to `StationService::tuneToIndex` by `PresetSlot`. +16. OTA updates, mDNS hostname (`digiradio.local`), HTTPS on LAN. +17. aptX license note (Feasycom) — commercial firmware variant if needed. + +## Test gaps + +| Missing test | Layer | +|--------------|-------| +| `BluetoothService` with fake driver | Host (needs `IBtModule` or inject interface) | +| `StationService` + fake `ISecureStore` | Host | +| `NvsSecureStore` station round-trip | Target / integration | +| BT1035 UART HIL | Hardware-only, marked separate | + +## Roadmap alignment (`instructions.md`) + +| Slice | Item | State | +|-------|------|-------| +| 4 | Station list + persistence + UI | **Done** (basic CRUD + tune) | +| 5 | Si4684 tuning / RSQ / DAB properties | Mostly done; UI polish open | +| 7 | BT1035 pairing | **Done** (discover + A2DP stat/disconnect) | +| 8 | TunerService + AudioService E2E | Partial — health chips OK; unified UX open | diff --git a/Software/docs/manual/ch-api.tex b/Software/docs/manual/ch-api.tex index 59f3b7a..12c8bd6 100644 --- a/Software/docs/manual/ch-api.tex +++ b/Software/docs/manual/ch-api.tex @@ -36,7 +36,7 @@ Returns a health-check DTO serialised by \begin{drnote}[Response schema] \begin{drcode}[JSON] -{"status":"ok","fw":"0.6.0", +{"status":"ok","fw":"0.7.0", "chips":{"si4684":true,"adau1701":true,"bt1035":true}} \end{drcode} \begin{itemize} @@ -252,11 +252,70 @@ Adjusts bass emphasis via a PEQ overlay on bands 1--2 (100\,Hz / 400\,Hz). Request and response schema match \texttt{POST /api/audio/stereo-enhance} (Section~\ref{sec:api-audio-stereo-enhance}). +% ------------------------------------------------------------------ +% Bluetooth (Slice 7) +% ------------------------------------------------------------------ + +\subsection{\texttt{GET /api/bluetooth/status}} +\label{sec:api-bluetooth-status} + +Returns BT1035 boot flag, whether discoverable mode was requested, and the +last read A2DP state. Serialised by +\texttt{core::serializeBluetoothStatusJson()}. + +\begin{drnote}[Response schema] +\begin{drcode}[JSON] +{"booted":true,"pairing":false,"a2dp":"standby"} +\end{drcode} +\end{drnote} + +\subsection{\texttt{POST /api/bluetooth/pair}} +\label{sec:api-bluetooth-pair} + +Enters discoverable mode (\texttt{AT+PAIR=1}). Success: +\texttt{\{"status":"pairing"\}}. + +\subsection{\texttt{POST /api/bluetooth/pair/stop}} +\label{sec:api-bluetooth-pair-stop} + +Leaves discoverable mode (\texttt{AT+PAIR=0}). Success: +\texttt{\{"status":"idle"\}}. + +\subsection{\texttt{POST /api/bluetooth/disconnect}} +\label{sec:api-bluetooth-disconnect} + +Releases the current A2DP session (\texttt{AT+A2DPDISC}). + +% ------------------------------------------------------------------ +% Station presets (Slice 4) +% ------------------------------------------------------------------ + +\subsection{\texttt{GET /api/stations}} +\label{sec:api-stations-get} + +Returns presets serialised by \texttt{core::serializeStationListJson()}. + +\subsection{\texttt{POST /api/stations}} +\label{sec:api-stations-post} + +Adds one preset (\texttt{core::parseStationJson()}); persists via +\texttt{ISecureStore::saveStationListJson()}. + +\subsection{\texttt{POST /api/stations/remove}} +\label{sec:api-stations-remove} + +Removes a preset by list index (\texttt{\{"index":0\}}). + +\subsection{\texttt{POST /api/stations/tune}} +\label{sec:api-stations-tune} + +Recalls a preset via \texttt{station::StationService::tuneToIndex()}. + \section{Boot and network state machine} \label{sec:api-boot-flow} -At boot, \texttt{net::NetBootstrap::start(store, tuner, audio)} consults -\texttt{ISecureStore::hasWifiCredentials()}: +At boot, \texttt{net::NetBootstrap::start(store, tuner, audio, bluetooth, +stations)} consults \texttt{ISecureStore::hasWifiCredentials()}: \begin{enumerate} \item \textbf{Credentials present} --- create STA netif, connect via @@ -275,7 +334,8 @@ This explicit \texttt{enum class NetState} replaces ad-hoc flags; see Wi-Fi credentials are stored in NVS namespace \texttt{digiradio}, keys \texttt{wifi\_ssid} and \texttt{wifi\_pwd}. Audio profiles (non-secret) use the same namespace, key \texttt{audio\_profile\_json}, via -\texttt{secure\_store::NvsAudioProfileStore}. Passwords are wrapped in +\texttt{secure\_store::NvsAudioProfileStore}. Station presets use key +\texttt{station\_list} (JSON blob). Passwords are wrapped in \texttt{core::Secret} in RAM and are never logged or returned by the API. \begin{drcaution}[Encryption at rest] diff --git a/Software/docs/manual/ch-bt1035.tex b/Software/docs/manual/ch-bt1035.tex index b500a62..4f6e397 100644 --- a/Software/docs/manual/ch-bt1035.tex +++ b/Software/docs/manual/ch-bt1035.tex @@ -131,8 +131,8 @@ only the Line-In bring-up required for the wired audio path. Host-testable command strings and OK/ERROR classification \\ \bottomrule \end{tabular} - \caption{BT1035 control stack (Slice~6). Future HTTP/UI for pairing will - sit above the driver without changing the init contract.} + \caption{BT1035 control stack (Slice~6--7). Pairing and A2DP status are + exposed on \texttt{/api/bluetooth/*} via \texttt{BluetoothService}.} \label{tab:bt1035-stack} \end{table} @@ -149,9 +149,14 @@ updating \texttt{core::Bt1035AtCommand}, the manual, and a host test. \midrule \texttt{Ping} & \texttt{AT} & Verify UART link \\ \texttt{AuxLineIn} & \texttt{AT+AUXCFG=1} & Enable Line-In from ADAU \\ + \texttt{PairDiscoverable} & \texttt{AT+PAIR=1} & Enter discoverable mode \\ + \texttt{PairHidden} & \texttt{AT+PAIR=0} & Leave discoverable mode \\ + \texttt{A2dpStat} & \texttt{AT+A2DPSTAT} & Read link state \\ + \texttt{A2dpDisconnect} & \texttt{AT+A2DPDISC} & Release A2DP session \\ \bottomrule \end{tabular} - \caption{AT commands used at boot (\texttt{core::bootInitSequence()}).} + \caption{Enumerated AT commands (\texttt{core::Bt1035AtCommand}). Boot + uses Ping + AuxLineIn only; pairing commands are runtime.} \label{tab:bt1035-at} \end{table} @@ -170,6 +175,10 @@ updating \texttt{core::Bt1035AtCommand}, the manual, and a host test. \texttt{boot()} & Reset, UART init, run \texttt{bootInitSequence()} \\ \texttt{isBooted()} & \texttt{true} after Line-In init succeeded \\ \texttt{sendCommand(cmd)} & Send one typed command, expect OK \\ + \texttt{enterPairingMode()} & \texttt{AT+PAIR=1} \\ + \texttt{leavePairingMode()} & \texttt{AT+PAIR=0} \\ + \texttt{queryA2dpState()} & \texttt{AT+A2DPSTAT}, parse \texttt{+A2DPSTAT=} \\ + \texttt{disconnectA2dp()} & \texttt{AT+A2DPDISC} \\ \bottomrule \end{tabular} \caption{Public driver API.} @@ -232,7 +241,7 @@ if (auto r = bt.sendCommand(core::Bt1035AtCommand::AuxLineIn); !r) { \begin{itemize} \item Feasycom FSC-BT1035 AT command manual (vendor) --- full command set - for pairing, name, and codec options not yet wrapped by firmware. + for name, paired-device list, and reconnect not yet wrapped by firmware. \item Chapter~\ref{ch:hardware} --- pin map and I\textsuperscript{2}S routing. \item Chapter~\ref{ch:adau1701} --- DSP output that feeds the module. \item \texttt{components/core/test/bt1035\_at\_test.cpp} --- init sequence test. diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index db70c9a..47e01ee 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -56,10 +56,13 @@ configured SoftAP. Imperative shell; no business logic. \section{SetupWebServer}\label{cls:SetupWebServer} Minimal HTTP server: gzipped setup UI, \texttt{GET /api/health}, -\texttt{POST /api/wifi}, tuner routes (\texttt{/api/tuner/*}), and audio -routes (\texttt{/api/audio/*}). JSON parsing and serialisation delegate to -the pure core; credentials persist via \texttt{ISecureStore}; tuner via -\texttt{tuner::TunerService}; audio via \texttt{audio::AudioService}. +\texttt{POST /api/wifi}, tuner routes (\texttt{/api/tuner/*}), audio +routes (\texttt{/api/audio/*}), Bluetooth (\texttt{/api/bluetooth/*}), and +station presets (\texttt{/api/stations/*}). JSON parsing and serialisation +delegate to the pure core; credentials persist via \texttt{ISecureStore}; +tuner via \texttt{tuner::TunerService}; audio via +\texttt{audio::AudioService}; Bluetooth via \texttt{bluetooth::BluetoothService}; +presets via \texttt{station::StationService}. \section{NetBootstrap}\label{cls:NetBootstrap} Owns network resources for setup or STA mode. @@ -222,3 +225,32 @@ Validated at the HTTP boundary by \texttt{core::parseEnhanceLevelJson()}. \section{NvsAudioProfileStore}\label{cls:NvsAudioProfileStore} \texttt{IAudioProfileStore} implementation storing serialised \texttt{AudioProfile} JSON in NVS namespace \texttt{digiradio}. + +\section{StationName}\label{cls:StationName} +Strong type for a preset label (1--32 bytes). Parsed at the HTTP boundary +via \texttt{StationName::tryFrom()}. + +\section{PresetSlot}\label{cls:PresetSlot} +Optional hardware preset button index (1--20). Validated by +\texttt{PresetSlot::tryFrom()}. + +\section{Station}\label{cls:Station} +Immutable preset value: name, band (DAB/FM), tune coordinates, optional +preset slot. FM presets carry \texttt{FrequencyKHz}; DAB presets carry +ensemble index and optional service/component ids for playback. + +\section{StationList}\label{cls:StationList} +Pure-domain ordered collection (max 20 entries) with add/remove/move and +duplicate tune-target rejection. Persisted as JSON through +\texttt{ISecureStore}. + +\section{StationService}\label{cls:StationService} +Application service loading/saving presets from NVS and recalling them via +\texttt{TunerService}. Exposed on \texttt{/api/stations/*} and the Presets +web UI section. + +\section{BluetoothService}\label{cls:BluetoothService} +Application service for BT1035 pairing and A2DP status +(Chapter~\ref{ch:bt1035}). Delegates to \texttt{Bt1035Driver}; tracks +whether discoverable mode was requested. Exposed on +\texttt{/api/bluetooth/*}. diff --git a/Software/instructions.md b/Software/instructions.md index 7725320..92de8ce 100644 --- a/Software/instructions.md +++ b/Software/instructions.md @@ -47,10 +47,12 @@ Repository: https://github.com/manvalan/DigiRadio 1. **Walking skeleton** — done (Slice 1). 2. **Secure store + Wi-Fi provisioning** — done (Slice 2). 3. **Companion-chip boot** — done (Slice 3): Si4684 DAB + ADAU1701 RAM load. -4. Station/frequency list model + persistence + UI. +4. **Station/frequency list model + persistence + UI** — done (fw 0.7.0): + `Station`/`StationList`, NVS key `station_list`, `/api/stations/*`, Presets UI. 5. Si4684 tuning: RSQ, station list, DAB properties. 6. **ADAU1701 runtime** — done (Slice 5): safeload EQ + input mixer + HTTP. -7. FSC-BT1035 driver: AT init (incl. `AT+AUXCFG=1`), audio out. +7. **FSC-BT1035 driver** — init + pairing (AT+PAIR, A2DP stat/disconnect) done; + name/plist/reconnect AT still open. 8. Integration: TunerService + AudioService end to end. ## Slice 1 — Walking skeleton (complete) diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index ead264f..ddaa7fe 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -3,5 +3,5 @@ idf_component_register( "main.cpp" "hardware_bootstrap.cpp" INCLUDE_DIRS "." - REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 + REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station ) diff --git a/Software/main/hardware_bootstrap.cpp b/Software/main/hardware_bootstrap.cpp index cf3393d..40ded40 100644 --- a/Software/main/hardware_bootstrap.cpp +++ b/Software/main/hardware_bootstrap.cpp @@ -124,4 +124,9 @@ core::CompanionChipStatus HardwareBootstrap::companionChipStatus() noexcept }; } +bt1035::Bt1035Driver& HardwareBootstrap::bt1035Driver() +{ + return gBt1035; +} + } // namespace hardware diff --git a/Software/main/hardware_bootstrap.hpp b/Software/main/hardware_bootstrap.hpp index 81626bb..1373482 100644 --- a/Software/main/hardware_bootstrap.hpp +++ b/Software/main/hardware_bootstrap.hpp @@ -14,6 +14,8 @@ #include "core/CompanionChipStatus.hpp" +#include "bt1035/Bt1035Driver.hpp" + #include namespace audio { @@ -108,6 +110,20 @@ public: * @date 2026-07-06 */ [[nodiscard]] static core::CompanionChipStatus companionChipStatus() noexcept; + + /** + * @brief bt1035Driver — borrow the BT1035 driver after boot. + * + * @dname bt1035Driver + * @return Reference to the static Bt1035Driver instance. + * @pubstate reads static storage initialised by boot(). + * + * Valid only after a successful boot() call in the same process. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static bt1035::Bt1035Driver& bt1035Driver(); }; } // namespace hardware diff --git a/Software/main/main.cpp b/Software/main/main.cpp index 38964b6..097ac4b 100644 --- a/Software/main/main.cpp +++ b/Software/main/main.cpp @@ -12,8 +12,10 @@ */ #include "hardware_bootstrap.hpp" +#include "bluetooth/BluetoothService.hpp" #include "net/NetBootstrap.hpp" #include "secure_store/NvsSecureStore.hpp" +#include "station/StationService.hpp" #include "tuner/TunerService.hpp" #include "esp_log.h" @@ -47,7 +49,7 @@ void heartbeatTask(void* arg) */ extern "C" void app_main() { - ESP_LOGI(kTag, "DigiRadio firmware boot — Slice 6"); + ESP_LOGI(kTag, "DigiRadio firmware boot — Slice 7"); auto hwResult = hardware::HardwareBootstrap::boot(); if (!hwResult) { @@ -60,8 +62,17 @@ extern "C" void app_main() static secure_store::NvsSecureStore store; + static station::StationService stationService(store, tunerService); + if (auto loaded = stationService.loadFromStore(); !loaded) { + ESP_LOGW(kTag, "station list load failed"); + } + + static bluetooth::BluetoothService bluetoothService( + hardware::HardwareBootstrap::bt1035Driver()); + auto netResult = net::NetBootstrap::start( store, tunerService, hardware::HardwareBootstrap::audioService(), + bluetoothService, stationService, hardware::HardwareBootstrap::companionChipStatus()); if (!netResult) { ESP_LOGE(kTag, "network bootstrap failed");