From d13012ce4bb3dde8e367dbf17ec49293c45e3cde Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 7 Jul 2026 00:17:13 +0200 Subject: [PATCH] Add integration startup service and fix Doxygen (fw 0.8.1). Replace the services stub with IntegrationService for boot preset recall and tune orchestration, persist last preset in NVS, and remove invalid @return tags that broke CI on T4 metadata headers. Co-authored-by: Cursor --- Software/README.md | 6 +- .../core/DabDynamicLabelAccumulator.hpp | 2 - .../core/include/core/ISecureStore.hpp | 53 ++++ .../core/include/core/IntegrationError.hpp | 34 +++ .../include/core/RdsMetadataAccumulator.hpp | 2 - Software/components/core/test/CMakeLists.txt | 16 + .../core/test/integration_service_test.cpp | 286 ++++++++++++++++++ .../core/test/station_service_test.cpp | 29 ++ Software/components/net/CMakeLists.txt | 2 +- .../net/include/net/NetBootstrap.hpp | 6 + .../net/include/net/SetupWebServer.hpp | 8 + Software/components/net/src/NetBootstrap.cpp | 11 +- .../components/net/src/SetupWebServer.cpp | 62 ++-- .../include/secure_store/NvsSecureStore.hpp | 11 + .../secure_store/src/NvsSecureStore.cpp | 72 +++++ .../services/{ => integration}/CMakeLists.txt | 4 +- .../integration/IntegrationService.hpp | 132 ++++++++ .../integration/src/IntegrationService.cpp | 109 +++++++ .../services/src/component_stub.cpp | 33 -- Software/docs/TODO.md | 22 +- Software/docs/manual/ch-api.tex | 14 +- Software/docs/manual/ch-classes.tex | 6 + Software/main/CMakeLists.txt | 2 +- Software/main/main.cpp | 28 +- 24 files changed, 858 insertions(+), 92 deletions(-) create mode 100644 Software/components/core/include/core/IntegrationError.hpp create mode 100644 Software/components/core/test/integration_service_test.cpp rename Software/components/services/{ => integration}/CMakeLists.txt (57%) create mode 100644 Software/components/services/integration/include/integration/IntegrationService.hpp create mode 100644 Software/components/services/integration/src/IntegrationService.cpp delete mode 100644 Software/components/services/src/component_stub.cpp diff --git a/Software/README.md b/Software/README.md index 0827db8..f9340f3 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.8.0** — RDS/DLS now-playing metadata in tuner status; -preset reorder, CI on `main` (host tests, Doxygen, manual sync). +**Status:** fw **0.8.1** — integration service for preset recall + audio; +RDS/DLS metadata; CI on `main`. 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.8.0) +## HTTP API (fw 0.8.1) | Method | Path | Purpose | |--------|------|---------| diff --git a/Software/components/core/include/core/DabDynamicLabelAccumulator.hpp b/Software/components/core/include/core/DabDynamicLabelAccumulator.hpp index c20191b..4f1562e 100644 --- a/Software/components/core/include/core/DabDynamicLabelAccumulator.hpp +++ b/Software/components/core/include/core/DabDynamicLabelAccumulator.hpp @@ -38,7 +38,6 @@ public: * @brief reset — discard accumulated DLS segments. * * @dname reset - * @return n/a * @pubstate clears buffer and segment flags. * * @author Michele Bigi @@ -53,7 +52,6 @@ public: * @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 diff --git a/Software/components/core/include/core/ISecureStore.hpp b/Software/components/core/include/core/ISecureStore.hpp index 7b49a8d..489f5df 100644 --- a/Software/components/core/include/core/ISecureStore.hpp +++ b/Software/components/core/include/core/ISecureStore.hpp @@ -20,6 +20,7 @@ #include "core/StoreError.hpp" #include "core/WifiCredentials.hpp" +#include #include #include #include @@ -157,6 +158,58 @@ public: */ [[nodiscard]] virtual std::expected clearStationList() = 0; + + /** + * @brief hasLastPresetIndex — check whether a last-recalled preset exists. + * + * @dname hasLastPresetIndex + * @return true when loadLastPresetIndex would succeed. + * @pubstate reads backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual bool hasLastPresetIndex() const = 0; + + /** + * @brief saveLastPresetIndex — persist the last recalled preset index. + * + * @dname saveLastPresetIndex + * @param index Zero-based preset list position (0–19). + * @return Ok on success, or StoreError::IoFailed. + * @pubstate writes backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + saveLastPresetIndex(std::uint8_t index) = 0; + + /** + * @brief loadLastPresetIndex — read the last recalled preset index. + * + * @dname loadLastPresetIndex + * @return Preset index on success, or StoreError. + * @pubstate reads backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + loadLastPresetIndex() const = 0; + + /** + * @brief clearLastPresetIndex — erase the last-recalled preset marker. + * + * @dname clearLastPresetIndex + * @return Ok on success, or StoreError::IoFailed. + * @pubstate clears backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + clearLastPresetIndex() = 0; }; } // namespace core diff --git a/Software/components/core/include/core/IntegrationError.hpp b/Software/components/core/include/core/IntegrationError.hpp new file mode 100644 index 0000000..db0d978 --- /dev/null +++ b/Software/components/core/include/core/IntegrationError.hpp @@ -0,0 +1,34 @@ +/** + * @file IntegrationError.hpp + * @brief Typed errors for application integration flows. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +namespace core { + +/** + * @brief IntegrationError — failure causes for preset recall orchestration. + * + * @dname IntegrationError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class IntegrationError { + StoreFailed, ///< NVS load/save failed during startup or recall. + PresetNotFound, ///< Preset index out of range. + TuneFailed, ///< Tuner could not recall the preset target. + AudioFailed, ///< ADAU1701 profile could not be applied. +}; + +} // namespace core diff --git a/Software/components/core/include/core/RdsMetadataAccumulator.hpp b/Software/components/core/include/core/RdsMetadataAccumulator.hpp index 2048377..3126817 100644 --- a/Software/components/core/include/core/RdsMetadataAccumulator.hpp +++ b/Software/components/core/include/core/RdsMetadataAccumulator.hpp @@ -36,7 +36,6 @@ public: * @brief reset — clear accumulated PS and radiotext. * * @dname reset - * @return n/a * @pubstate clears internal buffers. * * @author Michele Bigi @@ -52,7 +51,6 @@ public: * @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 diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index d662d38..7605fb5 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -104,3 +104,19 @@ 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) + +add_executable(integration_service_test integration_service_test.cpp) +target_include_directories(integration_service_test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/integration/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/station/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/tuner/include" + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/audio/include" +) +target_sources(integration_service_test PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/integration/src/IntegrationService.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/station/src/StationService.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/tuner/src/TunerService.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/../../services/audio/src/AudioService.cpp" +) +target_link_libraries(integration_service_test PRIVATE digiradio_core) +add_test(NAME integration_service_test COMMAND integration_service_test) diff --git a/Software/components/core/test/integration_service_test.cpp b/Software/components/core/test/integration_service_test.cpp new file mode 100644 index 0000000..dfa62cb --- /dev/null +++ b/Software/components/core/test/integration_service_test.cpp @@ -0,0 +1,286 @@ +/** + * @file integration_service_test.cpp + * @brief Host tests for IntegrationService preset recall flow. + * + * 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 "audio/AudioService.hpp" +#include "core/FrequencyKHz.hpp" +#include "core/IDsp.hpp" +#include "core/ISecureStore.hpp" +#include "core/StationName.hpp" +#include "core/TunerBand.hpp" +#include "integration/IntegrationService.hpp" +#include "station/StationService.hpp" +#include "tuner/TunerService.hpp" + +#include +#include +#include +#include + +namespace { + +class FakeDsp final : public core::IDsp { +public: + std::size_t applyProfileCalls{0U}; + + [[nodiscard]] std::expected applyProfile( + const core::AudioProfile&) override + { + ++applyProfileCalls; + return {}; + } + + [[nodiscard]] std::expected applyMixer( + const core::MixerState&) override + { + return {}; + } + + [[nodiscard]] std::expected applyEq( + const core::EqProfile&) override + { + return {}; + } + + [[nodiscard]] std::expected setInputVolume( + core::MixSource, core::GainDb, core::GainDb) override + { + return {}; + } + + [[nodiscard]] std::expected setMasterVolume( + core::GainDb, core::GainDb) override + { + return {}; + } + + [[nodiscard]] std::expected setEqBand( + core::EqBandIndex, core::GainDb, core::FrequencyHz, float) override + { + return {}; + } +}; + +class TrackingTuner final : public core::ITuner { +public: + bool tunedFm{false}; + + [[nodiscard]] std::expected boot( + core::TunerBand) override + { + return {}; + } + + [[nodiscard]] std::expected currentBand() + const override + { + return core::TunerBand::Fm; + } + + [[nodiscard]] std::expected readStatus() + override + { + core::TunerStatus status = {}; + status.booted = true; + status.band = core::TunerBand::Fm; + status.locked = true; + status.volume = 40; + return status; + } + + [[nodiscard]] std::expected tuneDab( + std::uint8_t) override + { + return {}; + } + + [[nodiscard]] std::expected tuneFm( + core::FrequencyKHz) override + { + tunedFm = true; + 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 {}; + } + + [[nodiscard]] bool hasLastPresetIndex() const override + { + return lastPresetIndex_.has_value(); + } + + [[nodiscard]] std::expected saveLastPresetIndex( + std::uint8_t index) override + { + lastPresetIndex_ = index; + return {}; + } + + [[nodiscard]] std::expected + loadLastPresetIndex() const override + { + if (!lastPresetIndex_) { + return std::unexpected(core::StoreError::NotFound); + } + return *lastPresetIndex_; + } + + [[nodiscard]] std::expected clearLastPresetIndex() + override + { + lastPresetIndex_.reset(); + return {}; + } + +private: + std::optional stationJson_; + std::optional lastPresetIndex_; +}; + +[[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 runRecallPresetTest() +{ + FakeSecureStore store; + TrackingTuner tunerHw; + FakeDsp dsp; + tuner::TunerService tunerService(tunerHw); + audio::AudioService audioService(dsp, nullptr); + station::StationService stationService(store, tunerService); + integration::IntegrationService integration( + store, tunerService, audioService, stationService); + + if (auto added = stationService.add(makeFmStation("Jazz", 101500U)); !added) { + std::cerr << "add preset failed\n"; + return EXIT_FAILURE; + } + + if (auto recalled = integration.recallPreset(0U); !recalled) { + std::cerr << "recallPreset failed\n"; + return EXIT_FAILURE; + } + if (!tunerHw.tunedFm || dsp.applyProfileCalls == 0U + || !store.hasLastPresetIndex()) { + std::cerr << "recallPreset side effects missing\n"; + return EXIT_FAILURE; + } + + tunerHw.tunedFm = false; + dsp.applyProfileCalls = 0U; + integration::IntegrationService rebooted( + store, tunerService, audioService, stationService); + if (auto started = rebooted.startup(); !started) { + std::cerr << "startup failed\n"; + return EXIT_FAILURE; + } + if (!tunerHw.tunedFm || dsp.applyProfileCalls == 0U) { + std::cerr << "startup did not recall last preset\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runRecallPresetTest() != 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 index b62ed77..43c661b 100644 --- a/Software/components/core/test/station_service_test.cpp +++ b/Software/components/core/test/station_service_test.cpp @@ -143,8 +143,37 @@ public: return {}; } + [[nodiscard]] bool hasLastPresetIndex() const override + { + return lastPresetIndex_.has_value(); + } + + [[nodiscard]] std::expected saveLastPresetIndex( + std::uint8_t index) override + { + lastPresetIndex_ = index; + return {}; + } + + [[nodiscard]] std::expected + loadLastPresetIndex() const override + { + if (!lastPresetIndex_) { + return std::unexpected(core::StoreError::NotFound); + } + return *lastPresetIndex_; + } + + [[nodiscard]] std::expected clearLastPresetIndex() + override + { + lastPresetIndex_.reset(); + return {}; + } + private: std::optional stationJson_; + std::optional lastPresetIndex_; }; [[nodiscard]] core::Station makeFmStation(const char* name, std::uint32_t khz) diff --git a/Software/components/net/CMakeLists.txt b/Software/components/net/CMakeLists.txt index c88ccad..660a17d 100644 --- a/Software/components/net/CMakeLists.txt +++ b/Software/components/net/CMakeLists.txt @@ -7,7 +7,7 @@ idf_component_register( "src/NetBootstrap.cpp" INCLUDE_DIRS "include" EMBED_FILES "www/index.html.gz" - REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server tuner audio bluetooth station bt1035 + REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server tuner audio bluetooth station integration bt1035 ) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/net/include/net/NetBootstrap.hpp b/Software/components/net/include/net/NetBootstrap.hpp index 62c8c47..c1550a9 100644 --- a/Software/components/net/include/net/NetBootstrap.hpp +++ b/Software/components/net/include/net/NetBootstrap.hpp @@ -40,6 +40,10 @@ namespace station { class StationService; } // namespace station +namespace integration { +class IntegrationService; +} // namespace integration + namespace tuner { class TunerService; } // namespace tuner @@ -68,6 +72,7 @@ public: * @param audio Audio service exposed by the HTTP API. * @param bluetooth Bluetooth pairing service for REST routes. * @param stations Station preset service for REST routes. + * @param integration Application orchestration for preset recall. * @param companionChips Boot flags exposed on GET /api/health. * @return NetBootstrap on success, or a NetError. * @pubstate none @@ -79,6 +84,7 @@ public: start(core::ISecureStore& store, tuner::TunerService& tuner, audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, station::StationService& stations, + integration::IntegrationService& integration, core::CompanionChipStatus companionChips); NetBootstrap(const NetBootstrap&) = delete; diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index 278ebbd..8a73951 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -38,6 +38,10 @@ namespace station { class StationService; } // namespace station +namespace integration { +class IntegrationService; +} // namespace integration + namespace tuner { class TunerService; } // namespace tuner @@ -63,6 +67,7 @@ struct HttpRouteContext { audio::AudioService* audio; ///< Audio service for ADAU1701 REST routes. bluetooth::BluetoothService* bluetooth; ///< Bluetooth pairing REST routes. station::StationService* stations; ///< Preset list REST routes. + integration::IntegrationService* integration; ///< Preset recall orchestration. core::CompanionChipStatus companionChips; ///< Boot flags for /api/health. }; @@ -139,6 +144,7 @@ public: * @param audio Audio service for the audio REST routes. * @param bluetooth Bluetooth service for pairing REST routes. * @param stations Station preset service for list REST routes. + * @param integration Application orchestration for preset recall. * @param companionChips Boot flags for GET /api/health. * @return Ok on success, or NetError::HttpServerStartFailed. * @pubstate writes server_, store_, netState_, and service pointers on success. @@ -151,6 +157,7 @@ public: tuner::TunerService& tuner, audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, station::StationService& stations, + integration::IntegrationService& integration, core::CompanionChipStatus companionChips); private: @@ -161,6 +168,7 @@ private: audio::AudioService* audio_; bluetooth::BluetoothService* bluetooth_; station::StationService* stations_; + integration::IntegrationService* integration_; HttpRouteContext routeContext_; }; diff --git a/Software/components/net/src/NetBootstrap.cpp b/Software/components/net/src/NetBootstrap.cpp index bab6b2f..176b998 100644 --- a/Software/components/net/src/NetBootstrap.cpp +++ b/Software/components/net/src/NetBootstrap.cpp @@ -105,6 +105,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, station::StationService& stations, + integration::IntegrationService& integration, core::CompanionChipStatus companionChips) { esp_netif_create_default_wifi_ap(); @@ -117,7 +118,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, SetupWebServer webServer; if (auto webResult = webServer.start(store, NetState::SoftApSetup, tuner, audio, - bluetooth, stations, companionChips); + bluetooth, stations, integration, companionChips); !webResult) { return std::unexpected(webResult.error()); } @@ -143,6 +144,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, station::StationService& stations, + integration::IntegrationService& integration, core::CompanionChipStatus companionChips) { auto credsResult = store.loadWifiCredentials(); @@ -161,7 +163,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, SetupWebServer webServer; if (auto webResult = webServer.start(store, NetState::StaConnected, tuner, audio, - bluetooth, stations, companionChips); + bluetooth, stations, integration, companionChips); !webResult) { return std::unexpected(webResult.error()); } @@ -178,6 +180,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, station::StationService& stations, + integration::IntegrationService& integration, core::CompanionChipStatus companionChips) { if (auto platform = initPlatform(); !platform) { @@ -190,14 +193,14 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, if (store.hasWifiCredentials()) { auto staResult = startStaMode(store, tuner, audio, bluetooth, stations, - companionChips); + integration, companionChips); if (staResult) { return staResult; } ESP_LOGW(kTag, "STA join failed — falling back to setup SoftAP"); } - return startSetupMode(store, tuner, audio, bluetooth, stations, + return startSetupMode(store, tuner, audio, bluetooth, stations, integration, companionChips); } diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 8aa2dad..da51bb1 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -25,6 +25,7 @@ #include "core/FirmwareVersion.hpp" #include "core/HealthStatus.hpp" #include "core/HealthStatusJson.hpp" +#include "core/IntegrationError.hpp" #include "core/ParseError.hpp" #include "core/SeekDirection.hpp" #include "core/StationListJson.hpp" @@ -35,6 +36,7 @@ #include "audio/AudioService.hpp" #include "bluetooth/BluetoothService.hpp" #include "station/StationService.hpp" +#include "integration/IntegrationService.hpp" #include "bt1035/Bt1035Error.hpp" #include "esp_http_server.h" @@ -50,7 +52,7 @@ namespace net { namespace { constexpr char kTag[] = "SetupWebServer"; -constexpr char kFirmwareVersion[] = "0.8.0"; +constexpr char kFirmwareVersion[] = "0.8.1"; constexpr unsigned kRebootDelaySec = 3; extern const uint8_t www_index_html_gz_start[] asm( @@ -136,6 +138,22 @@ void rebootTask(void* arg) return "tuner_error"; } +[[nodiscard]] const char* integrationErrorToken( + core::IntegrationError error) noexcept +{ + switch (error) { + case core::IntegrationError::StoreFailed: + return "store_failed"; + case core::IntegrationError::PresetNotFound: + return "not_found"; + case core::IntegrationError::TuneFailed: + return "tune_failed"; + case core::IntegrationError::AudioFailed: + return "audio_failed"; + } + return "integration_error"; +} + [[nodiscard]] const char* bt1035ErrorToken(bt1035::Bt1035Error error) noexcept { switch (error) { @@ -831,7 +849,7 @@ esp_err_t stationsReorderPostHandler(httpd_req_t* req) esp_err_t stationsTunePostHandler(httpd_req_t* req) { auto* ctx = routeContextFrom(req); - if (ctx == nullptr || ctx->stations == nullptr || ctx->tuner == nullptr) { + if (ctx == nullptr || ctx->integration == nullptr) { httpd_resp_set_status(req, "503 Service Unavailable"); return httpd_resp_send(req, nullptr, 0); } @@ -848,17 +866,14 @@ esp_err_t stationsTunePostHandler(httpd_req_t* req) httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, json.c_str(), json.size()); } - if (parsed->index >= ctx->stations->list().stations().size()) { - const std::string json = - core::serializeStationListErrorJson("not_found"); - httpd_resp_set_status(req, "404 Not Found"); - httpd_resp_set_type(req, "application/json"); - return httpd_resp_send(req, json.c_str(), json.size()); - } - if (auto tuned = ctx->stations->tuneToIndex(parsed->index); !tuned) { - const std::string json = - core::serializeTunerErrorJson(tunerErrorToken(tuned.error())); - httpd_resp_set_status(req, "500 Internal Server Error"); + if (auto tuned = ctx->integration->recallPreset(parsed->index); !tuned) { + const std::string json = core::serializeTunerErrorJson( + integrationErrorToken(tuned.error())); + if (tuned.error() == core::IntegrationError::PresetNotFound) { + httpd_resp_set_status(req, "404 Not Found"); + } else { + httpd_resp_set_status(req, "500 Internal Server Error"); + } httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, json.c_str(), json.size()); } @@ -876,7 +891,8 @@ SetupWebServer::SetupWebServer() , audio_(nullptr) , bluetooth_(nullptr) , stations_(nullptr) - , routeContext_{nullptr, nullptr, nullptr, nullptr, nullptr, {}} + , integration_(nullptr) + , routeContext_{nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, {}} { } @@ -888,6 +904,7 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept , audio_(other.audio_) , bluetooth_(other.bluetooth_) , stations_(other.stations_) + , integration_(other.integration_) , routeContext_(other.routeContext_) { other.server_ = nullptr; @@ -897,7 +914,9 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept other.audio_ = nullptr; other.bluetooth_ = nullptr; other.stations_ = nullptr; - other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}}; + other.integration_ = nullptr; + other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, + nullptr, {}}; } SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept @@ -913,6 +932,7 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept audio_ = other.audio_; bluetooth_ = other.bluetooth_; stations_ = other.stations_; + integration_ = other.integration_; routeContext_ = other.routeContext_; other.server_ = nullptr; other.store_ = nullptr; @@ -921,7 +941,9 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept other.audio_ = nullptr; other.bluetooth_ = nullptr; other.stations_ = nullptr; - other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}}; + other.integration_ = nullptr; + other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, + nullptr, {}}; } return *this; } @@ -932,7 +954,7 @@ SetupWebServer::~SetupWebServer() httpd_stop(server_); server_ = nullptr; } - routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}}; + routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, {}}; } std::expected SetupWebServer::start( @@ -942,6 +964,7 @@ std::expected SetupWebServer::start( audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, station::StationService& stations, + integration::IntegrationService& integration, core::CompanionChipStatus companionChips) { if (server_ != nullptr) { @@ -954,11 +977,13 @@ std::expected SetupWebServer::start( audio_ = &audio; bluetooth_ = &bluetooth; stations_ = &stations; + integration_ = &integration; routeContext_.store = &store; routeContext_.tuner = &tuner; routeContext_.audio = &audio; routeContext_.bluetooth = &bluetooth; routeContext_.stations = &stations; + routeContext_.integration = &integration; routeContext_.companionChips = companionChips; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); @@ -967,7 +992,8 @@ std::expected SetupWebServer::start( if (httpd_start(&server_, &config) != ESP_OK) { ESP_LOGE(kTag, "httpd_start failed"); - routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}}; + routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, + {}}; return std::unexpected(NetError::HttpServerStartFailed); } diff --git a/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp b/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp index 7bcb368..2475005 100644 --- a/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp +++ b/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp @@ -111,6 +111,17 @@ public: [[nodiscard]] std::expected clearStationList() override; + + [[nodiscard]] bool hasLastPresetIndex() const override; + + [[nodiscard]] std::expected + saveLastPresetIndex(std::uint8_t index) override; + + [[nodiscard]] std::expected + loadLastPresetIndex() const override; + + [[nodiscard]] std::expected + clearLastPresetIndex() override; }; } // namespace secure_store diff --git a/Software/components/secure_store/src/NvsSecureStore.cpp b/Software/components/secure_store/src/NvsSecureStore.cpp index db8d59d..f495ca2 100644 --- a/Software/components/secure_store/src/NvsSecureStore.cpp +++ b/Software/components/secure_store/src/NvsSecureStore.cpp @@ -31,6 +31,7 @@ constexpr char kNamespace[] = "digiradio"; constexpr char kSsidKey[] = "wifi_ssid"; constexpr char kPasswordKey[] = "wifi_pwd"; constexpr char kStationListKey[] = "station_list"; +constexpr char kLastPresetKey[] = "last_preset"; } // namespace bool NvsSecureStore::hasWifiCredentials() const @@ -229,4 +230,75 @@ std::expected NvsSecureStore::clearStationList() return {}; } +bool NvsSecureStore::hasLastPresetIndex() const +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) { + return false; + } + + std::uint8_t value = 0U; + const esp_err_t err = nvs_get_u8(handle, kLastPresetKey, &value); + nvs_close(handle); + + return err == ESP_OK; +} + +std::expected +NvsSecureStore::saveLastPresetIndex(std::uint8_t index) +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + + esp_err_t err = nvs_set_u8(handle, kLastPresetKey, index); + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + + if (err != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + return {}; +} + +std::expected +NvsSecureStore::loadLastPresetIndex() const +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::NotFound); + } + + std::uint8_t value = 0U; + const esp_err_t err = nvs_get_u8(handle, kLastPresetKey, &value); + nvs_close(handle); + + if (err != ESP_OK) { + return std::unexpected(core::StoreError::NotFound); + } + return value; +} + +std::expected NvsSecureStore::clearLastPresetIndex() +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + + esp_err_t err = nvs_erase_key(handle, kLastPresetKey); + if (err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND) { + err = nvs_commit(handle); + } + nvs_close(handle); + + if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) { + return std::unexpected(core::StoreError::IoFailed); + } + return {}; +} + } // namespace secure_store diff --git a/Software/components/services/CMakeLists.txt b/Software/components/services/integration/CMakeLists.txt similarity index 57% rename from Software/components/services/CMakeLists.txt rename to Software/components/services/integration/CMakeLists.txt index 6bf8c99..747428f 100644 --- a/Software/components/services/CMakeLists.txt +++ b/Software/components/services/integration/CMakeLists.txt @@ -1,6 +1,8 @@ idf_component_register( - SRCS "src/component_stub.cpp" + SRCS + "src/IntegrationService.cpp" INCLUDE_DIRS "include" + REQUIRES core tuner audio station ) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/services/integration/include/integration/IntegrationService.hpp b/Software/components/services/integration/include/integration/IntegrationService.hpp new file mode 100644 index 0000000..9cce7e5 --- /dev/null +++ b/Software/components/services/integration/include/integration/IntegrationService.hpp @@ -0,0 +1,132 @@ +/** + * @file IntegrationService.hpp + * @brief Orchestrates tuner, audio, and preset recall for app_main. + * + * 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 "audio/AudioService.hpp" +#include "core/IntegrationError.hpp" +#include "core/ISecureStore.hpp" +#include "station/StationService.hpp" +#include "tuner/TunerService.hpp" + +#include +#include + +namespace integration { + +/** + * @brief IntegrationService — end-to-end radio listening orchestration. + * + * @dname IntegrationService + * @return n/a (type) + * @pubstate Borrows store, tuner, audio, and stations for process lifetime. + * Called from app_main for startup and from HTTP for preset recall. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class IntegrationService { +public: + /** + * @brief IntegrationService — bind application services. + * + * @dname IntegrationService + * @param store Secure persistence for presets and last recall index. + * @param tuner Tuner orchestration. + * @param audio ADAU1701 profile orchestration. + * @param stations Preset list CRUD and tune delegation. + * @pubstate stores references; no side effects until startup(). + * + * @author Michele Bigi + * @date 2026-07-06 + */ + IntegrationService(core::ISecureStore& store, + tuner::TunerService& tuner, + audio::AudioService& audio, + station::StationService& stations); + + /** + * @brief startup — load presets and recall the last station if stored. + * + * @dname startup + * @return Ok on success, or IntegrationError when recall fails. + * @pubstate loads station list; may tune and refresh the audio profile. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected startup(); + + /** + * @brief recallPreset — tune a preset and apply the saved audio profile. + * + * @dname recallPreset + * @param index Zero-based preset list position. + * @return Ok on success, or IntegrationError. + * @pubstate tunes via StationService, safeloads AudioProfile, persists index. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected recallPreset( + std::size_t index); + + /** + * @brief stations — access the preset list service. + * + * @dname stations + * @return Reference to the injected StationService. + * @pubstate reads stations_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] station::StationService& stations() noexcept; + + /** + * @brief tuner — access the tuner service. + * + * @dname tuner + * @return Reference to the injected TunerService. + * @pubstate reads tuner_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] tuner::TunerService& tuner() noexcept; + + /** + * @brief audio — access the audio service. + * + * @dname audio + * @return Reference to the injected AudioService. + * @pubstate reads audio_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] audio::AudioService& audio() noexcept; + +private: + [[nodiscard]] std::expected + applyStoredAudioProfile(); + + [[nodiscard]] std::expected persistLastPreset( + std::size_t index); + + core::ISecureStore& store_; + tuner::TunerService& tuner_; + audio::AudioService& audio_; + station::StationService& stations_; +}; + +} // namespace integration diff --git a/Software/components/services/integration/src/IntegrationService.cpp b/Software/components/services/integration/src/IntegrationService.cpp new file mode 100644 index 0000000..60577fb --- /dev/null +++ b/Software/components/services/integration/src/IntegrationService.cpp @@ -0,0 +1,109 @@ +/** + * @file IntegrationService.cpp + * @brief IntegrationService 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 "integration/IntegrationService.hpp" + +namespace integration { + +IntegrationService::IntegrationService(core::ISecureStore& store, + tuner::TunerService& tuner, + audio::AudioService& audio, + station::StationService& stations) + : store_(store) + , tuner_(tuner) + , audio_(audio) + , stations_(stations) +{ +} + +std::expected IntegrationService::startup() +{ + if (auto loaded = stations_.loadFromStore(); !loaded) { + return std::unexpected(core::IntegrationError::StoreFailed); + } + + if (!store_.hasLastPresetIndex()) { + return {}; + } + + const auto index = store_.loadLastPresetIndex(); + if (!index) { + return std::unexpected(core::IntegrationError::StoreFailed); + } + + if (*index >= stations_.list().stations().size()) { + (void)store_.clearLastPresetIndex(); + return {}; + } + + (void)recallPreset(*index); + return {}; +} + +std::expected IntegrationService::recallPreset( + std::size_t index) +{ + if (index >= stations_.list().stations().size()) { + return std::unexpected(core::IntegrationError::PresetNotFound); + } + + if (auto tuned = stations_.tuneToIndex(index); !tuned) { + return std::unexpected(core::IntegrationError::TuneFailed); + } + + if (auto applied = applyStoredAudioProfile(); !applied) { + return applied; + } + + if (auto saved = persistLastPreset(index); !saved) { + return std::unexpected(core::IntegrationError::StoreFailed); + } + + return {}; +} + +station::StationService& IntegrationService::stations() noexcept +{ + return stations_; +} + +tuner::TunerService& IntegrationService::tuner() noexcept +{ + return tuner_; +} + +audio::AudioService& IntegrationService::audio() noexcept +{ + return audio_; +} + +std::expected +IntegrationService::applyStoredAudioProfile() +{ + if (auto applied = audio_.applyProfile(audio_.currentProfile(), false); + !applied) { + return std::unexpected(core::IntegrationError::AudioFailed); + } + return {}; +} + +std::expected IntegrationService::persistLastPreset( + std::size_t index) +{ + if (index > 255U) { + return std::unexpected(core::StoreError::InvalidData); + } + return store_.saveLastPresetIndex(static_cast(index)); +} + +} // namespace integration diff --git a/Software/components/services/src/component_stub.cpp b/Software/components/services/src/component_stub.cpp deleted file mode 100644 index 6083844..0000000 --- a/Software/components/services/src/component_stub.cpp +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @file component_stub.cpp - * @brief Application services placeholder (Slice 7). - * - * DigiRadio firmware — https://github.com/manvalan/DigiRadio - * - * Copyright 2026 Michele Bigi - * SPDX-License-Identifier: Apache-2.0 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * @author Michele Bigi - * @date 2026-07-06 - */ - -namespace services::detail { - -/** - * @brief servicesComponentLinked — ensures the services component links. - * - * @dname servicesComponentLinked - * @return n/a (type) - * @pubstate n/a - * - * @author Michele Bigi - * @date 2026-07-06 - */ -void servicesComponentLinked() noexcept {} - -} // namespace services::detail diff --git a/Software/docs/TODO.md b/Software/docs/TODO.md index f628843..ea3fb3e 100644 --- a/Software/docs/TODO.md +++ b/Software/docs/TODO.md @@ -12,15 +12,15 @@ errors, no plaintext secrets. Working directory for all commands is `Software/`. -**Current firmware:** `0.8.0` — broadcast metadata (RDS PS/RT, DAB DLS), -preset reorder, CI gate (Doxygen + host tests + manual sync). +**Current firmware:** `0.8.1` — integration service (preset recall + +audio profile), RDS/DLS metadata, CI gate. --- ## Completed (fw 0.7.0–0.7.2) -- **Broadcast metadata (T4)** — RDS PS/RT and DAB DLS in - `/api/tuner/status`, core parsers, Si4684 driver hook, UI lines. +- **Integration service (T5)** — preset recall with audio profile re-apply, + last-preset NVS, \texttt{app\_main} orchestration. - **BT1035 pairing** — `AT+PAIR`, `AT+A2DPSTAT`, `AT+A2DPDISC`, `BluetoothService`, REST + UI (not numbered below; landed with Slice 7). @@ -50,16 +50,10 @@ status and preset save, UI Up/Dn, host tests. **Remaining:** device HIL `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 -placeholder. Tuner and Audio services exist separately but nothing -orchestrates them together. -**What:** implement the integration layer that binds `TunerService`, -`AudioService`, the station list, and the network layer into the -application flow (e.g. "select a preset → tune → apply the stored audio -profile"). Replace the stub file. -**Done when:** `app_main` drives a real end-to-end flow through this -service; the stub is gone; a manual section documents the new class. +### T5. Remove the services stub — integration service — **DONE (fw 0.8.1)** +`integration::IntegrationService` orchestrates startup, preset recall, +audio profile re-apply, and last-preset NVS. Stub removed; `app_main` and +`POST /api/stations/tune` delegate here. --- diff --git a/Software/docs/manual/ch-api.tex b/Software/docs/manual/ch-api.tex index 59dd6cd..9c15b72 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.8.0", +{"status":"ok","fw":"0.8.1", "chips":{"si4684":true,"adau1701":true,"bt1035":true}} \end{drcode} \begin{itemize} @@ -320,13 +320,16 @@ Reorders presets using \texttt{StationList::move()}. Body: \subsection{\texttt{POST /api/stations/tune}} \label{sec:api-stations-tune} -Recalls a preset via \texttt{station::StationService::tuneToIndex()}. +Recalls a preset via \texttt{integration::IntegrationService::recallPreset()} +(tune, re-apply saved audio profile, persist last index). \section{Boot and network state machine} \label{sec:api-boot-flow} -At boot, \texttt{net::NetBootstrap::start(store, tuner, audio, bluetooth, -stations)} consults \texttt{ISecureStore::hasWifiCredentials()}: +At boot, \texttt{integration::IntegrationService::startup()} loads presets +and best-effort recalls the last station. Then +\texttt{net::NetBootstrap::start(store, tuner, audio, bluetooth, stations, +integration)} consults \texttt{ISecureStore::hasWifiCredentials()}: \begin{enumerate} \item \textbf{Credentials present} --- create STA netif, connect via @@ -346,7 +349,8 @@ Wi-Fi credentials are stored in NVS namespace \texttt{digiradio}, keys \texttt{wifi\_ssid} and \texttt{wifi\_pwd}. Audio profiles (non-secret) use the same namespace, key \texttt{audio\_profile\_json}, via \texttt{secure\_store::NvsAudioProfileStore}. Station presets use key -\texttt{station\_list} (JSON blob). Passwords are wrapped in +\texttt{station\_list} (JSON blob). The last recalled preset index is stored +as \texttt{last\_preset} (u8). Passwords are wrapped in \texttt{core::Secret} in RAM and are never logged or returned by the API. \begin{drcaution}[Encryption at rest] diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index 7632de5..3624478 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -261,6 +261,12 @@ Application service loading/saving presets from NVS and recalling them via \texttt{TunerService}. Exposed on \texttt{/api/stations/*} and the Presets web UI section. +\section{IntegrationService}\label{cls:IntegrationService} +End-to-end orchestration at boot and on preset recall: loads the station +list, reapplies the saved \texttt{AudioProfile} after tune, and persists +the last preset index (NVS key \texttt{last\_preset}). Used by +\texttt{app\_main} and \texttt{POST /api/stations/tune}. + \section{BluetoothService}\label{cls:BluetoothService} Application service for BT1035 pairing and A2DP status (Chapter~\ref{ch:bt1035}). Delegates to \texttt{Bt1035Driver}; tracks diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index ddaa7fe..a29752d 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -3,5 +3,5 @@ idf_component_register( "main.cpp" "hardware_bootstrap.cpp" INCLUDE_DIRS "." - REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station + REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration ) diff --git a/Software/main/main.cpp b/Software/main/main.cpp index 097ac4b..567cb65 100644 --- a/Software/main/main.cpp +++ b/Software/main/main.cpp @@ -13,6 +13,7 @@ #include "hardware_bootstrap.hpp" #include "bluetooth/BluetoothService.hpp" +#include "integration/IntegrationService.hpp" #include "net/NetBootstrap.hpp" #include "secure_store/NvsSecureStore.hpp" #include "station/StationService.hpp" @@ -42,14 +43,14 @@ void heartbeatTask(void* arg) * @brief app_main — ESP-IDF entry point for DigiRadio firmware. * * @dname app_main - * @pubstate boots companion chips, network stack, and heartbeat task. + * @pubstate boots companion chips, integration layer, network, and heartbeat. * * @author Michele Bigi * @date 2026-07-06 */ extern "C" void app_main() { - ESP_LOGI(kTag, "DigiRadio firmware boot — Slice 7"); + ESP_LOGI(kTag, "DigiRadio firmware boot"); auto hwResult = hardware::HardwareBootstrap::boot(); if (!hwResult) { @@ -57,22 +58,33 @@ extern "C" void app_main() return; } + static secure_store::NvsSecureStore store; + static tuner::TunerService tunerService( hardware::HardwareBootstrap::si4684Tuner()); - static secure_store::NvsSecureStore store; - static station::StationService stationService(store, tunerService); - if (auto loaded = stationService.loadFromStore(); !loaded) { - ESP_LOGW(kTag, "station list load failed"); + + static integration::IntegrationService integration( + store, + tunerService, + hardware::HardwareBootstrap::audioService(), + stationService); + + if (auto started = integration.startup(); !started) { + ESP_LOGW(kTag, "integration startup failed — continuing without recall"); } static bluetooth::BluetoothService bluetoothService( hardware::HardwareBootstrap::bt1035Driver()); auto netResult = net::NetBootstrap::start( - store, tunerService, hardware::HardwareBootstrap::audioService(), - bluetoothService, stationService, + store, + tunerService, + hardware::HardwareBootstrap::audioService(), + bluetoothService, + stationService, + integration, hardware::HardwareBootstrap::companionChipStatus()); if (!netResult) { ESP_LOGE(kTag, "network bootstrap failed");