Add BT1035 pairing and station presets (fw 0.7.0).

Expose discoverable mode and A2DP control over REST, add persisted
DAB/FM preset list with web UI, and document gaps in docs/TODO.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 17:42:11 +02:00
co-authored by Cursor
parent a08a9c19ee
commit 82c9f401da
46 changed files with 2819 additions and 33 deletions
+6
View File
@@ -23,6 +23,12 @@ idf_component_register(
"src/AudioProfile.cpp"
"src/AudioProfileJson.cpp"
"src/Bt1035At.cpp"
"src/StationName.cpp"
"src/PresetSlot.cpp"
"src/Station.cpp"
"src/StationList.cpp"
"src/StationListJson.cpp"
"src/BluetoothJson.cpp"
INCLUDE_DIRS "include"
)
@@ -0,0 +1,64 @@
/**
* @file BluetoothJson.hpp
* @brief JSON serialisation for Bluetooth REST 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/Bt1035At.hpp"
#include <string>
namespace core {
/**
* @brief BluetoothStatus — snapshot for GET /api/bluetooth/status.
*
* @dname BluetoothStatus
* @return n/a (type)
* @pubstate Plain DTO assembled by BluetoothService.
*
* @author Michele Bigi
* @date 2026-07-06
*/
struct BluetoothStatus {
bool booted; ///< BT1035 driver ready after boot().
bool pairing; ///< Discoverable mode requested by firmware.
Bt1035A2dpState a2dpState; ///< Last read A2DP link state.
};
/**
* @brief serializeBluetoothStatusJson — serialise BluetoothStatus for HTTP.
*
* @dname serializeBluetoothStatusJson
* @param status Domain snapshot.
* @return JSON object string.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::string serializeBluetoothStatusJson(
const BluetoothStatus& status);
/**
* @brief serializeBluetoothErrorJson — serialise a Bluetooth API error.
*
* @dname serializeBluetoothErrorJson
* @param reason Short machine-readable cause.
* @return JSON error object.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::string serializeBluetoothErrorJson(const char* reason);
} // namespace core
@@ -16,6 +16,7 @@
#include <array>
#include <cstddef>
#include <cstdint>
#include <expected>
#include <string>
#include <string_view>
@@ -33,8 +34,31 @@ namespace core {
* @date 2026-07-06
*/
enum class Bt1035AtCommand {
Ping, ///< AT — link check.
AuxLineIn, ///< AT+AUXCFG=1 — wired Line-In from ADAU1701 (mandatory).
Ping, ///< AT — link check.
AuxLineIn, ///< AT+AUXCFG=1 — wired Line-In from ADAU1701 (mandatory).
PairDiscoverable, ///< AT+PAIR=1 — enter BR/EDR/BLE discoverable mode.
PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
A2dpDisconnect, ///< AT+A2DPDISC — release current A2DP connection.
};
/**
* @brief Bt1035A2dpState — A2DP link state from +A2DPSTAT=Param (BT1035 manual).
*
* @dname Bt1035A2dpState
* @return n/a (type)
* @pubstate Numeric values match Feasycom firmware event codes.
*
* @author Michele Bigi
* @date 2026-07-06
*/
enum class Bt1035A2dpState : std::uint8_t {
Unsupported = 0,
Standby = 1,
Connecting = 2,
Connected = 3,
Streaming = 4,
Paused = 5,
};
/**
@@ -96,4 +120,31 @@ bootInitSequence() noexcept;
[[nodiscard]] Bt1035AtResponseKind parseBt1035AtResponse(
std::string_view line) noexcept;
/**
* @brief parseBt1035A2dpStatResponse — extract A2DP state from UART payload.
*
* @dname parseBt1035A2dpStatResponse
* @param response Full module reply (may include +A2DPSTAT and OK lines).
* @return Bt1035A2dpState on success, or ParseError::MissingField.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<Bt1035A2dpState, ParseError>
parseBt1035A2dpStatResponse(std::string_view response);
/**
* @brief a2dpStateToken — serialise A2DP state for JSON APIs.
*
* @dname a2dpStateToken
* @param state Parsed A2DP link state.
* @return Short stable string (e.g. "streaming").
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] const char* a2dpStateToken(Bt1035A2dpState state) noexcept;
} // namespace core
@@ -21,6 +21,8 @@
#include "core/WifiCredentials.hpp"
#include <expected>
#include <string>
#include <string_view>
namespace core {
@@ -103,6 +105,58 @@ public:
*/
[[nodiscard]] virtual std::expected<void, StoreError>
clearWifiCredentials() = 0;
/**
* @brief hasStationList — check whether presets are stored.
*
* @dname hasStationList
* @return true when loadStationListJson would succeed.
* @pubstate reads backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual bool hasStationList() const = 0;
/**
* @brief saveStationListJson — persist serialised preset list JSON.
*
* @dname saveStationListJson
* @param json Output of core::serializeStationListJson().
* @return Ok on success, or StoreError::IoFailed.
* @pubstate writes backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<void, StoreError>
saveStationListJson(std::string_view json) = 0;
/**
* @brief loadStationListJson — read stored preset list JSON.
*
* @dname loadStationListJson
* @return JSON blob on success, or StoreError.
* @pubstate reads backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<std::string, StoreError>
loadStationListJson() const = 0;
/**
* @brief clearStationList — erase stored presets.
*
* @dname clearStationList
* @return Ok on success, or StoreError::IoFailed.
* @pubstate clears backing storage via implementation.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<void, StoreError>
clearStationList() = 0;
};
} // namespace core
@@ -0,0 +1,69 @@
/**
* @file PresetSlot.hpp
* @brief Strong type for a physical preset button slot (120).
*
* 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 PresetSlot — optional hardware preset index on the radio.
*
* @dname PresetSlot
* @return n/a (type)
* @pubstate Owns slot_ in [1, kMaxSlot]. Immutable after construction.
*
* @author Michele Bigi
* @date 2026-07-06
*/
class PresetSlot {
public:
/** Highest preset button index supported by the list model. */
static constexpr std::uint8_t kMaxSlot = 20U;
/**
* @brief tryFrom — validate a preset slot at the boundary.
*
* @dname tryFrom
* @param slot Untrusted slot number from JSON (120).
* @return PresetSlot on success, or ParseError::MissingField.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] static std::expected<PresetSlot, ParseError>
tryFrom(unsigned slot) noexcept;
/**
* @brief value — read the slot number.
*
* @dname value
* @return Slot index 120.
* @pubstate reads slot_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::uint8_t value() const noexcept;
private:
explicit PresetSlot(std::uint8_t slot) noexcept;
std::uint8_t slot_;
};
} // namespace core
@@ -0,0 +1,93 @@
/**
* @file Station.hpp
* @brief Value type for one saved tuner preset (DAB or FM).
*
* 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/PresetSlot.hpp"
#include "core/StationName.hpp"
#include "core/TunerBand.hpp"
#include <cstdint>
#include <optional>
namespace core {
/**
* @brief Station — immutable preset: label, band, tune target, optional slot.
*
* @dname Station
* @return n/a (type)
* @pubstate All fields fixed at construction. FM presets carry fmFrequency_;
* DAB presets carry dabFreqIndex_ and optional service/component ids.
*
* @author Michele Bigi
* @date 2026-07-06
*/
class Station {
public:
/**
* @brief Station — construct a validated preset value.
*
* @dname Station
* @param name User-visible label.
* @param band DAB or FM target band.
* @param dabFreqIndex Ensemble index 037 when band is Dab.
* @param dabServiceId Optional DAB service id for play().
* @param dabComponentId Optional DAB component id for play().
* @param fmFrequency FM centre frequency when band is Fm.
* @param presetSlot Optional hardware preset button slot.
* @pubstate stores immutable tune target fields.
*
* @author Michele Bigi
* @date 2026-07-06
*/
Station(StationName name,
TunerBand band,
std::uint8_t dabFreqIndex,
std::optional<std::uint32_t> dabServiceId,
std::optional<std::uint32_t> dabComponentId,
std::optional<FrequencyKHz> fmFrequency,
std::optional<PresetSlot> presetSlot);
[[nodiscard]] const StationName& name() const noexcept;
[[nodiscard]] TunerBand band() const noexcept;
[[nodiscard]] std::uint8_t dabFreqIndex() const noexcept;
[[nodiscard]] std::optional<std::uint32_t> dabServiceId() const noexcept;
[[nodiscard]] std::optional<std::uint32_t> dabComponentId() const noexcept;
[[nodiscard]] std::optional<FrequencyKHz> fmFrequency() const noexcept;
[[nodiscard]] std::optional<PresetSlot> presetSlot() const noexcept;
/**
* @brief sameTuneTarget — compare band-specific tune coordinates.
*
* @dname sameTuneTarget
* @param other Candidate preset.
* @return true when both would tune/play the same source.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] bool sameTuneTarget(const Station& other) const noexcept;
private:
StationName name_;
TunerBand band_;
std::uint8_t dabFreqIndex_;
std::optional<std::uint32_t> dabServiceId_;
std::optional<std::uint32_t> dabComponentId_;
std::optional<FrequencyKHz> fmFrequency_;
std::optional<PresetSlot> presetSlot_;
};
} // namespace core
@@ -0,0 +1,123 @@
/**
* @file StationList.hpp
* @brief In-memory collection of tuner presets with CRUD 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
*/
#pragma once
#include "core/Station.hpp"
#include "core/StationListError.hpp"
#include <cstddef>
#include <expected>
#include <vector>
namespace core {
/**
* @brief StationList — ordered preset collection (pure domain).
*
* @dname StationList
* @return n/a (type)
* @pubstate Owns stations_ (max kMaxStations entries). Duplicate tune targets
* and preset slots are rejected on add().
*
* @author Michele Bigi
* @date 2026-07-06
*/
class StationList {
public:
/** Maximum presets persisted in NVS. */
static constexpr std::size_t kMaxStations = 20U;
/**
* @brief StationList — construct an empty list.
*
* @dname StationList
* @pubstate clears stations_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
StationList();
/**
* @brief stations — read the ordered preset vector.
*
* @dname stations
* @return Const reference to internal storage.
* @pubstate reads stations_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] const std::vector<Station>& stations() const noexcept;
/**
* @brief replaceAll — replace the entire list (e.g. after NVS load).
*
* @dname replaceAll
* @param stations Validated presets (caller must enforce limits).
* @pubstate moves stations into stations_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
void replaceAll(std::vector<Station> stations);
/**
* @brief add — append a preset after duplicate and capacity checks.
*
* @dname add
* @param station New preset value.
* @return Ok on success, or StationListError.
* @pubstate appends to stations_ on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, StationListError> add(Station station);
/**
* @brief removeAt — delete preset by list index.
*
* @dname removeAt
* @param index Zero-based position in stations().
* @return Ok on success, or StationListError::NotFound.
* @pubstate erases one element on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, StationListError> removeAt(std::size_t index);
/**
* @brief move — reorder a preset within the list.
*
* @dname move
* @param fromIndex Current index.
* @param toIndex Target index.
* @return Ok on success, or StationListError::NotFound.
* @pubstate reorders stations_ on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, StationListError> move(std::size_t fromIndex,
std::size_t toIndex);
private:
[[nodiscard]] bool hasDuplicateTuneTarget(const Station& candidate) const;
[[nodiscard]] bool slotTaken(const PresetSlot& slot) const;
std::vector<Station> stations_;
};
} // namespace core
@@ -0,0 +1,34 @@
/**
* @file StationListError.hpp
* @brief Domain errors for station preset list 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 core {
/**
* @brief StationListError — failure causes for preset list CRUD.
*
* @dname StationListError
* @return n/a (type)
* @pubstate n/a
*
* @author Michele Bigi
* @date 2026-07-06
*/
enum class StationListError {
Duplicate,
Full,
NotFound,
SlotInUse,
};
} // namespace core
@@ -0,0 +1,122 @@
/**
* @file StationListJson.hpp
* @brief JSON parse/serialise for station preset list (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/ParseError.hpp"
#include "core/Station.hpp"
#include "core/StationList.hpp"
#include "core/StationListError.hpp"
#include <cstddef>
#include <expected>
#include <string>
#include <string_view>
namespace core {
/**
* @brief StationRemoveRequest — parsed POST /api/stations/remove body.
*
* @dname StationRemoveRequest
* @return n/a (type)
* @pubstate Plain DTO filled by parseStationRemoveJson.
*
* @author Michele Bigi
* @date 2026-07-06
*/
struct StationRemoveRequest {
std::size_t index; ///< Zero-based list index to delete.
};
/**
* @brief serializeStationListJson — serialise all presets for GET /api/stations.
*
* @dname serializeStationListJson
* @param list Domain preset collection.
* @return JSON object with a stations array.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::string serializeStationListJson(const StationList& list);
/**
* @brief parseStationListJson — load presets from persisted NVS JSON.
*
* @dname parseStationListJson
* @param json Untrusted blob from secure storage.
* @return StationList on success, or ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<StationList, ParseError>
parseStationListJson(std::string_view json);
/**
* @brief parseStationJson — validate one POST /api/stations body.
*
* @dname parseStationJson
* @param json Untrusted request body.
* @return Station on success, or ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<Station, ParseError> parseStationJson(
std::string_view json);
/**
* @brief parseStationRemoveJson — validate POST /api/stations/remove body.
*
* @dname parseStationRemoveJson
* @param json Untrusted request body with index field.
* @return StationRemoveRequest on success, or ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<StationRemoveRequest, ParseError>
parseStationRemoveJson(std::string_view json);
/**
* @brief serializeStationListErrorJson — serialise a station API error.
*
* @dname serializeStationListErrorJson
* @param reason Short machine-readable cause.
* @return JSON error object.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::string serializeStationListErrorJson(const char* reason);
/**
* @brief stationListErrorToken — map domain error to API reason string.
*
* @dname stationListErrorToken
* @param error StationListError from add/remove/move.
* @return Stable reason token.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] const char* stationListErrorToken(StationListError error) noexcept;
} // namespace core
@@ -0,0 +1,71 @@
/**
* @file StationName.hpp
* @brief Strong type for a user-visible station preset label.
*
* 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 <cstddef>
#include <expected>
#include <string>
#include <string_view>
namespace core {
/**
* @brief StationName — validated preset display name (132 UTF-8 bytes).
*
* @dname StationName
* @return n/a (type)
* @pubstate Owns name_ (immutable after construction).
*
* @author Michele Bigi
* @date 2026-07-06
*/
class StationName {
public:
/** Maximum label length stored in NVS. */
static constexpr std::size_t kMaxLength = 32;
/**
* @brief tryFrom — validate a station name at the HTTP boundary.
*
* @dname tryFrom
* @param raw Untrusted label from JSON input.
* @return StationName on success, or ParseError::MissingField.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] static std::expected<StationName, ParseError>
tryFrom(std::string_view raw);
/**
* @brief value — read the stored label.
*
* @dname value
* @return Validated preset name.
* @pubstate reads name_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::string_view value() const noexcept;
private:
explicit StationName(std::string name);
std::string name_;
};
} // namespace core
@@ -0,0 +1,36 @@
/**
* @file BluetoothJson.cpp
* @brief BluetoothJson 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/BluetoothJson.hpp"
#include <sstream>
namespace core {
std::string serializeBluetoothStatusJson(const BluetoothStatus& status)
{
std::ostringstream out;
out << "{\"booted\":" << (status.booted ? "true" : "false")
<< ",\"pairing\":" << (status.pairing ? "true" : "false")
<< ",\"a2dp\":\"" << a2dpStateToken(status.a2dpState) << "\"}";
return out.str();
}
std::string serializeBluetoothErrorJson(const char* reason)
{
std::ostringstream out;
out << "{\"status\":\"error\",\"reason\":\"" << reason << "\"}";
return out.str();
}
} // namespace core
+114
View File
@@ -13,6 +13,8 @@
#include "core/Bt1035At.hpp"
#include <cstdlib>
namespace core {
namespace {
@@ -32,6 +34,65 @@ namespace {
return text;
}
[[nodiscard]] bool lineEqualsOk(std::string_view line) noexcept
{
return trimAscii(line) == "OK";
}
[[nodiscard]] bool lineIsError(std::string_view line) noexcept
{
const std::string_view trimmed = trimAscii(line);
return trimmed == "ERROR" || trimmed.starts_with("ERROR");
}
[[nodiscard]] bool responseContainsOk(std::string_view response) noexcept
{
std::size_t start = 0;
while (start < response.size()) {
const std::size_t end = response.find_first_of("\r\n", start);
const std::string_view line =
end == std::string_view::npos
? response.substr(start)
: response.substr(start, end - start);
if (lineEqualsOk(line)) {
return true;
}
if (end == std::string_view::npos) {
break;
}
start = end + 1U;
while (start < response.size()
&& (response[start] == '\r' || response[start] == '\n')) {
++start;
}
}
return lineEqualsOk(response);
}
[[nodiscard]] bool responseContainsError(std::string_view response) noexcept
{
std::size_t start = 0;
while (start < response.size()) {
const std::size_t end = response.find_first_of("\r\n", start);
const std::string_view line =
end == std::string_view::npos
? response.substr(start)
: response.substr(start, end - start);
if (lineIsError(line)) {
return true;
}
if (end == std::string_view::npos) {
break;
}
start = end + 1U;
while (start < response.size()
&& (response[start] == '\r' || response[start] == '\n')) {
++start;
}
}
return lineIsError(response);
}
} // namespace
std::string buildBt1035AtLine(Bt1035AtCommand command)
@@ -41,6 +102,14 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
return "AT\r\n";
case Bt1035AtCommand::AuxLineIn:
return "AT+AUXCFG=1\r\n";
case Bt1035AtCommand::PairDiscoverable:
return "AT+PAIR=1\r\n";
case Bt1035AtCommand::PairHidden:
return "AT+PAIR=0\r\n";
case Bt1035AtCommand::A2dpStat:
return "AT+A2DPSTAT\r\n";
case Bt1035AtCommand::A2dpDisconnect:
return "AT+A2DPDISC\r\n";
}
return "AT\r\n";
}
@@ -55,6 +124,12 @@ std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noex
Bt1035AtResponseKind parseBt1035AtResponse(std::string_view line) noexcept
{
if (responseContainsOk(line)) {
return Bt1035AtResponseKind::Ok;
}
if (responseContainsError(line)) {
return Bt1035AtResponseKind::Error;
}
const std::string_view trimmed = trimAscii(line);
if (trimmed == "OK") {
return Bt1035AtResponseKind::Ok;
@@ -65,4 +140,43 @@ Bt1035AtResponseKind parseBt1035AtResponse(std::string_view line) noexcept
return Bt1035AtResponseKind::Unexpected;
}
std::expected<Bt1035A2dpState, ParseError>
parseBt1035A2dpStatResponse(std::string_view response)
{
constexpr std::string_view kPrefix = "+A2DPSTAT=";
const std::size_t pos = response.find(kPrefix);
if (pos == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::size_t valueStart = pos + kPrefix.size();
char* end = nullptr;
const unsigned long raw =
std::strtoul(response.data() + valueStart, &end, 10);
if (end == response.data() + valueStart || raw > 5U) {
return std::unexpected(ParseError::MissingField);
}
return static_cast<Bt1035A2dpState>(raw);
}
const char* a2dpStateToken(Bt1035A2dpState state) noexcept
{
switch (state) {
case Bt1035A2dpState::Unsupported:
return "unsupported";
case Bt1035A2dpState::Standby:
return "standby";
case Bt1035A2dpState::Connecting:
return "connecting";
case Bt1035A2dpState::Connected:
return "connected";
case Bt1035A2dpState::Streaming:
return "streaming";
case Bt1035A2dpState::Paused:
return "paused";
}
return "unknown";
}
} // namespace core
@@ -0,0 +1,36 @@
/**
* @file PresetSlot.cpp
* @brief PresetSlot 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/PresetSlot.hpp"
namespace core {
std::expected<PresetSlot, ParseError> PresetSlot::tryFrom(unsigned slot) noexcept
{
if (slot < 1U || slot > kMaxSlot) {
return std::unexpected(ParseError::MissingField);
}
return PresetSlot(static_cast<std::uint8_t>(slot));
}
PresetSlot::PresetSlot(std::uint8_t slot) noexcept
: slot_(slot)
{
}
std::uint8_t PresetSlot::value() const noexcept
{
return slot_;
}
} // namespace core
+86
View File
@@ -0,0 +1,86 @@
/**
* @file Station.cpp
* @brief Station 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/Station.hpp"
namespace core {
Station::Station(StationName name,
TunerBand band,
std::uint8_t dabFreqIndex,
std::optional<std::uint32_t> dabServiceId,
std::optional<std::uint32_t> dabComponentId,
std::optional<FrequencyKHz> fmFrequency,
std::optional<PresetSlot> presetSlot)
: name_(std::move(name))
, band_(band)
, dabFreqIndex_(dabFreqIndex)
, dabServiceId_(dabServiceId)
, dabComponentId_(dabComponentId)
, fmFrequency_(std::move(fmFrequency))
, presetSlot_(std::move(presetSlot))
{
}
const StationName& Station::name() const noexcept
{
return name_;
}
TunerBand Station::band() const noexcept
{
return band_;
}
std::uint8_t Station::dabFreqIndex() const noexcept
{
return dabFreqIndex_;
}
std::optional<std::uint32_t> Station::dabServiceId() const noexcept
{
return dabServiceId_;
}
std::optional<std::uint32_t> Station::dabComponentId() const noexcept
{
return dabComponentId_;
}
std::optional<FrequencyKHz> Station::fmFrequency() const noexcept
{
return fmFrequency_;
}
std::optional<PresetSlot> Station::presetSlot() const noexcept
{
return presetSlot_;
}
bool Station::sameTuneTarget(const Station& other) const noexcept
{
if (band_ != other.band_) {
return false;
}
if (band_ == TunerBand::Fm) {
return fmFrequency_ && other.fmFrequency_
&& fmFrequency_->value() == other.fmFrequency_->value();
}
if (dabFreqIndex_ != other.dabFreqIndex_) {
return false;
}
return dabServiceId_ == other.dabServiceId_
&& dabComponentId_ == other.dabComponentId_;
}
} // namespace core
@@ -0,0 +1,92 @@
/**
* @file StationList.cpp
* @brief StationList 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/StationList.hpp"
#include <algorithm>
namespace core {
StationList::StationList() = default;
const std::vector<Station>& StationList::stations() const noexcept
{
return stations_;
}
void StationList::replaceAll(std::vector<Station> stations)
{
stations_ = std::move(stations);
}
bool StationList::hasDuplicateTuneTarget(const Station& candidate) const
{
for (const Station& existing : stations_) {
if (existing.sameTuneTarget(candidate)) {
return true;
}
}
return false;
}
bool StationList::slotTaken(const PresetSlot& slot) const
{
for (const Station& existing : stations_) {
if (existing.presetSlot() && existing.presetSlot()->value() == slot.value()) {
return true;
}
}
return false;
}
std::expected<void, StationListError> StationList::add(Station station)
{
if (stations_.size() >= kMaxStations) {
return std::unexpected(StationListError::Full);
}
if (hasDuplicateTuneTarget(station)) {
return std::unexpected(StationListError::Duplicate);
}
if (station.presetSlot() && slotTaken(*station.presetSlot())) {
return std::unexpected(StationListError::SlotInUse);
}
stations_.push_back(std::move(station));
return {};
}
std::expected<void, StationListError> StationList::removeAt(std::size_t index)
{
if (index >= stations_.size()) {
return std::unexpected(StationListError::NotFound);
}
stations_.erase(stations_.begin() + static_cast<std::ptrdiff_t>(index));
return {};
}
std::expected<void, StationListError> StationList::move(std::size_t fromIndex,
std::size_t toIndex)
{
if (fromIndex >= stations_.size() || toIndex >= stations_.size()) {
return std::unexpected(StationListError::NotFound);
}
if (fromIndex == toIndex) {
return {};
}
Station moving = std::move(stations_[fromIndex]);
stations_.erase(stations_.begin() + static_cast<std::ptrdiff_t>(fromIndex));
stations_.insert(stations_.begin() + static_cast<std::ptrdiff_t>(toIndex),
std::move(moving));
return {};
}
} // namespace core
@@ -0,0 +1,265 @@
/**
* @file StationListJson.cpp
* @brief StationListJson 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/StationListJson.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";
}
[[nodiscard]] std::expected<TunerBand, ParseError>
parseBandField(std::string_view json)
{
const std::string_view band = extractJsonString(json, "band");
if (band == "fm") {
return TunerBand::Fm;
}
if (band == "dab") {
return TunerBand::Dab;
}
return std::unexpected(ParseError::MissingField);
}
[[nodiscard]] std::expected<Station, ParseError>
parseStationObject(std::string_view json)
{
const std::string_view nameRaw = extractJsonString(json, "name");
auto name = StationName::tryFrom(nameRaw);
if (!name) {
return std::unexpected(ParseError::MissingField);
}
auto band = parseBandField(json);
if (!band) {
return std::unexpected(ParseError::MissingField);
}
std::optional<PresetSlot> slot;
unsigned long slotRaw = 0;
if (extractJsonUint(json, "preset_slot", slotRaw)) {
auto parsedSlot = PresetSlot::tryFrom(slotRaw);
if (!parsedSlot) {
return std::unexpected(ParseError::MissingField);
}
slot = *parsedSlot;
}
if (*band == TunerBand::Fm) {
unsigned long khz = 0;
if (!extractJsonUint(json, "fm_frequency_khz", khz)) {
return std::unexpected(ParseError::MissingField);
}
auto frequency = FrequencyKHz::tryFromKhz(static_cast<std::uint32_t>(khz));
if (!frequency) {
return std::unexpected(ParseError::MissingField);
}
return Station(*name, TunerBand::Fm, 0U, std::nullopt, std::nullopt,
*frequency, slot);
}
unsigned long dabIndex = 0;
if (!extractJsonUint(json, "dab_freq_index", dabIndex) || dabIndex > 37U) {
return std::unexpected(ParseError::MissingField);
}
std::optional<std::uint32_t> serviceId;
unsigned long serviceRaw = 0;
if (extractJsonUint(json, "dab_service_id", serviceRaw)) {
serviceId = static_cast<std::uint32_t>(serviceRaw);
}
std::optional<std::uint32_t> componentId;
unsigned long componentRaw = 0;
if (extractJsonUint(json, "dab_component_id", componentRaw)) {
componentId = static_cast<std::uint32_t>(componentRaw);
}
return Station(*name, TunerBand::Dab, static_cast<std::uint8_t>(dabIndex),
serviceId, componentId, std::nullopt, slot);
}
void appendStationJson(std::ostringstream& out, const Station& station)
{
out << "{\"name\":\"" << station.name().value() << "\",\"band\":\""
<< bandToken(station.band()) << "\"";
if (station.band() == TunerBand::Fm && station.fmFrequency()) {
out << ",\"fm_frequency_khz\":" << station.fmFrequency()->value();
} else {
out << ",\"dab_freq_index\":"
<< static_cast<unsigned>(station.dabFreqIndex());
if (station.dabServiceId()) {
out << ",\"dab_service_id\":" << *station.dabServiceId();
}
if (station.dabComponentId()) {
out << ",\"dab_component_id\":" << *station.dabComponentId();
}
}
if (station.presetSlot()) {
out << ",\"preset_slot\":"
<< static_cast<unsigned>(station.presetSlot()->value());
}
out << '}';
}
} // namespace
std::string serializeStationListJson(const StationList& list)
{
std::ostringstream out;
out << "{\"stations\":[";
bool first = true;
for (const Station& station : list.stations()) {
if (!first) {
out << ',';
}
first = false;
appendStationJson(out, station);
}
out << "]}";
return out.str();
}
std::expected<StationList, ParseError> parseStationListJson(std::string_view json)
{
StationList list;
const std::size_t arrayStart = json.find("\"stations\"");
if (arrayStart == std::string_view::npos) {
return list;
}
std::size_t pos = json.find('[', arrayStart);
if (pos == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
++pos;
while (pos < json.size()) {
while (pos < json.size()
&& (json[pos] == ' ' || json[pos] == ',' || json[pos] == '\n'
|| json[pos] == '\r')) {
++pos;
}
if (pos >= json.size() || json[pos] == ']') {
break;
}
if (json[pos] != '{') {
return std::unexpected(ParseError::InvalidJson);
}
const std::size_t objectStart = pos;
int depth = 0;
for (; pos < json.size(); ++pos) {
if (json[pos] == '{') {
++depth;
} else if (json[pos] == '}') {
--depth;
if (depth == 0) {
++pos;
break;
}
}
}
const std::string_view object =
json.substr(objectStart, pos - objectStart);
auto station = parseStationObject(object);
if (!station) {
return std::unexpected(station.error());
}
if (auto added = list.add(std::move(*station)); !added) {
return std::unexpected(ParseError::InvalidJson);
}
}
return list;
}
std::expected<Station, ParseError> parseStationJson(std::string_view json)
{
return parseStationObject(json);
}
std::expected<StationRemoveRequest, ParseError>
parseStationRemoveJson(std::string_view json)
{
unsigned long index = 0;
if (!extractJsonUint(json, "index", index)) {
return std::unexpected(ParseError::MissingField);
}
return StationRemoveRequest{.index = static_cast<std::size_t>(index)};
}
std::string serializeStationListErrorJson(const char* reason)
{
std::ostringstream out;
out << "{\"status\":\"error\",\"reason\":\"" << reason << "\"}";
return out.str();
}
const char* stationListErrorToken(StationListError error) noexcept
{
switch (error) {
case StationListError::Duplicate:
return "duplicate";
case StationListError::Full:
return "full";
case StationListError::NotFound:
return "not_found";
case StationListError::SlotInUse:
return "slot_in_use";
}
return "station_error";
}
} // namespace core
@@ -0,0 +1,36 @@
/**
* @file StationName.cpp
* @brief StationName 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/StationName.hpp"
namespace core {
std::expected<StationName, ParseError> StationName::tryFrom(std::string_view raw)
{
if (raw.empty() || raw.size() > kMaxLength) {
return std::unexpected(ParseError::MissingField);
}
return StationName(std::string(raw));
}
StationName::StationName(std::string name)
: name_(std::move(name))
{
}
std::string_view StationName::value() const noexcept
{
return name_;
}
} // namespace core
@@ -35,6 +35,12 @@ add_library(digiradio_core STATIC
"${CORE_SRC_DIR}/AudioProfile.cpp"
"${CORE_SRC_DIR}/AudioProfileJson.cpp"
"${CORE_SRC_DIR}/Bt1035At.cpp"
"${CORE_SRC_DIR}/StationName.cpp"
"${CORE_SRC_DIR}/PresetSlot.cpp"
"${CORE_SRC_DIR}/Station.cpp"
"${CORE_SRC_DIR}/StationList.cpp"
"${CORE_SRC_DIR}/StationListJson.cpp"
"${CORE_SRC_DIR}/BluetoothJson.cpp"
)
target_include_directories(digiradio_core PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/../include"
@@ -72,6 +78,10 @@ add_executable(enhancements_design_test enhancements_design_test.cpp)
target_link_libraries(enhancements_design_test PRIVATE digiradio_core)
add_test(NAME enhancements_design_test COMMAND enhancements_design_test)
add_executable(station_list_test station_list_test.cpp)
target_link_libraries(station_list_test PRIVATE digiradio_core)
add_test(NAME station_list_test COMMAND station_list_test)
add_executable(bt1035_at_test bt1035_at_test.cpp)
target_link_libraries(bt1035_at_test PRIVATE digiradio_core)
add_test(NAME bt1035_at_test COMMAND bt1035_at_test)
@@ -54,6 +54,23 @@ namespace {
std::cerr << "unexpected parse failed\n";
return EXIT_FAILURE;
}
if (core::parseBt1035AtResponse("+A2DPSTAT=3\r\nOK\r\n")
!= core::Bt1035AtResponseKind::Ok) {
std::cerr << "expected multiline OK detection\n";
return EXIT_FAILURE;
}
const std::string pairOn =
core::buildBt1035AtLine(core::Bt1035AtCommand::PairDiscoverable);
if (pairOn != "AT+PAIR=1\r\n") {
std::cerr << "expected AT+PAIR=1 line\n";
return EXIT_FAILURE;
}
const auto a2dp =
core::parseBt1035A2dpStatResponse("+A2DPSTAT=4\r\nOK\r\n");
if (!a2dp || *a2dp != core::Bt1035A2dpState::Streaming) {
std::cerr << "expected streaming A2DP state\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
@@ -0,0 +1,98 @@
/**
* @file station_list_test.cpp
* @brief Host tests for station preset list domain and JSON.
*
* 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/PresetSlot.hpp"
#include "core/Station.hpp"
#include "core/StationList.hpp"
#include "core/StationListError.hpp"
#include "core/StationListJson.hpp"
#include "core/StationName.hpp"
#include "core/TunerBand.hpp"
#include <cstdlib>
#include <iostream>
namespace {
[[nodiscard]] core::Station makeFmStation(const char* name, std::uint32_t khz,
unsigned slot)
{
auto label = core::StationName::tryFrom(name);
auto frequency = core::FrequencyKHz::tryFromKhz(khz);
auto preset = core::PresetSlot::tryFrom(slot);
return core::Station(*label, core::TunerBand::Fm, 0U, std::nullopt,
std::nullopt, *frequency, *preset);
}
[[nodiscard]] int runDuplicateRejectionTest()
{
core::StationList list;
if (auto added = list.add(makeFmStation("Radio 2", 88500U, 1U)); !added) {
std::cerr << "first add failed\n";
return EXIT_FAILURE;
}
if (list.add(makeFmStation("Radio 2 dup", 88500U, 2U))) {
std::cerr << "expected duplicate tune target rejection\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runJsonRoundTripTest()
{
core::StationList list;
if (auto added = list.add(makeFmStation("Jazz FM", 101500U, 3U)); !added) {
std::cerr << "add failed\n";
return EXIT_FAILURE;
}
const std::string json = core::serializeStationListJson(list);
auto parsed = core::parseStationListJson(json);
if (!parsed || parsed->stations().size() != 1U) {
std::cerr << "round-trip parse failed\n";
return EXIT_FAILURE;
}
if (parsed->stations()[0U].name().value() != "Jazz FM") {
std::cerr << "name mismatch after round-trip\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
[[nodiscard]] int runParseStationJsonTest()
{
const auto station = core::parseStationJson(
R"({"name":"BBC","band":"dab","dab_freq_index":12,"dab_service_id":42,"dab_component_id":1})");
if (!station || station->band() != core::TunerBand::Dab
|| station->dabFreqIndex() != 12U) {
std::cerr << "dab station parse failed\n";
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
} // namespace
int main()
{
if (runDuplicateRejectionTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runJsonRoundTripTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
if (runParseStationJsonTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}