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
@@ -19,6 +19,9 @@ add_library(digiradio_core STATIC
"${CORE_SRC_DIR}/WifiSsid.cpp"
"${CORE_SRC_DIR}/WifiCredentials.cpp"
"${CORE_SRC_DIR}/WifiProvisionJson.cpp"
"${CORE_SRC_DIR}/EmbeddedBlobReader.cpp"
"${CORE_SRC_DIR}/TunerJson.cpp"
"${CORE_SRC_DIR}/FrequencyKHz.cpp"
)
target_include_directories(digiradio_core PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/../include"
@@ -31,3 +34,15 @@ add_test(NAME health_status_test COMMAND health_status_test)
add_executable(wifi_provision_test wifi_provision_test.cpp)
target_link_libraries(wifi_provision_test PRIVATE digiradio_core)
add_test(NAME wifi_provision_test COMMAND wifi_provision_test)
add_executable(embedded_blob_reader_test embedded_blob_reader_test.cpp)
target_link_libraries(embedded_blob_reader_test PRIVATE digiradio_core)
add_test(NAME embedded_blob_reader_test COMMAND embedded_blob_reader_test)
add_executable(tuner_json_test tuner_json_test.cpp)
target_link_libraries(tuner_json_test PRIVATE digiradio_core)
add_test(NAME tuner_json_test COMMAND tuner_json_test)
add_executable(frequency_khz_test frequency_khz_test.cpp)
target_link_libraries(frequency_khz_test PRIVATE digiradio_core)
add_test(NAME frequency_khz_test COMMAND frequency_khz_test)
@@ -0,0 +1,79 @@
/**
* @file embedded_blob_reader_test.cpp
* @brief Host tests for EmbeddedBlobReader streaming reads.
*
* 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 <array>
#include <cstdlib>
#include <iostream>
namespace {
bool expectEqual(std::size_t actual, std::size_t expected, const char* label)
{
if (actual == expected) {
return true;
}
std::cerr << label << ": expected " << expected << ", got " << actual
<< '\n';
return false;
}
} // namespace
/**
* @brief main — run EmbeddedBlobReader host tests.
*
* @dname main
* @return 0 on success, 1 on failure.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
int main()
{
const std::array<std::byte, 8> source = {
std::byte{0x01}, std::byte{0x02}, std::byte{0x03}, std::byte{0x04},
std::byte{0x05}, std::byte{0x06}, std::byte{0x07}, std::byte{0x08},
};
core::EmbeddedBlobReader reader(source.data(), source.size());
if (!expectEqual(reader.size(), source.size(), "size")) {
return EXIT_FAILURE;
}
std::array<std::byte, 3> chunk = {};
if (!expectEqual(reader.read(0U, chunk), 3U, "first read length")) {
return EXIT_FAILURE;
}
if (chunk[0] != std::byte{0x01} || chunk[2] != std::byte{0x03}) {
std::cerr << "first read content mismatch\n";
return EXIT_FAILURE;
}
if (!expectEqual(reader.read(6U, chunk), 2U, "tail read length")) {
return EXIT_FAILURE;
}
if (chunk[0] != std::byte{0x07} || chunk[1] != std::byte{0x08}) {
std::cerr << "tail read content mismatch\n";
return EXIT_FAILURE;
}
if (!expectEqual(reader.read(source.size(), chunk), 0U, "past end")) {
return EXIT_FAILURE;
}
std::cout << "embedded_blob_reader_test: ok\n";
return EXIT_SUCCESS;
}
@@ -0,0 +1,53 @@
/**
* @file frequency_khz_test.cpp
* @brief Host tests for FrequencyKHz validation.
*
* 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"
#include "core/ParseError.hpp"
#include <cstdlib>
#include <iostream>
namespace {
[[nodiscard]] int runFrequencyAcceptTest()
{
const auto parsed = core::FrequencyKHz::tryFromKhz(101500U);
if (!parsed || parsed->value() != 101500U) {
std::cerr << "expected 101500 kHz accepted\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runFrequencyRejectTest()
{
const auto parsed = core::FrequencyKHz::tryFromKhz(50000U);
if (parsed || parsed.error() != core::ParseError::MissingField) {
std::cerr << "expected out-of-band frequency rejection\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
{
if (runFrequencyAcceptTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runFrequencyRejectTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
@@ -0,0 +1,156 @@
/**
* @file tuner_json_test.cpp
* @brief Host tests for tuner JSON parse/serialise.
*
* 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"
#include "core/ParseError.hpp"
#include "core/TunerBand.hpp"
#include "core/TunerJson.hpp"
#include "core/TunerStatus.hpp"
#include <cstdlib>
#include <iostream>
#include <string>
namespace {
[[nodiscard]] bool expectEqual(const std::string& actual,
const std::string& expected)
{
if (actual == expected) {
return true;
}
std::cerr << "expected: " << expected << "\nactual: " << actual << '\n';
return false;
}
[[nodiscard]] int runTunerTuneParseDabTest()
{
const auto parsed =
core::parseTunerTuneJson(R"({"band":"dab","freq_index":12})");
if (!parsed || parsed->band != core::TunerBand::Dab
|| parsed->dabFreqIndex != 12U) {
std::cerr << "DAB tune parse failed\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runTunerTuneParseFmTest()
{
const auto parsed =
core::parseTunerTuneJson(R"({"band":"fm","frequency_khz":101500})");
if (!parsed || parsed->band != core::TunerBand::Fm
|| !parsed->fmFrequency
|| parsed->fmFrequency->value() != 101500U) {
std::cerr << "FM tune parse failed\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runTunerTuneRejectTest()
{
const auto parsed =
core::parseTunerTuneJson(R"({"band":"fm","frequency_khz":50000})");
if (parsed) {
std::cerr << "expected FM frequency rejection\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runTunerPlayParseTest()
{
const auto parsed =
core::parseTunerPlayJson(R"({"service_id":42,"component_id":7})");
if (!parsed || parsed->serviceId != 42U || parsed->componentId != 7U) {
std::cerr << "play parse failed\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runTunerStatusSerialiseTest()
{
core::TunerStatus status = {};
status.booted = true;
status.band = core::TunerBand::Fm;
status.locked = true;
status.volume = 40;
status.fmFrequency = *core::FrequencyKHz::tryFromKhz(101500U);
status.fmRssiDbuV = -20;
status.fmSnrDb = 25;
status.fmStereo = true;
const std::string json = core::serializeTunerStatusJson(status);
if (json.find("\"booted\":true") == std::string::npos
|| json.find("\"band\":\"fm\"") == std::string::npos
|| json.find("\"frequency_khz\":101500") == std::string::npos) {
std::cerr << "status serialise missing fields: " << json << '\n';
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runTunerServicesSerialiseTest()
{
core::TunerServiceEntry entry = {};
entry.serviceId = 1U;
entry.componentId = 2U;
entry.label = {'R', 'A', 'I', '\0'};
const std::string json =
core::serializeTunerServicesJson({entry});
if (!expectEqual(json,
R"({"services":[{"service_id":1,"component_id":2,"label":"RAI"}]})")) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runTunerErrorSerialiseTest()
{
if (!expectEqual(core::serializeTunerErrorJson("not_booted"),
R"({"status":"error","reason":"not_booted"})")) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
{
if (runTunerTuneParseDabTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runTunerTuneParseFmTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runTunerTuneRejectTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runTunerPlayParseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runTunerStatusSerialiseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runTunerServicesSerialiseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runTunerErrorSerialiseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}