- NvsAudioProfileStore used the key "audio_profile_json" (18 chars), exceeding NVS's 15-char key-name limit. Every nvs_set_str call failed silently with ESP_ERR_NVS_KEY_TOO_LONG (0x1109): applyProfile() always updated live DSP audio correctly, so the bug was invisible except as "store_failed" in the HTTP response and settings never surviving a reboot. Renamed to "audio_profile" (13 chars); confirmed live, EQ/mixer changes now persist across a reset. - The hand-rolled JSON extractJsonString()/extractJsonBool() helpers (duplicated per-file: TunerJson, DspParamJson, StationListJson, WebRadioJson, WifiProvisionJson, AudioProfileJson, plus inline mac/name/ save parsing in BluetoothJson) matched only the exact literal `"key":"value"` / `"key":true`, with no tolerance for a space after the colon. Standard JSON encoders (e.g. Swift's JSONEncoder in its default, non-compact mode) emit `"key": "value"`, which silently failed to parse as invalid_json/missing_field. Numeric fields were already fine (strtoul/strtof skip leading whitespace per the C standard); fixed only the string/bool extractors to skip whitespace after the colon before matching the value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
418 lines
12 KiB
C++
418 lines
12 KiB
C++
/**
|
|
* @file TunerJson.cpp
|
|
* @brief TunerJson 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 "core/TunerJson.hpp"
|
|
|
|
#include <cstdlib>
|
|
#include <sstream>
|
|
|
|
namespace core {
|
|
|
|
namespace {
|
|
|
|
[[nodiscard]] std::string_view extractJsonString(std::string_view json,
|
|
std::string_view key)
|
|
{
|
|
const std::string needle =
|
|
std::string("\"") + std::string(key) + "\":";
|
|
const std::size_t needlePos = json.find(needle);
|
|
if (needlePos == std::string_view::npos) {
|
|
return {};
|
|
}
|
|
std::size_t start = needlePos + needle.size();
|
|
while (start < json.size()
|
|
&& (json[start] == ' ' || json[start] == '\t'
|
|
|| json[start] == '\r' || json[start] == '\n')) {
|
|
++start;
|
|
}
|
|
if (start >= json.size() || json[start] != '"') {
|
|
return {};
|
|
}
|
|
const std::size_t valueStart = start + 1U;
|
|
const std::size_t valueEnd = json.find('"', valueStart);
|
|
if (valueEnd == std::string_view::npos) {
|
|
return {};
|
|
}
|
|
return json.substr(valueStart, valueEnd - valueStart);
|
|
}
|
|
|
|
[[nodiscard]] bool extractJsonUint(std::string_view json,
|
|
std::string_view key,
|
|
unsigned long& out)
|
|
{
|
|
const std::string needle =
|
|
std::string("\"") + std::string(key) + "\":";
|
|
const std::size_t start = json.find(needle);
|
|
if (start == std::string_view::npos) {
|
|
return false;
|
|
}
|
|
const std::size_t valueStart = start + needle.size();
|
|
char* end = nullptr;
|
|
out = std::strtoul(json.data() + valueStart, &end, 10);
|
|
return end != json.data() + valueStart;
|
|
}
|
|
|
|
[[nodiscard]] const char* bandToken(TunerBand band) noexcept
|
|
{
|
|
return band == TunerBand::Fm ? "fm" : "dab";
|
|
}
|
|
|
|
void appendJsonString(std::ostringstream& out, std::string_view value)
|
|
{
|
|
out << '"';
|
|
for (char c : value) {
|
|
if (c == '"' || c == '\\') {
|
|
out << '\\';
|
|
}
|
|
out << c;
|
|
}
|
|
out << '"';
|
|
}
|
|
|
|
void appendOptionalLabel(std::ostringstream& out,
|
|
std::string_view key,
|
|
const std::optional<BroadcastLabel>& label)
|
|
{
|
|
if (!label) {
|
|
return;
|
|
}
|
|
out << ",\"" << key << "\":";
|
|
appendJsonString(out, label->value());
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::string serializeTunerStatusJson(const TunerStatus& status)
|
|
{
|
|
std::ostringstream out;
|
|
out << "{\"booted\":" << (status.booted ? "true" : "false")
|
|
<< ",\"band\":\"" << bandToken(status.band) << "\""
|
|
<< ",\"locked\":" << (status.locked ? "true" : "false")
|
|
<< ",\"volume\":" << static_cast<unsigned>(status.volume);
|
|
|
|
if (status.band == TunerBand::Dab) {
|
|
out << ",\"dab\":{";
|
|
if (status.dabFreqIndex) {
|
|
out << "\"freq_index\":" << static_cast<unsigned>(*status.dabFreqIndex);
|
|
}
|
|
if (status.dabFicQuality) {
|
|
if (status.dabFreqIndex) {
|
|
out << ',';
|
|
}
|
|
out << "\"fic_quality\":" << static_cast<unsigned>(*status.dabFicQuality);
|
|
}
|
|
if (status.dabCnrDb) {
|
|
out << ",\"cnr_db\":" << static_cast<int>(*status.dabCnrDb);
|
|
}
|
|
if (status.dabPlayingServiceId) {
|
|
out << ",\"playing_service_id\":"
|
|
<< *status.dabPlayingServiceId;
|
|
}
|
|
if (status.dabPlayingComponentId) {
|
|
out << ",\"playing_component_id\":"
|
|
<< *status.dabPlayingComponentId;
|
|
}
|
|
appendOptionalLabel(out, "dynamic_label", status.dabDynamicLabel);
|
|
out << "},\"fm\":null";
|
|
} else {
|
|
out << ",\"fm\":{";
|
|
if (status.fmFrequency) {
|
|
out << "\"frequency_khz\":" << status.fmFrequency->value();
|
|
}
|
|
if (status.fmRssiDbuV) {
|
|
if (status.fmFrequency) {
|
|
out << ',';
|
|
}
|
|
out << "\"rssi_dbuv\":" << static_cast<int>(*status.fmRssiDbuV);
|
|
}
|
|
if (status.fmSnrDb) {
|
|
out << ",\"snr_db\":" << static_cast<int>(*status.fmSnrDb);
|
|
}
|
|
if (status.fmFreqOffBppm) {
|
|
out << ",\"freqoff_ppm\":"
|
|
<< (static_cast<int>(*status.fmFreqOffBppm) * 2);
|
|
}
|
|
if (status.fmStereo) {
|
|
out << ",\"stereo\":" << (*status.fmStereo ? "true" : "false");
|
|
}
|
|
appendOptionalLabel(out, "station_name", status.fmStationName);
|
|
appendOptionalLabel(out, "radiotext", status.fmRadiotext);
|
|
out << "},\"dab\":null";
|
|
}
|
|
out << '}';
|
|
return out.str();
|
|
}
|
|
|
|
std::string serializeTunerServicesJson(
|
|
const std::vector<TunerServiceEntry>& services)
|
|
{
|
|
std::ostringstream out;
|
|
out << "{\"services\":[";
|
|
for (std::size_t i = 0; i < services.size(); ++i) {
|
|
if (i > 0U) {
|
|
out << ',';
|
|
}
|
|
const auto& s = services[i];
|
|
std::string_view labelText(s.label.data(), s.label.size());
|
|
const std::size_t nul = labelText.find('\0');
|
|
if (nul != std::string_view::npos) {
|
|
labelText = labelText.substr(0U, nul);
|
|
}
|
|
out << "{\"service_id\":" << s.serviceId
|
|
<< ",\"component_id\":" << s.componentId << ",\"label\":";
|
|
appendJsonString(out, labelText);
|
|
out << "}";
|
|
}
|
|
out << "]}";
|
|
return out.str();
|
|
}
|
|
|
|
std::string serializeTunerErrorJson(const char* reason)
|
|
{
|
|
return std::string("{\"status\":\"error\",\"reason\":\"") + reason + "\"}";
|
|
}
|
|
|
|
std::expected<TunerTuneRequest, ParseError> parseTunerTuneJson(
|
|
std::string_view json)
|
|
{
|
|
if (json.find('{') == std::string_view::npos) {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
|
|
const std::string_view band = extractJsonString(json, "band");
|
|
if (band.empty()) {
|
|
return std::unexpected(ParseError::MissingField);
|
|
}
|
|
|
|
TunerTuneRequest req = {};
|
|
if (band == "dab") {
|
|
req.band = TunerBand::Dab;
|
|
unsigned long idx = 0U;
|
|
if (!extractJsonUint(json, "freq_index", idx) || idx > 37U) {
|
|
return std::unexpected(ParseError::MissingField);
|
|
}
|
|
req.dabFreqIndex = static_cast<std::uint8_t>(idx);
|
|
} else if (band == "fm") {
|
|
req.band = TunerBand::Fm;
|
|
unsigned long khz = 0U;
|
|
if (!extractJsonUint(json, "frequency_khz", khz)) {
|
|
return std::unexpected(ParseError::MissingField);
|
|
}
|
|
auto freq = FrequencyKHz::tryFromKhz(static_cast<std::uint32_t>(khz));
|
|
if (!freq) {
|
|
return std::unexpected(freq.error());
|
|
}
|
|
req.fmFrequency = *freq;
|
|
} else {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
unsigned long antCap = 0U;
|
|
if (extractJsonUint(json, "antcap", antCap) && antCap <= 128U) {
|
|
req.antCap = static_cast<std::uint8_t>(antCap);
|
|
}
|
|
return req;
|
|
}
|
|
|
|
std::expected<TunerPlayRequest, ParseError> parseTunerPlayJson(
|
|
std::string_view json)
|
|
{
|
|
if (json.find('{') == std::string_view::npos) {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
|
|
unsigned long sid = 0U;
|
|
unsigned long cid = 0U;
|
|
if (!extractJsonUint(json, "service_id", sid)
|
|
|| !extractJsonUint(json, "component_id", cid)) {
|
|
return std::unexpected(ParseError::MissingField);
|
|
}
|
|
|
|
TunerPlayRequest req = {};
|
|
req.serviceId = static_cast<std::uint32_t>(sid);
|
|
req.componentId = static_cast<std::uint32_t>(cid);
|
|
return req;
|
|
}
|
|
|
|
std::expected<SeekDirection, ParseError> parseTunerSeekJson(
|
|
std::string_view json)
|
|
{
|
|
if (json.find('{') == std::string_view::npos) {
|
|
return SeekDirection::Up;
|
|
}
|
|
|
|
const std::string_view direction = extractJsonString(json, "direction");
|
|
if (direction.empty()) {
|
|
return SeekDirection::Up;
|
|
}
|
|
if (direction == "up") {
|
|
return SeekDirection::Up;
|
|
}
|
|
if (direction == "down") {
|
|
return SeekDirection::Down;
|
|
}
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
|
|
std::expected<TunerScanRequest, ParseError> parseTunerScanJson(
|
|
std::string_view json)
|
|
{
|
|
if (json.find('{') == std::string_view::npos) {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
|
|
const std::string_view band = extractJsonString(json, "band");
|
|
if (band.empty()) {
|
|
return std::unexpected(ParseError::MissingField);
|
|
}
|
|
|
|
TunerScanRequest req = {};
|
|
unsigned long maxSteps = 0U;
|
|
if (band == "fm") {
|
|
req.band = TunerBand::Fm;
|
|
req.maxSteps = 45U;
|
|
} else if (band == "dab") {
|
|
req.band = TunerBand::Dab;
|
|
req.maxSteps = 38U;
|
|
} else {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
|
|
if (extractJsonUint(json, "max_steps", maxSteps)) {
|
|
if (maxSteps == 0U || maxSteps > 255U) {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
req.maxSteps = static_cast<std::uint8_t>(maxSteps);
|
|
}
|
|
|
|
const std::string_view name = extractJsonString(json, "name");
|
|
if (!name.empty()) {
|
|
req.nameFilter.assign(name.begin(), name.end());
|
|
for (char& ch : req.nameFilter) {
|
|
if (ch >= 'A' && ch <= 'Z') {
|
|
ch = static_cast<char>(ch - 'A' + 'a');
|
|
}
|
|
}
|
|
}
|
|
return req;
|
|
}
|
|
|
|
std::string serializeTunerScanJson(const TunerScanResult& result)
|
|
{
|
|
std::ostringstream out;
|
|
out << "{\"status\":\"" << (result.found ? "found" : "not_found") << '"'
|
|
<< ",\"band\":\"" << bandToken(result.band) << '"'
|
|
<< ",\"steps\":" << result.stepsTried;
|
|
|
|
if (result.fmFrequency) {
|
|
out << ",\"frequency_khz\":" << result.fmFrequency->value();
|
|
}
|
|
if (result.dabFreqIndex) {
|
|
out << ",\"freq_index\":" << static_cast<unsigned>(*result.dabFreqIndex);
|
|
}
|
|
if (result.dabServiceId) {
|
|
out << ",\"service_id\":" << *result.dabServiceId;
|
|
}
|
|
if (result.dabComponentId) {
|
|
out << ",\"component_id\":" << *result.dabComponentId;
|
|
}
|
|
if (result.stationName) {
|
|
out << ",\"station_name\":";
|
|
appendJsonString(out, result.stationName->value());
|
|
}
|
|
out << '}';
|
|
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();
|
|
}
|
|
|
|
std::expected<AntennaCalibrationRequest, ParseError>
|
|
parseAntennaCalibrationJson(std::string_view json)
|
|
{
|
|
if (json.find('{') == std::string_view::npos) {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
unsigned long antCap = 0U;
|
|
if (!extractJsonUint(json, "antcap", antCap) || antCap > 128U) {
|
|
return std::unexpected(ParseError::MissingField);
|
|
}
|
|
|
|
AntennaCalibrationRequest req = {};
|
|
req.antCap = static_cast<std::uint8_t>(antCap);
|
|
const std::string_view band = extractJsonString(json, "band");
|
|
if (band == "dab") {
|
|
req.band = TunerBand::Dab;
|
|
} else if (band.empty() || band == "fm") {
|
|
req.band = TunerBand::Fm;
|
|
} else {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
return req;
|
|
}
|
|
|
|
std::expected<XtalCalibrationRequest, ParseError>
|
|
parseXtalCalibrationJson(std::string_view json)
|
|
{
|
|
if (json.find('{') == std::string_view::npos) {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
// Defaults match Si4684Driver::boot()'s own defaults -- a request that
|
|
// only wants to change one parameter can omit the others.
|
|
XtalCalibrationRequest req = {};
|
|
req.ibias = 72U;
|
|
req.ctun = 31U;
|
|
req.xtalFreqHz = 19200000U;
|
|
|
|
unsigned long ibias = 0U;
|
|
if (extractJsonUint(json, "ibias", ibias)) {
|
|
if (ibias > 127U) {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
req.ibias = static_cast<std::uint8_t>(ibias);
|
|
}
|
|
|
|
unsigned long ctun = 0U;
|
|
if (extractJsonUint(json, "ctun", ctun)) {
|
|
if (ctun > 63U) {
|
|
return std::unexpected(ParseError::InvalidJson);
|
|
}
|
|
req.ctun = static_cast<std::uint8_t>(ctun);
|
|
}
|
|
|
|
unsigned long xtalFreqHz = 0U;
|
|
if (extractJsonUint(json, "xtal_freq_hz", xtalFreqHz)) {
|
|
req.xtalFreqHz = static_cast<std::uint32_t>(xtalFreqHz);
|
|
}
|
|
|
|
return req;
|
|
}
|
|
|
|
} // namespace core
|