Add Slice 4 tuner stack with AGENTS-compliant core types and HTTP API.

Introduces Si4684/ADAU1701 drivers, TunerService, FrequencyKHz,
SeekDirection, /api/tuner routes without file-scope globals, host tests,
and firmware blob tooling (binaries remain gitignored).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 16:17:56 +02:00
co-authored by Cursor
parent c83c9bd765
commit 81d404a1df
68 changed files with 15411 additions and 139 deletions
@@ -0,0 +1,44 @@
/**
* @file EmbeddedBlobReader.cpp
* @brief EmbeddedBlobReader 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/EmbeddedBlobReader.hpp"
#include <algorithm>
#include <cstring>
namespace core {
EmbeddedBlobReader::EmbeddedBlobReader(const std::byte* data, std::size_t size)
: data_(data)
, size_(size)
{
}
std::size_t EmbeddedBlobReader::size() const
{
return size_;
}
std::size_t EmbeddedBlobReader::read(std::size_t offset,
std::span<std::byte> dest) const
{
if (offset >= size_ || dest.empty()) {
return 0;
}
const std::size_t available = size_ - offset;
const std::size_t toCopy = std::min(available, dest.size());
std::memcpy(dest.data(), data_ + offset, toCopy);
return toCopy;
}
} // namespace core
@@ -0,0 +1,37 @@
/**
* @file FrequencyKHz.cpp
* @brief FrequencyKHz 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/FrequencyKHz.hpp"
namespace core {
FrequencyKHz::FrequencyKHz(std::uint32_t khz) noexcept
: khz_(khz)
{
}
std::expected<FrequencyKHz, ParseError> FrequencyKHz::tryFromKhz(
std::uint32_t khz) noexcept
{
if (khz < kMinKhz || khz > kMaxKhz) {
return std::unexpected(ParseError::MissingField);
}
return FrequencyKHz(khz);
}
std::uint32_t FrequencyKHz::value() const noexcept
{
return khz_;
}
} // namespace core
+198
View File
@@ -0,0 +1,198 @@
/**
* @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 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 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";
}
} // 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);
}
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.fmStereo) {
out << ",\"stereo\":" << (*status.fmStereo ? "true" : "false");
}
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];
out << "{\"service_id\":" << s.serviceId
<< ",\"component_id\":" << s.componentId << ",\"label\":\"";
for (char c : s.label) {
if (c == '\0') {
break;
}
if (c == '"' || c == '\\') {
out << '\\';
}
out << c;
}
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);
}
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;
}
} // namespace core