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:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user