From 3a10ed75aa346d9f7afcd9bebfbf1c48dbdac750 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 18 Aug 2026 07:50:28 +0200 Subject: [PATCH] Add full FM band scan for building a station/channel list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New TunerService::scanFullFmBand(): tunes to the band bottom then seeks up repeatedly (reusing the existing hardware-seek + RDS-name-poll machinery from scanForStation()) until the sweep wraps back around, collecting every station that clears the existing scan RSSI/SNR thresholds. Returns the list without touching saved presets or leaving the tuner in any particular place — callers decide what to do with the results. New POST /api/tuner/scan/full endpoint (core::TunerFmScannedStation DTO, serializeTunerFmBandScanJson). Blocks for the whole sweep like the existing /api/tuner/scan. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0178rASQ6ZETPMUamvpoR2KR --- .../core/include/core/TunerJson.hpp | 32 ++++ Software/components/core/src/TunerJson.cpp | 20 +++ .../components/core/test/tuner_json_test.cpp | 26 ++++ .../components/net/src/SetupWebServer.cpp | 145 ++++++++++++++++++ .../tuner/include/tuner/TunerService.hpp | 16 ++ .../services/tuner/src/TunerService.cpp | 67 ++++++++ 6 files changed, 306 insertions(+) diff --git a/Software/components/core/include/core/TunerJson.hpp b/Software/components/core/include/core/TunerJson.hpp index 511d89a..53fae2d 100644 --- a/Software/components/core/include/core/TunerJson.hpp +++ b/Software/components/core/include/core/TunerJson.hpp @@ -95,6 +95,23 @@ struct TunerScanResult { std::optional stationName; ///< RDS PS or DAB label when known. }; +/** + * @brief TunerFmScannedStation — one hit from a full FM band scan. + * + * @dname TunerFmScannedStation + * @return n/a (type) + * @pubstate Plain DTO built by TunerService::scanFullFmBand(). + * + * @author Michele Bigi + * @date 2026-08-18 + */ +struct TunerFmScannedStation { + FrequencyKHz frequency; ///< Centre frequency of the hit. + std::int8_t rssiDbuV; ///< RSSI at the time of the hit. + std::int8_t snrDb; ///< SNR at the time of the hit. + std::optional stationName; ///< RDS PS name, if decoded in time. +}; + /** * @brief serializeTunerStatusJson — serialise a tuner snapshot for GET status. * @@ -207,4 +224,19 @@ struct TunerScanResult { */ [[nodiscard]] std::string serializeTunerScanJson(const TunerScanResult& result); +/** + * @brief serializeTunerFmBandScanJson — serialise a full FM band scan. + * + * @dname serializeTunerFmBandScanJson + * @param stations Hits from TunerService::scanFullFmBand(), in the + * order the sweep found them (ascending frequency). + * @return JSON object with a `stations` array for the HTTP response body. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-08-18 + */ +[[nodiscard]] std::string serializeTunerFmBandScanJson( + const std::vector& stations); + } // namespace core diff --git a/Software/components/core/src/TunerJson.cpp b/Software/components/core/src/TunerJson.cpp index 5ca251d..e2fef81 100644 --- a/Software/components/core/src/TunerJson.cpp +++ b/Software/components/core/src/TunerJson.cpp @@ -316,4 +316,24 @@ std::string serializeTunerScanJson(const TunerScanResult& result) return out.str(); } +std::string serializeTunerFmBandScanJson( + const std::vector& stations) +{ + std::ostringstream out; + out << "{\"stations\":["; + for (std::size_t i = 0; i < stations.size(); ++i) { + if (i > 0U) { + out << ','; + } + const auto& s = stations[i]; + out << "{\"frequency_khz\":" << s.frequency.value() + << ",\"rssi_dbuv\":" << static_cast(s.rssiDbuV) + << ",\"snr_db\":" << static_cast(s.snrDb); + appendOptionalLabel(out, "station_name", s.stationName); + out << "}"; + } + out << "]}"; + return out.str(); +} + } // namespace core diff --git a/Software/components/core/test/tuner_json_test.cpp b/Software/components/core/test/tuner_json_test.cpp index b94b76c..bb4997a 100644 --- a/Software/components/core/test/tuner_json_test.cpp +++ b/Software/components/core/test/tuner_json_test.cpp @@ -139,6 +139,29 @@ namespace { return EXIT_SUCCESS; } +[[nodiscard]] int runTunerFmBandScanSerialiseTest() +{ + core::TunerFmScannedStation withName{ + *core::FrequencyKHz::tryFromKhz(98300U), 12, 13, + core::BroadcastLabel::tryFromChipBytes("RADIO 1")}; + core::TunerFmScannedStation withoutName{ + *core::FrequencyKHz::tryFromKhz(105500U), -4, -2, std::nullopt}; + + const std::string json = + core::serializeTunerFmBandScanJson({withName, withoutName}); + if (!expectEqual( + json, + R"({"stations":[{"frequency_khz":98300,"rssi_dbuv":12,"snr_db":13,"station_name":"RADIO 1"},{"frequency_khz":105500,"rssi_dbuv":-4,"snr_db":-2}]})")) { + return EXIT_FAILURE; + } + + const std::string emptyJson = core::serializeTunerFmBandScanJson({}); + if (!expectEqual(emptyJson, R"({"stations":[]})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + [[nodiscard]] int runTunerStatusMetadataSerialiseTest() { core::TunerStatus status = {}; @@ -240,5 +263,8 @@ int main() if (runTunerSeekParseTest() != EXIT_SUCCESS) { return EXIT_FAILURE; } + if (runTunerFmBandScanSerialiseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } return EXIT_SUCCESS; } diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 2ca8167..e7b5379 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -30,6 +30,7 @@ #include "core/StationListJson.hpp" #include "core/StoreError.hpp" #include "core/TunerJson.hpp" +#include "core/DspParamJson.hpp" #include "core/WifiProvisionJson.hpp" #include "core/WifiScanJson.hpp" #include "net/WifiScanner.hpp" @@ -39,6 +40,7 @@ #include "station/StationService.hpp" #include "integration/IntegrationService.hpp" #include "adau1701/FlashDspProgramSource.hpp" +#include "adau1701/Adau1701ParamTable.hpp" #include "ota/OtaService.hpp" #include "ota/OtaError.hpp" #include "core/OtaAppDescriptor.hpp" @@ -595,6 +597,42 @@ esp_err_t tunerScanPostHandler(httpd_req_t* req) return httpd_resp_send(req, json.c_str(), json.size()); } +/** + * @brief tunerFullScanPostHandler — full FM band sweep for a channel list. + * + * @dname tunerFullScanPostHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate uses route context tuner service; blocks for the whole sweep + * (tens of seconds); does not persist to the station store. + * + * @author Michele Bigi + * @date 2026-08-18 + */ +esp_err_t tunerFullScanPostHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->tuner == nullptr) { + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_send(req, nullptr, 0); + } + + ESP_LOGI(kTag, "tuner full FM band scan HTTP request"); + + auto result = ctx->tuner->scanFullFmBand(); + if (!result) { + const std::string json = + core::serializeTunerErrorJson(tunerErrorToken(result.error())); + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + const std::string json = core::serializeTunerFmBandScanJson(*result); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + /** * @brief audioProfileGetHandler — serve GET /api/audio/profile JSON. * @@ -803,6 +841,89 @@ esp_err_t audioBeepPostHandler(httpd_req_t* req) return httpd_resp_send(req, json.c_str(), json.size()); } +/** + * @brief dspParamsGetHandler — serve GET /api/dsp/params as JSON. + * + * @dname dspParamsGetHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate reads the compiled DSP program's static cell table; no I/O. + * + * @author Michele Bigi + * @date 2026-08-18 + */ +esp_err_t dspParamsGetHandler(httpd_req_t* req) +{ + std::vector params; + params.reserve(adau1701::kAdau1701ParamTable.size()); + for (const auto& entry : adau1701::kAdau1701ParamTable) { + params.push_back(core::DspParamInfo{entry.name, entry.address}); + } + const std::string json = core::serializeDspParamListJson(params); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + +/** + * @brief dspParamPutHandler — accept PUT /api/dsp/param JSON. + * + * @dname dspParamPutHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate live-only safeload write; not part of AudioProfile, never + * persisted. No domain validation on the value — same trust + * level as SigmaStudio's own Remote Connection. + * + * @author Michele Bigi + * @date 2026-08-18 + */ +esp_err_t dspParamPutHandler(httpd_req_t* req) +{ + auto* ctx = routeContextFrom(req); + if (ctx == nullptr || ctx->audio == nullptr) { + httpd_resp_set_status(req, "503 Service Unavailable"); + return httpd_resp_send(req, nullptr, 0); + } + + std::array body{}; + if (!readRequestBody(req, body)) { + httpd_resp_set_status(req, "400 Bad Request"); + return httpd_resp_send(req, nullptr, 0); + } + + const auto parsed = + core::parseDspParamWriteJson(std::string_view(body.data())); + if (!parsed) { + const std::string json = + core::serializeDspParamErrorJson(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()); + } + + const auto address = adau1701::findAdau1701ParamAddress(parsed->name); + if (!address) { + const std::string json = + core::serializeDspParamErrorJson("unknown_param"); + 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 applied = ctx->audio->writeRawParam(*address, parsed->value); + !applied) { + const std::string json = + core::serializeDspParamErrorJson("dsp_failed"); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + const std::string json = core::serializeAudioSavedJson(); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + /** * @brief streamingGetHandler — serve GET /api/streaming as JSON. * @@ -1796,6 +1917,14 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &tunerScanUri); + const httpd_uri_t tunerFullScanUri = { + .uri = "/api/tuner/scan/full", + .method = HTTP_POST, + .handler = tunerFullScanPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &tunerFullScanUri); + const httpd_uri_t audioProfileGetUri = { .uri = "/api/audio/profile", .method = HTTP_GET, @@ -1844,6 +1973,22 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &audioBeepUri); + const httpd_uri_t dspParamsUri = { + .uri = "/api/dsp/params", + .method = HTTP_GET, + .handler = dspParamsGetHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &dspParamsUri); + + const httpd_uri_t dspParamUri = { + .uri = "/api/dsp/param", + .method = HTTP_PUT, + .handler = dspParamPutHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &dspParamUri); + const httpd_uri_t streamingGetUri = { .uri = "/api/streaming", .method = HTTP_GET, diff --git a/Software/components/services/tuner/include/tuner/TunerService.hpp b/Software/components/services/tuner/include/tuner/TunerService.hpp index 8912b86..46e67c6 100644 --- a/Software/components/services/tuner/include/tuner/TunerService.hpp +++ b/Software/components/services/tuner/include/tuner/TunerService.hpp @@ -124,6 +124,22 @@ public: [[nodiscard]] std::expected scanForStation(const core::TunerScanRequest& request); + /** + * @brief scanFullFmBand — sweep the whole FM band via hardware seek. + * + * @dname scanFullFmBand + * @return Every station found, ascending frequency, or a TunerError. + * @pubstate tunes to the band bottom then seeks up repeatedly until the + * sweep wraps back around; leaves the tuner parked wherever + * the last seek landed. Does not modify saved presets. + * + * @author Michele Bigi + * @date 2026-08-18 + */ + [[nodiscard]] std::expected, + core::TunerError> + scanFullFmBand(); + /** * @brief listDabServices — programmes available on the current ensemble. * diff --git a/Software/components/services/tuner/src/TunerService.cpp b/Software/components/services/tuner/src/TunerService.cpp index 55e8d75..b9d0878 100644 --- a/Software/components/services/tuner/src/TunerService.cpp +++ b/Software/components/services/tuner/src/TunerService.cpp @@ -340,6 +340,73 @@ TunerService::scanForStation(const core::TunerScanRequest& request) return makeScanResult(request, request.maxSteps, false); } +std::expected, core::TunerError> +TunerService::scanFullFmBand() +{ + const auto bandBottom = *core::FrequencyKHz::tryFromKhz(kFmScanMinKhz); + if (auto tuned = tuneFm(bandBottom); !tuned) { + return std::unexpected(tuned.error()); + } + + std::vector stations; + std::uint32_t previousKhz = kFmScanMinKhz; + ESP_LOGI(kTag, "FM full band scan start from %u kHz", + static_cast(kFmScanMinKhz)); + + // One hardware seek per candidate; the FM band cannot hold more + // stations than this at any legal channel spacing, so it is a safe + // upper bound against seek ever failing to detect the wrap-around. + constexpr int kMaxCandidates = 60; + for (int i = 0; i < kMaxCandidates; ++i) { + auto seeked = seekFm(core::SeekDirection::Up); + if (!seeked) { + return std::unexpected(seeked.error()); + } + const std::uint32_t freqKhz = seeked->value(); + if (freqKhz <= previousKhz) { + ESP_LOGI(kTag, "FM full band scan wrapped at %u kHz", + static_cast(freqKhz)); + break; + } + previousKhz = freqKhz; + + vTaskDelay(pdMS_TO_TICKS(kFmTuneSettleMs)); + auto status = refreshStatus(); + if (!status) { + return std::unexpected(status.error()); + } + if (!fmStatusUsableForScan(*status, freqKhz, /*requireLocked=*/true)) { + continue; + } + + std::optional stationName; + for (int attempt = 0; attempt < kFmNamePollAttempts; ++attempt) { + auto polled = refreshStatus(); + if (!polled) { + break; + } + if (polled->fmStationName) { + stationName = polled->fmStationName; + break; + } + vTaskDelay(pdMS_TO_TICKS(kFmNamePollMs)); + } + + const core::TunerFmScannedStation entry{ + *status->fmFrequency, + status->fmRssiDbuV.value_or(0), + status->fmSnrDb.value_or(0), + stationName, + }; + ESP_LOGI(kTag, "FM full band scan hit: %u kHz rssi=%d snr=%d", + static_cast(freqKhz), + static_cast(entry.rssiDbuV), + static_cast(entry.snrDb)); + stations.push_back(entry); + } + return stations; +} + std::expected, core::TunerError> TunerService::listDabServices() {