Add internet radio streaming with runtime API, modernize web UI, remove auto-tune/beep at boot
Streaming (main feature this session): - New WebRadioConfig/WebRadioJson core types, ISecureStore-backed persistence - New webradio::WebRadioService (thread-safe live config) + GET/POST /api/streaming - web_radio_stream task now runtime-toggleable (no reboot), no hardcoded URL - Content-Type diagnostic: warns clearly when a URL is a webpage, not an audio stream Boot cleanup: - Removed boot-time auto FM/DAB tune, auto-beep, and the (now-concluded) Si4684 crystal IBIAS/CTUN empirical sweep from main.cpp — tuning/beep are on-demand via the existing REST API only Web UI: - Modernized styling (cards, gradients, toggle switches, light/dark theme) - New Stream tab wired to /api/streaming Fixes found via real idf.py build (not just clangd): - Restored wrongly-removed si4684/Si4684Tuner.hpp include in main.cpp - Fixed MP3Decode() argument types in web_radio_stream.cpp (unsigned char**/int*) Quality-gate fixes: - Host-test stub headers (esp_log.h, freertos/*) so TunerService.cpp's scanForStation logging/pacing compiles for station_service_test / integration_service_test instead of running stale binaries - Added WifiScanner and WebRadioService manual sections; filled in missing Doxygen docs on BluetoothService, i2s_sdata_probe, test_firmware, Bt1035At - Ignore clangd's .cache/ index directory Also includes prior uncommitted work carried in the tree: Wi-Fi/Bluetooth device scan REST API and UI (WifiScanner, BT scan), SigmaStudio TCP bridge, and the current ADAU1701 SigmaStudio DSP program export. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,25 @@ namespace {
|
||||
return end != json.data() + valueStart;
|
||||
}
|
||||
|
||||
[[nodiscard]] bool extractJsonBool(std::string_view json,
|
||||
std::string_view key,
|
||||
bool& out)
|
||||
{
|
||||
const std::string trueNeedle =
|
||||
std::string("\"") + std::string(key) + "\":true";
|
||||
const std::string falseNeedle =
|
||||
std::string("\"") + std::string(key) + "\":false";
|
||||
if (json.find(trueNeedle) != std::string_view::npos) {
|
||||
out = true;
|
||||
return true;
|
||||
}
|
||||
if (json.find(falseNeedle) != std::string_view::npos) {
|
||||
out = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::expected<GainDb, ParseError> parseGainField(
|
||||
std::string_view json, std::string_view key)
|
||||
{
|
||||
@@ -277,4 +296,17 @@ std::expected<EnhanceLevel, ParseError> parseEnhanceLevelJson(
|
||||
return EnhanceLevel::tryFromLevel(level);
|
||||
}
|
||||
|
||||
std::expected<bool, ParseError> parseBeepEnabledJson(std::string_view json)
|
||||
{
|
||||
if (json.find('{') == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
|
||||
bool enabled = false;
|
||||
if (!extractJsonBool(json, "enabled", enabled)) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "core/BluetoothJson.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
namespace core {
|
||||
@@ -95,4 +96,112 @@ parseBluetoothAutoReconnectJson(std::string_view json)
|
||||
return static_cast<std::uint8_t>(raw);
|
||||
}
|
||||
|
||||
std::string serializeBluetoothScanJson(
|
||||
const std::vector<Bt1035ScannedDevice>& devices)
|
||||
{
|
||||
std::ostringstream out;
|
||||
out << "{\"devices\":[";
|
||||
for (std::size_t i = 0; i < devices.size(); ++i) {
|
||||
if (i > 0U) {
|
||||
out << ',';
|
||||
}
|
||||
const Bt1035ScannedDevice& device = devices[i];
|
||||
out << "{\"index\":" << static_cast<unsigned>(device.index)
|
||||
<< ",\"mac\":";
|
||||
appendJsonString(out, device.mac);
|
||||
out << ",\"name\":";
|
||||
appendJsonString(out, device.name);
|
||||
out << ",\"rssi_dbm\":" << device.rssiDbm << "}";
|
||||
}
|
||||
out << "]}";
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::expected<std::string, ParseError>
|
||||
parseBluetoothConnectJson(std::string_view json)
|
||||
{
|
||||
if (json.find('{') == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
const std::string needle = "\"mac\":\"";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
const std::string_view mac = json.substr(valueStart, valueEnd - valueStart);
|
||||
if (!isValidBt1035Mac(mac)) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
std::string normalized;
|
||||
normalized.reserve(12U);
|
||||
for (const char ch : mac) {
|
||||
normalized.push_back(
|
||||
static_cast<char>(std::toupper(static_cast<unsigned char>(ch))));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
BluetoothConnectRequest parseBluetoothConnectRequest(std::string_view json)
|
||||
{
|
||||
BluetoothConnectRequest request{
|
||||
.mac = {},
|
||||
.name = {},
|
||||
.save = false,
|
||||
};
|
||||
if (auto mac = parseBluetoothConnectJson(json); mac) {
|
||||
request.mac = std::move(*mac);
|
||||
}
|
||||
const std::string nameNeedle = "\"name\":\"";
|
||||
const std::size_t nameStart = json.find(nameNeedle);
|
||||
if (nameStart != std::string_view::npos) {
|
||||
const std::size_t valueStart = nameStart + nameNeedle.size();
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd != std::string_view::npos) {
|
||||
request.name.assign(json.substr(valueStart, valueEnd - valueStart));
|
||||
}
|
||||
}
|
||||
request.save = json.find("\"save\":true") != std::string_view::npos
|
||||
|| json.find("\"save\": true") != std::string_view::npos;
|
||||
return request;
|
||||
}
|
||||
|
||||
std::string serializeBluetoothSpeakerJson(const BtSpeakerTarget* target)
|
||||
{
|
||||
if (target == nullptr) {
|
||||
return "{\"configured\":false}";
|
||||
}
|
||||
std::ostringstream out;
|
||||
out << "{\"configured\":true,\"mac\":";
|
||||
appendJsonString(out, target->mac);
|
||||
out << ",\"name\":";
|
||||
appendJsonString(out, target->name);
|
||||
out << '}';
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::expected<BtSpeakerTarget, ParseError>
|
||||
parseBluetoothSpeakerJson(std::string_view json)
|
||||
{
|
||||
const auto mac = parseBluetoothConnectJson(json);
|
||||
if (!mac) {
|
||||
return std::unexpected(mac.error());
|
||||
}
|
||||
BtSpeakerTarget target{.mac = *mac, .name = {}};
|
||||
const std::string nameNeedle = "\"name\":\"";
|
||||
const std::size_t nameStart = json.find(nameNeedle);
|
||||
if (nameStart != std::string_view::npos) {
|
||||
const std::size_t valueStart = nameStart + nameNeedle.size();
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd != std::string_view::npos) {
|
||||
target.name.assign(json.substr(valueStart, valueEnd - valueStart));
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "core/Bt1035At.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <vector>
|
||||
|
||||
@@ -99,18 +100,22 @@ namespace {
|
||||
std::string buildBt1035AtLine(Bt1035AtCommand command)
|
||||
{
|
||||
switch (command) {
|
||||
case Bt1035AtCommand::Reset:
|
||||
return "AT+RESET\r\n";
|
||||
case Bt1035AtCommand::Ping:
|
||||
return "AT\r\n";
|
||||
case Bt1035AtCommand::I2sMode:
|
||||
return "AT+AUXCFG=3\r\n";
|
||||
case Bt1035AtCommand::I2sSlave48k32:
|
||||
return "AT+I2SCFG=67\r\n";
|
||||
case Bt1035AtCommand::I2sSlave48k24:
|
||||
return "AT+I2SCFG=35\r\n";
|
||||
case Bt1035AtCommand::PairDiscoverable:
|
||||
return "AT+PAIR=1\r\n";
|
||||
case Bt1035AtCommand::PairHidden:
|
||||
return "AT+PAIR=0\r\n";
|
||||
case Bt1035AtCommand::A2dpStat:
|
||||
return "AT+A2DPSTAT\r\n";
|
||||
case Bt1035AtCommand::A2dpEncoder:
|
||||
return "AT+A2DPENC\r\n";
|
||||
case Bt1035AtCommand::A2dpDisconnect:
|
||||
return "AT+A2DPDISC\r\n";
|
||||
case Bt1035AtCommand::QueryName:
|
||||
@@ -145,7 +150,7 @@ std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noex
|
||||
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
|
||||
Bt1035AtCommand::Ping,
|
||||
Bt1035AtCommand::I2sMode,
|
||||
Bt1035AtCommand::I2sSlave48k32,
|
||||
Bt1035AtCommand::I2sSlave48k24,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,6 +211,43 @@ const char* a2dpStateToken(Bt1035A2dpState state) noexcept
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
std::expected<Bt1035A2dpCodec, ParseError>
|
||||
parseBt1035A2dpEncoderResponse(std::string_view response)
|
||||
{
|
||||
constexpr std::string_view kPrefix = "+A2DPENC=";
|
||||
const std::size_t pos = response.find(kPrefix);
|
||||
if (pos == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
|
||||
const std::size_t valueStart = pos + kPrefix.size();
|
||||
char* end = nullptr;
|
||||
const unsigned long raw =
|
||||
std::strtoul(response.data() + valueStart, &end, 10);
|
||||
if (end == response.data() + valueStart || raw < 1U || raw > 5U) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
|
||||
return static_cast<Bt1035A2dpCodec>(raw);
|
||||
}
|
||||
|
||||
const char* a2dpCodecToken(Bt1035A2dpCodec codec) noexcept
|
||||
{
|
||||
switch (codec) {
|
||||
case Bt1035A2dpCodec::Sbc:
|
||||
return "sbc";
|
||||
case Bt1035A2dpCodec::Aptx:
|
||||
return "aptx";
|
||||
case Bt1035A2dpCodec::AptxHd:
|
||||
return "aptx-hd";
|
||||
case Bt1035A2dpCodec::AptxLl:
|
||||
return "aptx-ll";
|
||||
case Bt1035A2dpCodec::AptxAdaptive:
|
||||
return "aptx-adaptive";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
std::expected<std::string, ParseError>
|
||||
parseBt1035NameResponse(std::string_view response)
|
||||
{
|
||||
@@ -286,4 +328,179 @@ parseBt1035PairedListResponse(std::string_view response)
|
||||
return devices;
|
||||
}
|
||||
|
||||
std::string buildBt1035StartScanLine(std::uint8_t scanType, std::uint8_t scanSeconds)
|
||||
{
|
||||
const std::uint8_t type = (scanType == 2U) ? 2U : 1U;
|
||||
if (scanSeconds == 0U) {
|
||||
return "AT+SCAN=" + std::to_string(type) + "\r\n";
|
||||
}
|
||||
return "AT+SCAN=" + std::to_string(type) + ","
|
||||
+ std::to_string(scanSeconds) + "\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035StopScanLine()
|
||||
{
|
||||
return "AT+SCAN=0\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035EnablePrintLine()
|
||||
{
|
||||
return "AT+PRINT=1\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035DisconnectAllLine()
|
||||
{
|
||||
return "AT+DSCA\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035DisableAutoLinkLine()
|
||||
{
|
||||
return "AT+LINKCFG=0,0\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035ClearPairedListLine()
|
||||
{
|
||||
return "AT+PLIST=0\r\n";
|
||||
}
|
||||
|
||||
bool isValidBt1035Mac(std::string_view mac) noexcept
|
||||
{
|
||||
if (mac.size() != 12U) {
|
||||
return false;
|
||||
}
|
||||
for (const char ch : mac) {
|
||||
if (!std::isxdigit(static_cast<unsigned char>(ch))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string normalizeBt1035Mac(std::string_view mac)
|
||||
{
|
||||
std::string normalized;
|
||||
normalized.reserve(12U);
|
||||
for (const char ch : mac) {
|
||||
if (ch == ':' || ch == '-') {
|
||||
continue;
|
||||
}
|
||||
normalized.push_back(
|
||||
static_cast<char>(std::toupper(static_cast<unsigned char>(ch))));
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
std::string buildBt1035A2dpConnectLine(std::string_view mac)
|
||||
{
|
||||
if (!isValidBt1035Mac(mac)) {
|
||||
return {};
|
||||
}
|
||||
return std::string("AT+A2DPCONN=") + std::string(mac) + "\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035A2dpAudioLine(bool establish)
|
||||
{
|
||||
return establish ? "AT+A2DPAUDIO=1\r\n" : "AT+A2DPAUDIO=0\r\n";
|
||||
}
|
||||
|
||||
std::expected<std::vector<Bt1035ScannedDevice>, ParseError>
|
||||
parseBt1035ScanResponse(std::string_view response)
|
||||
{
|
||||
std::vector<Bt1035ScannedDevice> devices;
|
||||
std::size_t pos = 0;
|
||||
while ((pos = response.find("+SCAN", pos)) != std::string_view::npos) {
|
||||
std::size_t start = pos + 5U;
|
||||
if (start < response.size() && response[start] == '=') {
|
||||
++start;
|
||||
}
|
||||
while (start < response.size()
|
||||
&& (response[start] == ' ' || response[start] == '\t')) {
|
||||
++start;
|
||||
}
|
||||
const std::size_t end = response.find_first_of("\r\n", start);
|
||||
const std::string_view line =
|
||||
end == std::string_view::npos ? response.substr(start)
|
||||
: response.substr(start, end - start);
|
||||
pos = end == std::string_view::npos ? response.size() : end + 1U;
|
||||
if (line.empty() || line == "E" || line.front() == 'E') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const std::string lineStr(line);
|
||||
|
||||
const std::size_t comma1 = lineStr.find(',');
|
||||
const std::size_t comma2 =
|
||||
comma1 == std::string_view::npos ? std::string_view::npos
|
||||
: lineStr.find(',', comma1 + 1U);
|
||||
const std::size_t comma3 =
|
||||
comma2 == std::string_view::npos ? std::string_view::npos
|
||||
: lineStr.find(',', comma2 + 1U);
|
||||
const std::size_t comma4 =
|
||||
comma3 == std::string_view::npos ? std::string_view::npos
|
||||
: lineStr.find(',', comma3 + 1U);
|
||||
if (comma4 == std::string_view::npos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
char* endIdx = nullptr;
|
||||
const unsigned long index =
|
||||
std::strtoul(lineStr.c_str(), &endIdx, 10);
|
||||
if (endIdx == lineStr.c_str() || index == 0U) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const unsigned long addrType =
|
||||
std::strtoul(lineStr.c_str() + comma1 + 1U, &endIdx, 10);
|
||||
const std::string mac =
|
||||
normalizeBt1035Mac(lineStr.substr(comma2 + 1U, comma3 - comma2 - 1U));
|
||||
if (!isValidBt1035Mac(mac)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const long rssi =
|
||||
std::strtol(lineStr.c_str() + comma3 + 1U, &endIdx, 10);
|
||||
const unsigned long nameLen =
|
||||
std::strtoul(lineStr.c_str() + comma4 + 1U, &endIdx, 10);
|
||||
const std::size_t afterNameLen =
|
||||
static_cast<std::size_t>(endIdx - lineStr.c_str());
|
||||
|
||||
std::string name;
|
||||
std::string deviceClass;
|
||||
if (nameLen > 0U) {
|
||||
if (afterNameLen >= lineStr.size() || lineStr[afterNameLen] != ',') {
|
||||
continue;
|
||||
}
|
||||
const std::size_t nameStart = afterNameLen + 1U;
|
||||
if (nameStart + nameLen > lineStr.size()) {
|
||||
continue;
|
||||
}
|
||||
name.assign(lineStr.substr(nameStart, nameLen));
|
||||
const std::size_t afterName = nameStart + nameLen;
|
||||
if (afterName < lineStr.size() && lineStr[afterName] == ',') {
|
||||
deviceClass.assign(lineStr.substr(afterName + 1U));
|
||||
}
|
||||
} else if (afterNameLen < lineStr.size()
|
||||
&& lineStr[afterNameLen] == ',') {
|
||||
const std::size_t classStart = afterNameLen + 1U;
|
||||
if (classStart < lineStr.size()) {
|
||||
if (lineStr[classStart] == ',') {
|
||||
deviceClass.assign(lineStr.substr(classStart + 1U));
|
||||
} else {
|
||||
deviceClass.assign(lineStr.substr(classStart));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Bt1035ScannedDevice entry = {};
|
||||
entry.index = static_cast<std::uint8_t>(index);
|
||||
entry.addressType = static_cast<std::uint8_t>(addrType);
|
||||
entry.mac = mac;
|
||||
entry.rssiDbm = static_cast<std::int16_t>(rssi);
|
||||
entry.name = std::move(name);
|
||||
entry.deviceClass = std::move(deviceClass);
|
||||
devices.push_back(std::move(entry));
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -28,4 +28,18 @@ MixerState MixerState::factoryDefault() noexcept
|
||||
};
|
||||
}
|
||||
|
||||
MixerState MixerState::radioFirst() noexcept
|
||||
{
|
||||
const GainDb unity = GainDb::zero();
|
||||
const GainDb muted = *GainDb::tryFromDb(GainDb::kMinDb);
|
||||
return MixerState{
|
||||
.si4684Left = unity,
|
||||
.si4684Right = unity,
|
||||
.esp32Left = muted,
|
||||
.esp32Right = muted,
|
||||
.mixLeft = unity,
|
||||
.mixRight = muted,
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -246,4 +246,74 @@ std::expected<SeekDirection, ParseError> parseTunerSeekJson(
|
||||
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();
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file WebRadioJson.cpp
|
||||
* @brief WebRadioJson implementation.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-08
|
||||
*/
|
||||
|
||||
#include "core/WebRadioJson.hpp"
|
||||
|
||||
namespace core {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kMaxUrlLength = 200U;
|
||||
constexpr std::string_view kHttpPrefix = "http://";
|
||||
|
||||
[[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 start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd == std::string_view::npos) {
|
||||
return {};
|
||||
}
|
||||
return json.substr(valueStart, valueEnd - valueStart);
|
||||
}
|
||||
|
||||
[[nodiscard]] bool extractJsonBool(std::string_view json,
|
||||
std::string_view key, bool& out)
|
||||
{
|
||||
const std::string trueNeedle =
|
||||
std::string("\"") + std::string(key) + "\":true";
|
||||
const std::string falseNeedle =
|
||||
std::string("\"") + std::string(key) + "\":false";
|
||||
if (json.find(trueNeedle) != std::string_view::npos) {
|
||||
out = true;
|
||||
return true;
|
||||
}
|
||||
if (json.find(falseNeedle) != std::string_view::npos) {
|
||||
out = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::expected<WebRadioConfig, ParseError> parseWebRadioConfigJson(
|
||||
std::string_view json)
|
||||
{
|
||||
bool enabled = false;
|
||||
if (!extractJsonBool(json, "enabled", enabled)) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
|
||||
const std::string_view url = extractJsonString(json, "url");
|
||||
if (url.empty() || url.size() > kMaxUrlLength
|
||||
|| url.substr(0, kHttpPrefix.size()) != kHttpPrefix) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
|
||||
return WebRadioConfig{.enabled = enabled, .url = std::string(url)};
|
||||
}
|
||||
|
||||
std::string serializeWebRadioConfigJson(const WebRadioConfig& config)
|
||||
{
|
||||
return std::string(R"({"enabled":)") + (config.enabled ? "true" : "false")
|
||||
+ R"(,"url":")" + config.url + "\"}";
|
||||
}
|
||||
|
||||
std::string serializeWebRadioErrorJson(std::string_view reason)
|
||||
{
|
||||
return std::string(R"({"status":"error","reason":")")
|
||||
+ std::string(reason) + "\"}";
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @file WifiScanJson.cpp
|
||||
* @brief Wi-Fi scan JSON serialisation implementation.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-05
|
||||
*/
|
||||
|
||||
#include "core/WifiScanJson.hpp"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
namespace core {
|
||||
|
||||
namespace {
|
||||
|
||||
/**
|
||||
* @brief appendJsonString — emit a JSON string literal.
|
||||
*
|
||||
* @dname appendJsonString
|
||||
* @param out Output stream.
|
||||
* @param text Raw UTF-8 text.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-05
|
||||
*/
|
||||
void appendJsonString(std::ostringstream& out, std::string_view text)
|
||||
{
|
||||
out << '"';
|
||||
for (const char ch : text) {
|
||||
if (ch == '"' || ch == '\\') {
|
||||
out << '\\';
|
||||
}
|
||||
out << ch;
|
||||
}
|
||||
out << '"';
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string serializeWifiScanJson(
|
||||
const std::vector<WifiScannedNetwork>& networks)
|
||||
{
|
||||
std::ostringstream out;
|
||||
out << "{\"networks\":[";
|
||||
for (std::size_t i = 0; i < networks.size(); ++i) {
|
||||
if (i > 0U) {
|
||||
out << ',';
|
||||
}
|
||||
const WifiScannedNetwork& network = networks[i];
|
||||
out << "{\"ssid\":";
|
||||
appendJsonString(out, network.ssid);
|
||||
out << ",\"rssi_dbm\":" << network.rssiDbm << ",\"auth\":";
|
||||
appendJsonString(out, network.auth);
|
||||
out << ",\"channel\":" << static_cast<unsigned>(network.channel) << '}';
|
||||
}
|
||||
out << "]}";
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string serializeWifiScanErrorJson(std::string_view reason)
|
||||
{
|
||||
return std::string("{\"status\":\"error\",\"reason\":\"") + std::string(reason)
|
||||
+ "\"}";
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
Reference in New Issue
Block a user