From 0fc714e431d6df56673013c3e907b3a0af3cded6 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 7 Jul 2026 00:01:49 +0200 Subject: [PATCH] Add preset polish and broadcast metadata (fw 0.8.0). Ship station reorder, DAB playing ids in presets, RDS PS/RT and DAB DLS in tuner status, Si4684 data-service read, host tests, and UI now-playing lines. Co-authored-by: Cursor --- Software/README.md | 7 +- Software/components/core/CMakeLists.txt | 3 + .../core/include/core/BroadcastLabel.hpp | 69 ++++++ .../core/DabDynamicLabelAccumulator.hpp | 86 +++++++ .../include/core/RdsMetadataAccumulator.hpp | 102 ++++++++ .../core/include/core/StationListError.hpp | 1 + .../core/include/core/StationListJson.hpp | 29 +++ .../core/include/core/TunerStatus.hpp | 6 + .../components/core/src/BroadcastLabel.cpp | 58 +++++ .../core/src/DabDynamicLabelAccumulator.cpp | 70 ++++++ .../core/src/RdsMetadataAccumulator.cpp | 100 ++++++++ .../components/core/src/StationListJson.cpp | 17 ++ Software/components/core/src/TunerJson.cpp | 53 +++- Software/components/core/test/CMakeLists.txt | 19 ++ .../core/test/broadcast_metadata_test.cpp | 115 +++++++++ .../core/test/station_list_test.cpp | 37 +++ .../core/test/station_service_test.cpp | 226 ++++++++++++++++++ .../components/core/test/tuner_json_test.cpp | 57 +++++ .../si4684/include/si4684/Si4684Driver.hpp | 16 ++ .../si4684/include/si4684/Si4684Tuner.hpp | 8 + .../si4684/include/si4684/Si4684Types.hpp | 22 ++ .../drivers/si4684/src/Si4684Driver.cpp | 55 +++++ .../drivers/si4684/src/Si4684Tuner.cpp | 40 ++++ .../components/net/src/SetupWebServer.cpp | 42 +++- Software/components/net/www/index.html | 78 ++++-- Software/components/net/www/index.html.gz | Bin 5105 -> 5427 bytes .../include/station/StationService.hpp | 15 ++ .../services/station/src/StationService.cpp | 20 +- .../tuner/include/tuner/TunerService.hpp | 7 +- .../services/tuner/src/TunerService.cpp | 13 +- Software/docs/TODO.md | 38 +-- Software/docs/manual/ch-api.tex | 19 +- Software/docs/manual/ch-classes.tex | 12 + Software/docs/manual/ch-si4684.tex | 4 +- 34 files changed, 1379 insertions(+), 65 deletions(-) create mode 100644 Software/components/core/include/core/BroadcastLabel.hpp create mode 100644 Software/components/core/include/core/DabDynamicLabelAccumulator.hpp create mode 100644 Software/components/core/include/core/RdsMetadataAccumulator.hpp create mode 100644 Software/components/core/src/BroadcastLabel.cpp create mode 100644 Software/components/core/src/DabDynamicLabelAccumulator.cpp create mode 100644 Software/components/core/src/RdsMetadataAccumulator.cpp create mode 100644 Software/components/core/test/broadcast_metadata_test.cpp create mode 100644 Software/components/core/test/station_service_test.cpp 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 63bef04f81da7ae058a57201c1352a853d038c6e..913be0928eb8046ab06f63dc6fa18e0181e46728 100644 GIT binary patch literal 5427 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 literal 5105 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

gMPmG z3${1T^6eF6wxK;9O`;bN+kSZecNh(}DmI-osYUouUrfh}rOOZv;_>$dSCK%y|(_7>NjcbcSO=6ce= zHcV`H``nRlY28$ExuI*Gf0o_z-nnGv51T2hO-ogqcii3z+S#k`4uzB*TH>`0lq zH|Tm>;d4#H)6Em0rx7?)uMdH~o8jIEXPK#C12jvR4 zuY?*1S>ix-I}8B=_Y_Dy4yEJB^tQ$_aF<1!n61JKR&7=h9ivwbWD|WYXmdhxuF4dJ z9yOYLUSnp9q+*Ursg;wEX@phlqoVD9F_1HM~%xEP3B*bk1>=v|CZ*@^D+o z8_Hveg!LLF<(2o|ZP^$1{?T4=mFxuUl87gzl-&~1Hw~ulmKIX5h_G85%8kvS9dTS| z0}I=>#wskYHSk4{>m zUcisaR8pdcAj37;%MC@@G&#UW3(zLX#bXbkL20P1!masHt7sGJ(xz<~iYG7bLHIe| zk0Jc#A)(Lbmhgl|V@gjyW~AM;gB7fnk%i?$lU}J&{qFSWOQ}d8A79E`lq7ad21*@F zzWusKEx+wjNQg;JA3VwtHH0y_n3stZ6RJ<=9LPzToKcW6&nXeXyYS7+`d*mxF8AYo zN?s2r>I+yf`i^t*>QHP!K(QRhsaN)amaK9KyWC>5WY!wOl}NxGKkqOCKR zR=tHox5qBL3QZe7x^OJcQV1k=W zu0Ef&LnzKk6%nZ07o{-Mi}ci;Z3L(|;$#5AvtNBtciIMAepaapjaS)#2?Hfh{2-`G zAW=`feiTVZsY8=n`KP?q?q-0sLq)YIGjSIXwWW-hA4jEkS@X{v; zoGlv0Lq}cK^+_(7T{*-cO!+YF`*da~c%u_1P8!P0(Mtf)kUOeql94__a6%0>1Xt0g z3OKF@8$v6qlL1U;W=Zt{yeMdD8{=il^^Gm20OM9(u)c?eWNjD2LO=)pzP7!F!RSyC zxLOla+Xz6uBf9mNkl_|7^d4Se6;+bJT4;tH(oW>8&u`kyT)imN7^zkmtM;!pt7`g5 zdt&v6PnTCUOv=N_pte1~VH$tlSMVOewiozs45<=a{VtxvFSi`&Bi81;q|IZVDS6#NaKld;zj+ggZ{umj9_&F{y<#Ef^QWASb+;>y#U(mb zFYnZ84W5q=k5w%lyOFH({QpZgBoBmsSbA2`s;g`U$*3S@i8P&#^xwuh4Ay`Ni7{7V|Hu z*4pSn>X1tOVd)ik2$W-?*Ta8%^biwVJt33->XCpkd8l)SW5Rua9B}o z402}y4dQO_Q8FKqK5r8A#uzCm&Nk) zFCCYrw0!Tl`7n_FRhLc90&x`m{97`>mY;s^+K#ep;ZbgVhFhLw(ND1?eg17Z+#Bu` z7ozdKtDO!}7imw8gKAv~xvFfz25vH#7`R1?C@1{)mb_)b zLEh-p*Iqe9C{>Sj$7?5*sEA`Jxx2exIMca^pH};k`WwtM{|H-&pWGh{2wg#}Xne}Q z^rlfN8`W+H=Sunc_u;Vc54`uLKD}4F_e)A 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}