diff --git a/Software/README.md b/Software/README.md index a31c175..0827db8 100644 --- a/Software/README.md +++ b/Software/README.md @@ -2,8 +2,8 @@ Open-source Hi-Fi DAB+/FM receiver firmware for the ESP32-S3. -**Status:** fw **0.7.1** — CI on `main` (host tests, Doxygen, manual sync); -companion-chip boot, REST API, Bluetooth pairing, station presets. +**Status:** fw **0.8.0** — RDS/DLS now-playing metadata in tuner status; +preset reorder, CI on `main` (host tests, Doxygen, manual sync). See [`docs/TODO.md`](docs/TODO.md) for the agent task list. ## Quick start @@ -39,7 +39,7 @@ Manual PDF (design + HTTP API + class reference): cd docs/manual && latexmk -lualatex manual.tex ``` -## HTTP API (fw 0.7.0) +## HTTP API (fw 0.8.0) | Method | Path | Purpose | |--------|------|---------| @@ -61,6 +61,7 @@ cd docs/manual && latexmk -lualatex manual.tex | GET | `/api/stations` | List saved presets | | POST | `/api/stations` | Add preset | | POST | `/api/stations/remove` | Remove preset by index | +| POST | `/api/stations/reorder` | Move preset (`from`/`to` indices) | | POST | `/api/stations/tune` | Recall preset on tuner | Full schemas, error tokens, and boot flow: [`docs/manual/ch-api.tex`](docs/manual/ch-api.tex). diff --git a/Software/components/core/CMakeLists.txt b/Software/components/core/CMakeLists.txt index e77856b..8632dff 100644 --- a/Software/components/core/CMakeLists.txt +++ b/Software/components/core/CMakeLists.txt @@ -29,6 +29,9 @@ idf_component_register( "src/StationList.cpp" "src/StationListJson.cpp" "src/BluetoothJson.cpp" + "src/BroadcastLabel.cpp" + "src/RdsMetadataAccumulator.cpp" + "src/DabDynamicLabelAccumulator.cpp" INCLUDE_DIRS "include" ) diff --git a/Software/components/core/include/core/BroadcastLabel.hpp b/Software/components/core/include/core/BroadcastLabel.hpp new file mode 100644 index 0000000..ec1eab3 --- /dev/null +++ b/Software/components/core/include/core/BroadcastLabel.hpp @@ -0,0 +1,69 @@ +/** + * @file BroadcastLabel.hpp + * @brief Validated on-air label text (RDS PS/RT, DAB DLS). + * + * 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 +#include +#include +#include + +namespace core { + +/** + * @brief BroadcastLabel — trimmed UTF-8 label from chip byte buffers. + * + * @dname BroadcastLabel + * @return n/a (type) + * @pubstate Owns label_ (immutable after construction). + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class BroadcastLabel { +public: + /** Maximum DAB DLS length supported in status JSON. */ + static constexpr std::size_t kMaxLength = 128; + + /** + * @brief tryFromChipBytes — parse a NUL- or space-padded chip buffer. + * + * @dname tryFromChipBytes + * @param raw Raw bytes from RDS or DAB data service (may contain NUL). + * @return BroadcastLabel when non-empty after trim, otherwise nullopt. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static std::optional + tryFromChipBytes(std::string_view raw); + + /** + * @brief value — read the validated label text. + * + * @dname value + * @return Trimmed label string. + * @pubstate reads label_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::string_view value() const noexcept; + +private: + explicit BroadcastLabel(std::string label); + + std::string label_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/DabDynamicLabelAccumulator.hpp b/Software/components/core/include/core/DabDynamicLabelAccumulator.hpp new file mode 100644 index 0000000..c20191b --- /dev/null +++ b/Software/components/core/include/core/DabDynamicLabelAccumulator.hpp @@ -0,0 +1,86 @@ +/** + * @file DabDynamicLabelAccumulator.hpp + * @brief Reassemble DAB DLS payloads into one dynamic label string. + * + * 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/BroadcastLabel.hpp" + +#include +#include +#include +#include +#include + +namespace core { + +/** + * @brief DabDynamicLabelAccumulator — segmented DAB DLS label buffer. + * + * @dname DabDynamicLabelAccumulator + * @return n/a (type) + * @pubstate Holds partial DLS segments until the label can be read out. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class DabDynamicLabelAccumulator { +public: + /** + * @brief reset — discard accumulated DLS segments. + * + * @dname reset + * @return n/a + * @pubstate clears buffer and segment flags. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + void reset() noexcept; + + /** + * @brief applySegment — ingest one DLS payload block from the driver. + * + * @dname applySegment + * @param segmentIndex Zero-based segment index from the chip. + * @param segmentCount Total segments advertised for this label. + * @param payload Raw UTF-8 bytes for this segment. + * @return n/a + * @pubstate copies payload into buffer_ at the computed offset. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + void applySegment(std::uint16_t segmentIndex, + std::uint16_t segmentCount, + std::span payload) noexcept; + + /** + * @brief label — read the assembled dynamic label when complete. + * + * @dname label + * @return Trimmed DLS text when all segments were received. + * @pubstate reads buffer_ and segmentValid_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::optional label() const; + +private: + static constexpr std::size_t kMaxSegments = 32U; + + std::array buffer_{}; + std::array segmentValid_{}; + std::uint16_t expectedSegments_{0U}; +}; + +} // namespace core diff --git a/Software/components/core/include/core/RdsMetadataAccumulator.hpp b/Software/components/core/include/core/RdsMetadataAccumulator.hpp new file mode 100644 index 0000000..2048377 --- /dev/null +++ b/Software/components/core/include/core/RdsMetadataAccumulator.hpp @@ -0,0 +1,102 @@ +/** + * @file RdsMetadataAccumulator.hpp + * @brief Accumulate FM RDS program name and radiotext from raw groups. + * + * 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/BroadcastLabel.hpp" + +#include +#include +#include + +namespace core { + +/** + * @brief RdsMetadataAccumulator — stateful RDS PS/RT decoder (IEC 62106). + * + * @dname RdsMetadataAccumulator + * @return n/a (type) + * @pubstate Holds partial PS and RT buffers until complete groups arrive. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class RdsMetadataAccumulator { +public: + /** + * @brief reset — clear accumulated PS and radiotext. + * + * @dname reset + * @return n/a + * @pubstate clears internal buffers. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + void reset() noexcept; + + /** + * @brief applyGroup — ingest one RDS group (blocks A–D). + * + * @dname applyGroup + * @param blockA RDS block A (PI code). + * @param blockB RDS block B (group type and address). + * @param blockC RDS block C (text payload). + * @param blockD RDS block D (text payload for RT). + * @return n/a + * @pubstate updates PS/RT buffers for group types 0A and 2A. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + void applyGroup(std::uint16_t blockA, + std::uint16_t blockB, + std::uint16_t blockC, + std::uint16_t blockD) noexcept; + + /** + * @brief programName — read the accumulated 8-character PS name. + * + * @dname programName + * @return Trimmed PS label when all four segments were received. + * @pubstate reads psBuffer_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::optional programName() const; + + /** + * @brief radiotext — read the accumulated 64-character RT string. + * + * @dname radiotext + * @return Trimmed radiotext when at least one RT segment was received. + * @pubstate reads rtBuffer_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::optional radiotext() const; + +private: + static constexpr std::size_t kPsSegments = 4U; + static constexpr std::size_t kRtSegments = 16U; + + std::array psBuffer_{}; + std::array psSegmentValid_{}; + std::array rtBuffer_{}; + std::array rtSegmentValid_{}; + bool rtAbFlag_{false}; + bool rtAbInitialized_{false}; +}; + +} // namespace core diff --git a/Software/components/core/include/core/StationListError.hpp b/Software/components/core/include/core/StationListError.hpp index 7011e44..9464f36 100644 --- a/Software/components/core/include/core/StationListError.hpp +++ b/Software/components/core/include/core/StationListError.hpp @@ -29,6 +29,7 @@ enum class StationListError { Full, NotFound, SlotInUse, + PersistFailed, }; } // namespace core diff --git a/Software/components/core/include/core/StationListJson.hpp b/Software/components/core/include/core/StationListJson.hpp index 55f4fa5..1e17fca 100644 --- a/Software/components/core/include/core/StationListJson.hpp +++ b/Software/components/core/include/core/StationListJson.hpp @@ -38,6 +38,21 @@ struct StationRemoveRequest { std::size_t index; ///< Zero-based list index to delete. }; +/** + * @brief StationReorderRequest — parsed POST /api/stations/reorder body. + * + * @dname StationReorderRequest + * @return n/a (type) + * @pubstate Plain DTO filled by parseStationReorderJson. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct StationReorderRequest { + std::size_t fromIndex; ///< Current list position. + std::size_t toIndex; ///< Target list position. +}; + /** * @brief serializeStationListJson — serialise all presets for GET /api/stations. * @@ -93,6 +108,20 @@ parseStationListJson(std::string_view json); [[nodiscard]] std::expected parseStationRemoveJson(std::string_view json); +/** + * @brief parseStationReorderJson — validate POST /api/stations/reorder body. + * + * @dname parseStationReorderJson + * @param json Untrusted request body with from/to index fields. + * @return StationReorderRequest on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected +parseStationReorderJson(std::string_view json); + /** * @brief serializeStationListErrorJson — serialise a station API error. * diff --git a/Software/components/core/include/core/TunerStatus.hpp b/Software/components/core/include/core/TunerStatus.hpp index 1a09365..6ddab37 100644 --- a/Software/components/core/include/core/TunerStatus.hpp +++ b/Software/components/core/include/core/TunerStatus.hpp @@ -12,6 +12,7 @@ */ #pragma once +#include "core/BroadcastLabel.hpp" #include "core/FrequencyKHz.hpp" #include "core/TunerBand.hpp" @@ -56,10 +57,15 @@ struct TunerStatus { std::optional dabFreqIndex; ///< Current Band III ensemble index. std::optional dabFicQuality; ///< FIC quality 0–100 when DAB. std::optional dabCnrDb; ///< CNR in dB when DAB. + std::optional dabPlayingServiceId; ///< Last played DAB service. + std::optional dabPlayingComponentId; ///< Last played DAB component. std::optional fmFrequency; ///< Tuned FM centre frequency. std::optional fmRssiDbuV; ///< FM RSSI in dBµV. std::optional fmSnrDb; ///< FM SNR in dB. std::optional fmStereo; ///< FM stereo pilot detected. + std::optional fmStationName; ///< FM RDS program service name. + std::optional fmRadiotext; ///< FM RDS radiotext (RT). + std::optional dabDynamicLabel; ///< DAB DLS now-playing label. }; } // namespace core diff --git a/Software/components/core/src/BroadcastLabel.cpp b/Software/components/core/src/BroadcastLabel.cpp new file mode 100644 index 0000000..ec985bb --- /dev/null +++ b/Software/components/core/src/BroadcastLabel.cpp @@ -0,0 +1,58 @@ +/** + * @file BroadcastLabel.cpp + * @brief BroadcastLabel 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/BroadcastLabel.hpp" + +namespace core { + +namespace { + +[[nodiscard]] std::string trimChipPadding(std::string_view raw) +{ + std::size_t end = raw.size(); + if (end > BroadcastLabel::kMaxLength) { + end = BroadcastLabel::kMaxLength; + } + while (end > 0U && (raw[end - 1U] == '\0' || raw[end - 1U] == ' ')) { + --end; + } + std::size_t start = 0U; + while (start < end && raw[start] == ' ') { + ++start; + } + return std::string(raw.substr(start, end - start)); +} + +} // namespace + +std::optional BroadcastLabel::tryFromChipBytes( + std::string_view raw) +{ + const std::string trimmed = trimChipPadding(raw); + if (trimmed.empty()) { + return std::nullopt; + } + return BroadcastLabel(trimmed); +} + +BroadcastLabel::BroadcastLabel(std::string label) + : label_(std::move(label)) +{ +} + +std::string_view BroadcastLabel::value() const noexcept +{ + return label_; +} + +} // namespace core diff --git a/Software/components/core/src/DabDynamicLabelAccumulator.cpp b/Software/components/core/src/DabDynamicLabelAccumulator.cpp new file mode 100644 index 0000000..3482e28 --- /dev/null +++ b/Software/components/core/src/DabDynamicLabelAccumulator.cpp @@ -0,0 +1,70 @@ +/** + * @file DabDynamicLabelAccumulator.cpp + * @brief DabDynamicLabelAccumulator 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/DabDynamicLabelAccumulator.hpp" + +#include +#include + +namespace core { + +void DabDynamicLabelAccumulator::reset() noexcept +{ + buffer_.fill('\0'); + segmentValid_.fill(false); + expectedSegments_ = 0U; +} + +void DabDynamicLabelAccumulator::applySegment( + std::uint16_t segmentIndex, + std::uint16_t segmentCount, + std::span payload) noexcept +{ + if (segmentCount == 0U || segmentCount > kMaxSegments) { + return; + } + if (segmentIndex >= segmentCount) { + return; + } + if (expectedSegments_ != segmentCount) { + reset(); + expectedSegments_ = segmentCount; + } + + const std::size_t offset = + static_cast(segmentIndex) + * static_cast(payload.size()); + if (offset >= buffer_.size()) { + return; + } + const std::size_t copyLen = + std::min(payload.size(), buffer_.size() - offset); + std::memcpy(buffer_.data() + offset, payload.data(), copyLen); + segmentValid_[segmentIndex] = true; +} + +std::optional DabDynamicLabelAccumulator::label() const +{ + if (expectedSegments_ == 0U) { + return std::nullopt; + } + for (std::uint16_t i = 0U; i < expectedSegments_; ++i) { + if (!segmentValid_[i]) { + return std::nullopt; + } + } + return BroadcastLabel::tryFromChipBytes( + std::string_view(buffer_.data(), buffer_.size())); +} + +} // namespace core diff --git a/Software/components/core/src/RdsMetadataAccumulator.cpp b/Software/components/core/src/RdsMetadataAccumulator.cpp new file mode 100644 index 0000000..8ef84b2 --- /dev/null +++ b/Software/components/core/src/RdsMetadataAccumulator.cpp @@ -0,0 +1,100 @@ +/** + * @file RdsMetadataAccumulator.cpp + * @brief RdsMetadataAccumulator 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/RdsMetadataAccumulator.hpp" + +namespace core { + +void RdsMetadataAccumulator::reset() noexcept +{ + psBuffer_.fill('\0'); + psSegmentValid_.fill(false); + rtBuffer_.fill('\0'); + rtSegmentValid_.fill(false); + rtAbFlag_ = false; + rtAbInitialized_ = false; +} + +void RdsMetadataAccumulator::applyGroup(std::uint16_t blockA, + std::uint16_t blockB, + std::uint16_t blockC, + std::uint16_t blockD) noexcept +{ + (void)blockA; + const std::uint8_t groupType = + static_cast((blockB >> 12U) & 0x0FU); + + if (groupType == 0U) { + const std::size_t index = + static_cast((blockB >> 1U) & 0x03U); + if (index < kPsSegments) { + psBuffer_[index * 2U] = + static_cast((blockC >> 8U) & 0xFFU); + psBuffer_[index * 2U + 1U] = static_cast(blockC & 0xFFU); + psSegmentValid_[index] = true; + } + return; + } + + if (groupType == 2U) { + const bool abFlag = (blockB & 0x10U) != 0U; + if (!rtAbInitialized_ || abFlag != rtAbFlag_) { + rtBuffer_.fill('\0'); + rtSegmentValid_.fill(false); + rtAbFlag_ = abFlag; + rtAbInitialized_ = true; + } + const std::size_t index = + static_cast(blockB & 0x0FU); + if (index < kRtSegments) { + rtBuffer_[index * 4U] = + static_cast((blockC >> 8U) & 0xFFU); + rtBuffer_[index * 4U + 1U] = + static_cast(blockC & 0xFFU); + rtBuffer_[index * 4U + 2U] = + static_cast((blockD >> 8U) & 0xFFU); + rtBuffer_[index * 4U + 3U] = + static_cast(blockD & 0xFFU); + rtSegmentValid_[index] = true; + } + } +} + +std::optional RdsMetadataAccumulator::programName() const +{ + for (bool valid : psSegmentValid_) { + if (!valid) { + return std::nullopt; + } + } + return BroadcastLabel::tryFromChipBytes( + std::string_view(psBuffer_.data(), psBuffer_.size())); +} + +std::optional RdsMetadataAccumulator::radiotext() const +{ + bool any = false; + for (bool valid : rtSegmentValid_) { + if (valid) { + any = true; + break; + } + } + if (!any) { + return std::nullopt; + } + return BroadcastLabel::tryFromChipBytes( + std::string_view(rtBuffer_.data(), rtBuffer_.size())); +} + +} // namespace core diff --git a/Software/components/core/src/StationListJson.cpp b/Software/components/core/src/StationListJson.cpp index 8e6b7ad..a46287c 100644 --- a/Software/components/core/src/StationListJson.cpp +++ b/Software/components/core/src/StationListJson.cpp @@ -240,6 +240,21 @@ parseStationRemoveJson(std::string_view json) return StationRemoveRequest{.index = static_cast(index)}; } +std::expected +parseStationReorderJson(std::string_view json) +{ + unsigned long fromIndex = 0; + unsigned long toIndex = 0; + if (!extractJsonUint(json, "from", fromIndex) + || !extractJsonUint(json, "to", toIndex)) { + return std::unexpected(ParseError::MissingField); + } + return StationReorderRequest{ + .fromIndex = static_cast(fromIndex), + .toIndex = static_cast(toIndex), + }; +} + std::string serializeStationListErrorJson(const char* reason) { std::ostringstream out; @@ -258,6 +273,8 @@ const char* stationListErrorToken(StationListError error) noexcept return "not_found"; case StationListError::SlotInUse: return "slot_in_use"; + case StationListError::PersistFailed: + return "persist_failed"; } return "station_error"; } diff --git a/Software/components/core/src/TunerJson.cpp b/Software/components/core/src/TunerJson.cpp index 3328c97..96adbef 100644 --- a/Software/components/core/src/TunerJson.cpp +++ b/Software/components/core/src/TunerJson.cpp @@ -58,6 +58,29 @@ namespace { return band == TunerBand::Fm ? "fm" : "dab"; } +void appendJsonString(std::ostringstream& out, std::string_view value) +{ + out << '"'; + for (char c : value) { + if (c == '"' || c == '\\') { + out << '\\'; + } + out << c; + } + out << '"'; +} + +void appendOptionalLabel(std::ostringstream& out, + std::string_view key, + const std::optional& label) +{ + if (!label) { + return; + } + out << ",\"" << key << "\":"; + appendJsonString(out, label->value()); +} + } // namespace std::string serializeTunerStatusJson(const TunerStatus& status) @@ -82,6 +105,15 @@ std::string serializeTunerStatusJson(const TunerStatus& status) if (status.dabCnrDb) { out << ",\"cnr_db\":" << static_cast(*status.dabCnrDb); } + if (status.dabPlayingServiceId) { + out << ",\"playing_service_id\":" + << *status.dabPlayingServiceId; + } + if (status.dabPlayingComponentId) { + out << ",\"playing_component_id\":" + << *status.dabPlayingComponentId; + } + appendOptionalLabel(out, "dynamic_label", status.dabDynamicLabel); out << "},\"fm\":null"; } else { out << ",\"fm\":{"; @@ -100,6 +132,8 @@ std::string serializeTunerStatusJson(const TunerStatus& status) if (status.fmStereo) { out << ",\"stereo\":" << (*status.fmStereo ? "true" : "false"); } + appendOptionalLabel(out, "station_name", status.fmStationName); + appendOptionalLabel(out, "radiotext", status.fmRadiotext); out << "},\"dab\":null"; } out << '}'; @@ -116,18 +150,15 @@ std::string serializeTunerServicesJson( out << ','; } const auto& s = services[i]; - out << "{\"service_id\":" << s.serviceId - << ",\"component_id\":" << s.componentId << ",\"label\":\""; - for (char c : s.label) { - if (c == '\0') { - break; - } - if (c == '"' || c == '\\') { - out << '\\'; - } - out << c; + std::string_view labelText(s.label.data(), s.label.size()); + const std::size_t nul = labelText.find('\0'); + if (nul != std::string_view::npos) { + labelText = labelText.substr(0U, nul); } - out << "\"}"; + out << "{\"service_id\":" << s.serviceId + << ",\"component_id\":" << s.componentId << ",\"label\":"; + appendJsonString(out, labelText); + out << "}"; } out << "]}"; return out.str(); diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index 0ba5eaa..d662d38 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -41,6 +41,9 @@ add_library(digiradio_core STATIC "${CORE_SRC_DIR}/StationList.cpp" "${CORE_SRC_DIR}/StationListJson.cpp" "${CORE_SRC_DIR}/BluetoothJson.cpp" + "${CORE_SRC_DIR}/BroadcastLabel.cpp" + "${CORE_SRC_DIR}/RdsMetadataAccumulator.cpp" + "${CORE_SRC_DIR}/DabDynamicLabelAccumulator.cpp" ) target_include_directories(digiradio_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/../include" @@ -78,6 +81,18 @@ 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_service_test station_service_test.cpp) +target_include_directories(station_service_test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/station/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/tuner/include" +) +target_sources(station_service_test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/station/src/StationService.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/tuner/src/TunerService.cpp" +) +target_link_libraries(station_service_test PRIVATE digiradio_core) +add_test(NAME station_service_test COMMAND station_service_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) @@ -85,3 +100,7 @@ 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) + +add_executable(broadcast_metadata_test broadcast_metadata_test.cpp) +target_link_libraries(broadcast_metadata_test PRIVATE digiradio_core) +add_test(NAME broadcast_metadata_test COMMAND broadcast_metadata_test) diff --git a/Software/components/core/test/broadcast_metadata_test.cpp b/Software/components/core/test/broadcast_metadata_test.cpp new file mode 100644 index 0000000..fca2b6e --- /dev/null +++ b/Software/components/core/test/broadcast_metadata_test.cpp @@ -0,0 +1,115 @@ +/** + * @file broadcast_metadata_test.cpp + * @brief Host tests for broadcast metadata parsing (RDS / DLS). + * + * 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/BroadcastLabel.hpp" +#include "core/DabDynamicLabelAccumulator.hpp" +#include "core/RdsMetadataAccumulator.hpp" + +#include +#include +#include +#include + +namespace { + +[[nodiscard]] int runChipLabelTrimTest() +{ + const auto label = core::BroadcastLabel::tryFromChipBytes(" RAI 1 \0\0"); + if (!label || label->value() != "RAI 1") { + std::cerr << "chip label trim failed\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runRdsProgramNameTest() +{ + core::RdsMetadataAccumulator acc; + acc.applyGroup(0U, 0x0000U, 0x5445U, 0U); + acc.applyGroup(0U, 0x0002U, 0x5354U, 0U); + acc.applyGroup(0U, 0x0004U, 0x2020U, 0U); + acc.applyGroup(0U, 0x0006U, 0x2020U, 0U); + + const auto name = acc.programName(); + if (!name || name->value() != "TEST") { + std::cerr << "RDS PS parse failed\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runRdsRadiotextTest() +{ + core::RdsMetadataAccumulator acc; + acc.applyGroup(0U, 0x2000U, 0x4E6FU, 0x7720U); + + const auto text = acc.radiotext(); + if (!text || text->value() != "Now") { + std::cerr << "RDS RT parse failed: " + << (text ? std::string(text->value()) : "nullopt") << '\n'; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runDabDynamicLabelTest() +{ + core::DabDynamicLabelAccumulator acc; + const std::vector payload = {'L', 'i', 'v', 'e', ' ', 'D', 'J'}; + acc.applySegment(0U, 1U, payload); + + const auto label = acc.label(); + if (!label || label->value() != "Live DJ") { + std::cerr << "DLS single-segment parse failed\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runDabDynamicLabelSegmentsTest() +{ + core::DabDynamicLabelAccumulator acc; + const std::vector part1 = {'A', 'B', 'C'}; + const std::vector part2 = {'D', 'E', 'F'}; + acc.applySegment(0U, 2U, part1); + acc.applySegment(1U, 2U, part2); + + const auto label = acc.label(); + if (!label || label->value() != "ABCDEF") { + std::cerr << "DLS multi-segment parse failed\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runChipLabelTrimTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runRdsProgramNameTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runRdsRadiotextTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runDabDynamicLabelTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runDabDynamicLabelSegmentsTest() != EXIT_SUCCESS) { + 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 index 6a5584a..785ffb8 100644 --- a/Software/components/core/test/station_list_test.cpp +++ b/Software/components/core/test/station_list_test.cpp @@ -81,6 +81,37 @@ namespace { return EXIT_SUCCESS; } +[[nodiscard]] int runParseStationReorderJsonTest() +{ + const auto parsed = + core::parseStationReorderJson(R"({"from":1,"to":0})"); + if (!parsed || parsed->fromIndex != 1U || parsed->toIndex != 0U) { + std::cerr << "reorder parse failed\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runStationListReorderTest() +{ + core::StationList list; + if (auto a = list.add(makeFmStation("A", 88000U, 1U)); !a) { + return EXIT_FAILURE; + } + if (auto b = list.add(makeFmStation("B", 90000U, 2U)); !b) { + return EXIT_FAILURE; + } + if (auto moved = list.move(1U, 0U); !moved) { + std::cerr << "move failed\n"; + return EXIT_FAILURE; + } + if (list.stations()[0U].name().value() != "B") { + std::cerr << "expected B first after reorder\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + } // namespace int main() @@ -94,5 +125,11 @@ int main() if (runParseStationJsonTest() != EXIT_SUCCESS) { return EXIT_FAILURE; } + if (runParseStationReorderJsonTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runStationListReorderTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } return EXIT_SUCCESS; } diff --git a/Software/components/core/test/station_service_test.cpp b/Software/components/core/test/station_service_test.cpp new file mode 100644 index 0000000..b62ed77 --- /dev/null +++ b/Software/components/core/test/station_service_test.cpp @@ -0,0 +1,226 @@ +/** + * @file station_service_test.cpp + * @brief Host tests for StationService with in-memory fake store. + * + * 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/ISecureStore.hpp" +#include "core/StationListJson.hpp" +#include "core/StationName.hpp" +#include "core/TunerBand.hpp" +#include "station/StationService.hpp" +#include "tuner/TunerService.hpp" + +#include +#include +#include +#include + +namespace { + +class FakeTuner final : public core::ITuner { +public: + [[nodiscard]] std::expected boot( + core::TunerBand) override + { + return {}; + } + + [[nodiscard]] std::expected currentBand() + const override + { + return core::TunerBand::Dab; + } + + [[nodiscard]] std::expected readStatus() + override + { + core::TunerStatus status = {}; + status.booted = true; + status.band = core::TunerBand::Dab; + status.locked = true; + status.volume = 40; + status.dabFreqIndex = 12U; + return status; + } + + [[nodiscard]] std::expected tuneDab( + std::uint8_t) override + { + return {}; + } + + [[nodiscard]] std::expected tuneFm( + core::FrequencyKHz) override + { + return {}; + } + + [[nodiscard]] std::expected seekFm( + core::SeekDirection) override + { + return std::unexpected(core::TunerError::WrongBand); + } + + [[nodiscard]] std::expected, + core::TunerError> + listDabServices() override + { + return std::vector{}; + } + + [[nodiscard]] std::expected playDabService( + std::uint32_t, std::uint32_t) override + { + return {}; + } + + [[nodiscard]] std::expected setVolume( + std::uint8_t) override + { + return {}; + } +}; + +class FakeSecureStore final : public core::ISecureStore { +public: + [[nodiscard]] bool hasWifiCredentials() const override + { + return false; + } + + [[nodiscard]] std::expected saveWifiCredentials( + const core::WifiCredentials&) override + { + return std::unexpected(core::StoreError::IoFailed); + } + + [[nodiscard]] std::expected + loadWifiCredentials() const override + { + return std::unexpected(core::StoreError::NotFound); + } + + [[nodiscard]] std::expected clearWifiCredentials() + override + { + return {}; + } + + [[nodiscard]] bool hasStationList() const override + { + return stationJson_.has_value(); + } + + [[nodiscard]] std::expected saveStationListJson( + std::string_view json) override + { + stationJson_ = std::string(json); + return {}; + } + + [[nodiscard]] std::expected + loadStationListJson() const override + { + if (!stationJson_) { + return std::unexpected(core::StoreError::NotFound); + } + return *stationJson_; + } + + [[nodiscard]] std::expected clearStationList() + override + { + stationJson_.reset(); + return {}; + } + +private: + std::optional stationJson_; +}; + +[[nodiscard]] core::Station makeFmStation(const char* name, std::uint32_t khz) +{ + return core::Station( + *core::StationName::tryFrom(name), + core::TunerBand::Fm, + 0U, + std::nullopt, + std::nullopt, + *core::FrequencyKHz::tryFromKhz(khz), + std::nullopt); +} + +[[nodiscard]] int runPersistRoundTripTest() +{ + FakeSecureStore store; + FakeTuner tuner; + tuner::TunerService tunerService(tuner); + station::StationService service(store, tunerService); + + if (auto added = service.add(makeFmStation("Jazz", 101500U)); !added) { + std::cerr << "add failed\n"; + return EXIT_FAILURE; + } + + station::StationService reloaded(store, tunerService); + if (auto loaded = reloaded.loadFromStore(); !loaded) { + std::cerr << "load failed\n"; + return EXIT_FAILURE; + } + if (reloaded.list().stations().size() != 1U) { + std::cerr << "expected one preset after reload\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runReorderPersistTest() +{ + FakeSecureStore store; + FakeTuner tuner; + tuner::TunerService tunerService(tuner); + station::StationService service(store, tunerService); + + if (auto a = service.add(makeFmStation("A", 88000U)); !a) { + return EXIT_FAILURE; + } + if (auto b = service.add(makeFmStation("B", 90000U)); !b) { + return EXIT_FAILURE; + } + if (auto moved = service.reorder(1U, 0U); !moved) { + std::cerr << "reorder failed\n"; + return EXIT_FAILURE; + } + + station::StationService reloaded(store, tunerService); + if (auto loaded = reloaded.loadFromStore(); !loaded) { + return EXIT_FAILURE; + } + if (reloaded.list().stations()[0U].name().value() != "B") { + std::cerr << "reorder not persisted\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runPersistRoundTripTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runReorderPersistTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/core/test/tuner_json_test.cpp b/Software/components/core/test/tuner_json_test.cpp index 598dfd8..da25697 100644 --- a/Software/components/core/test/tuner_json_test.cpp +++ b/Software/components/core/test/tuner_json_test.cpp @@ -16,6 +16,7 @@ #include "core/TunerBand.hpp" #include "core/TunerJson.hpp" #include "core/TunerStatus.hpp" +#include "core/BroadcastLabel.hpp" #include #include @@ -102,6 +103,26 @@ namespace { return EXIT_SUCCESS; } +[[nodiscard]] int runTunerStatusPlayingSerialiseTest() +{ + core::TunerStatus status = {}; + status.booted = true; + status.band = core::TunerBand::Dab; + status.locked = true; + status.volume = 40; + status.dabFreqIndex = 12U; + status.dabPlayingServiceId = 42U; + status.dabPlayingComponentId = 7U; + + const std::string json = core::serializeTunerStatusJson(status); + if (json.find("\"playing_service_id\":42") == std::string::npos + || json.find("\"playing_component_id\":7") == std::string::npos) { + std::cerr << "playing ids missing: " << json << '\n'; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + [[nodiscard]] int runTunerServicesSerialiseTest() { core::TunerServiceEntry entry = {}; @@ -118,6 +139,36 @@ namespace { return EXIT_SUCCESS; } +[[nodiscard]] int runTunerStatusMetadataSerialiseTest() +{ + core::TunerStatus status = {}; + status.booted = true; + status.band = core::TunerBand::Fm; + status.locked = true; + status.volume = 40; + status.fmFrequency = *core::FrequencyKHz::tryFromKhz(101500U); + status.fmStationName = core::BroadcastLabel::tryFromChipBytes("RADIO 1"); + status.fmRadiotext = core::BroadcastLabel::tryFromChipBytes("Now playing"); + + const std::string json = core::serializeTunerStatusJson(status); + if (json.find("\"station_name\":\"RADIO 1\"") == std::string::npos + || json.find("\"radiotext\":\"Now playing\"") == std::string::npos) { + std::cerr << "FM metadata missing: " << json << '\n'; + return EXIT_FAILURE; + } + + status.band = core::TunerBand::Dab; + status.dabFreqIndex = 5U; + status.dabDynamicLabel = + core::BroadcastLabel::tryFromChipBytes("Live show"); + const std::string dabJson = core::serializeTunerStatusJson(status); + if (dabJson.find("\"dynamic_label\":\"Live show\"") == std::string::npos) { + std::cerr << "DAB metadata missing: " << dabJson << '\n'; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + [[nodiscard]] int runTunerErrorSerialiseTest() { if (!expectEqual(core::serializeTunerErrorJson("not_booted"), @@ -146,6 +197,12 @@ int main() if (runTunerStatusSerialiseTest() != EXIT_SUCCESS) { return EXIT_FAILURE; } + if (runTunerStatusPlayingSerialiseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runTunerStatusMetadataSerialiseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } if (runTunerServicesSerialiseTest() != EXIT_SUCCESS) { return EXIT_FAILURE; } diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp index c51ed63..a7fec05 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -236,6 +237,21 @@ public: */ [[nodiscard]] std::expected readFmRds(); + /** + * @brief readDabServiceData — read one queued DAB data-service block. + * + * @dname readDabServiceData + * @param statusOnly Poll queue depth without consuming payload. + * @param ack Acknowledge DSRVINT when reading payload. + * @return Data block on success, nullopt when queue empty, or Si4684Error. + * @pubstate reads GET_DIGITAL_SERVICE_DATA response bytes. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected, Si4684Error> + readDabServiceData(bool statusOnly, bool ack); + /** * @brief installDefaultDabFrequencyPlan — load Band III frequency list. * diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp index d5d800b..a5ed82f 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp @@ -12,10 +12,14 @@ */ #pragma once +#include "core/DabDynamicLabelAccumulator.hpp" #include "core/FrequencyKHz.hpp" #include "core/ITuner.hpp" +#include "core/RdsMetadataAccumulator.hpp" #include "si4684/Si4684Driver.hpp" +#include + namespace si4684 { /** @@ -190,6 +194,10 @@ private: std::uint8_t dabIndex_; core::FrequencyKHz fmFrequency_; std::uint8_t volume_; + core::RdsMetadataAccumulator rdsMetadata_; + core::DabDynamicLabelAccumulator dabDynamicLabel_; + std::optional lastDabServiceId_; + std::optional lastDabComponentId_; }; } // namespace si4684 diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp index 19becd4..26629f8 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp @@ -18,6 +18,7 @@ #include #include #include +#include namespace si4684 { @@ -113,6 +114,27 @@ struct Si4684FmRdsStatus { std::uint16_t blockC; ///< RDS block C. std::uint16_t blockD; ///< RDS block D. bool received; ///< Group received flag. + std::uint8_t fifoUsed; ///< Remaining groups in the RDS FIFO. +}; + +/** + * @brief Si4684DabServiceData — one GET_DIGITAL_SERVICE_DATA block. + * + * @dname Si4684DabServiceData + * @return n/a (type) + * @pubstate Plain DTO from GET_DIGITAL_SERVICE_DATA response bytes. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +struct Si4684DabServiceData { + std::uint32_t serviceId; ///< Associated DAB service identifier. + std::uint32_t componentId; ///< Associated component identifier. + std::uint8_t dataSrc; ///< Payload source (2 = DLS PAD). + std::uint16_t byteCount; ///< Payload byte count. + std::uint16_t segmentIndex; ///< Zero-based segment index. + std::uint16_t segmentCount; ///< Total segments for this label object. + std::vector payload; ///< Raw payload bytes. }; /** diff --git a/Software/components/drivers/si4684/src/Si4684Driver.cpp b/Software/components/drivers/si4684/src/Si4684Driver.cpp index 40b8970..47d7ef5 100644 --- a/Software/components/drivers/si4684/src/Si4684Driver.cpp +++ b/Software/components/drivers/si4684/src/Si4684Driver.cpp @@ -587,6 +587,7 @@ std::expected Si4684Driver::readFmRds() Si4684FmRdsStatus rds = {}; rds.received = (raw[4] & 0x01U) != 0U; + rds.fifoUsed = raw[10]; rds.blockA = readLe16(raw.data() + 12); rds.blockB = readLe16(raw.data() + 14); rds.blockC = readLe16(raw.data() + 16); @@ -594,6 +595,60 @@ std::expected Si4684Driver::readFmRds() return rds; } +std::expected, Si4684Error> +Si4684Driver::readDabServiceData(bool statusOnly, bool ack) +{ + if (auto band = ensureBand(Si4684Band::Dab); !band) { + return std::unexpected(band.error()); + } + + const std::uint8_t arg1 = + static_cast((statusOnly ? 0x08U : 0x00U) + | (ack ? 0x01U : 0x00U)); + const std::uint8_t args[] = {arg1}; + if (auto cmd = + writeCommand(Command::GetDigitalServiceData, args, sizeof(args)); + !cmd) { + return std::unexpected(Si4684Error::CommandFailed); + } + + std::array header = {}; + if (auto rd = readRaw(header); !rd) { + return std::unexpected(rd.error()); + } + + if (statusOnly) { + if (header[5] == 0U) { + return std::optional{}; + } + } + + const std::uint16_t byteCount = readLe16(header.data() + 18); + if (byteCount == 0U) { + return std::optional{}; + } + if (byteCount + 24U > kSpiBufferSize) { + return std::unexpected(Si4684Error::ReplyTooShort); + } + + std::vector body(byteCount, 0U); + if (byteCount > 0U) { + if (auto rd = readRaw(body); !rd) { + return std::unexpected(rd.error()); + } + } + + Si4684DabServiceData data = {}; + data.dataSrc = static_cast((header[7] >> 6U) & 0x03U); + data.serviceId = readLe32(header.data() + 8); + data.componentId = readLe32(header.data() + 12); + data.byteCount = byteCount; + data.segmentIndex = readLe16(header.data() + 20); + data.segmentCount = readLe16(header.data() + 22); + data.payload = std::move(body); + return data; +} + std::expected Si4684Driver::installDefaultDabFrequencyPlan() { if (auto band = ensureBand(Si4684Band::Dab); !band) { diff --git a/Software/components/drivers/si4684/src/Si4684Tuner.cpp b/Software/components/drivers/si4684/src/Si4684Tuner.cpp index 4e0cc51..74a5ac3 100644 --- a/Software/components/drivers/si4684/src/Si4684Tuner.cpp +++ b/Software/components/drivers/si4684/src/Si4684Tuner.cpp @@ -89,6 +89,19 @@ std::expected Si4684Tuner::readStatus() } else { return std::unexpected(mapError(dig.error())); } + + if (lastDabServiceId_) { + if (auto data = driver_.readDabServiceData(false, true); data) { + if (*data && (*data)->dataSrc == 2U) { + dabDynamicLabel_.applySegment((*data)->segmentIndex, + (*data)->segmentCount, + (*data)->payload); + status.dabDynamicLabel = dabDynamicLabel_.label(); + } + } else { + return std::unexpected(mapError(data.error())); + } + } } else { status.fmFrequency = fmFrequency_; if (auto rsq = driver_.readFmRsq(); rsq) { @@ -101,6 +114,25 @@ std::expected Si4684Tuner::readStatus() } else { return std::unexpected(mapError(rsq.error())); } + + for (int attempt = 0; attempt < 8; ++attempt) { + auto rds = driver_.readFmRds(); + if (!rds) { + return std::unexpected(mapError(rds.error())); + } + if (!rds->received) { + break; + } + rdsMetadata_.applyGroup(rds->blockA, + rds->blockB, + rds->blockC, + rds->blockD); + if (rds->fifoUsed <= 1U) { + break; + } + } + status.fmStationName = rdsMetadata_.programName(); + status.fmRadiotext = rdsMetadata_.radiotext(); } return status; } @@ -112,6 +144,9 @@ std::expected Si4684Tuner::tuneDab( return std::unexpected(mapError(result.error())); } dabIndex_ = freqIndex; + dabDynamicLabel_.reset(); + lastDabServiceId_.reset(); + lastDabComponentId_.reset(); return {}; } @@ -122,6 +157,7 @@ std::expected Si4684Tuner::tuneFm( return std::unexpected(mapError(result.error())); } fmFrequency_ = frequency; + rdsMetadata_.reset(); return {}; } @@ -132,6 +168,7 @@ std::expected Si4684Tuner::seekFm( return std::unexpected(mapError(result.error())); } fmFrequency_ = *result; + rdsMetadata_.reset(); return *result; } @@ -171,6 +208,9 @@ std::expected Si4684Tuner::playDabService( if (auto result = driver_.startDabService(serviceId, componentId); !result) { return std::unexpected(mapError(result.error())); } + lastDabServiceId_ = serviceId; + lastDabComponentId_ = componentId; + dabDynamicLabel_.reset(); return {}; } diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 8c13833..8aa2dad 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -50,7 +50,7 @@ namespace net { namespace { constexpr char kTag[] = "SetupWebServer"; -constexpr char kFirmwareVersion[] = "0.7.0"; +constexpr char kFirmwareVersion[] = "0.8.0"; constexpr unsigned kRebootDelaySec = 3; extern const uint8_t www_index_html_gz_start[] asm( @@ -796,6 +796,38 @@ esp_err_t stationsRemovePostHandler(httpd_req_t* req) return httpd_resp_send(req, "{\"status\":\"removed\"}", 20); } +esp_err_t stationsReorderPostHandler(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::parseStationReorderJson(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 moved = ctx->stations->reorder(parsed->fromIndex, parsed->toIndex); + !moved) { + const std::string json = core::serializeStationListErrorJson( + core::stationListErrorToken(moved.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\":\"reordered\"}", 22); +} + esp_err_t stationsTunePostHandler(httpd_req_t* req) { auto* ctx = routeContextFrom(req); @@ -1101,6 +1133,14 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &stationsRemoveUri); + const httpd_uri_t stationsReorderUri = { + .uri = "/api/stations/reorder", + .method = HTTP_POST, + .handler = stationsReorderPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &stationsReorderUri); + const httpd_uri_t stationsTuneUri = { .uri = "/api/stations/tune", .method = HTTP_POST, diff --git a/Software/components/net/www/index.html b/Software/components/net/www/index.html index 94daa25..2624568 100644 --- a/Software/components/net/www/index.html +++ b/Software/components/net/www/index.html @@ -261,11 +261,14 @@ if (s.dab) { lines.push("DAB index: " + (s.dab.freq_index != null ? s.dab.freq_index : "—")); if (s.dab.fic_quality != null) lines.push("FIC: " + s.dab.fic_quality); + if (s.dab.dynamic_label) lines.push("Now: " + s.dab.dynamic_label); } if (s.fm) { if (s.fm.frequency_khz != null) lines.push("FM: " + s.fm.frequency_khz + " kHz"); if (s.fm.rssi_dbuv != null) lines.push("RSSI: " + s.fm.rssi_dbuv + " dBµV"); if (s.fm.stereo != null) lines.push("Stereo: " + s.fm.stereo); + if (s.fm.station_name) lines.push("Station: " + s.fm.station_name); + if (s.fm.radiotext) lines.push("Text: " + s.fm.radiotext); } return lines.join("\n"); } @@ -623,6 +626,7 @@ 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; + if (s.dab_service_id != null) label += " svc " + s.dab_service_id; li.textContent = label; var tuneBtn = document.createElement("button"); tuneBtn.textContent = "Tune"; @@ -643,12 +647,60 @@ body: JSON.stringify({ index: idx }) }).then(function () { loadPresets(); }); }); + var upBtn = document.createElement("button"); + upBtn.textContent = "Up"; + upBtn.className = "secondary"; + upBtn.disabled = idx === 0; + upBtn.addEventListener("click", function () { + fetch("/api/stations/reorder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from: idx, to: idx - 1 }) + }).then(function () { loadPresets(); }); + }); + var downBtn = document.createElement("button"); + downBtn.textContent = "Dn"; + downBtn.className = "secondary"; + downBtn.disabled = idx >= (data.stations.length - 1); + downBtn.addEventListener("click", function () { + fetch("/api/stations/reorder", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from: idx, to: idx + 1 }) + }).then(function () { loadPresets(); }); + }); li.appendChild(tuneBtn); + li.appendChild(upBtn); + li.appendChild(downBtn); li.appendChild(delBtn); list.appendChild(li); }); } + function buildPresetBody(name, band, status) { + var body = { name: name, band: band }; + if (band === "fm") { + var khz = status && status.fm && status.fm.frequency_khz != null + ? status.fm.frequency_khz + : parseInt(document.getElementById("fm-khz").value, 10); + body.fm_frequency_khz = khz; + } else { + var idx = status && status.dab && status.dab.freq_index != null + ? status.dab.freq_index + : parseInt(document.getElementById("dab-index").value, 10); + body.dab_freq_index = idx; + if (status && status.dab && status.dab.playing_service_id != null) { + body.dab_service_id = status.dab.playing_service_id; + } + if (status && status.dab && status.dab.playing_component_id != null) { + body.dab_component_id = status.dab.playing_component_id; + } + } + var slotVal = document.getElementById("preset-slot").value; + if (slotVal) body.preset_slot = parseInt(slotVal, 10); + return body; + } + function loadPresets() { return fetch("/api/stations") .then(function (r) { return r.json(); }) @@ -664,20 +716,18 @@ 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 }; }); }) + fetch("/api/tuner/status") + .then(function (r) { return r.json(); }) + .then(function (status) { + var body = buildPresetBody(name, band, status); + return 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); diff --git a/Software/components/net/www/index.html.gz b/Software/components/net/www/index.html.gz index 63bef04..913be09 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/services/station/include/station/StationService.hpp b/Software/components/services/station/include/station/StationService.hpp index 045f4bd..93c7f39 100644 --- a/Software/components/services/station/include/station/StationService.hpp +++ b/Software/components/services/station/include/station/StationService.hpp @@ -102,6 +102,21 @@ public: [[nodiscard]] std::expected removeAt( std::size_t index); + /** + * @brief reorder — move a preset within the list and persist. + * + * @dname reorder + * @param fromIndex Current list position. + * @param toIndex Target list position. + * @return Ok on success, or StationListError. + * @pubstate mutates list_ and NVS on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected reorder( + std::size_t fromIndex, std::size_t toIndex); + /** * @brief tuneToIndex — recall a saved preset on the tuner. * diff --git a/Software/components/services/station/src/StationService.cpp b/Software/components/services/station/src/StationService.cpp index 4a82c10..598dc1c 100644 --- a/Software/components/services/station/src/StationService.cpp +++ b/Software/components/services/station/src/StationService.cpp @@ -62,8 +62,8 @@ std::expected StationService::add( return added; } if (auto saved = persist(); !saved) { - list_.removeAt(list_.stations().size() - 1U); - return std::unexpected(core::StationListError::Full); + (void)list_.removeAt(list_.stations().size() - 1U); + return std::unexpected(core::StationListError::PersistFailed); } return {}; } @@ -75,6 +75,22 @@ std::expected StationService::removeAt( return removed; } if (auto saved = persist(); !saved) { + return std::unexpected(core::StationListError::PersistFailed); + } + return {}; +} + +std::expected StationService::reorder( + std::size_t fromIndex, + std::size_t toIndex) +{ + if (auto moved = list_.move(fromIndex, toIndex); !moved) { + return moved; + } + if (auto saved = persist(); !saved) { + if (list_.move(toIndex, fromIndex)) { + return std::unexpected(core::StationListError::PersistFailed); + } return std::unexpected(core::StationListError::NotFound); } return {}; diff --git a/Software/components/services/tuner/include/tuner/TunerService.hpp b/Software/components/services/tuner/include/tuner/TunerService.hpp index 0b38832..db8fd01 100644 --- a/Software/components/services/tuner/include/tuner/TunerService.hpp +++ b/Software/components/services/tuner/include/tuner/TunerService.hpp @@ -20,6 +20,7 @@ #include #include +#include #include namespace tuner { @@ -72,7 +73,7 @@ public: * @dname tuneDab * @param freqIndex Ensemble index 0–37. * @return Ok on success, or a TunerError from ITuner. - * @pubstate writes lastDabIndex_ on success. + * @pubstate writes lastDabIndex_ on success; clears last-played DAB ids. * * @author Michele Bigi * @date 2026-07-06 @@ -129,7 +130,7 @@ public: * @param serviceId Selected service identifier. * @param componentId Audio component within the service. * @return Ok on success, or a TunerError from ITuner. - * @pubstate delegates to tuner_. + * @pubstate delegates to tuner_; caches service/component for status JSON. * * @author Michele Bigi * @date 2026-07-06 @@ -168,6 +169,8 @@ private: std::uint8_t lastDabIndex_; core::FrequencyKHz lastFmFrequency_; std::uint8_t volume_; + std::optional lastPlayedServiceId_; + std::optional lastPlayedComponentId_; }; } // namespace tuner diff --git a/Software/components/services/tuner/src/TunerService.cpp b/Software/components/services/tuner/src/TunerService.cpp index 8b3c975..87be681 100644 --- a/Software/components/services/tuner/src/TunerService.cpp +++ b/Software/components/services/tuner/src/TunerService.cpp @@ -37,6 +37,10 @@ std::expected TunerService::refreshStatus() auto status = tuner_.readStatus(); if (status) { volume_ = status->volume; + if (status->band == core::TunerBand::Dab) { + status->dabPlayingServiceId = lastPlayedServiceId_; + status->dabPlayingComponentId = lastPlayedComponentId_; + } } return status; } @@ -48,6 +52,8 @@ std::expected TunerService::tuneDab( return result; } lastDabIndex_ = freqIndex; + lastPlayedServiceId_.reset(); + lastPlayedComponentId_.reset(); return {}; } @@ -81,7 +87,12 @@ std::expected TunerService::playDabService( std::uint32_t serviceId, std::uint32_t componentId) { - return tuner_.playDabService(serviceId, componentId); + if (auto result = tuner_.playDabService(serviceId, componentId); !result) { + return result; + } + lastPlayedServiceId_ = serviceId; + lastPlayedComponentId_ = componentId; + return {}; } std::expected TunerService::setVolume(std::uint8_t level) diff --git a/Software/docs/TODO.md b/Software/docs/TODO.md index 088417d..f628843 100644 --- a/Software/docs/TODO.md +++ b/Software/docs/TODO.md @@ -12,16 +12,15 @@ errors, no plaintext secrets. Working directory for all commands is `Software/`. -**Current firmware:** `0.7.1` — CI green gate (Doxygen + host tests + -manual sync), BT1035 pairing, station presets. +**Current firmware:** `0.8.0` — broadcast metadata (RDS PS/RT, DAB DLS), +preset reorder, CI gate (Doxygen + host tests + manual sync). --- -## Completed (fw 0.7.0) +## Completed (fw 0.7.0–0.7.2) -- **Station / preset list (T3 core)** — `Station`, `StationList`, NVS key - `station_list`, `StationService`, REST + Presets UI. Remaining polish: - reorder API, save DAB `service_id`/`component_id` from UI, HIL on device. +- **Broadcast metadata (T4)** — RDS PS/RT and DAB DLS in + `/api/tuner/status`, core parsers, Si4684 driver hook, UI lines. - **BT1035 pairing** — `AT+PAIR`, `AT+A2DPSTAT`, `AT+A2DPDISC`, `BluetoothService`, REST + UI (not numbered below; landed with Slice 7). @@ -42,27 +41,14 @@ push/PR to `main`. ## P1 — Missing domain features -### T3. Station / preset list — polish *(core done in 0.7.0)* -**Status:** CRUD, NVS persistence, tune recall, and basic UI are shipped. -**Remaining:** -- `POST /api/stations/reorder` (core has `StationList::move()`). -- Save DAB presets with `service_id` / `component_id` from last played service. -- Device HIL: preset survives reboot, tune recall on hardware. -**Done when:** the gaps above are closed and covered by tests. +### T3. Station / preset list — polish — **DONE (fw 0.7.2)** +Reorder API (`POST /api/stations/reorder`), DAB playing ids in tuner +status and preset save, UI Up/Dn, host tests. **Remaining:** device HIL +(preset survives reboot) — manual only. -### T4. Broadcast metadata (RDS / DLS) -**Why:** `TunerStatus` currently carries only a 17-char `label`. Real -radio UX needs the FM RDS station name/radiotext and DAB DLS dynamic -label so the UI can show "what's playing". -**What:** -- Extend the tuner data model with structured metadata (station name, - radiotext/dynamic label), read from the Si4684 in the driver. -- Surface it through `TunerService::refreshStatus` and the - `/api/tuner/status` JSON. -- Keep the Si4684 register/command details in the driver; the core model - stays hardware-free. -**Done when:** the status JSON exposes the metadata and host tests cover -the parsing of raw label bytes into the model. +### T4. Broadcast metadata (RDS / DLS) — **DONE (fw 0.8.0)** +`BroadcastLabel`, RDS accumulator, DAB DLS accumulator, driver +`readDabServiceData`, status JSON fields, UI now-playing lines, host tests. ### T5. Remove the services stub — integration service **Why:** `components/services/src/component_stub.cpp` is a Slice-7 diff --git a/Software/docs/manual/ch-api.tex b/Software/docs/manual/ch-api.tex index 12c8bd6..59dd6cd 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.7.0", +{"status":"ok","fw":"0.8.0", "chips":{"si4684":true,"adau1701":true,"bt1035":true}} \end{drcode} \begin{itemize} @@ -108,15 +108,20 @@ and~\ref{sec:si4684-fm-session}. \begin{drnote}[Response schema (DAB example)] \begin{drcode}[JSON] {"booted":true,"band":"dab","locked":true,"volume":63, - "dab":{"freq_index":12,"fic_quality":80,"cnr_db":25},"fm":null} + "dab":{"freq_index":12,"fic_quality":80,"cnr_db":25, + "playing_service_id":42,"playing_component_id":7, + "dynamic_label":"Live show"},"fm":null} \end{drcode} For FM, \texttt{fm} carries \texttt{frequency\_khz}, \texttt{rssi\_dbuv}, -\texttt{snr\_db}, and \texttt{stereo}; \texttt{dab} is \texttt{null}. +\texttt{snr\_db}, \texttt{stereo}, optional \texttt{station\_name} (RDS PS), +and \texttt{radiotext} (RDS RT); \texttt{dab} is \texttt{null}. \end{drnote} HTTP status: \textbf{200 OK}; \textbf{500} with \texttt{\{"status":"error","reason":...\}} on driver failure; -\textbf{503} when the tuner service is unavailable. +\textbf{503} when the tuner service is unavailable. FM responses may include +\texttt{station\_name} and \texttt{radiotext}; DAB responses may include +\texttt{dynamic\_label} when a programme is playing. \subsection{\texttt{GET /api/tuner/services}} \label{sec:api-tuner-services} @@ -306,6 +311,12 @@ Adds one preset (\texttt{core::parseStationJson()}); persists via Removes a preset by list index (\texttt{\{"index":0\}}). +\subsection{\texttt{POST /api/stations/reorder}} +\label{sec:api-stations-reorder} + +Reorders presets using \texttt{StationList::move()}. Body: +\texttt{\{"from":1,"to":0\}}. Success: \texttt{\{"status":"reordered"\}}. + \subsection{\texttt{POST /api/stations/tune}} \label{sec:api-stations-tune} diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index 47e01ee..7632de5 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -230,6 +230,18 @@ Validated at the HTTP boundary by \texttt{core::parseEnhanceLevelJson()}. Strong type for a preset label (1--32 bytes). Parsed at the HTTP boundary via \texttt{StationName::tryFrom()}. +\section{BroadcastLabel}\label{cls:BroadcastLabel} +Trimmed on-air label from RDS or DAB chip byte buffers (up to 128 bytes). +Built by \texttt{BroadcastLabel::tryFromChipBytes()} in the pure core. + +\section{RdsMetadataAccumulator}\label{cls:RdsMetadataAccumulator} +Stateful RDS decoder assembling program service name and radiotext from +raw block~A--D snapshots returned by the Si4684 driver. + +\section{DabDynamicLabelAccumulator}\label{cls:DabDynamicLabelAccumulator} +Reassembles segmented DAB DLS payloads into one dynamic label for +\texttt{TunerStatus}. + \section{PresetSlot}\label{cls:PresetSlot} Optional hardware preset button index (1--20). Validated by \texttt{PresetSlot::tryFrom()}. diff --git a/Software/docs/manual/ch-si4684.tex b/Software/docs/manual/ch-si4684.tex index 454cbf5..1322bb8 100644 --- a/Software/docs/manual/ch-si4684.tex +++ b/Software/docs/manual/ch-si4684.tex @@ -180,7 +180,9 @@ Requires \texttt{boot(Si4684Band::Fm)}. \item \texttt{tuneFm(frequencyKhz)} --- FM\_TUNE\_FREQ, waits for STC. \item \texttt{seekFm(up, wrap)} --- FM\_SEEK\_START; returns tuned kHz. \item \texttt{readFmRsq()} --- RSSI, SNR, stereo flag, validity. - \item \texttt{readFmRds()} --- last RDS group blocks A--D. + \item \texttt{readFmRds()} --- last RDS group blocks A--D (FIFO depth). + \item \texttt{readDabServiceData(statusOnly, ack)} --- DAB data-service + payload (DLS when \texttt{DATA\_SRC=2}). \end{itemize} \subsection{DAB operations}