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:
@@ -7,6 +7,9 @@ idf_component_register(
|
||||
"src/WifiSsid.cpp"
|
||||
"src/WifiCredentials.cpp"
|
||||
"src/WifiProvisionJson.cpp"
|
||||
"src/EmbeddedBlobReader.cpp"
|
||||
"src/TunerJson.cpp"
|
||||
"src/FrequencyKHz.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
|
||||
|
||||
@@ -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\,000–108\,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 0–37.
|
||||
* @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 0–63.
|
||||
* @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 (0–37) 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 0–63.
|
||||
std::optional<std::uint8_t> dabFreqIndex; ///< Current Band III ensemble index.
|
||||
std::optional<std::uint8_t> dabFicQuality; ///< FIC quality 0–100 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
set(ADAU_FW_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../Firmware/ADAU1701-Firmware")
|
||||
|
||||
idf_component_register(
|
||||
SRCS "src/component_stub.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
SRCS
|
||||
"src/Adau1701Driver.cpp"
|
||||
"src/SigmaStudioFW.c"
|
||||
"src/adau1701_program.c"
|
||||
INCLUDE_DIRS
|
||||
"include"
|
||||
"${ADAU_FW_DIR}"
|
||||
REQUIRES driver esp_driver_gpio esp_driver_i2c
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @file Adau1701Driver.hpp
|
||||
* @brief ADAU1701 SigmaDSP driver — RAM boot on every power-up.
|
||||
*
|
||||
* 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 "adau1701/Adau1701Error.hpp"
|
||||
|
||||
#include <expected>
|
||||
|
||||
namespace adau1701 {
|
||||
|
||||
/**
|
||||
* @brief Adau1701Pins — board GPIO/I2C identifiers for the DSP.
|
||||
*
|
||||
* @dname Adau1701Pins
|
||||
* @return n/a (type)
|
||||
* @pubstate Immutable wiring snapshot from board_pins.hpp.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Adau1701Pins {
|
||||
int i2cSda; ///< I2C SDA GPIO.
|
||||
int i2cScl; ///< I2C SCL GPIO.
|
||||
int resetGpio; ///< DSP RESET# GPIO (active low).
|
||||
int i2cAddr7; ///< 7-bit I2C address of the ADAU1701.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Adau1701Driver — owns I2C + reset and loads SigmaStudio RAM.
|
||||
*
|
||||
* @dname Adau1701Driver
|
||||
* @param pins Board wiring for I2C and RESET#.
|
||||
* @return n/a (type)
|
||||
* @pubstate Owns I2C bus/device handles (RAII). booted_ true after a
|
||||
* successful default_download replay.
|
||||
*
|
||||
* Writes the SigmaStudio export from Firmware/ADAU1701-Firmware on every
|
||||
* boot (no EEPROM self-boot on DigiRadio).
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class Adau1701Driver {
|
||||
public:
|
||||
/**
|
||||
* @brief Adau1701Driver — construct with board pin map.
|
||||
*
|
||||
* @dname Adau1701Driver
|
||||
* @param pins SDA/SCL/reset/address configuration.
|
||||
* @pubstate stores pins_; not booted until boot().
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
explicit Adau1701Driver(Adau1701Pins pins);
|
||||
|
||||
/**
|
||||
* @brief ~Adau1701Driver — release I2C resources.
|
||||
*
|
||||
* @dname ~Adau1701Driver
|
||||
* @pubstate deletes bus/device handles when created.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
~Adau1701Driver();
|
||||
|
||||
Adau1701Driver(const Adau1701Driver&) = delete;
|
||||
Adau1701Driver& operator=(const Adau1701Driver&) = delete;
|
||||
|
||||
/**
|
||||
* @brief boot — reset the DSP and replay the SigmaStudio download.
|
||||
*
|
||||
* @dname boot
|
||||
* @return Ok on success, or Adau1701Error.
|
||||
* @pubstate writes booted_ on success; uses embedded program data.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Adau1701Error> boot();
|
||||
|
||||
/**
|
||||
* @brief isBooted — query whether RAM download succeeded.
|
||||
*
|
||||
* @dname isBooted
|
||||
* @return true after a successful boot().
|
||||
* @pubstate reads booted_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] bool isBooted() const noexcept;
|
||||
|
||||
private:
|
||||
Adau1701Pins pins_;
|
||||
bool booted_;
|
||||
void* i2cBus_;
|
||||
void* i2cDev_;
|
||||
};
|
||||
|
||||
} // namespace adau1701
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file Adau1701Error.hpp
|
||||
* @brief Typed errors for ADAU1701 driver operations.
|
||||
*
|
||||
* 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 adau1701 {
|
||||
|
||||
/**
|
||||
* @brief Adau1701Error — failure causes for ADAU1701 bring-up.
|
||||
*
|
||||
* @dname Adau1701Error
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
enum class Adau1701Error {
|
||||
I2cInitFailed,
|
||||
ResetFailed,
|
||||
DownloadFailed,
|
||||
};
|
||||
|
||||
} // namespace adau1701
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @file Adau1701Driver.cpp
|
||||
* @brief Adau1701Driver 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 "adau1701/Adau1701Driver.hpp"
|
||||
|
||||
#include "SigmaStudioFW.h"
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/i2c_master.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
extern "C" {
|
||||
|
||||
/** @brief SigmaStudio default program download (generated C, not part of the C++ API). */
|
||||
void adau1701_run_default_download(void);
|
||||
|
||||
} // extern "C"
|
||||
|
||||
namespace adau1701 {
|
||||
|
||||
namespace {
|
||||
constexpr char kTag[] = "Adau1701";
|
||||
constexpr int kI2cPort = 0;
|
||||
} // namespace
|
||||
|
||||
Adau1701Driver::Adau1701Driver(Adau1701Pins pins)
|
||||
: pins_(pins)
|
||||
, booted_(false)
|
||||
, i2cBus_(nullptr)
|
||||
, i2cDev_(nullptr)
|
||||
{
|
||||
}
|
||||
|
||||
Adau1701Driver::~Adau1701Driver()
|
||||
{
|
||||
auto* dev = static_cast<i2c_master_dev_handle_t>(i2cDev_);
|
||||
auto* bus = static_cast<i2c_master_bus_handle_t>(i2cBus_);
|
||||
if (dev != nullptr) {
|
||||
i2c_master_bus_rm_device(dev);
|
||||
}
|
||||
if (bus != nullptr) {
|
||||
i2c_del_master_bus(bus);
|
||||
}
|
||||
}
|
||||
|
||||
std::expected<void, Adau1701Error> Adau1701Driver::boot()
|
||||
{
|
||||
if (booted_) {
|
||||
return {};
|
||||
}
|
||||
|
||||
gpio_config_t resetCfg = {};
|
||||
resetCfg.pin_bit_mask = 1ULL << pins_.resetGpio;
|
||||
resetCfg.mode = GPIO_MODE_OUTPUT;
|
||||
if (gpio_config(&resetCfg) != ESP_OK) {
|
||||
return std::unexpected(Adau1701Error::ResetFailed);
|
||||
}
|
||||
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
|
||||
i2c_master_bus_config_t busCfg = {};
|
||||
busCfg.i2c_port = static_cast<i2c_port_num_t>(kI2cPort);
|
||||
busCfg.sda_io_num = static_cast<gpio_num_t>(pins_.i2cSda);
|
||||
busCfg.scl_io_num = static_cast<gpio_num_t>(pins_.i2cScl);
|
||||
busCfg.clk_source = I2C_CLK_SRC_DEFAULT;
|
||||
busCfg.glitch_ignore_cnt = 7;
|
||||
busCfg.flags.enable_internal_pullup = true;
|
||||
|
||||
i2c_master_bus_handle_t bus = nullptr;
|
||||
if (i2c_new_master_bus(&busCfg, &bus) != ESP_OK) {
|
||||
ESP_LOGE(kTag, "i2c_new_master_bus failed");
|
||||
return std::unexpected(Adau1701Error::I2cInitFailed);
|
||||
}
|
||||
i2cBus_ = bus;
|
||||
|
||||
i2c_device_config_t devCfg = {};
|
||||
devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7;
|
||||
devCfg.device_address = static_cast<uint16_t>(pins_.i2cAddr7);
|
||||
devCfg.scl_speed_hz = 100000;
|
||||
|
||||
i2c_master_dev_handle_t dev = nullptr;
|
||||
if (i2c_master_bus_add_device(bus, &devCfg, &dev) != ESP_OK) {
|
||||
ESP_LOGE(kTag, "i2c_master_bus_add_device failed");
|
||||
return std::unexpected(Adau1701Error::I2cInitFailed);
|
||||
}
|
||||
i2cDev_ = dev;
|
||||
|
||||
sigma_studio_bind_i2c(kI2cPort, static_cast<unsigned char>(pins_.i2cAddr7));
|
||||
sigma_studio_set_device(dev);
|
||||
|
||||
adau1701_run_default_download();
|
||||
|
||||
booted_ = true;
|
||||
ESP_LOGI(kTag, "SigmaStudio program loaded");
|
||||
return {};
|
||||
}
|
||||
|
||||
bool Adau1701Driver::isBooted() const noexcept
|
||||
{
|
||||
return booted_;
|
||||
}
|
||||
|
||||
} // namespace adau1701
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file SigmaStudioFW.c
|
||||
* @brief SigmaStudio SIGMA_WRITE_REGISTER_BLOCK for ESP-IDF I2C.
|
||||
*
|
||||
* 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 "SigmaStudioFW.h"
|
||||
|
||||
#include "driver/i2c_master.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static i2c_master_dev_handle_t s_dev = NULL;
|
||||
|
||||
void sigma_studio_bind_i2c(int port, unsigned char addr7)
|
||||
{
|
||||
(void)port;
|
||||
(void)addr7;
|
||||
}
|
||||
|
||||
void sigma_studio_set_device(void* i2cDevHandle)
|
||||
{
|
||||
s_dev = (i2c_master_dev_handle_t)i2cDevHandle;
|
||||
}
|
||||
|
||||
void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
|
||||
unsigned int address,
|
||||
unsigned char length,
|
||||
ADI_REG_TYPE* pData)
|
||||
{
|
||||
(void)devAddress;
|
||||
if (s_dev == NULL || pData == NULL || length == 0U) {
|
||||
return;
|
||||
}
|
||||
|
||||
enum { kChunk = 64U };
|
||||
unsigned int addr = address;
|
||||
unsigned char remaining = length;
|
||||
ADI_REG_TYPE* cursor = pData;
|
||||
|
||||
while (remaining > 0U) {
|
||||
const unsigned char chunk =
|
||||
remaining > kChunk ? kChunk : remaining;
|
||||
unsigned char buf[2U + 64U];
|
||||
buf[0] = (unsigned char)((addr >> 8) & 0xFFU);
|
||||
buf[1] = (unsigned char)(addr & 0xFFU);
|
||||
memcpy(buf + 2U, cursor, chunk);
|
||||
i2c_master_transmit(s_dev, buf, (size_t)(2U + chunk), 1000);
|
||||
addr += chunk;
|
||||
cursor += chunk;
|
||||
remaining = (unsigned char)(remaining - chunk);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file adau1701_program.c
|
||||
* @brief SigmaStudio default download for DigiRadio ADAU1701.
|
||||
*
|
||||
* 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 "SigmaStudioFW.h"
|
||||
#include "DigiRadio_IC_1.h"
|
||||
|
||||
void adau1701_run_default_download(void)
|
||||
{
|
||||
default_download_IC_1();
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @file component_stub.cpp
|
||||
* @brief ADAU1701 driver component placeholder (Slice 5).
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
namespace adau1701::detail {
|
||||
|
||||
/**
|
||||
* @brief adau1701ComponentLinked — ensures the driver component links.
|
||||
*
|
||||
* @dname adau1701ComponentLinked
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
void adau1701ComponentLinked() noexcept {}
|
||||
|
||||
} // namespace adau1701::detail
|
||||
@@ -1,6 +1,23 @@
|
||||
set(SI4684_FW_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../Firmware/Si4684-Firmware")
|
||||
|
||||
if(NOT EXISTS "${SI4684_FW_DIR}/fm_firmware.bin")
|
||||
message(FATAL_ERROR
|
||||
"Missing ${SI4684_FW_DIR}/fm_firmware.bin — run:\n"
|
||||
" python3 tools/fetch_si4684_firmware.py --from-ugreen-radio-cli <Files_v16.zip>\n"
|
||||
" python3 tools/fetch_si4684_firmware.py --si46xx-dir <si46xx_firmware>")
|
||||
endif()
|
||||
|
||||
idf_component_register(
|
||||
SRCS "src/component_stub.cpp"
|
||||
SRCS
|
||||
"src/Si4684Driver.cpp"
|
||||
"src/Si4684EmbeddedImages.cpp"
|
||||
"src/Si4684Tuner.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES core driver esp_driver_gpio esp_driver_spi
|
||||
EMBED_FILES
|
||||
"${SI4684_FW_DIR}/rom_patch_016.bin"
|
||||
"${SI4684_FW_DIR}/dab_firmware.bin"
|
||||
"${SI4684_FW_DIR}/fm_firmware.bin"
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file Si4684Band.hpp
|
||||
* @brief Tuner band selection for Si4684 firmware images.
|
||||
*
|
||||
* 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 si4684 {
|
||||
|
||||
/**
|
||||
* @brief Si4684Band — application image loaded after ROM patch.
|
||||
*
|
||||
* @dname Si4684Band
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
enum class Si4684Band {
|
||||
Dab,
|
||||
Fm,
|
||||
};
|
||||
|
||||
} // namespace si4684
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* @file Si4684Driver.hpp
|
||||
* @brief Si4684 DAB+/FM tuner — boot, tuning, status, and service control.
|
||||
*
|
||||
* 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/IFirmwareBlobReader.hpp"
|
||||
#include "core/SeekDirection.hpp"
|
||||
#include "si4684/Si4684Band.hpp"
|
||||
#include "si4684/Si4684Error.hpp"
|
||||
#include "si4684/Si4684Types.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace si4684 {
|
||||
|
||||
/**
|
||||
* @brief Si4684Pins — board GPIO/SPI identifiers for the tuner.
|
||||
*
|
||||
* @dname Si4684Pins
|
||||
* @return n/a (type)
|
||||
* @pubstate Immutable wiring snapshot from board_pins.hpp.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Si4684Pins {
|
||||
int spiHost; ///< ESP32 SPI host peripheral index.
|
||||
int csGpio; ///< Chip-select GPIO.
|
||||
int misoGpio; ///< MISO GPIO.
|
||||
int mosiGpio; ///< MOSI GPIO.
|
||||
int sclkGpio; ///< SCLK GPIO.
|
||||
int rstbGpio; ///< RESET# GPIO (active low).
|
||||
int intbGpio; ///< INTB GPIO (optional status interrupt).
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Si4684Driver — full Si4684 control over SPI (AN649 / PE5PVB).
|
||||
*
|
||||
* @dname Si4684Driver
|
||||
* @return n/a (type)
|
||||
* @pubstate Owns the SPI link and boot state. Register-level command bytes
|
||||
* stay private; callers use intent-level methods only.
|
||||
*
|
||||
* Implements the documented boot sequence plus band-specific tuning, property
|
||||
* access, RSQ/DIGRAD reads, and DAB service selection.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class Si4684Driver {
|
||||
public:
|
||||
/**
|
||||
* @brief Si4684Driver — construct with board pins and firmware blobs.
|
||||
*
|
||||
* @dname Si4684Driver
|
||||
* @param pins Board SPI and GPIO wiring.
|
||||
* @param patch ROM patch blob reader for HOST_LOAD.
|
||||
* @param dabImage DAB application image reader.
|
||||
* @param fmImage FM application image reader.
|
||||
* @pubstate stores pin map and blob references; not booted until boot().
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
Si4684Driver(Si4684Pins pins,
|
||||
const core::IFirmwareBlobReader& patch,
|
||||
const core::IFirmwareBlobReader& dabImage,
|
||||
const core::IFirmwareBlobReader& fmImage);
|
||||
|
||||
/**
|
||||
* @brief ~Si4684Driver — release SPI resources.
|
||||
*
|
||||
* @dname ~Si4684Driver
|
||||
* @pubstate removes SPI device and bus when active.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
~Si4684Driver();
|
||||
|
||||
Si4684Driver(const Si4684Driver&) = delete;
|
||||
Si4684Driver& operator=(const Si4684Driver&) = delete;
|
||||
|
||||
/**
|
||||
* @brief boot — cold-start: load patch, image, and configure I/O.
|
||||
*
|
||||
* @dname boot
|
||||
* @param band DAB or FM application to load.
|
||||
* @return Ok on success, or Si4684Error.
|
||||
* @pubstate writes booted_ and loadedBand_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> boot(Si4684Band band);
|
||||
|
||||
/**
|
||||
* @brief isBooted — query whether boot completed successfully.
|
||||
*
|
||||
* @dname isBooted
|
||||
* @return true after a successful boot().
|
||||
* @pubstate reads booted_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] bool isBooted() const noexcept;
|
||||
|
||||
/**
|
||||
* @brief loadedBand — read the active application band.
|
||||
*
|
||||
* @dname loadedBand
|
||||
* @return Si4684Band loaded by the last successful boot().
|
||||
* @pubstate reads loadedBand_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] Si4684Band loadedBand() const noexcept;
|
||||
|
||||
/**
|
||||
* @brief getPartInfo — read chip identity and firmware revision.
|
||||
*
|
||||
* @dname getPartInfo
|
||||
* @return Si4684PartInfo on success, or Si4684Error.
|
||||
* @pubstate reads chip via GET_PART_INFO (AN649).
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<Si4684PartInfo, Si4684Error> getPartInfo();
|
||||
|
||||
/**
|
||||
* @brief getSysState — read the running application state.
|
||||
*
|
||||
* @dname getSysState
|
||||
* @return Si4684SysState on success, or Si4684Error.
|
||||
* @pubstate reads chip via GET_SYS_STATE.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<Si4684SysState, Si4684Error> getSysState();
|
||||
|
||||
/**
|
||||
* @brief setProperty — write a Skyworks property (SET_PROPERTY 0x13).
|
||||
*
|
||||
* @dname setProperty
|
||||
* @param propertyId 16-bit property address.
|
||||
* @param value 16-bit property value.
|
||||
* @return Ok on success, or Si4684Error.
|
||||
* @pubstate writes property via SPI command.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> setProperty(
|
||||
std::uint16_t propertyId, std::uint16_t value);
|
||||
|
||||
/**
|
||||
* @brief setVolume — set audio attenuator 0–63 (property 0x0300).
|
||||
*
|
||||
* @dname setVolume
|
||||
* @param level Attenuator level 0–63.
|
||||
* @return Ok on success, or Si4684Error.
|
||||
* @pubstate writes AUDIO_VOLUME property.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> setVolume(std::uint8_t level);
|
||||
|
||||
/**
|
||||
* @brief tuneFm — tune FM to a validated centre frequency.
|
||||
*
|
||||
* @dname tuneFm
|
||||
* @param frequency FM centre frequency in kHz.
|
||||
* @return Ok on success, or Si4684Error::WrongBand / TuneFailed.
|
||||
* @pubstate sends FM_TUNE_FREQ and waits for STC (AN649).
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> tuneFm(
|
||||
core::FrequencyKHz frequency);
|
||||
|
||||
/**
|
||||
* @brief seekFm — seek FM in the given direction.
|
||||
*
|
||||
* @dname seekFm
|
||||
* @param direction Up or Down scan direction.
|
||||
* @param wrap Band wrap behaviour for FM_SEEK_START.
|
||||
* @return New centre frequency on success, or Si4684Error.
|
||||
* @pubstate sends FM_SEEK_START and waits for STC.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::FrequencyKHz, Si4684Error> seekFm(
|
||||
core::SeekDirection direction, SeekBandWrap wrap);
|
||||
|
||||
/**
|
||||
* @brief readFmRsq — read FM signal quality metrics.
|
||||
*
|
||||
* @dname readFmRsq
|
||||
* @return Si4684FmRsq on success, or Si4684Error.
|
||||
* @pubstate reads FM_RSQ_STATUS response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<Si4684FmRsq, Si4684Error> readFmRsq();
|
||||
|
||||
/**
|
||||
* @brief readFmRds — read a raw RDS group snapshot.
|
||||
*
|
||||
* @dname readFmRds
|
||||
* @return Si4684FmRdsStatus on success, or Si4684Error.
|
||||
* @pubstate reads FM_RDS_STATUS response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<Si4684FmRdsStatus, Si4684Error> readFmRds();
|
||||
|
||||
/**
|
||||
* @brief installDefaultDabFrequencyPlan — load Band III frequency list.
|
||||
*
|
||||
* @dname installDefaultDabFrequencyPlan
|
||||
* @return Ok on success, or Si4684Error.
|
||||
* @pubstate sends DAB_SET_FREQ_LIST with kDefaultDabFrequencyKhz.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> installDefaultDabFrequencyPlan();
|
||||
|
||||
/**
|
||||
* @brief tuneDab — tune to a Band III ensemble index.
|
||||
*
|
||||
* @dname tuneDab
|
||||
* @param freqIndex Ensemble index 0–37.
|
||||
* @return Ok on success, or Si4684Error.
|
||||
* @pubstate sends DAB_TUNE_FREQ and waits for STC.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> tuneDab(std::uint8_t freqIndex);
|
||||
|
||||
/**
|
||||
* @brief readDabDigRadStatus — read ensemble lock metrics.
|
||||
*
|
||||
* @dname readDabDigRadStatus
|
||||
* @return Si4684DabDigRadStatus on success, or Si4684Error.
|
||||
* @pubstate reads DAB_DIGRAD_STATUS response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<Si4684DabDigRadStatus, Si4684Error>
|
||||
readDabDigRadStatus();
|
||||
|
||||
/**
|
||||
* @brief readDabEventStatus — read DAB event flags.
|
||||
*
|
||||
* @dname readDabEventStatus
|
||||
* @return Si4684DabEventStatus on success, or Si4684Error.
|
||||
* @pubstate reads DAB_GET_EVENT_STATUS response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<Si4684DabEventStatus, Si4684Error>
|
||||
readDabEventStatus();
|
||||
|
||||
/**
|
||||
* @brief fetchDabServiceList — retrieve programmes for the ensemble.
|
||||
*
|
||||
* @dname fetchDabServiceList
|
||||
* @return Service rows on success, or Si4684Error.
|
||||
* @pubstate reads GET_DIGITAL_SERVICE_LIST chunks from the chip.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::vector<Si4684DabService>, Si4684Error>
|
||||
fetchDabServiceList();
|
||||
|
||||
/**
|
||||
* @brief startDabService — start DAB audio for a programme.
|
||||
*
|
||||
* @dname startDabService
|
||||
* @param serviceId Selected service identifier.
|
||||
* @param componentId Audio component within the service.
|
||||
* @param type Digital service type (audio by default).
|
||||
* @return Ok on success, or Si4684Error.
|
||||
* @pubstate sends START_DIGITAL_SERVICE.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> startDabService(
|
||||
std::uint32_t serviceId,
|
||||
std::uint32_t componentId,
|
||||
Si4684DigitalServiceType type = Si4684DigitalServiceType::Audio);
|
||||
|
||||
private:
|
||||
enum class Command : std::uint8_t {
|
||||
PowerUp = 0x01,
|
||||
HostLoad = 0x04,
|
||||
LoadInit = 0x06,
|
||||
BootCmd = 0x07,
|
||||
GetPartInfo = 0x08,
|
||||
GetSysState = 0x09,
|
||||
GetFuncInfo = 0x12,
|
||||
SetProperty = 0x13,
|
||||
FmTuneFreq = 0x30,
|
||||
FmSeekStart = 0x31,
|
||||
FmRsqStatus = 0x32,
|
||||
FmRdsStatus = 0x34,
|
||||
GetDigitalServiceList = 0x80,
|
||||
StartDigitalService = 0x81,
|
||||
GetDigitalServiceData = 0x84,
|
||||
DabTuneFreq = 0xB0,
|
||||
DabDigRadStatus = 0xB2,
|
||||
DabGetEventStatus = 0xB3,
|
||||
DabSetFreqList = 0xB8,
|
||||
};
|
||||
|
||||
[[nodiscard]] std::expected<void, Si4684Error> ensureBooted() const;
|
||||
[[nodiscard]] std::expected<void, Si4684Error> ensureBand(
|
||||
Si4684Band band) const;
|
||||
[[nodiscard]] std::expected<void, Si4684Error> waitCts();
|
||||
[[nodiscard]] std::expected<void, Si4684Error> waitStc();
|
||||
[[nodiscard]] std::expected<void, Si4684Error> sendCommand(
|
||||
std::span<const std::uint8_t> bytes);
|
||||
[[nodiscard]] std::expected<void, Si4684Error> readRaw(
|
||||
std::span<std::uint8_t> buffer);
|
||||
[[nodiscard]] std::expected<void, Si4684Error> writeCommand(
|
||||
Command cmd, const std::uint8_t* payload, std::size_t length);
|
||||
[[nodiscard]] std::expected<void, Si4684Error> hostLoadBlob(
|
||||
const core::IFirmwareBlobReader& blob, std::size_t chunkPayload);
|
||||
[[nodiscard]] std::expected<void, Si4684Error> configureAfterBoot(
|
||||
Si4684Band band);
|
||||
|
||||
Si4684Pins pins_;
|
||||
const core::IFirmwareBlobReader& patch_;
|
||||
const core::IFirmwareBlobReader& dabImage_;
|
||||
const core::IFirmwareBlobReader& fmImage_;
|
||||
bool booted_;
|
||||
Si4684Band loadedBand_;
|
||||
bool spiBusActive_;
|
||||
void* spiDevice_;
|
||||
};
|
||||
|
||||
} // namespace si4684
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Si4684EmbeddedImages.hpp
|
||||
* @brief Embedded Si4684 ROM patch, DAB and FM firmware blob accessors.
|
||||
*
|
||||
* 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/EmbeddedBlobReader.hpp"
|
||||
#include "si4684/Si4684Band.hpp"
|
||||
|
||||
namespace si4684 {
|
||||
|
||||
/**
|
||||
* @brief Si4684EmbeddedImages — flash-backed Si4684 firmware blobs.
|
||||
*
|
||||
* @dname Si4684EmbeddedImages
|
||||
* @return n/a (type)
|
||||
* @pubstate Owns EmbeddedBlobReader views over EMBED_FILES symbols from
|
||||
* Firmware/Si4684-Firmware/.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class Si4684EmbeddedImages {
|
||||
public:
|
||||
/**
|
||||
* @brief Si4684EmbeddedImages — bind linker-embedded binaries.
|
||||
*
|
||||
* @dname Si4684EmbeddedImages
|
||||
* @pubstate constructs patch_, dab_, and fm_ readers from flash symbols.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
Si4684EmbeddedImages();
|
||||
|
||||
/**
|
||||
* @brief romPatch — ROM patch blob for HOST_LOAD before main image.
|
||||
*
|
||||
* @dname romPatch
|
||||
* @return Reader over rom_patch_016.bin.
|
||||
* @pubstate reads patch_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] const core::IFirmwareBlobReader& romPatch() const noexcept;
|
||||
|
||||
/**
|
||||
* @brief dabFirmware — DAB application image blob.
|
||||
*
|
||||
* @dname dabFirmware
|
||||
* @return Reader over dab_firmware.bin.
|
||||
* @pubstate reads dab_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] const core::IFirmwareBlobReader& dabFirmware() const noexcept;
|
||||
|
||||
/**
|
||||
* @brief fmFirmware — FM application image blob.
|
||||
*
|
||||
* @dname fmFirmware
|
||||
* @return Reader over fm_firmware.bin.
|
||||
* @pubstate reads fm_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] const core::IFirmwareBlobReader& fmFirmware() const noexcept;
|
||||
|
||||
/**
|
||||
* @brief applicationImage — map band to embedded application blob.
|
||||
*
|
||||
* @dname applicationImage
|
||||
* @param band DAB or FM image selector.
|
||||
* @return Reader for the selected application firmware.
|
||||
* @pubstate reads dab_ or fm_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] const core::IFirmwareBlobReader& applicationImage(
|
||||
Si4684Band band) const noexcept;
|
||||
|
||||
private:
|
||||
core::EmbeddedBlobReader patch_;
|
||||
core::EmbeddedBlobReader dab_;
|
||||
core::EmbeddedBlobReader fm_;
|
||||
};
|
||||
|
||||
} // namespace si4684
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file Si4684Error.hpp
|
||||
* @brief Typed errors for Si4684 driver operations.
|
||||
*
|
||||
* 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 si4684 {
|
||||
|
||||
/**
|
||||
* @brief Si4684Error — failure causes for Si4684 bring-up and tuning.
|
||||
*
|
||||
* @dname Si4684Error
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
enum class Si4684Error {
|
||||
SpiInitFailed,
|
||||
ResetFailed,
|
||||
CtsTimeout,
|
||||
PowerUpFailed,
|
||||
PatchLoadFailed,
|
||||
ImageLoadFailed,
|
||||
BootFailed,
|
||||
NotBooted,
|
||||
WrongBand,
|
||||
CommandFailed,
|
||||
StcTimeout,
|
||||
TuneFailed,
|
||||
ReplyTooShort,
|
||||
BufferTooSmall,
|
||||
};
|
||||
|
||||
} // namespace si4684
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file Si4684Tuner.hpp
|
||||
* @brief core::ITuner adapter over Si4684Driver.
|
||||
*
|
||||
* 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/ITuner.hpp"
|
||||
#include "si4684/Si4684Driver.hpp"
|
||||
|
||||
namespace si4684 {
|
||||
|
||||
/**
|
||||
* @brief Si4684Tuner — maps Si4684Driver to core::ITuner.
|
||||
*
|
||||
* @dname Si4684Tuner
|
||||
* @param driver Borrowed Si4684Driver (must outlive this adapter).
|
||||
* @return n/a (type)
|
||||
* @pubstate Borrows driver_. Caches last DAB index, FM frequency, and volume
|
||||
* for status reporting. No public data members.
|
||||
*
|
||||
* Translates domain calls into SPI commands and maps Si4684Error to
|
||||
* core::TunerError at this boundary.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class Si4684Tuner : public core::ITuner {
|
||||
public:
|
||||
/**
|
||||
* @brief Si4684Tuner — bind to a booted or bootable driver instance.
|
||||
*
|
||||
* @dname Si4684Tuner
|
||||
* @param driver Si4684 driver constructed by HardwareBootstrap.
|
||||
* @pubstate stores driver reference; sets default tune targets.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
explicit Si4684Tuner(Si4684Driver& driver);
|
||||
|
||||
/**
|
||||
* @brief boot — load the requested Si4684 application image.
|
||||
*
|
||||
* @dname boot
|
||||
* @param band DAB or FM image to load.
|
||||
* @return Ok on success, or a mapped TunerError.
|
||||
* @pubstate delegates to driver_.boot().
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> boot(
|
||||
core::TunerBand band) override;
|
||||
|
||||
/**
|
||||
* @brief currentBand — read the loaded application band.
|
||||
*
|
||||
* @dname currentBand
|
||||
* @return Active TunerBand, or TunerError::NotBooted.
|
||||
* @pubstate reads driver_.loadedBand().
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::TunerBand, core::TunerError> currentBand()
|
||||
const override;
|
||||
|
||||
/**
|
||||
* @brief readStatus — build a core TunerStatus from driver metrics.
|
||||
*
|
||||
* @dname readStatus
|
||||
* @return TunerStatus on success, or a mapped TunerError.
|
||||
* @pubstate reads driver_; refreshes cached tune targets from RSQ/DIGRAD.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::TunerStatus, core::TunerError> readStatus()
|
||||
override;
|
||||
|
||||
/**
|
||||
* @brief tuneDab — tune to a Band III ensemble index.
|
||||
*
|
||||
* @dname tuneDab
|
||||
* @param freqIndex Ensemble index 0–37.
|
||||
* @return Ok on success, or a mapped TunerError.
|
||||
* @pubstate writes dabIndex_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
|
||||
std::uint8_t freqIndex) override;
|
||||
|
||||
/**
|
||||
* @brief tuneFm — tune to an FM centre frequency in kHz.
|
||||
*
|
||||
* @dname tuneFm
|
||||
* @param frequency Validated FM centre frequency.
|
||||
* @return Ok on success, or a mapped TunerError.
|
||||
* @pubstate writes fmFrequency_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> tuneFm(
|
||||
core::FrequencyKHz frequency) override;
|
||||
|
||||
/**
|
||||
* @brief seekFm — seek FM with band wrap.
|
||||
*
|
||||
* @dname seekFm
|
||||
* @param direction Up or Down scan direction.
|
||||
* @return New centre frequency, or a mapped TunerError.
|
||||
* @pubstate writes fmFrequency_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::FrequencyKHz, core::TunerError> seekFm(
|
||||
core::SeekDirection direction) override;
|
||||
|
||||
/**
|
||||
* @brief listDabServices — fetch programmes for the current ensemble.
|
||||
*
|
||||
* @dname listDabServices
|
||||
* @return Service entries, ServiceListEmpty, or a mapped TunerError.
|
||||
* @pubstate reads driver_ service list when ready.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::vector<core::TunerServiceEntry>,
|
||||
core::TunerError>
|
||||
listDabServices() override;
|
||||
|
||||
/**
|
||||
* @brief playDabService — start DAB audio output.
|
||||
*
|
||||
* @dname playDabService
|
||||
* @param serviceId Selected service identifier.
|
||||
* @param componentId Audio component within the service.
|
||||
* @return Ok on success, or a mapped TunerError.
|
||||
* @pubstate delegates to driver_.startDabService().
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> playDabService(
|
||||
std::uint32_t serviceId, std::uint32_t componentId) override;
|
||||
|
||||
/**
|
||||
* @brief setVolume — set Si4684 output attenuation.
|
||||
*
|
||||
* @dname setVolume
|
||||
* @param level Attenuator 0–63.
|
||||
* @return Ok on success, or a mapped TunerError.
|
||||
* @pubstate writes volume_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> setVolume(
|
||||
std::uint8_t level) override;
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief mapError — translate Si4684Error to core::TunerError.
|
||||
*
|
||||
* @dname mapError
|
||||
* @param error Driver-level failure cause.
|
||||
* @return Equivalent TunerError for services and HTTP layer.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static core::TunerError mapError(Si4684Error error) noexcept;
|
||||
|
||||
Si4684Driver& driver_;
|
||||
std::uint8_t dabIndex_;
|
||||
core::FrequencyKHz fmFrequency_;
|
||||
std::uint8_t volume_;
|
||||
};
|
||||
|
||||
} // namespace si4684
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @file Si4684Types.hpp
|
||||
* @brief Domain types for Si4684 tuner operations (DAB/FM status, services).
|
||||
*
|
||||
* 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 <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace si4684 {
|
||||
|
||||
/**
|
||||
* @brief Si4684DigitalServiceType — START_DIGITAL_SERVICE mode byte.
|
||||
*
|
||||
* @dname Si4684DigitalServiceType
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
enum class Si4684DigitalServiceType : std::uint8_t {
|
||||
Audio = 0x00,
|
||||
Packet = 0x01,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief SeekBandWrap — FM seek band-wrap behaviour (FM_SEEK_START).
|
||||
*
|
||||
* @dname SeekBandWrap
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
enum class SeekBandWrap { Wrap, NoWrap };
|
||||
|
||||
/**
|
||||
* @brief Si4684PartInfo — chip identity from GET_PART_INFO (AN649).
|
||||
*
|
||||
* @dname Si4684PartInfo
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO from GET_PART_INFO response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Si4684PartInfo {
|
||||
std::uint16_t chipId; ///< Part identifier from the chip.
|
||||
std::uint8_t firmwareMajor; ///< Loaded firmware major version.
|
||||
std::uint8_t firmwareMinor; ///< Loaded firmware minor version.
|
||||
std::uint8_t firmwareBuild; ///< Loaded firmware build number.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Si4684SysState — running application after boot.
|
||||
*
|
||||
* @dname Si4684SysState
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO from GET_SYS_STATE.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Si4684SysState {
|
||||
std::uint8_t imageType; ///< Loaded image type byte from the chip.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Si4684FmRsq — FM received-signal quality (FM_RSQ_STATUS).
|
||||
*
|
||||
* @dname Si4684FmRsq
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO from FM_RSQ_STATUS response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Si4684FmRsq {
|
||||
core::FrequencyKHz frequency; ///< Tuned centre frequency in kHz.
|
||||
std::int8_t rssiDbuV; ///< RSSI in dBµV.
|
||||
std::int8_t snrDb; ///< SNR in dB.
|
||||
bool valid; ///< RSQ valid flag from the chip.
|
||||
bool stereo; ///< Stereo pilot detected.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Si4684FmRdsStatus — raw RDS group snapshot (FM_RDS_STATUS).
|
||||
*
|
||||
* @dname Si4684FmRdsStatus
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO from FM_RDS_STATUS response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Si4684FmRdsStatus {
|
||||
std::uint16_t blockA; ///< RDS block A.
|
||||
std::uint16_t blockB; ///< RDS block B.
|
||||
std::uint16_t blockC; ///< RDS block C.
|
||||
std::uint16_t blockD; ///< RDS block D.
|
||||
bool received; ///< Group received flag.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Si4684DabDigRadStatus — ensemble lock metrics (DAB_DIGRAD_STATUS).
|
||||
*
|
||||
* @dname Si4684DabDigRadStatus
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO from DAB_DIGRAD_STATUS response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Si4684DabDigRadStatus {
|
||||
std::uint8_t ficQuality; ///< FIC quality 0–100.
|
||||
std::uint8_t cnrDb; ///< CNR in dB.
|
||||
bool acquired; ///< Ensemble acquired flag.
|
||||
bool valid; ///< DIGRAD valid flag.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Si4684DabEventStatus — DAB event flags (DAB_GET_EVENT_STATUS).
|
||||
*
|
||||
* @dname Si4684DabEventStatus
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO from DAB_GET_EVENT_STATUS response bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Si4684DabEventStatus {
|
||||
bool serviceListReady; ///< Service list available for fetch.
|
||||
bool reconfig; ///< Ensemble reconfiguration event.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Si4684DabService — one row from GET_DIGITAL_SERVICE_LIST.
|
||||
*
|
||||
* @dname Si4684DabService
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO parsed from a service-list chunk.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct Si4684DabService {
|
||||
std::uint32_t serviceId; ///< DAB service identifier.
|
||||
std::uint32_t componentId; ///< Audio component identifier.
|
||||
std::array<char, 17> label; ///< Programme label, NUL-terminated.
|
||||
std::uint8_t serviceType; ///< Service type byte from the list.
|
||||
};
|
||||
|
||||
/** Band III DAB channel plan (kHz), PE5PVB / ETSI EN 300 401 Table 14. */
|
||||
inline constexpr std::array<std::uint32_t, 38> kDefaultDabFrequencyKhz = {
|
||||
174928, 176640, 178352, 180064, 181936, 183648, 185360, 187072, 188928,
|
||||
190640, 192352, 194064, 195936, 197648, 199360, 201072, 202928, 204640,
|
||||
206352, 208064, 209936, 211648, 213360, 215072, 216928, 218640, 220352,
|
||||
222064, 223936, 225648, 227360, 229072, 230784, 232496, 234208, 235776,
|
||||
237488, 239200,
|
||||
};
|
||||
|
||||
} // namespace si4684
|
||||
@@ -0,0 +1,776 @@
|
||||
/**
|
||||
* @file Si4684Driver.cpp
|
||||
* @brief Si4684Driver 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 "si4684/Si4684Driver.hpp"
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/spi_master.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
|
||||
namespace si4684 {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kTag[] = "Si4684";
|
||||
constexpr std::size_t kSpiBufferSize = 4096U;
|
||||
constexpr int kCtsPollMs = 2;
|
||||
constexpr int kCtsRetries = 200;
|
||||
constexpr int kStcRetries = 250;
|
||||
constexpr int kStcPollMs = 20;
|
||||
|
||||
constexpr std::uint16_t kPropDigitalIoOutputSelect = 0x0200U;
|
||||
constexpr std::uint16_t kPropDigitalIoSampleRate = 0x0201U;
|
||||
constexpr std::uint16_t kPropPinConfigEnable = 0x0800U;
|
||||
constexpr std::uint16_t kPropAudioVolume = 0x0300U;
|
||||
constexpr std::uint16_t kPropFmRdsConfig = 0x3C02U;
|
||||
constexpr std::uint16_t kPropDabTuneFeCfg = 0x1712U;
|
||||
constexpr std::uint16_t kPropDabXpadEnable = 0xB400U;
|
||||
constexpr std::uint16_t kPropDigitalServiceIntSource = 0x8100U;
|
||||
|
||||
std::uint16_t readLe16(const std::uint8_t* p)
|
||||
{
|
||||
return static_cast<std::uint16_t>(p[0] | (static_cast<std::uint16_t>(p[1]) << 8));
|
||||
}
|
||||
|
||||
std::uint32_t readLe32(const std::uint8_t* p)
|
||||
{
|
||||
return static_cast<std::uint32_t>(p[0])
|
||||
| (static_cast<std::uint32_t>(p[1]) << 8)
|
||||
| (static_cast<std::uint32_t>(p[2]) << 16)
|
||||
| (static_cast<std::uint32_t>(p[3]) << 24);
|
||||
}
|
||||
|
||||
/** FM_TUNE_FREQ uses 10 kHz units (AN649); API uses kHz. */
|
||||
[[nodiscard]] std::uint16_t kHzToChipFmFreq(std::uint32_t frequencyKhz)
|
||||
{
|
||||
return static_cast<std::uint16_t>(frequencyKhz / 10U);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::uint32_t chipFmFreqToKHz(std::uint16_t chipFreq)
|
||||
{
|
||||
return static_cast<std::uint32_t>(chipFreq) * 10U;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Si4684Driver::Si4684Driver(Si4684Pins pins,
|
||||
const core::IFirmwareBlobReader& patch,
|
||||
const core::IFirmwareBlobReader& dabImage,
|
||||
const core::IFirmwareBlobReader& fmImage)
|
||||
: pins_(pins)
|
||||
, patch_(patch)
|
||||
, dabImage_(dabImage)
|
||||
, fmImage_(fmImage)
|
||||
, booted_(false)
|
||||
, loadedBand_(Si4684Band::Dab)
|
||||
, spiBusActive_(false)
|
||||
, spiDevice_(nullptr)
|
||||
{
|
||||
}
|
||||
|
||||
Si4684Driver::~Si4684Driver()
|
||||
{
|
||||
if (spiDevice_ != nullptr) {
|
||||
spi_bus_remove_device(static_cast<spi_device_handle_t>(spiDevice_));
|
||||
spiDevice_ = nullptr;
|
||||
}
|
||||
if (spiBusActive_) {
|
||||
spi_bus_free(static_cast<spi_host_device_t>(pins_.spiHost));
|
||||
spiBusActive_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::ensureBooted() const
|
||||
{
|
||||
if (!booted_) {
|
||||
return std::unexpected(Si4684Error::NotBooted);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::ensureBand(
|
||||
Si4684Band band) const
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return ready;
|
||||
}
|
||||
if (loadedBand_ != band) {
|
||||
return std::unexpected(Si4684Error::WrongBand);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::waitCts()
|
||||
{
|
||||
std::array<std::uint8_t, 5> pollTx = {};
|
||||
std::array<std::uint8_t, 5> pollRx = {};
|
||||
|
||||
for (int attempt = 0; attempt < kCtsRetries; ++attempt) {
|
||||
vTaskDelay(pdMS_TO_TICKS(kCtsPollMs));
|
||||
spi_transaction_t txn = {};
|
||||
txn.length = pollTx.size() * 8U;
|
||||
txn.tx_buffer = pollTx.data();
|
||||
txn.rx_buffer = pollRx.data();
|
||||
if (spi_device_transmit(static_cast<spi_device_handle_t>(spiDevice_),
|
||||
&txn) != ESP_OK) {
|
||||
return std::unexpected(Si4684Error::SpiInitFailed);
|
||||
}
|
||||
if ((pollRx[1] & 0x80U) != 0U) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return std::unexpected(Si4684Error::CtsTimeout);
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::waitStc()
|
||||
{
|
||||
std::array<std::uint8_t, 5> pollTx = {};
|
||||
std::array<std::uint8_t, 5> pollRx = {};
|
||||
|
||||
for (int attempt = 0; attempt < kStcRetries; ++attempt) {
|
||||
vTaskDelay(pdMS_TO_TICKS(kStcPollMs));
|
||||
spi_transaction_t txn = {};
|
||||
txn.length = pollTx.size() * 8U;
|
||||
txn.tx_buffer = pollTx.data();
|
||||
txn.rx_buffer = pollRx.data();
|
||||
if (spi_device_transmit(static_cast<spi_device_handle_t>(spiDevice_),
|
||||
&txn) != ESP_OK) {
|
||||
return std::unexpected(Si4684Error::SpiInitFailed);
|
||||
}
|
||||
if ((pollRx[1] & 0x01U) != 0U) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return std::unexpected(Si4684Error::StcTimeout);
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::sendCommand(
|
||||
std::span<const std::uint8_t> bytes)
|
||||
{
|
||||
if (bytes.empty() || bytes.size() > kSpiBufferSize) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
spi_transaction_t txn = {};
|
||||
txn.length = bytes.size() * 8U;
|
||||
txn.tx_buffer = bytes.data();
|
||||
if (spi_device_transmit(static_cast<spi_device_handle_t>(spiDevice_),
|
||||
&txn) != ESP_OK) {
|
||||
return std::unexpected(Si4684Error::SpiInitFailed);
|
||||
}
|
||||
return waitCts();
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::readRaw(
|
||||
std::span<std::uint8_t> buffer)
|
||||
{
|
||||
if (buffer.empty() || buffer.size() > kSpiBufferSize) {
|
||||
return std::unexpected(Si4684Error::ReplyTooShort);
|
||||
}
|
||||
std::fill(buffer.begin(), buffer.end(), 0U);
|
||||
spi_transaction_t txn = {};
|
||||
txn.length = buffer.size() * 8U;
|
||||
txn.tx_buffer = buffer.data();
|
||||
txn.rx_buffer = buffer.data();
|
||||
if (spi_device_transmit(static_cast<spi_device_handle_t>(spiDevice_),
|
||||
&txn) != ESP_OK) {
|
||||
return std::unexpected(Si4684Error::SpiInitFailed);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::writeCommand(
|
||||
Command cmd, const std::uint8_t* payload, std::size_t length)
|
||||
{
|
||||
if (length + 2U > kSpiBufferSize) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
std::array<std::uint8_t, kSpiBufferSize> buffer = {};
|
||||
buffer[0] = static_cast<std::uint8_t>(cmd);
|
||||
buffer[1] = 0x00U;
|
||||
if (payload != nullptr && length > 0U) {
|
||||
std::memcpy(buffer.data() + 2U, payload, length);
|
||||
}
|
||||
return sendCommand({buffer.data(), 2U + length});
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::hostLoadBlob(
|
||||
const core::IFirmwareBlobReader& blob, std::size_t chunkPayload)
|
||||
{
|
||||
std::array<std::byte, 2044> payload = {};
|
||||
std::size_t offset = 0U;
|
||||
|
||||
while (offset < blob.size()) {
|
||||
const std::size_t maxChunk = std::min(chunkPayload, payload.size());
|
||||
const std::size_t copied =
|
||||
blob.read(offset, std::span<std::byte>(payload.data(), maxChunk));
|
||||
if (copied == 0U) {
|
||||
return std::unexpected(Si4684Error::ImageLoadFailed);
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, kSpiBufferSize> tx = {};
|
||||
tx[0] = static_cast<std::uint8_t>(Command::HostLoad);
|
||||
tx[1] = 0x00U;
|
||||
tx[2] = 0x00U;
|
||||
tx[3] = 0x00U;
|
||||
std::memcpy(tx.data() + 4U, payload.data(), copied);
|
||||
|
||||
spi_transaction_t txn = {};
|
||||
txn.length = (4U + copied) * 8U;
|
||||
txn.tx_buffer = tx.data();
|
||||
if (spi_device_transmit(static_cast<spi_device_handle_t>(spiDevice_),
|
||||
&txn) != ESP_OK) {
|
||||
return std::unexpected(Si4684Error::ImageLoadFailed);
|
||||
}
|
||||
if (auto cts = waitCts(); !cts) {
|
||||
return cts;
|
||||
}
|
||||
offset += copied;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::setProperty(
|
||||
std::uint16_t propertyId, std::uint16_t value)
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return ready;
|
||||
}
|
||||
const std::uint8_t args[] = {
|
||||
static_cast<std::uint8_t>(propertyId & 0xFFU),
|
||||
static_cast<std::uint8_t>(propertyId >> 8),
|
||||
static_cast<std::uint8_t>(value & 0xFFU),
|
||||
static_cast<std::uint8_t>(value >> 8),
|
||||
};
|
||||
if (auto cmd = writeCommand(Command::SetProperty, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::setVolume(std::uint8_t level)
|
||||
{
|
||||
return setProperty(kPropAudioVolume,
|
||||
static_cast<std::uint16_t>(level & 0x3FU));
|
||||
}
|
||||
|
||||
std::expected<Si4684PartInfo, Si4684Error> Si4684Driver::getPartInfo()
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return std::unexpected(ready.error());
|
||||
}
|
||||
if (auto cmd = writeCommand(Command::GetPartInfo, nullptr, 0U); !cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
std::array<std::uint8_t, 24> raw = {};
|
||||
if (auto rd = readRaw(raw); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
if (auto fn = writeCommand(Command::GetFuncInfo, nullptr, 0U); !fn) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
std::array<std::uint8_t, 13> fnRaw = {};
|
||||
if (auto rd = readRaw(fnRaw); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
|
||||
Si4684PartInfo info = {};
|
||||
info.chipId = readLe16(raw.data() + 9);
|
||||
info.firmwareMajor = fnRaw[5];
|
||||
info.firmwareMinor = fnRaw[6];
|
||||
info.firmwareBuild = fnRaw[7];
|
||||
return info;
|
||||
}
|
||||
|
||||
std::expected<Si4684SysState, Si4684Error> Si4684Driver::getSysState()
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return std::unexpected(ready.error());
|
||||
}
|
||||
if (auto cmd = writeCommand(Command::GetSysState, nullptr, 0U); !cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
std::array<std::uint8_t, 7> raw = {};
|
||||
if (auto rd = readRaw(raw); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
Si4684SysState state = {};
|
||||
state.imageType = raw[5];
|
||||
return state;
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
|
||||
Si4684Band band)
|
||||
{
|
||||
if (band == Si4684Band::Dab) {
|
||||
if (auto plan = installDefaultDabFrequencyPlan(); !plan) {
|
||||
return plan;
|
||||
}
|
||||
static constexpr std::uint16_t kDabProps[][2] = {
|
||||
{0x0202U, 0x1600U},
|
||||
{0x1710U, 0xFC4AU},
|
||||
{0x1711U, 0x00F8U},
|
||||
{0x8101U, 0x0064U},
|
||||
{0xB200U, 0x0000U},
|
||||
{0xB201U, 0x0080U},
|
||||
{0xB301U, 0x0000U},
|
||||
{0xB302U, 0x0000U},
|
||||
{0xB303U, 0x0000U},
|
||||
{0xB401U, 0x0002U},
|
||||
{0xB500U, 0x0000U},
|
||||
};
|
||||
for (const auto& prop : kDabProps) {
|
||||
if (auto set = setProperty(prop[0], prop[1]); !set) {
|
||||
return set;
|
||||
}
|
||||
}
|
||||
if (auto xpad = setProperty(kPropDabXpadEnable, 0x0097U); !xpad) {
|
||||
return xpad;
|
||||
}
|
||||
} else {
|
||||
if (auto rds = setProperty(kPropFmRdsConfig, 0x0001U); !rds) {
|
||||
return rds;
|
||||
}
|
||||
}
|
||||
|
||||
if (auto i2s = setProperty(kPropDigitalIoOutputSelect, 0x8000U); !i2s) {
|
||||
return i2s;
|
||||
}
|
||||
if (auto rate = setProperty(kPropDigitalIoSampleRate, 0xAC44U); !rate) {
|
||||
return rate;
|
||||
}
|
||||
if (auto pins = setProperty(kPropPinConfigEnable, 0x0003U); !pins) {
|
||||
return pins;
|
||||
}
|
||||
if (auto dabFe = setProperty(kPropDabTuneFeCfg, 0x0001U); !dabFe) {
|
||||
return dabFe;
|
||||
}
|
||||
if (auto dsrv = setProperty(kPropDigitalServiceIntSource, 0x0001U);
|
||||
!dsrv) {
|
||||
return dsrv;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::boot(Si4684Band band)
|
||||
{
|
||||
if (booted_ && loadedBand_ == band) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (booted_) {
|
||||
booted_ = false;
|
||||
if (spiDevice_ != nullptr) {
|
||||
spi_bus_remove_device(static_cast<spi_device_handle_t>(spiDevice_));
|
||||
spiDevice_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
const core::IFirmwareBlobReader& image =
|
||||
(band == Si4684Band::Fm) ? fmImage_ : dabImage_;
|
||||
|
||||
gpio_config_t rstCfg = {};
|
||||
rstCfg.pin_bit_mask = 1ULL << pins_.rstbGpio;
|
||||
rstCfg.mode = GPIO_MODE_OUTPUT;
|
||||
if (gpio_config(&rstCfg) != ESP_OK) {
|
||||
return std::unexpected(Si4684Error::ResetFailed);
|
||||
}
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.rstbGpio), 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.rstbGpio), 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(3));
|
||||
|
||||
if (!spiBusActive_) {
|
||||
spi_bus_config_t busCfg = {};
|
||||
busCfg.miso_io_num = pins_.misoGpio;
|
||||
busCfg.mosi_io_num = pins_.mosiGpio;
|
||||
busCfg.sclk_io_num = pins_.sclkGpio;
|
||||
busCfg.quadwp_io_num = -1;
|
||||
busCfg.quadhd_io_num = -1;
|
||||
busCfg.max_transfer_sz = static_cast<int>(kSpiBufferSize);
|
||||
|
||||
if (spi_bus_initialize(static_cast<spi_host_device_t>(pins_.spiHost),
|
||||
&busCfg, SPI_DMA_CH_AUTO) != ESP_OK) {
|
||||
return std::unexpected(Si4684Error::SpiInitFailed);
|
||||
}
|
||||
spiBusActive_ = true;
|
||||
}
|
||||
|
||||
if (spiDevice_ == nullptr) {
|
||||
spi_device_interface_config_t devCfg = {};
|
||||
devCfg.clock_speed_hz = 10 * 1000 * 1000;
|
||||
devCfg.mode = 0;
|
||||
devCfg.spics_io_num = pins_.csGpio;
|
||||
devCfg.queue_size = 1;
|
||||
|
||||
spi_device_handle_t dev = nullptr;
|
||||
if (spi_bus_add_device(static_cast<spi_host_device_t>(pins_.spiHost),
|
||||
&devCfg, &dev) != ESP_OK) {
|
||||
return std::unexpected(Si4684Error::SpiInitFailed);
|
||||
}
|
||||
spiDevice_ = dev;
|
||||
}
|
||||
|
||||
if (auto st = writeCommand(Command::GetSysState, nullptr, 0U); !st) {
|
||||
return st;
|
||||
}
|
||||
|
||||
const std::uint8_t powerUp[] = {
|
||||
0x17, 0x48, 0x00, 0xf8, 0x24, 0x01, 0x1F, 0x10,
|
||||
0x00, 0x00, 0x00, 0x18, 0x00, 0x00,
|
||||
};
|
||||
if (auto pu = writeCommand(Command::PowerUp, powerUp, sizeof(powerUp));
|
||||
!pu) {
|
||||
return std::unexpected(Si4684Error::PowerUpFailed);
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(1));
|
||||
|
||||
if (auto li = writeCommand(Command::LoadInit, nullptr, 0U); !li) {
|
||||
return std::unexpected(Si4684Error::PatchLoadFailed);
|
||||
}
|
||||
|
||||
if (auto patch = hostLoadBlob(patch_, 124U); !patch) {
|
||||
return std::unexpected(Si4684Error::PatchLoadFailed);
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(4));
|
||||
|
||||
if (auto li2 = writeCommand(Command::LoadInit, nullptr, 0U); !li2) {
|
||||
return std::unexpected(Si4684Error::ImageLoadFailed);
|
||||
}
|
||||
|
||||
if (auto fw = hostLoadBlob(image, 2044U); !fw) {
|
||||
return std::unexpected(Si4684Error::ImageLoadFailed);
|
||||
}
|
||||
|
||||
if (auto bootCmd = writeCommand(Command::BootCmd, nullptr, 0U); !bootCmd) {
|
||||
return std::unexpected(Si4684Error::BootFailed);
|
||||
}
|
||||
|
||||
booted_ = true;
|
||||
loadedBand_ = band;
|
||||
|
||||
if (auto cfg = configureAfterBoot(band); !cfg) {
|
||||
booted_ = false;
|
||||
return cfg;
|
||||
}
|
||||
|
||||
ESP_LOGI(kTag, "%s firmware booted",
|
||||
band == Si4684Band::Fm ? "FM" : "DAB");
|
||||
return {};
|
||||
}
|
||||
|
||||
bool Si4684Driver::isBooted() const noexcept
|
||||
{
|
||||
return booted_;
|
||||
}
|
||||
|
||||
Si4684Band Si4684Driver::loadedBand() const noexcept
|
||||
{
|
||||
return loadedBand_;
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::tuneFm(
|
||||
core::FrequencyKHz frequency)
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Fm); !band) {
|
||||
return band;
|
||||
}
|
||||
const std::uint16_t chipFreq = kHzToChipFmFreq(frequency.value());
|
||||
const std::uint8_t args[] = {
|
||||
0x00U,
|
||||
static_cast<std::uint8_t>(chipFreq & 0xFFU),
|
||||
static_cast<std::uint8_t>(chipFreq >> 8),
|
||||
0x00U,
|
||||
0x00U,
|
||||
};
|
||||
if (auto cmd = writeCommand(Command::FmTuneFreq, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::TuneFailed);
|
||||
}
|
||||
if (auto stc = waitStc(); !stc) {
|
||||
return stc;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<core::FrequencyKHz, Si4684Error> Si4684Driver::seekFm(
|
||||
core::SeekDirection direction, SeekBandWrap wrap)
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Fm); !band) {
|
||||
return std::unexpected(band.error());
|
||||
}
|
||||
const bool seekUp = direction == core::SeekDirection::Up;
|
||||
const bool wrapBand = wrap == SeekBandWrap::Wrap;
|
||||
const std::uint8_t args[] = {
|
||||
0x10U,
|
||||
static_cast<std::uint8_t>(((seekUp ? 1U : 0U) << 1U) | (wrapBand ? 1U : 0U)),
|
||||
0x00U,
|
||||
0x00U,
|
||||
0x00U,
|
||||
};
|
||||
if (auto cmd = writeCommand(Command::FmSeekStart, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::TuneFailed);
|
||||
}
|
||||
if (auto stc = waitStc(); !stc) {
|
||||
return std::unexpected(stc.error());
|
||||
}
|
||||
auto rsq = readFmRsq();
|
||||
if (!rsq) {
|
||||
return std::unexpected(rsq.error());
|
||||
}
|
||||
return rsq->frequency;
|
||||
}
|
||||
|
||||
std::expected<Si4684FmRsq, Si4684Error> Si4684Driver::readFmRsq()
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Fm); !band) {
|
||||
return std::unexpected(band.error());
|
||||
}
|
||||
const std::uint8_t args[] = {0x00U};
|
||||
if (auto cmd = writeCommand(Command::FmRsqStatus, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
std::array<std::uint8_t, 23> raw = {};
|
||||
if (auto rd = readRaw(raw); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
|
||||
Si4684FmRsq rsq = {};
|
||||
const auto khz = chipFmFreqToKHz(readLe16(raw.data() + 6));
|
||||
if (auto freq = core::FrequencyKHz::tryFromKhz(khz); freq) {
|
||||
rsq.frequency = *freq;
|
||||
} else {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
rsq.valid = (raw[4] & 0x01U) != 0U;
|
||||
rsq.stereo = (raw[4] & 0x02U) != 0U;
|
||||
rsq.rssiDbuV = static_cast<std::int8_t>(raw[8]);
|
||||
rsq.snrDb = static_cast<std::int8_t>(raw[9]);
|
||||
return rsq;
|
||||
}
|
||||
|
||||
std::expected<Si4684FmRdsStatus, Si4684Error> Si4684Driver::readFmRds()
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Fm); !band) {
|
||||
return std::unexpected(band.error());
|
||||
}
|
||||
const std::uint8_t args[] = {0x01U};
|
||||
if (auto cmd = writeCommand(Command::FmRdsStatus, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
std::array<std::uint8_t, 21> raw = {};
|
||||
if (auto rd = readRaw(raw); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
|
||||
Si4684FmRdsStatus rds = {};
|
||||
rds.received = (raw[4] & 0x01U) != 0U;
|
||||
rds.blockA = readLe16(raw.data() + 12);
|
||||
rds.blockB = readLe16(raw.data() + 14);
|
||||
rds.blockC = readLe16(raw.data() + 16);
|
||||
rds.blockD = readLe16(raw.data() + 18);
|
||||
return rds;
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::installDefaultDabFrequencyPlan()
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Dab); !band) {
|
||||
return band;
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 4U + kDefaultDabFrequencyKhz.size() * 4U> cmd =
|
||||
{};
|
||||
cmd[0] = static_cast<std::uint8_t>(Command::DabSetFreqList);
|
||||
cmd[1] = static_cast<std::uint8_t>(kDefaultDabFrequencyKhz.size());
|
||||
cmd[2] = 0x00U;
|
||||
cmd[3] = 0x00U;
|
||||
for (std::size_t i = 0; i < kDefaultDabFrequencyKhz.size(); ++i) {
|
||||
const std::uint32_t hz = kDefaultDabFrequencyKhz[i];
|
||||
const std::size_t off = 4U + i * 4U;
|
||||
cmd[off] = static_cast<std::uint8_t>(hz & 0xFFU);
|
||||
cmd[off + 1] = static_cast<std::uint8_t>((hz >> 8) & 0xFFU);
|
||||
cmd[off + 2] = static_cast<std::uint8_t>((hz >> 16) & 0xFFU);
|
||||
cmd[off + 3] = static_cast<std::uint8_t>(hz >> 24);
|
||||
}
|
||||
return sendCommand(cmd);
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::tuneDab(std::uint8_t freqIndex)
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Dab); !band) {
|
||||
return band;
|
||||
}
|
||||
if (freqIndex >= kDefaultDabFrequencyKhz.size()) {
|
||||
return std::unexpected(Si4684Error::TuneFailed);
|
||||
}
|
||||
const std::uint8_t args[] = {0x00U, freqIndex, 0x00U, 0x00U, 0x00U};
|
||||
if (auto cmd = writeCommand(Command::DabTuneFreq, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::TuneFailed);
|
||||
}
|
||||
if (auto stc = waitStc(); !stc) {
|
||||
return stc;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<Si4684DabDigRadStatus, Si4684Error>
|
||||
Si4684Driver::readDabDigRadStatus()
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Dab); !band) {
|
||||
return std::unexpected(band.error());
|
||||
}
|
||||
const std::uint8_t args[] = {0x01U};
|
||||
if (auto cmd =
|
||||
writeCommand(Command::DabDigRadStatus, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
std::array<std::uint8_t, 20> raw = {};
|
||||
if (auto rd = readRaw(raw); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
|
||||
Si4684DabDigRadStatus status = {};
|
||||
status.ficQuality = raw[9];
|
||||
status.cnrDb = raw[10];
|
||||
status.acquired = (raw[4] & 0x08U) != 0U;
|
||||
status.valid = status.ficQuality > 0U;
|
||||
return status;
|
||||
}
|
||||
|
||||
std::expected<Si4684DabEventStatus, Si4684Error>
|
||||
Si4684Driver::readDabEventStatus()
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Dab); !band) {
|
||||
return std::unexpected(band.error());
|
||||
}
|
||||
const std::uint8_t args[] = {0x00U};
|
||||
if (auto cmd =
|
||||
writeCommand(Command::DabGetEventStatus, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
std::array<std::uint8_t, 9> raw = {};
|
||||
if (auto rd = readRaw(raw); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
|
||||
Si4684DabEventStatus events = {};
|
||||
events.serviceListReady = (raw[4] & 0x01U) != 0U;
|
||||
events.reconfig = (raw[4] & 0x02U) != 0U;
|
||||
return events;
|
||||
}
|
||||
|
||||
std::expected<std::vector<Si4684DabService>, Si4684Error>
|
||||
Si4684Driver::fetchDabServiceList()
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Dab); !band) {
|
||||
return std::unexpected(band.error());
|
||||
}
|
||||
|
||||
const std::uint8_t args[] = {0x00U};
|
||||
if (auto cmd = writeCommand(Command::GetDigitalServiceList, args,
|
||||
sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 9> header = {};
|
||||
if (auto rd = readRaw(header); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
|
||||
const std::uint16_t payloadSize = readLe16(header.data() + 5);
|
||||
if (payloadSize == 0U || payloadSize + 6U > kSpiBufferSize) {
|
||||
return std::unexpected(Si4684Error::ReplyTooShort);
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> body(payloadSize + 6U, 0U);
|
||||
if (auto rd = readRaw(body); !rd) {
|
||||
return std::unexpected(rd.error());
|
||||
}
|
||||
|
||||
const std::uint8_t serviceCount = body[9];
|
||||
std::vector<Si4684DabService> services;
|
||||
services.reserve(serviceCount);
|
||||
|
||||
std::size_t offset = 13U;
|
||||
for (std::uint8_t i = 0; i < serviceCount; ++i) {
|
||||
if (offset + 24U > body.size()) {
|
||||
break;
|
||||
}
|
||||
Si4684DabService entry = {};
|
||||
entry.serviceId = readLe32(body.data() + offset);
|
||||
offset += 4U;
|
||||
entry.serviceType = body[offset] & 0x3FU;
|
||||
const std::uint8_t componentCount = body[offset + 1] & 0x0FU;
|
||||
offset += 4U;
|
||||
std::memcpy(entry.label.data(), body.data() + offset, 16U);
|
||||
entry.label[16] = '\0';
|
||||
offset += 16U;
|
||||
|
||||
if (componentCount > 0U && offset + 4U <= body.size()) {
|
||||
entry.componentId = readLe32(body.data() + offset);
|
||||
offset += 4U;
|
||||
if (offset < body.size()) {
|
||||
++offset;
|
||||
}
|
||||
}
|
||||
services.push_back(entry);
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::startDabService(
|
||||
std::uint32_t serviceId,
|
||||
std::uint32_t componentId,
|
||||
Si4684DigitalServiceType type)
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Dab); !band) {
|
||||
return band;
|
||||
}
|
||||
const std::uint8_t args[] = {
|
||||
static_cast<std::uint8_t>(type),
|
||||
0x00U,
|
||||
0x00U,
|
||||
static_cast<std::uint8_t>(serviceId & 0xFFU),
|
||||
static_cast<std::uint8_t>((serviceId >> 8) & 0xFFU),
|
||||
static_cast<std::uint8_t>((serviceId >> 16) & 0xFFU),
|
||||
static_cast<std::uint8_t>(serviceId >> 24),
|
||||
static_cast<std::uint8_t>(componentId & 0xFFU),
|
||||
static_cast<std::uint8_t>((componentId >> 8) & 0xFFU),
|
||||
static_cast<std::uint8_t>((componentId >> 16) & 0xFFU),
|
||||
static_cast<std::uint8_t>(componentId >> 24),
|
||||
};
|
||||
if (auto cmd =
|
||||
writeCommand(Command::StartDigitalService, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::CommandFailed);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace si4684
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @file Si4684EmbeddedImages.cpp
|
||||
* @brief Si4684EmbeddedImages 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 "si4684/Si4684EmbeddedImages.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
/** @cond linker_symbols */
|
||||
extern "C" {
|
||||
extern const std::uint8_t rom_patch_016_bin_start[]
|
||||
asm("_binary_rom_patch_016_bin_start");
|
||||
extern const std::uint8_t rom_patch_016_bin_end[]
|
||||
asm("_binary_rom_patch_016_bin_end");
|
||||
extern const std::uint8_t dab_firmware_bin_start[]
|
||||
asm("_binary_dab_firmware_bin_start");
|
||||
extern const std::uint8_t dab_firmware_bin_end[]
|
||||
asm("_binary_dab_firmware_bin_end");
|
||||
extern const std::uint8_t fm_firmware_bin_start[]
|
||||
asm("_binary_fm_firmware_bin_start");
|
||||
extern const std::uint8_t fm_firmware_bin_end[]
|
||||
asm("_binary_fm_firmware_bin_end");
|
||||
}
|
||||
/** @endcond */
|
||||
|
||||
namespace si4684 {
|
||||
|
||||
namespace {
|
||||
|
||||
const std::byte* asBytes(const std::uint8_t* ptr)
|
||||
{
|
||||
return reinterpret_cast<const std::byte*>(ptr);
|
||||
}
|
||||
|
||||
std::size_t embeddedSize(const std::uint8_t* start, const std::uint8_t* end)
|
||||
{
|
||||
return static_cast<std::size_t>(end - start);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Si4684EmbeddedImages::Si4684EmbeddedImages()
|
||||
: patch_(asBytes(rom_patch_016_bin_start),
|
||||
embeddedSize(rom_patch_016_bin_start, rom_patch_016_bin_end))
|
||||
, dab_(asBytes(dab_firmware_bin_start),
|
||||
embeddedSize(dab_firmware_bin_start, dab_firmware_bin_end))
|
||||
, fm_(asBytes(fm_firmware_bin_start),
|
||||
embeddedSize(fm_firmware_bin_start, fm_firmware_bin_end))
|
||||
{
|
||||
}
|
||||
|
||||
const core::IFirmwareBlobReader& Si4684EmbeddedImages::romPatch() const noexcept
|
||||
{
|
||||
return patch_;
|
||||
}
|
||||
|
||||
const core::IFirmwareBlobReader& Si4684EmbeddedImages::dabFirmware() const noexcept
|
||||
{
|
||||
return dab_;
|
||||
}
|
||||
|
||||
const core::IFirmwareBlobReader& Si4684EmbeddedImages::fmFirmware() const noexcept
|
||||
{
|
||||
return fm_;
|
||||
}
|
||||
|
||||
const core::IFirmwareBlobReader& Si4684EmbeddedImages::applicationImage(
|
||||
Si4684Band band) const noexcept
|
||||
{
|
||||
switch (band) {
|
||||
case Si4684Band::Dab:
|
||||
return dab_;
|
||||
case Si4684Band::Fm:
|
||||
return fm_;
|
||||
}
|
||||
return dab_;
|
||||
}
|
||||
|
||||
} // namespace si4684
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @file Si4684Tuner.cpp
|
||||
* @brief Si4684Tuner 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 "si4684/Si4684Tuner.hpp"
|
||||
|
||||
#include "si4684/Si4684Band.hpp"
|
||||
|
||||
namespace si4684 {
|
||||
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] core::FrequencyKHz defaultFmFrequency()
|
||||
{
|
||||
return *core::FrequencyKHz::tryFromKhz(101500U);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Si4684Tuner::Si4684Tuner(Si4684Driver& driver)
|
||||
: driver_(driver)
|
||||
, dabIndex_(0U)
|
||||
, fmFrequency_(defaultFmFrequency())
|
||||
, volume_(40U)
|
||||
{
|
||||
}
|
||||
|
||||
core::TunerError Si4684Tuner::mapError(Si4684Error error) noexcept
|
||||
{
|
||||
switch (error) {
|
||||
case Si4684Error::NotBooted:
|
||||
return core::TunerError::NotBooted;
|
||||
case Si4684Error::WrongBand:
|
||||
return core::TunerError::WrongBand;
|
||||
case Si4684Error::TuneFailed:
|
||||
case Si4684Error::StcTimeout:
|
||||
return core::TunerError::TuneFailed;
|
||||
default:
|
||||
return core::TunerError::HardwareFailed;
|
||||
}
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> Si4684Tuner::boot(core::TunerBand band)
|
||||
{
|
||||
const Si4684Band hwBand =
|
||||
(band == core::TunerBand::Fm) ? Si4684Band::Fm : Si4684Band::Dab;
|
||||
if (auto result = driver_.boot(hwBand); !result) {
|
||||
return std::unexpected(mapError(result.error()));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<core::TunerBand, core::TunerError> Si4684Tuner::currentBand() const
|
||||
{
|
||||
if (!driver_.isBooted()) {
|
||||
return std::unexpected(core::TunerError::NotBooted);
|
||||
}
|
||||
return driver_.loadedBand() == Si4684Band::Fm ? core::TunerBand::Fm
|
||||
: core::TunerBand::Dab;
|
||||
}
|
||||
|
||||
std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
|
||||
{
|
||||
if (!driver_.isBooted()) {
|
||||
return std::unexpected(core::TunerError::NotBooted);
|
||||
}
|
||||
|
||||
core::TunerStatus status = {};
|
||||
status.booted = true;
|
||||
status.volume = volume_;
|
||||
status.band = driver_.loadedBand() == Si4684Band::Fm ? core::TunerBand::Fm
|
||||
: core::TunerBand::Dab;
|
||||
|
||||
if (status.band == core::TunerBand::Dab) {
|
||||
status.dabFreqIndex = dabIndex_;
|
||||
if (auto dig = driver_.readDabDigRadStatus(); dig) {
|
||||
status.locked = dig->valid;
|
||||
status.dabFicQuality = dig->ficQuality;
|
||||
status.dabCnrDb = dig->cnrDb;
|
||||
} else {
|
||||
return std::unexpected(mapError(dig.error()));
|
||||
}
|
||||
} else {
|
||||
status.fmFrequency = fmFrequency_;
|
||||
if (auto rsq = driver_.readFmRsq(); rsq) {
|
||||
status.locked = rsq->valid;
|
||||
status.fmFrequency = rsq->frequency;
|
||||
status.fmRssiDbuV = rsq->rssiDbuV;
|
||||
status.fmSnrDb = rsq->snrDb;
|
||||
status.fmStereo = rsq->stereo;
|
||||
fmFrequency_ = rsq->frequency;
|
||||
} else {
|
||||
return std::unexpected(mapError(rsq.error()));
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> Si4684Tuner::tuneDab(
|
||||
std::uint8_t freqIndex)
|
||||
{
|
||||
if (auto result = driver_.tuneDab(freqIndex); !result) {
|
||||
return std::unexpected(mapError(result.error()));
|
||||
}
|
||||
dabIndex_ = freqIndex;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> Si4684Tuner::tuneFm(
|
||||
core::FrequencyKHz frequency)
|
||||
{
|
||||
if (auto result = driver_.tuneFm(frequency); !result) {
|
||||
return std::unexpected(mapError(result.error()));
|
||||
}
|
||||
fmFrequency_ = frequency;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<core::FrequencyKHz, core::TunerError> Si4684Tuner::seekFm(
|
||||
core::SeekDirection direction)
|
||||
{
|
||||
if (auto result = driver_.seekFm(direction, SeekBandWrap::Wrap); !result) {
|
||||
return std::unexpected(mapError(result.error()));
|
||||
}
|
||||
fmFrequency_ = *result;
|
||||
return *result;
|
||||
}
|
||||
|
||||
std::expected<std::vector<core::TunerServiceEntry>, core::TunerError>
|
||||
Si4684Tuner::listDabServices()
|
||||
{
|
||||
if (auto events = driver_.readDabEventStatus(); events) {
|
||||
if (!events->serviceListReady) {
|
||||
return std::unexpected(core::TunerError::ServiceListEmpty);
|
||||
}
|
||||
} else {
|
||||
return std::unexpected(mapError(events.error()));
|
||||
}
|
||||
|
||||
if (auto list = driver_.fetchDabServiceList(); list) {
|
||||
std::vector<core::TunerServiceEntry> out;
|
||||
out.reserve(list->size());
|
||||
for (const auto& item : *list) {
|
||||
core::TunerServiceEntry entry = {};
|
||||
entry.serviceId = item.serviceId;
|
||||
entry.componentId = item.componentId;
|
||||
entry.label = item.label;
|
||||
out.push_back(entry);
|
||||
}
|
||||
if (out.empty()) {
|
||||
return std::unexpected(core::TunerError::ServiceListEmpty);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return std::unexpected(mapError(list.error()));
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> Si4684Tuner::playDabService(
|
||||
std::uint32_t serviceId,
|
||||
std::uint32_t componentId)
|
||||
{
|
||||
if (auto result = driver_.startDabService(serviceId, componentId); !result) {
|
||||
return std::unexpected(mapError(result.error()));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> Si4684Tuner::setVolume(std::uint8_t level)
|
||||
{
|
||||
if (auto result = driver_.setVolume(level); !result) {
|
||||
return std::unexpected(mapError(result.error()));
|
||||
}
|
||||
volume_ = level & 0x3FU;
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace si4684
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @file component_stub.cpp
|
||||
* @brief Si4684 driver component placeholder (Slice 4).
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
|
||||
namespace si4684::detail {
|
||||
|
||||
/**
|
||||
* @brief si4684ComponentLinked — ensures the driver component links.
|
||||
*
|
||||
* @dname si4684ComponentLinked
|
||||
* @return n/a (type)
|
||||
* @pubstate n/a
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
void si4684ComponentLinked() noexcept {}
|
||||
|
||||
} // namespace si4684::detail
|
||||
@@ -7,7 +7,7 @@ idf_component_register(
|
||||
"src/NetBootstrap.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
EMBED_FILES "www/index.html.gz"
|
||||
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server
|
||||
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server tuner
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
|
||||
namespace tuner {
|
||||
class TunerService;
|
||||
} // namespace tuner
|
||||
|
||||
namespace net {
|
||||
|
||||
/**
|
||||
@@ -47,6 +51,7 @@ public:
|
||||
*
|
||||
* @dname start
|
||||
* @param store Secure store consulted for saved STA credentials.
|
||||
* @param tuner Tuner service exposed by the HTTP API.
|
||||
* @return NetBootstrap on success, or a NetError.
|
||||
* @pubstate none
|
||||
*
|
||||
@@ -54,7 +59,7 @@ public:
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static std::expected<NetBootstrap, NetError>
|
||||
start(core::ISecureStore& store);
|
||||
start(core::ISecureStore& store, tuner::TunerService& tuner);
|
||||
|
||||
NetBootstrap(const NetBootstrap&) = delete;
|
||||
NetBootstrap& operator=(const NetBootstrap&) = delete;
|
||||
|
||||
@@ -23,10 +23,32 @@
|
||||
|
||||
#include <expected>
|
||||
|
||||
struct httpd_req;
|
||||
|
||||
namespace tuner {
|
||||
class TunerService;
|
||||
} // namespace tuner
|
||||
|
||||
struct httpd_handle;
|
||||
|
||||
namespace net {
|
||||
|
||||
/**
|
||||
* @brief HttpRouteContext — dependencies injected into HTTP handlers.
|
||||
*
|
||||
* @dname HttpRouteContext
|
||||
* @return n/a (type)
|
||||
* @pubstate Borrows store and tuner for the lifetime of SetupWebServer.
|
||||
* Passed as esp_http_server user_ctx (no file-scope globals).
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
struct HttpRouteContext {
|
||||
core::ISecureStore* store; ///< Secure store for Wi-Fi provisioning.
|
||||
tuner::TunerService* tuner; ///< Tuner service for tuner REST routes.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief SetupWebServer — setup UI, health, and Wi-Fi provisioning API.
|
||||
*
|
||||
@@ -96,19 +118,23 @@ public:
|
||||
* @dname start
|
||||
* @param store Secure store for POST /api/wifi persistence.
|
||||
* @param netState Active network phase exposed to handlers.
|
||||
* @param tuner Tuner service for the tuner REST routes.
|
||||
* @return Ok on success, or NetError::HttpServerStartFailed.
|
||||
* @pubstate writes server_, store_, and netState_ on success.
|
||||
* @pubstate writes server_, store_, netState_, and tuner_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, NetError> start(core::ISecureStore& store,
|
||||
NetState netState);
|
||||
NetState netState,
|
||||
tuner::TunerService& tuner);
|
||||
|
||||
private:
|
||||
httpd_handle* server_;
|
||||
core::ISecureStore* store_;
|
||||
NetState netState_;
|
||||
tuner::TunerService* tuner_;
|
||||
HttpRouteContext routeContext_;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "esp_netif.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "tuner/TunerService.hpp"
|
||||
|
||||
namespace net {
|
||||
|
||||
@@ -97,7 +98,7 @@ constexpr char kTag[] = "NetBootstrap";
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<NetBootstrap, NetError>
|
||||
startSetupMode(core::ISecureStore& store)
|
||||
startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner)
|
||||
{
|
||||
esp_netif_create_default_wifi_ap();
|
||||
|
||||
@@ -107,7 +108,7 @@ startSetupMode(core::ISecureStore& store)
|
||||
}
|
||||
|
||||
SetupWebServer webServer;
|
||||
if (auto webResult = webServer.start(store, NetState::SoftApSetup);
|
||||
if (auto webResult = webServer.start(store, NetState::SoftApSetup, tuner);
|
||||
!webResult) {
|
||||
return std::unexpected(webResult.error());
|
||||
}
|
||||
@@ -129,7 +130,7 @@ startSetupMode(core::ISecureStore& store)
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<NetBootstrap, NetError>
|
||||
startStaMode(core::ISecureStore& store)
|
||||
startStaMode(core::ISecureStore& store, tuner::TunerService& tuner)
|
||||
{
|
||||
auto credsResult = store.loadWifiCredentials();
|
||||
if (!credsResult) {
|
||||
@@ -145,7 +146,7 @@ startStaMode(core::ISecureStore& store)
|
||||
}
|
||||
|
||||
SetupWebServer webServer;
|
||||
if (auto webResult = webServer.start(store, NetState::StaConnected);
|
||||
if (auto webResult = webServer.start(store, NetState::StaConnected, tuner);
|
||||
!webResult) {
|
||||
return std::unexpected(webResult.error());
|
||||
}
|
||||
@@ -158,7 +159,7 @@ startStaMode(core::ISecureStore& store)
|
||||
} // namespace
|
||||
|
||||
std::expected<NetBootstrap, NetError>
|
||||
NetBootstrap::start(core::ISecureStore& store)
|
||||
NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner)
|
||||
{
|
||||
if (auto platform = initPlatform(); !platform) {
|
||||
return std::unexpected(platform.error());
|
||||
@@ -169,14 +170,14 @@ NetBootstrap::start(core::ISecureStore& store)
|
||||
}
|
||||
|
||||
if (store.hasWifiCredentials()) {
|
||||
auto staResult = startStaMode(store);
|
||||
auto staResult = startStaMode(store, tuner);
|
||||
if (staResult) {
|
||||
return staResult;
|
||||
}
|
||||
ESP_LOGW(kTag, "STA join failed — falling back to setup SoftAP");
|
||||
}
|
||||
|
||||
return startSetupMode(store);
|
||||
return startSetupMode(store, tuner);
|
||||
}
|
||||
|
||||
NetBootstrap::NetBootstrap(std::optional<SoftApHost> softAp,
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
#include "core/HealthStatus.hpp"
|
||||
#include "core/HealthStatusJson.hpp"
|
||||
#include "core/ParseError.hpp"
|
||||
#include "core/SeekDirection.hpp"
|
||||
#include "core/TunerJson.hpp"
|
||||
#include "core/WifiProvisionJson.hpp"
|
||||
#include "tuner/TunerService.hpp"
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
@@ -37,17 +40,30 @@ namespace net {
|
||||
|
||||
namespace {
|
||||
constexpr char kTag[] = "SetupWebServer";
|
||||
constexpr char kFirmwareVersion[] = "0.2.0";
|
||||
constexpr char kFirmwareVersion[] = "0.4.0";
|
||||
constexpr unsigned kRebootDelaySec = 3;
|
||||
|
||||
SetupWebServer* gActiveServer = nullptr;
|
||||
core::ISecureStore* gStore = nullptr;
|
||||
|
||||
extern const uint8_t www_index_html_gz_start[] asm(
|
||||
"_binary_www_index_html_gz_start");
|
||||
extern const uint8_t www_index_html_gz_end[] asm(
|
||||
"_binary_www_index_html_gz_end");
|
||||
|
||||
/**
|
||||
* @brief routeContextFrom — read handler dependencies from user_ctx.
|
||||
*
|
||||
* @dname routeContextFrom
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return Route context pointer, or nullptr when unset.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] HttpRouteContext* routeContextFrom(httpd_req_t* req) noexcept
|
||||
{
|
||||
return static_cast<HttpRouteContext*>(httpd_req_get_user_ctx(req));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief rebootTask — restart after provisioning so STA mode can run.
|
||||
*
|
||||
@@ -91,6 +107,40 @@ void rebootTask(void* arg)
|
||||
return "parse_error";
|
||||
}
|
||||
|
||||
[[nodiscard]] const char* tunerErrorToken(core::TunerError error) noexcept
|
||||
{
|
||||
switch (error) {
|
||||
case core::TunerError::NotBooted:
|
||||
return "not_booted";
|
||||
case core::TunerError::WrongBand:
|
||||
return "wrong_band";
|
||||
case core::TunerError::TuneFailed:
|
||||
return "tune_failed";
|
||||
case core::TunerError::ServiceListEmpty:
|
||||
return "service_list_empty";
|
||||
case core::TunerError::InvalidInput:
|
||||
return "invalid_input";
|
||||
case core::TunerError::HardwareFailed:
|
||||
return "hardware_failed";
|
||||
}
|
||||
return "tuner_error";
|
||||
}
|
||||
|
||||
[[nodiscard]] bool readRequestBody(httpd_req_t* req, std::array<char, 512>& body)
|
||||
{
|
||||
int received = 0;
|
||||
while (received < static_cast<int>(body.size()) - 1) {
|
||||
const int chunk = httpd_req_recv(req, body.data() + received,
|
||||
body.size() - 1 - received);
|
||||
if (chunk <= 0) {
|
||||
break;
|
||||
}
|
||||
received += chunk;
|
||||
}
|
||||
body[static_cast<std::size_t>(received)] = '\0';
|
||||
return received > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief healthGetHandler — serve GET /api/health as JSON.
|
||||
*
|
||||
@@ -111,6 +161,208 @@ esp_err_t healthGetHandler(httpd_req_t* req)
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief tunerStatusGetHandler — serve GET /api/tuner/status as JSON.
|
||||
*
|
||||
* @dname tunerStatusGetHandler
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return ESP_OK on success, or an esp_err_t error code.
|
||||
* @pubstate uses route context tuner service.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
esp_err_t tunerStatusGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->tuner == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
auto status = ctx->tuner->refreshStatus();
|
||||
if (!status) {
|
||||
const std::string json =
|
||||
core::serializeTunerErrorJson(tunerErrorToken(status.error()));
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
const std::string json = core::serializeTunerStatusJson(*status);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief tunerServicesGetHandler — serve GET /api/tuner/services as JSON.
|
||||
*
|
||||
* @dname tunerServicesGetHandler
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return ESP_OK on success, or an esp_err_t error code.
|
||||
* @pubstate uses route context tuner service.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
esp_err_t tunerServicesGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->tuner == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
auto services = ctx->tuner->listDabServices();
|
||||
if (!services) {
|
||||
const std::string json =
|
||||
core::serializeTunerErrorJson(tunerErrorToken(services.error()));
|
||||
httpd_resp_set_status(req, "409 Conflict");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
const std::string json = core::serializeTunerServicesJson(*services);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief tunerTunePostHandler — accept POST /api/tuner/tune JSON.
|
||||
*
|
||||
* @dname tunerTunePostHandler
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return ESP_OK on success, or an esp_err_t error code.
|
||||
* @pubstate uses route context tuner service.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
esp_err_t tunerTunePostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->tuner == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
std::array<char, 512> body{};
|
||||
if (!readRequestBody(req, body)) {
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
const auto parsed =
|
||||
core::parseTunerTuneJson(std::string_view(body.data()));
|
||||
if (!parsed) {
|
||||
const std::string json = core::serializeTunerErrorJson("invalid_json");
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> result = std::unexpected(
|
||||
core::TunerError::InvalidInput);
|
||||
if (parsed->band == core::TunerBand::Dab) {
|
||||
result = ctx->tuner->tuneDab(parsed->dabFreqIndex);
|
||||
} else if (parsed->fmFrequency) {
|
||||
result = ctx->tuner->tuneFm(*parsed->fmFrequency);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const std::string json =
|
||||
core::serializeTunerErrorJson(tunerErrorToken(result.error()));
|
||||
httpd_resp_set_status(req, "409 Conflict");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
auto status = ctx->tuner->refreshStatus();
|
||||
const std::string json = status
|
||||
? core::serializeTunerStatusJson(*status)
|
||||
: std::string("{\"status\":\"ok\"}");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief tunerPlayPostHandler — accept POST /api/tuner/play JSON.
|
||||
*
|
||||
* @dname tunerPlayPostHandler
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return ESP_OK on success, or an esp_err_t error code.
|
||||
* @pubstate uses route context tuner service.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
esp_err_t tunerPlayPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->tuner == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
std::array<char, 512> body{};
|
||||
if (!readRequestBody(req, body)) {
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
const auto parsed =
|
||||
core::parseTunerPlayJson(std::string_view(body.data()));
|
||||
if (!parsed) {
|
||||
const std::string json = core::serializeTunerErrorJson("invalid_json");
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
if (auto play = ctx->tuner->playDabService(parsed->serviceId,
|
||||
parsed->componentId);
|
||||
!play) {
|
||||
const std::string json =
|
||||
core::serializeTunerErrorJson(tunerErrorToken(play.error()));
|
||||
httpd_resp_set_status(req, "409 Conflict");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, "{\"status\":\"playing\"}", 20);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief tunerSeekPostHandler — accept POST /api/tuner/seek (FM up).
|
||||
*
|
||||
* @dname tunerSeekPostHandler
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return ESP_OK on success, or an esp_err_t error code.
|
||||
* @pubstate uses route context tuner service with SeekDirection::Up.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
esp_err_t tunerSeekPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->tuner == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
auto freq = ctx->tuner->seekFm(core::SeekDirection::Up);
|
||||
if (freq) {
|
||||
const std::string json = std::string("{\"frequency_khz\":")
|
||||
+ std::to_string(freq->value()) + "}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
const std::string json =
|
||||
core::serializeTunerErrorJson(tunerErrorToken(freq.error()));
|
||||
httpd_resp_set_status(req, "409 Conflict");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief indexGetHandler — serve gzipped setup page from flash.
|
||||
*
|
||||
@@ -139,14 +391,15 @@ esp_err_t indexGetHandler(httpd_req_t* req)
|
||||
* @dname wifiPostHandler
|
||||
* @param req HTTP request handle from esp_http_server.
|
||||
* @return ESP_OK on success, or an esp_err_t error code.
|
||||
* @pubstate uses gActiveServer->store_ for persistence.
|
||||
* @pubstate uses route context secure store for persistence.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
esp_err_t wifiPostHandler(httpd_req_t* req)
|
||||
{
|
||||
if (gStore == nullptr) {
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->store == nullptr) {
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
@@ -173,7 +426,7 @@ esp_err_t wifiPostHandler(httpd_req_t* req)
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
if (!gStore->saveWifiCredentials(parsed.value())) {
|
||||
if (!ctx->store->saveWifiCredentials(parsed.value())) {
|
||||
const std::string json =
|
||||
core::serializeWifiProvisionErrorJson("store_failed");
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
@@ -196,6 +449,8 @@ SetupWebServer::SetupWebServer()
|
||||
: server_(nullptr)
|
||||
, store_(nullptr)
|
||||
, netState_(NetState::Uninitialized)
|
||||
, tuner_(nullptr)
|
||||
, routeContext_{nullptr, nullptr}
|
||||
{
|
||||
}
|
||||
|
||||
@@ -203,11 +458,14 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept
|
||||
: server_(other.server_)
|
||||
, store_(other.store_)
|
||||
, netState_(other.netState_)
|
||||
, tuner_(other.tuner_)
|
||||
, routeContext_(other.routeContext_)
|
||||
{
|
||||
other.server_ = nullptr;
|
||||
other.store_ = nullptr;
|
||||
other.netState_ = NetState::Uninitialized;
|
||||
gActiveServer = this;
|
||||
other.tuner_ = nullptr;
|
||||
other.routeContext_ = {nullptr, nullptr};
|
||||
}
|
||||
|
||||
SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
|
||||
@@ -219,30 +477,30 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
|
||||
server_ = other.server_;
|
||||
store_ = other.store_;
|
||||
netState_ = other.netState_;
|
||||
tuner_ = other.tuner_;
|
||||
routeContext_ = other.routeContext_;
|
||||
other.server_ = nullptr;
|
||||
other.store_ = nullptr;
|
||||
other.netState_ = NetState::Uninitialized;
|
||||
gActiveServer = this;
|
||||
other.tuner_ = nullptr;
|
||||
other.routeContext_ = {nullptr, nullptr};
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
SetupWebServer::~SetupWebServer()
|
||||
{
|
||||
if (gActiveServer == this) {
|
||||
gActiveServer = nullptr;
|
||||
}
|
||||
if (gStore == store_) {
|
||||
gStore = nullptr;
|
||||
}
|
||||
if (server_ != nullptr) {
|
||||
httpd_stop(server_);
|
||||
server_ = nullptr;
|
||||
}
|
||||
routeContext_ = {nullptr, nullptr};
|
||||
}
|
||||
|
||||
std::expected<void, NetError> SetupWebServer::start(core::ISecureStore& store,
|
||||
NetState netState)
|
||||
std::expected<void, NetError> SetupWebServer::start(
|
||||
core::ISecureStore& store,
|
||||
NetState netState,
|
||||
tuner::TunerService& tuner)
|
||||
{
|
||||
if (server_ != nullptr) {
|
||||
return {};
|
||||
@@ -250,8 +508,9 @@ std::expected<void, NetError> SetupWebServer::start(core::ISecureStore& store,
|
||||
|
||||
store_ = &store;
|
||||
netState_ = netState;
|
||||
gActiveServer = this;
|
||||
gStore = &store;
|
||||
tuner_ = &tuner;
|
||||
routeContext_.store = &store;
|
||||
routeContext_.tuner = &tuner;
|
||||
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.server_port = 80;
|
||||
@@ -259,16 +518,17 @@ std::expected<void, NetError> SetupWebServer::start(core::ISecureStore& store,
|
||||
|
||||
if (httpd_start(&server_, &config) != ESP_OK) {
|
||||
ESP_LOGE(kTag, "httpd_start failed");
|
||||
gActiveServer = nullptr;
|
||||
gStore = nullptr;
|
||||
routeContext_ = {nullptr, nullptr};
|
||||
return std::unexpected(NetError::HttpServerStartFailed);
|
||||
}
|
||||
|
||||
void* routeCtx = &routeContext_;
|
||||
|
||||
const httpd_uri_t healthUri = {
|
||||
.uri = "/api/health",
|
||||
.method = HTTP_GET,
|
||||
.handler = healthGetHandler,
|
||||
.user_ctx = nullptr,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &healthUri);
|
||||
|
||||
@@ -276,7 +536,7 @@ std::expected<void, NetError> SetupWebServer::start(core::ISecureStore& store,
|
||||
.uri = "/",
|
||||
.method = HTTP_GET,
|
||||
.handler = indexGetHandler,
|
||||
.user_ctx = nullptr,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &indexUri);
|
||||
|
||||
@@ -284,10 +544,50 @@ std::expected<void, NetError> SetupWebServer::start(core::ISecureStore& store,
|
||||
.uri = "/api/wifi",
|
||||
.method = HTTP_POST,
|
||||
.handler = wifiPostHandler,
|
||||
.user_ctx = nullptr,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &wifiUri);
|
||||
|
||||
const httpd_uri_t tunerStatusUri = {
|
||||
.uri = "/api/tuner/status",
|
||||
.method = HTTP_GET,
|
||||
.handler = tunerStatusGetHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &tunerStatusUri);
|
||||
|
||||
const httpd_uri_t tunerServicesUri = {
|
||||
.uri = "/api/tuner/services",
|
||||
.method = HTTP_GET,
|
||||
.handler = tunerServicesGetHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &tunerServicesUri);
|
||||
|
||||
const httpd_uri_t tunerTuneUri = {
|
||||
.uri = "/api/tuner/tune",
|
||||
.method = HTTP_POST,
|
||||
.handler = tunerTunePostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &tunerTuneUri);
|
||||
|
||||
const httpd_uri_t tunerPlayUri = {
|
||||
.uri = "/api/tuner/play",
|
||||
.method = HTTP_POST,
|
||||
.handler = tunerPlayPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &tunerPlayUri);
|
||||
|
||||
const httpd_uri_t tunerSeekUri = {
|
||||
.uri = "/api/tuner/seek",
|
||||
.method = HTTP_POST,
|
||||
.handler = tunerSeekPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &tunerSeekUri);
|
||||
|
||||
ESP_LOGI(kTag, "HTTP server listening on port 80");
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"src/TunerService.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES core
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @file TunerService.hpp
|
||||
* @brief Application service orchestrating tuner operations for UI/API.
|
||||
*
|
||||
* 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/ITuner.hpp"
|
||||
#include "core/SeekDirection.hpp"
|
||||
#include "core/TunerError.hpp"
|
||||
#include "core/TunerStatus.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <vector>
|
||||
|
||||
namespace tuner {
|
||||
|
||||
/**
|
||||
* @brief TunerService — intent-level tuner API for HTTP and future UI.
|
||||
*
|
||||
* @dname TunerService
|
||||
* @return n/a (type)
|
||||
* @pubstate Borrows core::ITuner for the process lifetime. Tracks last tune
|
||||
* target and cached volume for status reporting. No public data
|
||||
* members.
|
||||
*
|
||||
* Delegates hardware to core::ITuner; maps driver failures to TunerError
|
||||
* without exposing SPI details to the shell.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
class TunerService {
|
||||
public:
|
||||
/**
|
||||
* @brief TunerService — bind to a tuner driver for the process lifetime.
|
||||
*
|
||||
* @dname TunerService
|
||||
* @param tuner Driver implementation (must outlive this service).
|
||||
* @pubstate stores tuner reference; initialises last tune defaults.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
explicit TunerService(core::ITuner& tuner);
|
||||
|
||||
/**
|
||||
* @brief refreshStatus — read a fresh tuner snapshot from the driver.
|
||||
*
|
||||
* @dname refreshStatus
|
||||
* @return TunerStatus on success, or a TunerError from ITuner.
|
||||
* @pubstate reads tuner_; updates cached volume_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::TunerStatus, core::TunerError>
|
||||
refreshStatus();
|
||||
|
||||
/**
|
||||
* @brief tuneDab — tune to a Band III ensemble index.
|
||||
*
|
||||
* @dname tuneDab
|
||||
* @param freqIndex Ensemble index 0–37.
|
||||
* @return Ok on success, or a TunerError from ITuner.
|
||||
* @pubstate writes lastDabIndex_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
|
||||
std::uint8_t freqIndex);
|
||||
|
||||
/**
|
||||
* @brief tuneFm — tune to an FM centre frequency.
|
||||
*
|
||||
* @dname tuneFm
|
||||
* @param frequency Validated FM centre frequency.
|
||||
* @return Ok on success, or a TunerError from ITuner.
|
||||
* @pubstate writes lastFmFrequency_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> tuneFm(
|
||||
core::FrequencyKHz frequency);
|
||||
|
||||
/**
|
||||
* @brief seekFm — seek FM in the given direction.
|
||||
*
|
||||
* @dname seekFm
|
||||
* @param direction Up or Down scan direction.
|
||||
* @return New centre frequency, or a TunerError.
|
||||
* @pubstate writes lastFmFrequency_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::FrequencyKHz, core::TunerError> seekFm(
|
||||
core::SeekDirection direction);
|
||||
|
||||
/**
|
||||
* @brief listDabServices — programmes available on the current ensemble.
|
||||
*
|
||||
* @dname listDabServices
|
||||
* @return Service entries, or a TunerError from ITuner.
|
||||
* @pubstate reads tuner_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::vector<core::TunerServiceEntry>,
|
||||
core::TunerError>
|
||||
listDabServices();
|
||||
|
||||
/**
|
||||
* @brief playDabService — start playback of a DAB programme.
|
||||
*
|
||||
* @dname playDabService
|
||||
* @param serviceId Selected service identifier.
|
||||
* @param componentId Audio component within the service.
|
||||
* @return Ok on success, or a TunerError from ITuner.
|
||||
* @pubstate delegates to tuner_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> playDabService(
|
||||
std::uint32_t serviceId, std::uint32_t componentId);
|
||||
|
||||
/**
|
||||
* @brief setVolume — set tuner output attenuation.
|
||||
*
|
||||
* @dname setVolume
|
||||
* @param level Attenuator 0–63.
|
||||
* @return Ok on success, or a TunerError from ITuner.
|
||||
* @pubstate writes volume_ on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> setVolume(
|
||||
std::uint8_t level);
|
||||
|
||||
/**
|
||||
* @brief tuner — borrow the underlying driver for diagnostics.
|
||||
*
|
||||
* @dname tuner
|
||||
* @return Reference to the injected ITuner implementation.
|
||||
* @pubstate reads tuner_.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] core::ITuner& tuner() noexcept;
|
||||
|
||||
private:
|
||||
core::ITuner& tuner_;
|
||||
std::uint8_t lastDabIndex_;
|
||||
core::FrequencyKHz lastFmFrequency_;
|
||||
std::uint8_t volume_;
|
||||
};
|
||||
|
||||
} // namespace tuner
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file TunerService.cpp
|
||||
* @brief TunerService 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 "tuner/TunerService.hpp"
|
||||
|
||||
namespace tuner {
|
||||
|
||||
namespace {
|
||||
|
||||
[[nodiscard]] core::FrequencyKHz defaultFmFrequency()
|
||||
{
|
||||
return *core::FrequencyKHz::tryFromKhz(101500U);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TunerService::TunerService(core::ITuner& tuner)
|
||||
: tuner_(tuner)
|
||||
, lastDabIndex_(0U)
|
||||
, lastFmFrequency_(defaultFmFrequency())
|
||||
, volume_(40U)
|
||||
{
|
||||
}
|
||||
|
||||
std::expected<core::TunerStatus, core::TunerError> TunerService::refreshStatus()
|
||||
{
|
||||
auto status = tuner_.readStatus();
|
||||
if (status) {
|
||||
volume_ = status->volume;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> TunerService::tuneDab(
|
||||
std::uint8_t freqIndex)
|
||||
{
|
||||
if (auto result = tuner_.tuneDab(freqIndex); !result) {
|
||||
return result;
|
||||
}
|
||||
lastDabIndex_ = freqIndex;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> TunerService::tuneFm(
|
||||
core::FrequencyKHz frequency)
|
||||
{
|
||||
if (auto result = tuner_.tuneFm(frequency); !result) {
|
||||
return result;
|
||||
}
|
||||
lastFmFrequency_ = frequency;
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<core::FrequencyKHz, core::TunerError> TunerService::seekFm(
|
||||
core::SeekDirection direction)
|
||||
{
|
||||
auto result = tuner_.seekFm(direction);
|
||||
if (result) {
|
||||
lastFmFrequency_ = *result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::expected<std::vector<core::TunerServiceEntry>, core::TunerError>
|
||||
TunerService::listDabServices()
|
||||
{
|
||||
return tuner_.listDabServices();
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> TunerService::playDabService(
|
||||
std::uint32_t serviceId,
|
||||
std::uint32_t componentId)
|
||||
{
|
||||
return tuner_.playDabService(serviceId, componentId);
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> TunerService::setVolume(std::uint8_t level)
|
||||
{
|
||||
if (auto result = tuner_.setVolume(level); !result) {
|
||||
return result;
|
||||
}
|
||||
volume_ = level & 0x3FU;
|
||||
return {};
|
||||
}
|
||||
|
||||
core::ITuner& TunerService::tuner() noexcept
|
||||
{
|
||||
return tuner_;
|
||||
}
|
||||
|
||||
} // namespace tuner
|
||||
Reference in New Issue
Block a user