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
@@ -0,0 +1,8 @@
idf_component_register(
SRCS
"src/BluetoothService.cpp"
INCLUDE_DIRS "include"
REQUIRES core bt1035
)
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -0,0 +1,102 @@
/**
* @file BluetoothService.hpp
* @brief Intent-level Bluetooth API for HTTP and UI.
*
* 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 "bt1035/Bt1035Driver.hpp"
#include "bt1035/Bt1035Error.hpp"
#include "core/BluetoothJson.hpp"
#include <expected>
namespace bluetooth {
/**
* @brief BluetoothService — pairing and A2DP status for the web API.
*
* @dname BluetoothService
* @return n/a (type)
* @pubstate Borrows bt1035::Bt1035Driver for the process lifetime. Tracks
* whether discoverable mode was requested via startPairing().
*
* @author Michele Bigi
* @date 2026-07-06
*/
class BluetoothService {
public:
/**
* @brief BluetoothService — bind to the BT1035 driver.
*
* @dname BluetoothService
* @param driver Booted BT1035 driver (must outlive this service).
* @pubstate stores driver reference; pairing inactive initially.
*
* @author Michele Bigi
* @date 2026-07-06
*/
explicit BluetoothService(bt1035::Bt1035Driver& driver);
/**
* @brief refreshStatus — read module boot, pairing, and A2DP state.
*
* @dname refreshStatus
* @return BluetoothStatus on success, or Bt1035Error from the driver.
* @pubstate queries driver for A2DP state when booted.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<core::BluetoothStatus, bt1035::Bt1035Error>
refreshStatus();
/**
* @brief startPairing — enter discoverable mode (AT+PAIR=1).
*
* @dname startPairing
* @return Ok on success, or Bt1035Error.
* @pubstate sets pairingActive_ on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> startPairing();
/**
* @brief stopPairing — leave discoverable mode (AT+PAIR=0).
*
* @dname stopPairing
* @return Ok on success, or Bt1035Error.
* @pubstate clears pairingActive_ on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> stopPairing();
/**
* @brief disconnect — release the active A2DP link (AT+A2DPDISC).
*
* @dname disconnect
* @return Ok on success, or Bt1035Error.
* @pubstate delegates to driver.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> disconnect();
private:
bt1035::Bt1035Driver& driver_;
bool pairingActive_;
};
} // namespace bluetooth
@@ -0,0 +1,68 @@
/**
* @file BluetoothService.cpp
* @brief BluetoothService 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 "bluetooth/BluetoothService.hpp"
namespace bluetooth {
BluetoothService::BluetoothService(bt1035::Bt1035Driver& driver)
: driver_(driver)
, pairingActive_(false)
{
}
std::expected<core::BluetoothStatus, bt1035::Bt1035Error>
BluetoothService::refreshStatus()
{
core::BluetoothStatus status{
.booted = driver_.isBooted(),
.pairing = pairingActive_,
.a2dpState = core::Bt1035A2dpState::Standby,
};
if (!status.booted) {
return status;
}
auto a2dp = driver_.queryA2dpState();
if (!a2dp) {
return std::unexpected(a2dp.error());
}
status.a2dpState = *a2dp;
return status;
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::startPairing()
{
if (auto result = driver_.enterPairingMode(); !result) {
return result;
}
pairingActive_ = true;
return {};
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::stopPairing()
{
if (auto result = driver_.leavePairingMode(); !result) {
return result;
}
pairingActive_ = false;
return {};
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::disconnect()
{
return driver_.disconnectA2dp();
}
} // namespace bluetooth
@@ -0,0 +1,8 @@
idf_component_register(
SRCS
"src/StationService.cpp"
INCLUDE_DIRS "include"
REQUIRES core tuner
)
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
@@ -0,0 +1,127 @@
/**
* @file StationService.hpp
* @brief Preset list orchestration with NVS persistence and tuner recall.
*
* 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/ISecureStore.hpp"
#include "core/Station.hpp"
#include "core/StationList.hpp"
#include "core/StationListError.hpp"
#include "core/StoreError.hpp"
#include "core/TunerError.hpp"
#include "tuner/TunerService.hpp"
#include <cstddef>
#include <expected>
namespace station {
/**
* @brief StationService — CRUD presets and tune via TunerService.
*
* @dname StationService
* @return n/a (type)
* @pubstate Owns in-memory StationList mirrored to ISecureStore on mutation.
*
* @author Michele Bigi
* @date 2026-07-06
*/
class StationService {
public:
/**
* @brief StationService — bind store and tuner for the process lifetime.
*
* @dname StationService
* @param store Secure persistence for preset JSON.
* @param tuner Tuner orchestration for recall.
* @pubstate stores references; list empty until loadFromStore().
*
* @author Michele Bigi
* @date 2026-07-06
*/
StationService(core::ISecureStore& store, tuner::TunerService& tuner);
/**
* @brief loadFromStore — hydrate list from NVS (empty when absent).
*
* @dname loadFromStore
* @return Ok on success, or StoreError / parse failure as IoFailed.
* @pubstate replaces list_ from NVS JSON when present.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, core::StoreError> loadFromStore();
/**
* @brief list — read the in-memory preset collection.
*
* @dname list
* @return Const reference to list_.
* @pubstate reads list_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] const core::StationList& list() const noexcept;
/**
* @brief add — append a preset and persist.
*
* @dname add
* @param station Validated preset from JSON boundary.
* @return Ok on success, or StationListError / StoreError.
* @pubstate mutates list_ and NVS on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, core::StationListError> add(
core::Station station);
/**
* @brief removeAt — delete preset by index and persist.
*
* @dname removeAt
* @param index Zero-based list position.
* @return Ok on success, or StationListError / StoreError.
* @pubstate mutates list_ and NVS on success.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, core::StationListError> removeAt(
std::size_t index);
/**
* @brief tuneToIndex — recall a saved preset on the tuner.
*
* @dname tuneToIndex
* @param index Zero-based list position.
* @return Ok on success, or StationListError / TunerError.
* @pubstate delegates tune/play to tuner_.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, core::TunerError> tuneToIndex(
std::size_t index);
private:
[[nodiscard]] std::expected<void, core::StoreError> persist();
core::ISecureStore& store_;
tuner::TunerService& tuner_;
core::StationList list_;
};
} // namespace station
@@ -0,0 +1,109 @@
/**
* @file StationService.cpp
* @brief StationService 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 "station/StationService.hpp"
#include "core/StationListJson.hpp"
#include "core/TunerBand.hpp"
namespace station {
StationService::StationService(core::ISecureStore& store,
tuner::TunerService& tuner)
: store_(store)
, tuner_(tuner)
{
}
std::expected<void, core::StoreError> StationService::loadFromStore()
{
if (!store_.hasStationList()) {
list_ = core::StationList{};
return {};
}
auto blob = store_.loadStationListJson();
if (!blob) {
return std::unexpected(blob.error());
}
auto parsed = core::parseStationListJson(*blob);
if (!parsed) {
return std::unexpected(core::StoreError::InvalidData);
}
list_ = std::move(*parsed);
return {};
}
const core::StationList& StationService::list() const noexcept
{
return list_;
}
std::expected<void, core::StoreError> StationService::persist()
{
return store_.saveStationListJson(core::serializeStationListJson(list_));
}
std::expected<void, core::StationListError> StationService::add(
core::Station station)
{
if (auto added = list_.add(std::move(station)); !added) {
return added;
}
if (auto saved = persist(); !saved) {
list_.removeAt(list_.stations().size() - 1U);
return std::unexpected(core::StationListError::Full);
}
return {};
}
std::expected<void, core::StationListError> StationService::removeAt(
std::size_t index)
{
if (auto removed = list_.removeAt(index); !removed) {
return removed;
}
if (auto saved = persist(); !saved) {
return std::unexpected(core::StationListError::NotFound);
}
return {};
}
std::expected<void, core::TunerError> StationService::tuneToIndex(
std::size_t index)
{
if (index >= list_.stations().size()) {
return std::unexpected(core::TunerError::NotBooted);
}
const core::Station& station = list_.stations()[index];
if (station.band() == core::TunerBand::Fm) {
if (!station.fmFrequency()) {
return std::unexpected(core::TunerError::WrongBand);
}
return tuner_.tuneFm(*station.fmFrequency());
}
if (auto tuned = tuner_.tuneDab(station.dabFreqIndex()); !tuned) {
return tuned;
}
if (station.dabServiceId() && station.dabComponentId()) {
return tuner_.playDabService(*station.dabServiceId(),
*station.dabComponentId());
}
return {};
}
} // namespace station