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
@@ -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()
{