Add full FM band scan for building a station/channel list

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178rASQ6ZETPMUamvpoR2KR
This commit is contained in:
2026-08-18 07:50:28 +02:00
co-authored by Claude Sonnet 5
parent 8a69cbed08
commit 3a10ed75aa
6 changed files with 306 additions and 0 deletions
@@ -95,6 +95,23 @@ struct TunerScanResult {
std::optional<BroadcastLabel> 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<BroadcastLabel> 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<TunerFmScannedStation>& stations);
} // namespace core
@@ -316,4 +316,24 @@ std::string serializeTunerScanJson(const TunerScanResult& result)
return out.str();
}
std::string serializeTunerFmBandScanJson(
const std::vector<TunerFmScannedStation>& 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<int>(s.rssiDbuV)
<< ",\"snr_db\":" << static_cast<int>(s.snrDb);
appendOptionalLabel(out, "station_name", s.stationName);
out << "}";
}
out << "]}";
return out.str();
}
} // namespace core
@@ -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;
}
@@ -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<core::DspParamInfo> 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<char, 128> 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<void, NetError> 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<void, NetError> 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,
@@ -124,6 +124,22 @@ public:
[[nodiscard]] std::expected<core::TunerScanResult, core::TunerError>
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<std::vector<core::TunerFmScannedStation>,
core::TunerError>
scanFullFmBand();
/**
* @brief listDabServices — programmes available on the current ensemble.
*
@@ -340,6 +340,73 @@ TunerService::scanForStation(const core::TunerScanRequest& request)
return makeScanResult(request, request.maxSteps, false);
}
std::expected<std::vector<core::TunerFmScannedStation>, core::TunerError>
TunerService::scanFullFmBand()
{
const auto bandBottom = *core::FrequencyKHz::tryFromKhz(kFmScanMinKhz);
if (auto tuned = tuneFm(bandBottom); !tuned) {
return std::unexpected(tuned.error());
}
std::vector<core::TunerFmScannedStation> stations;
std::uint32_t previousKhz = kFmScanMinKhz;
ESP_LOGI(kTag, "FM full band scan start from %u kHz",
static_cast<unsigned>(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<unsigned>(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<core::BroadcastLabel> 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<unsigned>(freqKhz),
static_cast<int>(entry.rssiDbuV),
static_cast<int>(entry.snrDb));
stations.push_back(entry);
}
return stations;
}
std::expected<std::vector<core::TunerServiceEntry>, core::TunerError>
TunerService::listDabServices()
{