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,57 @@
/**
* @file EmbeddedBlobReader.hpp
* @brief IFirmwareBlobReader over a contiguous embedded flash region.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
#include "core/IFirmwareBlobReader.hpp"
#include <cstddef>
#include <span>
namespace core {
/**
* @brief EmbeddedBlobReader — non-owning view of an embedded binary.
*
* @dname EmbeddedBlobReader
* @return n/a (type)
* @pubstate Borrows [data_, data_ + size_) for the reader lifetime.
*
* @author Michele Bigi
* @date 2026-07-06
*/
class EmbeddedBlobReader final : public IFirmwareBlobReader {
public:
/**
* @brief EmbeddedBlobReader — construct over a flash-backed range.
*
* @dname EmbeddedBlobReader
* @param data Start of embedded blob (e.g. linker symbol).
* @param size Length in bytes.
* @pubstate stores data_ and size_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
EmbeddedBlobReader(const std::byte* data, std::size_t size);
[[nodiscard]] std::size_t size() const override;
[[nodiscard]] std::size_t read(std::size_t offset,
std::span<std::byte> dest) const override;
private:
const std::byte* data_;
std::size_t size_;
};
} // namespace core
@@ -0,0 +1,73 @@
/**
* @file FrequencyKHz.hpp
* @brief Strong type for FM centre frequency in kilohertz.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
#include "core/ParseError.hpp"
#include <cstdint>
#include <expected>
namespace core {
/**
* @brief FrequencyKHz — validated FM centre frequency (European band).
*
* @dname FrequencyKHz
* @return n/a (type)
* @pubstate Owns khz_ within 64\,000108\,000 kHz. Immutable after construction.
*
* Parsed once at the HTTP boundary; trusted downstream by ITuner and drivers.
*
* @author Michele Bigi
* @date 2026-07-06
*/
class FrequencyKHz {
public:
/** Minimum valid FM frequency for DigiRadio (64.0 MHz). */
static constexpr std::uint32_t kMinKhz = 64000U;
/** Maximum valid FM frequency for DigiRadio (108.0 MHz). */
static constexpr std::uint32_t kMaxKhz = 108000U;
/**
* @brief tryFromKhz — validate an FM frequency at the boundary.
*
* @dname tryFromKhz
* @param khz Untrusted frequency in kilohertz from JSON input.
* @return FrequencyKHz on success, or ParseError::MissingField.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] static std::expected<FrequencyKHz, ParseError> tryFromKhz(
std::uint32_t khz) noexcept;
/**
* @brief value — read the stored frequency in kilohertz.
*
* @dname value
* @return Validated centre frequency in kHz.
* @pubstate reads khz_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::uint32_t value() const noexcept;
private:
explicit FrequencyKHz(std::uint32_t khz) noexcept;
std::uint32_t khz_;
};
} // namespace core
@@ -0,0 +1,72 @@
/**
* @file IFirmwareBlobReader.hpp
* @brief Read-only streaming access to a firmware blob in flash.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
#include <cstddef>
#include <span>
namespace core {
/**
* @brief IFirmwareBlobReader — streaming firmware image access.
*
* @dname IFirmwareBlobReader
* @return n/a (type)
* @pubstate Implementations wrap embedded flash or test buffers. Used by
* Si4684Driver to HOST_LOAD in bounded chunks (AGENTS.md §7.1).
*
* @author Michele Bigi
* @date 2026-07-06
*/
class IFirmwareBlobReader {
public:
/**
* @brief ~IFirmwareBlobReader — virtual destructor.
*
* @dname ~IFirmwareBlobReader
* @pubstate n/a
*
* @author Michele Bigi
* @date 2026-07-06
*/
virtual ~IFirmwareBlobReader() = default;
/**
* @brief size — total blob length in bytes.
*
* @dname size
* @return Blob size.
* @pubstate reads backing storage.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::size_t size() const = 0;
/**
* @brief read — copy bytes from the blob at an offset.
*
* @dname read
* @param offset Byte offset from the start of the blob.
* @param dest Destination buffer; truncated at end of blob.
* @return Number of bytes copied into dest.
* @pubstate reads backing storage.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::size_t read(std::size_t offset,
std::span<std::byte> dest) const = 0;
};
} // namespace core
@@ -0,0 +1,170 @@
/**
* @file ITuner.hpp
* @brief Abstract tuner driver boundary (host-testable).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
#include "core/FrequencyKHz.hpp"
#include "core/SeekDirection.hpp"
#include "core/TunerBand.hpp"
#include "core/TunerError.hpp"
#include "core/TunerStatus.hpp"
#include <cstdint>
#include <expected>
#include <vector>
namespace core {
/**
* @brief ITuner — hardware abstraction for DAB/FM tuning.
*
* @dname ITuner
* @return n/a (type)
* @pubstate Implemented by si4684::Si4684Tuner on device; fakes in host tests.
* No public data members.
*
* Services depend on this interface, not on SPI details. All methods return
* std::expected with TunerError on failure.
*
* @author Michele Bigi
* @date 2026-07-06
*/
class ITuner {
public:
virtual ~ITuner() = default;
/**
* @brief boot — load the requested band application image.
*
* @dname boot
* @param band DAB or FM image to load.
* @return Ok on success, or TunerError::HardwareFailed / NotBooted.
* @pubstate none (implementation-defined driver state).
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<void, TunerError> boot(
TunerBand band) = 0;
/**
* @brief currentBand — read the loaded application band.
*
* @dname currentBand
* @return Active TunerBand, or TunerError::NotBooted.
* @pubstate reads driver boot state.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<TunerBand, TunerError> currentBand()
const = 0;
/**
* @brief readStatus — snapshot lock, metrics, and tune target.
*
* @dname readStatus
* @return TunerStatus on success, or a TunerError.
* @pubstate reads driver metrics; may refresh RSQ/DIGRAD.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<TunerStatus, TunerError> readStatus() = 0;
/**
* @brief tuneDab — select a Band III ensemble by index.
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @return Ok on success, or WrongBand / TuneFailed / NotBooted.
* @pubstate writes last tune target in the adapter.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<void, TunerError> tuneDab(
std::uint8_t freqIndex) = 0;
/**
* @brief tuneFm — tune to an FM centre frequency.
*
* @dname tuneFm
* @param frequency Validated FM centre frequency.
* @return Ok on success, or WrongBand / TuneFailed / NotBooted.
* @pubstate writes last tune target in the adapter.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<void, TunerError> tuneFm(
FrequencyKHz frequency) = 0;
/**
* @brief seekFm — seek to the next valid FM station.
*
* @dname seekFm
* @param direction Up or Down scan direction.
* @return New centre frequency, or a TunerError.
* @pubstate updates last tune target on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<FrequencyKHz, TunerError> seekFm(
SeekDirection direction) = 0;
/**
* @brief listDabServices — fetch programmes for the current ensemble.
*
* @dname listDabServices
* @return Service entries, ServiceListEmpty, WrongBand, or NotBooted.
* @pubstate reads DAB service list from the driver.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<std::vector<TunerServiceEntry>,
TunerError>
listDabServices() = 0;
/**
* @brief playDabService — start DAB audio for a programme.
*
* @dname playDabService
* @param serviceId Selected service identifier.
* @param componentId Audio component within the service.
* @return Ok on success, or WrongBand / HardwareFailed / NotBooted.
* @pubstate starts digital audio output on the tuner I2S port.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<void, TunerError> playDabService(
std::uint32_t serviceId, std::uint32_t componentId) = 0;
/**
* @brief setVolume — set tuner output attenuation.
*
* @dname setVolume
* @param level Attenuator 063.
* @return Ok on success, or HardwareFailed / NotBooted.
* @pubstate writes volume property on the driver.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<void, TunerError> setVolume(
std::uint8_t level) = 0;
};
} // namespace core
@@ -0,0 +1,31 @@
/**
* @file SeekDirection.hpp
* @brief FM seek direction selector (core domain).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
namespace core {
/**
* @brief SeekDirection — FM seek scan direction.
*
* @dname SeekDirection
* @return n/a (type)
* @pubstate n/a
*
* Replaces bare bool parameters in public tuner APIs (AGENTS.md §2.1).
*
* @author Michele Bigi
* @date 2026-07-06
*/
enum class SeekDirection { Up, Down };
} // namespace core
@@ -0,0 +1,29 @@
/**
* @file TunerBand.hpp
* @brief Strong band selector for tuner operations (core domain).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
namespace core {
/**
* @brief TunerBand — active RF application (DAB or FM image).
*
* @dname TunerBand
* @return n/a (type)
* @pubstate n/a
*
* @author Michele Bigi
* @date 2026-07-06
*/
enum class TunerBand { Dab, Fm };
} // namespace core
@@ -0,0 +1,36 @@
/**
* @file TunerError.hpp
* @brief Typed errors for tuner service and ITuner adapters.
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
namespace core {
/**
* @brief TunerError — failure causes for tuner operations.
*
* @dname TunerError
* @return n/a (type)
* @pubstate Stable error set mapped from driver failures at the service boundary.
*
* @author Michele Bigi
* @date 2026-07-06
*/
enum class TunerError {
NotBooted,
WrongBand,
HardwareFailed,
TuneFailed,
ServiceListEmpty,
InvalidInput,
};
} // namespace core
@@ -0,0 +1,131 @@
/**
* @file TunerJson.hpp
* @brief JSON parse/serialise for tuner API (pure core).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
#include "core/FrequencyKHz.hpp"
#include "core/ParseError.hpp"
#include "core/TunerBand.hpp"
#include "core/TunerStatus.hpp"
#include <cstdint>
#include <expected>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace core {
/**
* @brief TunerTuneRequest — parsed POST /api/tuner/tune body.
*
* @dname TunerTuneRequest
* @return n/a (type)
* @pubstate Plain DTO filled by parseTunerTuneJson at the HTTP boundary.
*
* @author Michele Bigi
* @date 2026-07-06
*/
struct TunerTuneRequest {
TunerBand band; ///< Target band (Dab or Fm).
std::uint8_t dabFreqIndex; ///< Band III ensemble index (037) when band is Dab.
std::optional<FrequencyKHz> fmFrequency; ///< FM centre frequency when band is Fm.
};
/**
* @brief TunerPlayRequest — parsed POST /api/tuner/play body.
*
* @dname TunerPlayRequest
* @return n/a (type)
* @pubstate Plain DTO filled by parseTunerPlayJson at the HTTP boundary.
*
* @author Michele Bigi
* @date 2026-07-06
*/
struct TunerPlayRequest {
std::uint32_t serviceId; ///< DAB service identifier from the ensemble list.
std::uint32_t componentId; ///< Audio component within the service.
};
/**
* @brief serializeTunerStatusJson — serialise a tuner snapshot for GET status.
*
* @dname serializeTunerStatusJson
* @param status Domain snapshot from TunerService::refreshStatus().
* @return JSON object string for the HTTP response body.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::string serializeTunerStatusJson(const TunerStatus& status);
/**
* @brief serializeTunerServicesJson — serialise the DAB service list.
*
* @dname serializeTunerServicesJson
* @param services Programme entries for the current ensemble.
* @return JSON object string with a services array.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::string serializeTunerServicesJson(
const std::vector<TunerServiceEntry>& services);
/**
* @brief serializeTunerErrorJson — serialise a tuner API error response.
*
* @dname serializeTunerErrorJson
* @param reason Short machine-readable cause (never a secret).
* @return JSON object string with status error and reason fields.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::string serializeTunerErrorJson(const char* reason);
/**
* @brief parseTunerTuneJson — validate POST /api/tuner/tune body.
*
* @dname parseTunerTuneJson
* @param json Untrusted request body from the HTTP handler.
* @return TunerTuneRequest on success, or a ParseError.
* @pubstate none
*
* Rejects malformed JSON, missing fields, and out-of-range frequencies
* before any driver call.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<TunerTuneRequest, ParseError> parseTunerTuneJson(
std::string_view json);
/**
* @brief parseTunerPlayJson — validate POST /api/tuner/play body.
*
* @dname parseTunerPlayJson
* @param json Untrusted request body from the HTTP handler.
* @return TunerPlayRequest on success, or a ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<TunerPlayRequest, ParseError> parseTunerPlayJson(
std::string_view json);
} // namespace core
@@ -0,0 +1,65 @@
/**
* @file TunerStatus.hpp
* @brief Tuner status DTO for API and UI (pure core).
*
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*
* @author Michele Bigi
* @date 2026-07-06
*/
#pragma once
#include "core/FrequencyKHz.hpp"
#include "core/TunerBand.hpp"
#include <array>
#include <cstdint>
#include <optional>
namespace core {
/**
* @brief TunerServiceEntry — one DAB programme in the current ensemble.
*
* @dname TunerServiceEntry
* @return n/a (type)
* @pubstate Plain DTO returned by ITuner::listDabServices().
*
* @author Michele Bigi
* @date 2026-07-06
*/
struct TunerServiceEntry {
std::uint32_t serviceId; ///< DAB service identifier.
std::uint32_t componentId; ///< Audio component within the service.
std::array<char, 17> label; ///< UTF-8 programme label, NUL-terminated.
};
/**
* @brief TunerStatus — snapshot for GET /api/tuner/status.
*
* @dname TunerStatus
* @return n/a (type)
* @pubstate Plain DTO built by ITuner::readStatus(); serialised by
* serializeTunerStatusJson(). Band-specific fields are optional.
*
* @author Michele Bigi
* @date 2026-07-06
*/
struct TunerStatus {
bool booted; ///< Companion tuner boot completed.
TunerBand band; ///< Active loaded application band.
bool locked; ///< RF lock / valid signal.
std::uint8_t volume; ///< Attenuator level 063.
std::optional<std::uint8_t> dabFreqIndex; ///< Current Band III ensemble index.
std::optional<std::uint8_t> dabFicQuality; ///< FIC quality 0100 when DAB.
std::optional<std::int8_t> dabCnrDb; ///< CNR in dB when DAB.
std::optional<FrequencyKHz> fmFrequency; ///< Tuned FM centre frequency.
std::optional<std::int8_t> fmRssiDbuV; ///< FM RSSI in dBµV.
std::optional<std::int8_t> fmSnrDb; ///< FM SNR in dB.
std::optional<bool> fmStereo; ///< FM stereo pilot detected.
};
} // namespace core