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:
@@ -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 (1–20).
|
||||
*
|
||||
* 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 (1–20).
|
||||
* @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 1–20.
|
||||
* @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 0–37 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 (1–32 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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -119,9 +119,60 @@ public:
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> sendCommand(
|
||||
core::Bt1035AtCommand command);
|
||||
|
||||
/**
|
||||
* @brief enterPairingMode — make module discoverable (AT+PAIR=1).
|
||||
*
|
||||
* @dname enterPairingMode
|
||||
* @return Ok on OK response, or Bt1035Error.
|
||||
* @pubstate writes UART; module advertises until paired or leavePairingMode().
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> enterPairingMode();
|
||||
|
||||
/**
|
||||
* @brief leavePairingMode — stop discoverable advertising (AT+PAIR=0).
|
||||
*
|
||||
* @dname leavePairingMode
|
||||
* @return Ok on OK response, or Bt1035Error.
|
||||
* @pubstate writes UART.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> leavePairingMode();
|
||||
|
||||
/**
|
||||
* @brief queryA2dpState — read current A2DP link state (AT+A2DPSTAT).
|
||||
*
|
||||
* @dname queryA2dpState
|
||||
* @return Parsed A2DP state on success, or Bt1035Error.
|
||||
* @pubstate writes UART; parses +A2DPSTAT from the reply.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::Bt1035A2dpState, Bt1035Error>
|
||||
queryA2dpState();
|
||||
|
||||
/**
|
||||
* @brief disconnectA2dp — release the active A2DP session (AT+A2DPDISC).
|
||||
*
|
||||
* @dname disconnectA2dp
|
||||
* @return Ok on OK response, or Bt1035Error.
|
||||
* @pubstate writes UART.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> disconnectA2dp();
|
||||
|
||||
private:
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> ensureBooted() const;
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> runInitSequence();
|
||||
[[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollect(
|
||||
std::string_view commandLine);
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> transmitAndExpectOk(
|
||||
std::string_view commandLine);
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ std::expected<void, Bt1035Error> Bt1035Driver::ensureBooted() const
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::transmitAndExpectOk(
|
||||
std::expected<std::string, Bt1035Error> Bt1035Driver::transmitAndCollect(
|
||||
std::string_view commandLine)
|
||||
{
|
||||
const int written = uart_write_bytes(static_cast<uart_port_t>(uartPort_),
|
||||
@@ -89,7 +89,7 @@ std::expected<void, Bt1035Error> Bt1035Driver::transmitAndExpectOk(
|
||||
const core::Bt1035AtResponseKind kind =
|
||||
core::parseBt1035AtResponse(accumulated);
|
||||
if (kind == core::Bt1035AtResponseKind::Ok) {
|
||||
return {};
|
||||
return accumulated;
|
||||
}
|
||||
if (kind == core::Bt1035AtResponseKind::Error) {
|
||||
return std::unexpected(Bt1035Error::AtError);
|
||||
@@ -100,6 +100,15 @@ std::expected<void, Bt1035Error> Bt1035Driver::transmitAndExpectOk(
|
||||
return std::unexpected(Bt1035Error::AtTimeout);
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::transmitAndExpectOk(
|
||||
std::string_view commandLine)
|
||||
{
|
||||
if (auto collected = transmitAndCollect(commandLine); collected) {
|
||||
return {};
|
||||
}
|
||||
return std::unexpected(collected.error());
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::sendCommand(
|
||||
core::Bt1035AtCommand command)
|
||||
{
|
||||
@@ -109,6 +118,49 @@ std::expected<void, Bt1035Error> Bt1035Driver::sendCommand(
|
||||
return transmitAndExpectOk(core::buildBt1035AtLine(command));
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::enterPairingMode()
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return ready;
|
||||
}
|
||||
return sendCommand(core::Bt1035AtCommand::PairDiscoverable);
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::leavePairingMode()
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return ready;
|
||||
}
|
||||
return sendCommand(core::Bt1035AtCommand::PairHidden);
|
||||
}
|
||||
|
||||
std::expected<core::Bt1035A2dpState, Bt1035Error> Bt1035Driver::queryA2dpState()
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return std::unexpected(ready.error());
|
||||
}
|
||||
|
||||
auto response =
|
||||
transmitAndCollect(core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpStat));
|
||||
if (!response) {
|
||||
return std::unexpected(response.error());
|
||||
}
|
||||
|
||||
auto parsed = core::parseBt1035A2dpStatResponse(*response);
|
||||
if (!parsed) {
|
||||
return std::unexpected(Bt1035Error::UnexpectedResponse);
|
||||
}
|
||||
return *parsed;
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::disconnectA2dp()
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return ready;
|
||||
}
|
||||
return sendCommand(core::Bt1035AtCommand::A2dpDisconnect);
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::runInitSequence()
|
||||
{
|
||||
for (const core::Bt1035AtCommand command : core::bootInitSequence()) {
|
||||
|
||||
@@ -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 tuner audio
|
||||
REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server tuner audio bluetooth station bt1035
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
|
||||
@@ -32,6 +32,14 @@ namespace audio {
|
||||
class AudioService;
|
||||
} // namespace audio
|
||||
|
||||
namespace bluetooth {
|
||||
class BluetoothService;
|
||||
} // namespace bluetooth
|
||||
|
||||
namespace station {
|
||||
class StationService;
|
||||
} // namespace station
|
||||
|
||||
namespace tuner {
|
||||
class TunerService;
|
||||
} // namespace tuner
|
||||
@@ -66,7 +74,8 @@ public:
|
||||
*/
|
||||
[[nodiscard]] static std::expected<NetBootstrap, NetError>
|
||||
start(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
audio::AudioService& audio,
|
||||
audio::AudioService& audio, bluetooth::BluetoothService& bluetooth,
|
||||
station::StationService& stations,
|
||||
core::CompanionChipStatus companionChips);
|
||||
|
||||
NetBootstrap(const NetBootstrap&) = delete;
|
||||
|
||||
@@ -30,6 +30,14 @@ namespace audio {
|
||||
class AudioService;
|
||||
} // namespace audio
|
||||
|
||||
namespace bluetooth {
|
||||
class BluetoothService;
|
||||
} // namespace bluetooth
|
||||
|
||||
namespace station {
|
||||
class StationService;
|
||||
} // namespace station
|
||||
|
||||
namespace tuner {
|
||||
class TunerService;
|
||||
} // namespace tuner
|
||||
@@ -53,6 +61,8 @@ struct HttpRouteContext {
|
||||
core::ISecureStore* store; ///< Secure store for Wi-Fi provisioning.
|
||||
tuner::TunerService* tuner; ///< Tuner service for tuner REST routes.
|
||||
audio::AudioService* audio; ///< Audio service for ADAU1701 REST routes.
|
||||
bluetooth::BluetoothService* bluetooth; ///< Bluetooth pairing REST routes.
|
||||
station::StationService* stations; ///< Preset list REST routes.
|
||||
core::CompanionChipStatus companionChips; ///< Boot flags for /api/health.
|
||||
};
|
||||
|
||||
@@ -127,8 +137,10 @@ public:
|
||||
* @param netState Active network phase exposed to handlers.
|
||||
* @param tuner Tuner service for the tuner REST routes.
|
||||
* @param audio Audio service for the audio REST routes.
|
||||
* @param bluetooth Bluetooth service for pairing REST routes.
|
||||
* @param stations Station preset service for list REST routes.
|
||||
* @return Ok on success, or NetError::HttpServerStartFailed.
|
||||
* @pubstate writes server_, store_, netState_, tuner_, and audio_ on success.
|
||||
* @pubstate writes server_, store_, netState_, and service pointers on success.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
@@ -136,6 +148,8 @@ public:
|
||||
[[nodiscard]] std::expected<void, NetError> start(
|
||||
core::ISecureStore& store, NetState netState,
|
||||
tuner::TunerService& tuner, audio::AudioService& audio,
|
||||
bluetooth::BluetoothService& bluetooth,
|
||||
station::StationService& stations,
|
||||
core::CompanionChipStatus companionChips);
|
||||
|
||||
private:
|
||||
@@ -144,6 +158,8 @@ private:
|
||||
NetState netState_;
|
||||
tuner::TunerService* tuner_;
|
||||
audio::AudioService* audio_;
|
||||
bluetooth::BluetoothService* bluetooth_;
|
||||
station::StationService* stations_;
|
||||
HttpRouteContext routeContext_;
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
#include "esp_wifi.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "audio/AudioService.hpp"
|
||||
#include "bluetooth/BluetoothService.hpp"
|
||||
#include "station/StationService.hpp"
|
||||
#include "tuner/TunerService.hpp"
|
||||
|
||||
namespace net {
|
||||
@@ -101,6 +103,8 @@ constexpr char kTag[] = "NetBootstrap";
|
||||
[[nodiscard]] std::expected<NetBootstrap, NetError>
|
||||
startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
audio::AudioService& audio,
|
||||
bluetooth::BluetoothService& bluetooth,
|
||||
station::StationService& stations,
|
||||
core::CompanionChipStatus companionChips)
|
||||
{
|
||||
esp_netif_create_default_wifi_ap();
|
||||
@@ -113,7 +117,7 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
SetupWebServer webServer;
|
||||
if (auto webResult =
|
||||
webServer.start(store, NetState::SoftApSetup, tuner, audio,
|
||||
companionChips);
|
||||
bluetooth, stations, companionChips);
|
||||
!webResult) {
|
||||
return std::unexpected(webResult.error());
|
||||
}
|
||||
@@ -137,6 +141,8 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
[[nodiscard]] std::expected<NetBootstrap, NetError>
|
||||
startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
audio::AudioService& audio,
|
||||
bluetooth::BluetoothService& bluetooth,
|
||||
station::StationService& stations,
|
||||
core::CompanionChipStatus companionChips)
|
||||
{
|
||||
auto credsResult = store.loadWifiCredentials();
|
||||
@@ -155,7 +161,7 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
SetupWebServer webServer;
|
||||
if (auto webResult =
|
||||
webServer.start(store, NetState::StaConnected, tuner, audio,
|
||||
companionChips);
|
||||
bluetooth, stations, companionChips);
|
||||
!webResult) {
|
||||
return std::unexpected(webResult.error());
|
||||
}
|
||||
@@ -170,6 +176,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
std::expected<NetBootstrap, NetError>
|
||||
NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
audio::AudioService& audio,
|
||||
bluetooth::BluetoothService& bluetooth,
|
||||
station::StationService& stations,
|
||||
core::CompanionChipStatus companionChips)
|
||||
{
|
||||
if (auto platform = initPlatform(); !platform) {
|
||||
@@ -181,14 +189,16 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner,
|
||||
}
|
||||
|
||||
if (store.hasWifiCredentials()) {
|
||||
auto staResult = startStaMode(store, tuner, audio, companionChips);
|
||||
auto staResult = startStaMode(store, tuner, audio, bluetooth, stations,
|
||||
companionChips);
|
||||
if (staResult) {
|
||||
return staResult;
|
||||
}
|
||||
ESP_LOGW(kTag, "STA join failed — falling back to setup SoftAP");
|
||||
}
|
||||
|
||||
return startSetupMode(store, tuner, audio, companionChips);
|
||||
return startSetupMode(store, tuner, audio, bluetooth, stations,
|
||||
companionChips);
|
||||
}
|
||||
|
||||
NetBootstrap::NetBootstrap(std::optional<SoftApHost> softAp,
|
||||
|
||||
@@ -20,17 +20,22 @@
|
||||
|
||||
#include "core/AudioProfile.hpp"
|
||||
#include "core/AudioProfileJson.hpp"
|
||||
#include "core/BluetoothJson.hpp"
|
||||
#include "core/CompanionChipStatus.hpp"
|
||||
#include "core/FirmwareVersion.hpp"
|
||||
#include "core/HealthStatus.hpp"
|
||||
#include "core/HealthStatusJson.hpp"
|
||||
#include "core/ParseError.hpp"
|
||||
#include "core/SeekDirection.hpp"
|
||||
#include "core/StationListJson.hpp"
|
||||
#include "core/StoreError.hpp"
|
||||
#include "core/TunerJson.hpp"
|
||||
#include "core/WifiProvisionJson.hpp"
|
||||
#include "tuner/TunerService.hpp"
|
||||
#include "audio/AudioService.hpp"
|
||||
#include "bluetooth/BluetoothService.hpp"
|
||||
#include "station/StationService.hpp"
|
||||
#include "bt1035/Bt1035Error.hpp"
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
@@ -45,7 +50,7 @@ namespace net {
|
||||
|
||||
namespace {
|
||||
constexpr char kTag[] = "SetupWebServer";
|
||||
constexpr char kFirmwareVersion[] = "0.6.0";
|
||||
constexpr char kFirmwareVersion[] = "0.7.0";
|
||||
constexpr unsigned kRebootDelaySec = 3;
|
||||
|
||||
extern const uint8_t www_index_html_gz_start[] asm(
|
||||
@@ -131,6 +136,24 @@ void rebootTask(void* arg)
|
||||
return "tuner_error";
|
||||
}
|
||||
|
||||
[[nodiscard]] const char* bt1035ErrorToken(bt1035::Bt1035Error error) noexcept
|
||||
{
|
||||
switch (error) {
|
||||
case bt1035::Bt1035Error::NotBooted:
|
||||
return "not_booted";
|
||||
case bt1035::Bt1035Error::AtTimeout:
|
||||
return "at_timeout";
|
||||
case bt1035::Bt1035Error::AtError:
|
||||
return "at_error";
|
||||
case bt1035::Bt1035Error::UnexpectedResponse:
|
||||
return "unexpected_response";
|
||||
case bt1035::Bt1035Error::ResetFailed:
|
||||
case bt1035::Bt1035Error::UartInitFailed:
|
||||
return "driver_failed";
|
||||
}
|
||||
return "bluetooth_error";
|
||||
}
|
||||
|
||||
template <std::size_t N>
|
||||
[[nodiscard]] bool readRequestBodyImpl(httpd_req_t* req, std::array<char, N>& body)
|
||||
{
|
||||
@@ -624,6 +647,193 @@ esp_err_t wifiPostHandler(httpd_req_t* req)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t bluetoothStatusGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
auto status = ctx->bluetooth->refreshStatus();
|
||||
if (!status) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(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::serializeBluetoothStatusJson(*status);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
esp_err_t bluetoothPairPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
if (auto result = ctx->bluetooth->startPairing(); !result) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(result.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());
|
||||
}
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, "{\"status\":\"pairing\"}", 20);
|
||||
}
|
||||
|
||||
esp_err_t bluetoothPairStopPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
if (auto result = ctx->bluetooth->stopPairing(); !result) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(result.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());
|
||||
}
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, "{\"status\":\"idle\"}", 17);
|
||||
}
|
||||
|
||||
esp_err_t bluetoothDisconnectPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
if (auto result = ctx->bluetooth->disconnect(); !result) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(result.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());
|
||||
}
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, "{\"status\":\"disconnected\"}", 24);
|
||||
}
|
||||
|
||||
esp_err_t stationsGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->stations == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
const std::string json =
|
||||
core::serializeStationListJson(ctx->stations->list());
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
esp_err_t stationsPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->stations == 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);
|
||||
}
|
||||
auto parsed = core::parseStationJson(body.data());
|
||||
if (!parsed) {
|
||||
const std::string json =
|
||||
core::serializeStationListErrorJson(parseErrorToken(parsed.error()));
|
||||
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 added = ctx->stations->add(std::move(*parsed)); !added) {
|
||||
const std::string json = core::serializeStationListErrorJson(
|
||||
core::stationListErrorToken(added.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\":\"saved\"}", 18);
|
||||
}
|
||||
|
||||
esp_err_t stationsRemovePostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->stations == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
std::array<char, 128> body{};
|
||||
if (!readRequestBody(req, body)) {
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
auto parsed = core::parseStationRemoveJson(body.data());
|
||||
if (!parsed) {
|
||||
const std::string json =
|
||||
core::serializeStationListErrorJson(parseErrorToken(parsed.error()));
|
||||
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 removed = ctx->stations->removeAt(parsed->index); !removed) {
|
||||
const std::string json = core::serializeStationListErrorJson(
|
||||
core::stationListErrorToken(removed.error()));
|
||||
httpd_resp_set_status(req, "404 Not Found");
|
||||
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\":\"removed\"}", 20);
|
||||
}
|
||||
|
||||
esp_err_t stationsTunePostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->stations == nullptr || ctx->tuner == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
std::array<char, 128> body{};
|
||||
if (!readRequestBody(req, body)) {
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
auto parsed = core::parseStationRemoveJson(body.data());
|
||||
if (!parsed) {
|
||||
const std::string json =
|
||||
core::serializeStationListErrorJson(parseErrorToken(parsed.error()));
|
||||
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 (parsed->index >= ctx->stations->list().stations().size()) {
|
||||
const std::string json =
|
||||
core::serializeStationListErrorJson("not_found");
|
||||
httpd_resp_set_status(req, "404 Not Found");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
if (auto tuned = ctx->stations->tuneToIndex(parsed->index); !tuned) {
|
||||
const std::string json =
|
||||
core::serializeTunerErrorJson(tunerErrorToken(tuned.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());
|
||||
}
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, "{\"status\":\"tuned\"}", 18);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SetupWebServer::SetupWebServer()
|
||||
@@ -632,7 +842,9 @@ SetupWebServer::SetupWebServer()
|
||||
, netState_(NetState::Uninitialized)
|
||||
, tuner_(nullptr)
|
||||
, audio_(nullptr)
|
||||
, routeContext_{nullptr, nullptr, nullptr, {}}
|
||||
, bluetooth_(nullptr)
|
||||
, stations_(nullptr)
|
||||
, routeContext_{nullptr, nullptr, nullptr, nullptr, nullptr, {}}
|
||||
{
|
||||
}
|
||||
|
||||
@@ -642,6 +854,8 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept
|
||||
, netState_(other.netState_)
|
||||
, tuner_(other.tuner_)
|
||||
, audio_(other.audio_)
|
||||
, bluetooth_(other.bluetooth_)
|
||||
, stations_(other.stations_)
|
||||
, routeContext_(other.routeContext_)
|
||||
{
|
||||
other.server_ = nullptr;
|
||||
@@ -649,7 +863,9 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept
|
||||
other.netState_ = NetState::Uninitialized;
|
||||
other.tuner_ = nullptr;
|
||||
other.audio_ = nullptr;
|
||||
other.routeContext_ = {nullptr, nullptr, nullptr, {}};
|
||||
other.bluetooth_ = nullptr;
|
||||
other.stations_ = nullptr;
|
||||
other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}};
|
||||
}
|
||||
|
||||
SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
|
||||
@@ -663,13 +879,17 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept
|
||||
netState_ = other.netState_;
|
||||
tuner_ = other.tuner_;
|
||||
audio_ = other.audio_;
|
||||
bluetooth_ = other.bluetooth_;
|
||||
stations_ = other.stations_;
|
||||
routeContext_ = other.routeContext_;
|
||||
other.server_ = nullptr;
|
||||
other.store_ = nullptr;
|
||||
other.netState_ = NetState::Uninitialized;
|
||||
other.tuner_ = nullptr;
|
||||
other.audio_ = nullptr;
|
||||
other.routeContext_ = {nullptr, nullptr, nullptr, {}};
|
||||
other.bluetooth_ = nullptr;
|
||||
other.stations_ = nullptr;
|
||||
other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}};
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
@@ -680,7 +900,7 @@ SetupWebServer::~SetupWebServer()
|
||||
httpd_stop(server_);
|
||||
server_ = nullptr;
|
||||
}
|
||||
routeContext_ = {nullptr, nullptr, nullptr, {}};
|
||||
routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}};
|
||||
}
|
||||
|
||||
std::expected<void, NetError> SetupWebServer::start(
|
||||
@@ -688,6 +908,8 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
NetState netState,
|
||||
tuner::TunerService& tuner,
|
||||
audio::AudioService& audio,
|
||||
bluetooth::BluetoothService& bluetooth,
|
||||
station::StationService& stations,
|
||||
core::CompanionChipStatus companionChips)
|
||||
{
|
||||
if (server_ != nullptr) {
|
||||
@@ -698,9 +920,13 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
netState_ = netState;
|
||||
tuner_ = &tuner;
|
||||
audio_ = &audio;
|
||||
bluetooth_ = &bluetooth;
|
||||
stations_ = &stations;
|
||||
routeContext_.store = &store;
|
||||
routeContext_.tuner = &tuner;
|
||||
routeContext_.audio = &audio;
|
||||
routeContext_.bluetooth = &bluetooth;
|
||||
routeContext_.stations = &stations;
|
||||
routeContext_.companionChips = companionChips;
|
||||
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
@@ -709,7 +935,7 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
|
||||
if (httpd_start(&server_, &config) != ESP_OK) {
|
||||
ESP_LOGE(kTag, "httpd_start failed");
|
||||
routeContext_ = {nullptr, nullptr, nullptr, {}};
|
||||
routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, {}};
|
||||
return std::unexpected(NetError::HttpServerStartFailed);
|
||||
}
|
||||
|
||||
@@ -819,6 +1045,70 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
};
|
||||
httpd_register_uri_handler(server_, &audioBassEnhanceUri);
|
||||
|
||||
const httpd_uri_t bluetoothStatusUri = {
|
||||
.uri = "/api/bluetooth/status",
|
||||
.method = HTTP_GET,
|
||||
.handler = bluetoothStatusGetHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothStatusUri);
|
||||
|
||||
const httpd_uri_t bluetoothPairUri = {
|
||||
.uri = "/api/bluetooth/pair",
|
||||
.method = HTTP_POST,
|
||||
.handler = bluetoothPairPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothPairUri);
|
||||
|
||||
const httpd_uri_t bluetoothPairStopUri = {
|
||||
.uri = "/api/bluetooth/pair/stop",
|
||||
.method = HTTP_POST,
|
||||
.handler = bluetoothPairStopPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothPairStopUri);
|
||||
|
||||
const httpd_uri_t bluetoothDisconnectUri = {
|
||||
.uri = "/api/bluetooth/disconnect",
|
||||
.method = HTTP_POST,
|
||||
.handler = bluetoothDisconnectPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothDisconnectUri);
|
||||
|
||||
const httpd_uri_t stationsGetUri = {
|
||||
.uri = "/api/stations",
|
||||
.method = HTTP_GET,
|
||||
.handler = stationsGetHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &stationsGetUri);
|
||||
|
||||
const httpd_uri_t stationsPostUri = {
|
||||
.uri = "/api/stations",
|
||||
.method = HTTP_POST,
|
||||
.handler = stationsPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &stationsPostUri);
|
||||
|
||||
const httpd_uri_t stationsRemoveUri = {
|
||||
.uri = "/api/stations/remove",
|
||||
.method = HTTP_POST,
|
||||
.handler = stationsRemovePostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &stationsRemoveUri);
|
||||
|
||||
const httpd_uri_t stationsTuneUri = {
|
||||
.uri = "/api/stations/tune",
|
||||
.method = HTTP_POST,
|
||||
.handler = stationsTunePostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &stationsTuneUri);
|
||||
|
||||
ESP_LOGI(kTag, "HTTP server listening on port 80");
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -198,6 +198,33 @@
|
||||
<p class="msg" id="tuner-msg" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section id="presets-section">
|
||||
<h2>Presets</h2>
|
||||
<p>Saved DAB/FM stations (persisted in NVS).</p>
|
||||
<ul class="services" id="preset-list"></ul>
|
||||
<label for="preset-name">Name</label>
|
||||
<input id="preset-name" maxlength="32" placeholder="My station">
|
||||
<label for="preset-slot">Preset slot (1–20, optional)</label>
|
||||
<input id="preset-slot" type="number" min="1" max="20">
|
||||
<button type="button" id="save-preset">Save current tune target</button>
|
||||
<p class="msg" id="preset-msg" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section id="bluetooth-section">
|
||||
<h2>Bluetooth</h2>
|
||||
<p>FSC-BT1035 aptX transmitter — pair headphones or speakers.</p>
|
||||
<div class="tuner-status" id="bt-status">No status yet.</div>
|
||||
<div class="row">
|
||||
<button type="button" id="bt-refresh">Refresh</button>
|
||||
<button type="button" class="secondary" id="bt-pair">Start pairing</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" class="secondary" id="bt-stop-pair">Stop pairing</button>
|
||||
<button type="button" class="secondary" id="bt-disconnect">Disconnect</button>
|
||||
</div>
|
||||
<p class="msg" id="bt-msg" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section id="audio-section">
|
||||
<h2>Audio</h2>
|
||||
<p>ADAU1701 mixer and master volume (safeload at runtime).</p>
|
||||
@@ -521,6 +548,149 @@
|
||||
.catch(function () { showMsg(msg, "Reset request failed.", false); });
|
||||
});
|
||||
|
||||
function formatBtStatus(s) {
|
||||
return [
|
||||
"Booted: " + s.booted,
|
||||
"Pairing: " + s.pairing,
|
||||
"A2DP: " + s.a2dp
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function refreshBluetooth() {
|
||||
var msg = document.getElementById("bt-msg");
|
||||
return fetch("/api/bluetooth/status")
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok && res.d.booted != null) {
|
||||
document.getElementById("bt-status").textContent = formatBtStatus(res.d);
|
||||
showMsg(msg, "", true);
|
||||
} else {
|
||||
showMsg(msg, "BT error: " + (res.d.reason || "unknown"), false);
|
||||
}
|
||||
})
|
||||
.catch(function () { showMsg(msg, "BT request failed.", false); });
|
||||
}
|
||||
|
||||
document.getElementById("bt-refresh").addEventListener("click", refreshBluetooth);
|
||||
document.getElementById("bt-pair").addEventListener("click", function () {
|
||||
var msg = document.getElementById("bt-msg");
|
||||
fetch("/api/bluetooth/pair", { method: "POST" })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
showMsg(msg, "Discoverable — pair from your phone.", true);
|
||||
refreshBluetooth();
|
||||
} else {
|
||||
showMsg(msg, "Pair failed: " + (res.d.reason || "unknown"), false);
|
||||
}
|
||||
})
|
||||
.catch(function () { showMsg(msg, "Pair request failed.", false); });
|
||||
});
|
||||
document.getElementById("bt-stop-pair").addEventListener("click", function () {
|
||||
var msg = document.getElementById("bt-msg");
|
||||
fetch("/api/bluetooth/pair/stop", { method: "POST" })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
showMsg(msg, "Pairing stopped.", true);
|
||||
refreshBluetooth();
|
||||
} else {
|
||||
showMsg(msg, "Stop failed: " + (res.d.reason || "unknown"), false);
|
||||
}
|
||||
})
|
||||
.catch(function () { showMsg(msg, "Stop request failed.", false); });
|
||||
});
|
||||
document.getElementById("bt-disconnect").addEventListener("click", function () {
|
||||
var msg = document.getElementById("bt-msg");
|
||||
fetch("/api/bluetooth/disconnect", { method: "POST" })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
showMsg(msg, "A2DP disconnected.", true);
|
||||
refreshBluetooth();
|
||||
} else {
|
||||
showMsg(msg, "Disconnect failed: " + (res.d.reason || "unknown"), false);
|
||||
}
|
||||
})
|
||||
.catch(function () { showMsg(msg, "Disconnect request failed.", false); });
|
||||
});
|
||||
|
||||
function renderPresets(data) {
|
||||
var list = document.getElementById("preset-list");
|
||||
list.innerHTML = "";
|
||||
(data.stations || []).forEach(function (s, idx) {
|
||||
var li = document.createElement("li");
|
||||
var label = s.name + " (" + s.band + ")";
|
||||
if (s.fm_frequency_khz) label += " " + s.fm_frequency_khz + " kHz";
|
||||
if (s.dab_freq_index != null) label += " idx " + s.dab_freq_index;
|
||||
li.textContent = label;
|
||||
var tuneBtn = document.createElement("button");
|
||||
tuneBtn.textContent = "Tune";
|
||||
tuneBtn.addEventListener("click", function () {
|
||||
fetch("/api/stations/tune", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ index: idx })
|
||||
}).then(function () { refreshTunerStatus(); });
|
||||
});
|
||||
var delBtn = document.createElement("button");
|
||||
delBtn.textContent = "Del";
|
||||
delBtn.className = "secondary";
|
||||
delBtn.addEventListener("click", function () {
|
||||
fetch("/api/stations/remove", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ index: idx })
|
||||
}).then(function () { loadPresets(); });
|
||||
});
|
||||
li.appendChild(tuneBtn);
|
||||
li.appendChild(delBtn);
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function loadPresets() {
|
||||
return fetch("/api/stations")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { renderPresets(d); })
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
document.getElementById("save-preset").addEventListener("click", function () {
|
||||
var msg = document.getElementById("preset-msg");
|
||||
var name = document.getElementById("preset-name").value.trim();
|
||||
if (!name) {
|
||||
showMsg(msg, "Enter a name.", false);
|
||||
return;
|
||||
}
|
||||
var band = document.getElementById("band").value;
|
||||
var body = { name: name, band: band };
|
||||
if (band === "fm") {
|
||||
body.fm_frequency_khz = parseInt(document.getElementById("fm-khz").value, 10);
|
||||
} else {
|
||||
body.dab_freq_index = parseInt(document.getElementById("dab-index").value, 10);
|
||||
}
|
||||
var slotVal = document.getElementById("preset-slot").value;
|
||||
if (slotVal) body.preset_slot = parseInt(slotVal, 10);
|
||||
fetch("/api/stations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok && res.d.status === "saved") {
|
||||
showMsg(msg, "Preset saved.", true);
|
||||
loadPresets();
|
||||
} else {
|
||||
showMsg(msg, "Save failed: " + (res.d.reason || "unknown"), false);
|
||||
}
|
||||
})
|
||||
.catch(function () { showMsg(msg, "Save request failed.", false); });
|
||||
});
|
||||
|
||||
refreshBluetooth();
|
||||
loadPresets();
|
||||
loadAudioProfile();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Binary file not shown.
@@ -100,6 +100,17 @@ public:
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::StoreError>
|
||||
clearWifiCredentials() override;
|
||||
|
||||
[[nodiscard]] bool hasStationList() const override;
|
||||
|
||||
[[nodiscard]] std::expected<void, core::StoreError>
|
||||
saveStationListJson(std::string_view json) override;
|
||||
|
||||
[[nodiscard]] std::expected<std::string, core::StoreError>
|
||||
loadStationListJson() const override;
|
||||
|
||||
[[nodiscard]] std::expected<void, core::StoreError>
|
||||
clearStationList() override;
|
||||
};
|
||||
|
||||
} // namespace secure_store
|
||||
|
||||
@@ -30,6 +30,7 @@ namespace {
|
||||
constexpr char kNamespace[] = "digiradio";
|
||||
constexpr char kSsidKey[] = "wifi_ssid";
|
||||
constexpr char kPasswordKey[] = "wifi_pwd";
|
||||
constexpr char kStationListKey[] = "station_list";
|
||||
} // namespace
|
||||
|
||||
bool NvsSecureStore::hasWifiCredentials() const
|
||||
@@ -148,4 +149,84 @@ std::expected<void, core::StoreError> NvsSecureStore::clearWifiCredentials()
|
||||
return {};
|
||||
}
|
||||
|
||||
bool NvsSecureStore::hasStationList() const
|
||||
{
|
||||
nvs_handle_t handle = 0;
|
||||
if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::size_t len = 0;
|
||||
const esp_err_t err =
|
||||
nvs_get_str(handle, kStationListKey, nullptr, &len);
|
||||
nvs_close(handle);
|
||||
|
||||
return err == ESP_OK && len > 1;
|
||||
}
|
||||
|
||||
std::expected<void, core::StoreError>
|
||||
NvsSecureStore::saveStationListJson(std::string_view json)
|
||||
{
|
||||
nvs_handle_t handle = 0;
|
||||
if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
return std::unexpected(core::StoreError::IoFailed);
|
||||
}
|
||||
|
||||
const std::string payload(json);
|
||||
esp_err_t err = nvs_set_str(handle, kStationListKey, payload.c_str());
|
||||
if (err == ESP_OK) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
return std::unexpected(core::StoreError::IoFailed);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<std::string, core::StoreError>
|
||||
NvsSecureStore::loadStationListJson() const
|
||||
{
|
||||
nvs_handle_t handle = 0;
|
||||
if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) {
|
||||
return std::unexpected(core::StoreError::NotFound);
|
||||
}
|
||||
|
||||
std::size_t len = 0;
|
||||
if (nvs_get_str(handle, kStationListKey, nullptr, &len) != ESP_OK
|
||||
|| len == 0) {
|
||||
nvs_close(handle);
|
||||
return std::unexpected(core::StoreError::NotFound);
|
||||
}
|
||||
|
||||
std::vector<char> buffer(len);
|
||||
if (nvs_get_str(handle, kStationListKey, buffer.data(), &len) != ESP_OK) {
|
||||
nvs_close(handle);
|
||||
return std::unexpected(core::StoreError::IoFailed);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
return std::string(buffer.data());
|
||||
}
|
||||
|
||||
std::expected<void, core::StoreError> NvsSecureStore::clearStationList()
|
||||
{
|
||||
nvs_handle_t handle = 0;
|
||||
if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) {
|
||||
return std::unexpected(core::StoreError::IoFailed);
|
||||
}
|
||||
|
||||
esp_err_t err = nvs_erase_key(handle, kStationListKey);
|
||||
if (err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
err = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
|
||||
if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) {
|
||||
return std::unexpected(core::StoreError::IoFailed);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace secure_store
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
# DigiRadio — consolidated TODO (audit 2026-07-06)
|
||||
|
||||
Firmware **0.7.0** after BT1035 pairing + Slice 4 presets. This list
|
||||
cross-checks code, manual, API, UI, and `instructions.md`.
|
||||
|
||||
## Done in this slice
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| BT1035 pairing AT (`AT+PAIR`, `AT+A2DPSTAT`, `AT+A2DPDISC`) | Done |
|
||||
| `BluetoothService` + `/api/bluetooth/*` + UI | Done |
|
||||
| `Station`, `StationList`, NVS persistence | Done |
|
||||
| `StationService` + `/api/stations/*` + UI | Done |
|
||||
| Host tests (10/10 green) | Done |
|
||||
| Manual: `ch-api`, `ch-bt1035`, `ch-classes` | Done |
|
||||
|
||||
## High priority (next)
|
||||
|
||||
1. **Device flash / HIL** — verify Si4684, ADAU1701, BT1035 on hardware; pairing with real headphones; preset recall across reboot.
|
||||
2. **DAB preset save from UI** — “Save current tune target” stores ensemble index only; capture `service_id` / `component_id` from the last played DAB service (needs UI state or tuner cache).
|
||||
3. **FM band switch UX** — Si4684 boots DAB; FM tune may reload FM image; document/limit band changes in UI (auto-reload or explicit band selector).
|
||||
4. **NVS encryption** — enable `nvs_keys` partition for production (Wi-Fi, presets, future user creds).
|
||||
5. **Slice 8 integration** — unify tuner + audio + presets in a single “now playing” model; source selection (DAB / FM / BT Line-In).
|
||||
|
||||
## Medium priority
|
||||
|
||||
6. **IBtModule interface** — AGENTS mentions it; driver is used directly today. Add when a second BT module or host fake is needed.
|
||||
7. **BT1035 extended AT** — `AT+NAME`, `AT+PLIST`, `AT+A2DPCONN`, event-driven `+PAIRED` / `+A2DPDEV` (UART listener task).
|
||||
8. **Station list reorder API** — `StationList::move()` exists in core; no HTTP route yet.
|
||||
9. **EQ UI completeness** — web UI exposes master/mixer/enhance; per-band EQ editing not in UI (API supports full profile PUT).
|
||||
10. **Si4684 RSQ / scan UX** — driver + HTTP largely done; polish seek, service list refresh, signal display on UI.
|
||||
11. **User credentials** — `ISecureStore` extension for login (out of scope until product needs it).
|
||||
|
||||
## Documentation / tooling
|
||||
|
||||
12. **Overleaf sync** — GitHub is source of truth; root `docs/` symlink can break Overleaf push; compile from `Software/docs/manual/manual.tex`.
|
||||
13. **Firmware version single source** — `0.7.0` in `SetupWebServer.cpp`; align `FirmwareVersion` / health test constants if desired.
|
||||
14. **Doxygen pass** — run `doxygen Doxyfile` on CI host with ESP-IDF toolchain.
|
||||
|
||||
## Low priority / ideas
|
||||
|
||||
15. Physical preset buttons → map GPIO to `StationService::tuneToIndex` by `PresetSlot`.
|
||||
16. OTA updates, mDNS hostname (`digiradio.local`), HTTPS on LAN.
|
||||
17. aptX license note (Feasycom) — commercial firmware variant if needed.
|
||||
|
||||
## Test gaps
|
||||
|
||||
| Missing test | Layer |
|
||||
|--------------|-------|
|
||||
| `BluetoothService` with fake driver | Host (needs `IBtModule` or inject interface) |
|
||||
| `StationService` + fake `ISecureStore` | Host |
|
||||
| `NvsSecureStore` station round-trip | Target / integration |
|
||||
| BT1035 UART HIL | Hardware-only, marked separate |
|
||||
|
||||
## Roadmap alignment (`instructions.md`)
|
||||
|
||||
| Slice | Item | State |
|
||||
|-------|------|-------|
|
||||
| 4 | Station list + persistence + UI | **Done** (basic CRUD + tune) |
|
||||
| 5 | Si4684 tuning / RSQ / DAB properties | Mostly done; UI polish open |
|
||||
| 7 | BT1035 pairing | **Done** (discover + A2DP stat/disconnect) |
|
||||
| 8 | TunerService + AudioService E2E | Partial — health chips OK; unified UX open |
|
||||
@@ -36,7 +36,7 @@ Returns a health-check DTO serialised by
|
||||
|
||||
\begin{drnote}[Response schema]
|
||||
\begin{drcode}[JSON]
|
||||
{"status":"ok","fw":"0.6.0",
|
||||
{"status":"ok","fw":"0.7.0",
|
||||
"chips":{"si4684":true,"adau1701":true,"bt1035":true}}
|
||||
\end{drcode}
|
||||
\begin{itemize}
|
||||
@@ -252,11 +252,70 @@ Adjusts bass emphasis via a PEQ overlay on bands 1--2 (100\,Hz /
|
||||
400\,Hz). Request and response schema match
|
||||
\texttt{POST /api/audio/stereo-enhance} (Section~\ref{sec:api-audio-stereo-enhance}).
|
||||
|
||||
% ------------------------------------------------------------------
|
||||
% Bluetooth (Slice 7)
|
||||
% ------------------------------------------------------------------
|
||||
|
||||
\subsection{\texttt{GET /api/bluetooth/status}}
|
||||
\label{sec:api-bluetooth-status}
|
||||
|
||||
Returns BT1035 boot flag, whether discoverable mode was requested, and the
|
||||
last read A2DP state. Serialised by
|
||||
\texttt{core::serializeBluetoothStatusJson()}.
|
||||
|
||||
\begin{drnote}[Response schema]
|
||||
\begin{drcode}[JSON]
|
||||
{"booted":true,"pairing":false,"a2dp":"standby"}
|
||||
\end{drcode}
|
||||
\end{drnote}
|
||||
|
||||
\subsection{\texttt{POST /api/bluetooth/pair}}
|
||||
\label{sec:api-bluetooth-pair}
|
||||
|
||||
Enters discoverable mode (\texttt{AT+PAIR=1}). Success:
|
||||
\texttt{\{"status":"pairing"\}}.
|
||||
|
||||
\subsection{\texttt{POST /api/bluetooth/pair/stop}}
|
||||
\label{sec:api-bluetooth-pair-stop}
|
||||
|
||||
Leaves discoverable mode (\texttt{AT+PAIR=0}). Success:
|
||||
\texttt{\{"status":"idle"\}}.
|
||||
|
||||
\subsection{\texttt{POST /api/bluetooth/disconnect}}
|
||||
\label{sec:api-bluetooth-disconnect}
|
||||
|
||||
Releases the current A2DP session (\texttt{AT+A2DPDISC}).
|
||||
|
||||
% ------------------------------------------------------------------
|
||||
% Station presets (Slice 4)
|
||||
% ------------------------------------------------------------------
|
||||
|
||||
\subsection{\texttt{GET /api/stations}}
|
||||
\label{sec:api-stations-get}
|
||||
|
||||
Returns presets serialised by \texttt{core::serializeStationListJson()}.
|
||||
|
||||
\subsection{\texttt{POST /api/stations}}
|
||||
\label{sec:api-stations-post}
|
||||
|
||||
Adds one preset (\texttt{core::parseStationJson()}); persists via
|
||||
\texttt{ISecureStore::saveStationListJson()}.
|
||||
|
||||
\subsection{\texttt{POST /api/stations/remove}}
|
||||
\label{sec:api-stations-remove}
|
||||
|
||||
Removes a preset by list index (\texttt{\{"index":0\}}).
|
||||
|
||||
\subsection{\texttt{POST /api/stations/tune}}
|
||||
\label{sec:api-stations-tune}
|
||||
|
||||
Recalls a preset via \texttt{station::StationService::tuneToIndex()}.
|
||||
|
||||
\section{Boot and network state machine}
|
||||
\label{sec:api-boot-flow}
|
||||
|
||||
At boot, \texttt{net::NetBootstrap::start(store, tuner, audio)} consults
|
||||
\texttt{ISecureStore::hasWifiCredentials()}:
|
||||
At boot, \texttt{net::NetBootstrap::start(store, tuner, audio, bluetooth,
|
||||
stations)} consults \texttt{ISecureStore::hasWifiCredentials()}:
|
||||
|
||||
\begin{enumerate}
|
||||
\item \textbf{Credentials present} --- create STA netif, connect via
|
||||
@@ -275,7 +334,8 @@ This explicit \texttt{enum class NetState} replaces ad-hoc flags; see
|
||||
Wi-Fi credentials are stored in NVS namespace \texttt{digiradio}, keys
|
||||
\texttt{wifi\_ssid} and \texttt{wifi\_pwd}. Audio profiles (non-secret) use
|
||||
the same namespace, key \texttt{audio\_profile\_json}, via
|
||||
\texttt{secure\_store::NvsAudioProfileStore}. Passwords are wrapped in
|
||||
\texttt{secure\_store::NvsAudioProfileStore}. Station presets use key
|
||||
\texttt{station\_list} (JSON blob). Passwords are wrapped in
|
||||
\texttt{core::Secret} in RAM and are never logged or returned by the API.
|
||||
|
||||
\begin{drcaution}[Encryption at rest]
|
||||
|
||||
@@ -131,8 +131,8 @@ only the Line-In bring-up required for the wired audio path.
|
||||
Host-testable command strings and OK/ERROR classification \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{BT1035 control stack (Slice~6). Future HTTP/UI for pairing will
|
||||
sit above the driver without changing the init contract.}
|
||||
\caption{BT1035 control stack (Slice~6--7). Pairing and A2DP status are
|
||||
exposed on \texttt{/api/bluetooth/*} via \texttt{BluetoothService}.}
|
||||
\label{tab:bt1035-stack}
|
||||
\end{table}
|
||||
|
||||
@@ -149,9 +149,14 @@ updating \texttt{core::Bt1035AtCommand}, the manual, and a host test.
|
||||
\midrule
|
||||
\texttt{Ping} & \texttt{AT} & Verify UART link \\
|
||||
\texttt{AuxLineIn} & \texttt{AT+AUXCFG=1} & Enable Line-In from ADAU \\
|
||||
\texttt{PairDiscoverable} & \texttt{AT+PAIR=1} & Enter discoverable mode \\
|
||||
\texttt{PairHidden} & \texttt{AT+PAIR=0} & Leave discoverable mode \\
|
||||
\texttt{A2dpStat} & \texttt{AT+A2DPSTAT} & Read link state \\
|
||||
\texttt{A2dpDisconnect} & \texttt{AT+A2DPDISC} & Release A2DP session \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{AT commands used at boot (\texttt{core::bootInitSequence()}).}
|
||||
\caption{Enumerated AT commands (\texttt{core::Bt1035AtCommand}). Boot
|
||||
uses Ping + AuxLineIn only; pairing commands are runtime.}
|
||||
\label{tab:bt1035-at}
|
||||
\end{table}
|
||||
|
||||
@@ -170,6 +175,10 @@ updating \texttt{core::Bt1035AtCommand}, the manual, and a host test.
|
||||
\texttt{boot()} & Reset, UART init, run \texttt{bootInitSequence()} \\
|
||||
\texttt{isBooted()} & \texttt{true} after Line-In init succeeded \\
|
||||
\texttt{sendCommand(cmd)} & Send one typed command, expect OK \\
|
||||
\texttt{enterPairingMode()} & \texttt{AT+PAIR=1} \\
|
||||
\texttt{leavePairingMode()} & \texttt{AT+PAIR=0} \\
|
||||
\texttt{queryA2dpState()} & \texttt{AT+A2DPSTAT}, parse \texttt{+A2DPSTAT=} \\
|
||||
\texttt{disconnectA2dp()} & \texttt{AT+A2DPDISC} \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{Public driver API.}
|
||||
@@ -232,7 +241,7 @@ if (auto r = bt.sendCommand(core::Bt1035AtCommand::AuxLineIn); !r) {
|
||||
|
||||
\begin{itemize}
|
||||
\item Feasycom FSC-BT1035 AT command manual (vendor) --- full command set
|
||||
for pairing, name, and codec options not yet wrapped by firmware.
|
||||
for name, paired-device list, and reconnect not yet wrapped by firmware.
|
||||
\item Chapter~\ref{ch:hardware} --- pin map and I\textsuperscript{2}S routing.
|
||||
\item Chapter~\ref{ch:adau1701} --- DSP output that feeds the module.
|
||||
\item \texttt{components/core/test/bt1035\_at\_test.cpp} --- init sequence test.
|
||||
|
||||
@@ -56,10 +56,13 @@ configured SoftAP. Imperative shell; no business logic.
|
||||
|
||||
\section{SetupWebServer}\label{cls:SetupWebServer}
|
||||
Minimal HTTP server: gzipped setup UI, \texttt{GET /api/health},
|
||||
\texttt{POST /api/wifi}, tuner routes (\texttt{/api/tuner/*}), and audio
|
||||
routes (\texttt{/api/audio/*}). JSON parsing and serialisation delegate to
|
||||
the pure core; credentials persist via \texttt{ISecureStore}; tuner via
|
||||
\texttt{tuner::TunerService}; audio via \texttt{audio::AudioService}.
|
||||
\texttt{POST /api/wifi}, tuner routes (\texttt{/api/tuner/*}), audio
|
||||
routes (\texttt{/api/audio/*}), Bluetooth (\texttt{/api/bluetooth/*}), and
|
||||
station presets (\texttt{/api/stations/*}). JSON parsing and serialisation
|
||||
delegate to the pure core; credentials persist via \texttt{ISecureStore};
|
||||
tuner via \texttt{tuner::TunerService}; audio via
|
||||
\texttt{audio::AudioService}; Bluetooth via \texttt{bluetooth::BluetoothService};
|
||||
presets via \texttt{station::StationService}.
|
||||
|
||||
\section{NetBootstrap}\label{cls:NetBootstrap}
|
||||
Owns network resources for setup or STA mode.
|
||||
@@ -222,3 +225,32 @@ Validated at the HTTP boundary by \texttt{core::parseEnhanceLevelJson()}.
|
||||
\section{NvsAudioProfileStore}\label{cls:NvsAudioProfileStore}
|
||||
\texttt{IAudioProfileStore} implementation storing serialised
|
||||
\texttt{AudioProfile} JSON in NVS namespace \texttt{digiradio}.
|
||||
|
||||
\section{StationName}\label{cls:StationName}
|
||||
Strong type for a preset label (1--32 bytes). Parsed at the HTTP boundary
|
||||
via \texttt{StationName::tryFrom()}.
|
||||
|
||||
\section{PresetSlot}\label{cls:PresetSlot}
|
||||
Optional hardware preset button index (1--20). Validated by
|
||||
\texttt{PresetSlot::tryFrom()}.
|
||||
|
||||
\section{Station}\label{cls:Station}
|
||||
Immutable preset value: name, band (DAB/FM), tune coordinates, optional
|
||||
preset slot. FM presets carry \texttt{FrequencyKHz}; DAB presets carry
|
||||
ensemble index and optional service/component ids for playback.
|
||||
|
||||
\section{StationList}\label{cls:StationList}
|
||||
Pure-domain ordered collection (max 20 entries) with add/remove/move and
|
||||
duplicate tune-target rejection. Persisted as JSON through
|
||||
\texttt{ISecureStore}.
|
||||
|
||||
\section{StationService}\label{cls:StationService}
|
||||
Application service loading/saving presets from NVS and recalling them via
|
||||
\texttt{TunerService}. Exposed on \texttt{/api/stations/*} and the Presets
|
||||
web UI section.
|
||||
|
||||
\section{BluetoothService}\label{cls:BluetoothService}
|
||||
Application service for BT1035 pairing and A2DP status
|
||||
(Chapter~\ref{ch:bt1035}). Delegates to \texttt{Bt1035Driver}; tracks
|
||||
whether discoverable mode was requested. Exposed on
|
||||
\texttt{/api/bluetooth/*}.
|
||||
|
||||
@@ -47,10 +47,12 @@ Repository: https://github.com/manvalan/DigiRadio
|
||||
1. **Walking skeleton** — done (Slice 1).
|
||||
2. **Secure store + Wi-Fi provisioning** — done (Slice 2).
|
||||
3. **Companion-chip boot** — done (Slice 3): Si4684 DAB + ADAU1701 RAM load.
|
||||
4. Station/frequency list model + persistence + UI.
|
||||
4. **Station/frequency list model + persistence + UI** — done (fw 0.7.0):
|
||||
`Station`/`StationList`, NVS key `station_list`, `/api/stations/*`, Presets UI.
|
||||
5. Si4684 tuning: RSQ, station list, DAB properties.
|
||||
6. **ADAU1701 runtime** — done (Slice 5): safeload EQ + input mixer + HTTP.
|
||||
7. FSC-BT1035 driver: AT init (incl. `AT+AUXCFG=1`), audio out.
|
||||
7. **FSC-BT1035 driver** — init + pairing (AT+PAIR, A2DP stat/disconnect) done;
|
||||
name/plist/reconnect AT still open.
|
||||
8. Integration: TunerService + AudioService end to end.
|
||||
|
||||
## Slice 1 — Walking skeleton (complete)
|
||||
|
||||
@@ -3,5 +3,5 @@ idf_component_register(
|
||||
"main.cpp"
|
||||
"hardware_bootstrap.cpp"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035
|
||||
REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station
|
||||
)
|
||||
|
||||
@@ -124,4 +124,9 @@ core::CompanionChipStatus HardwareBootstrap::companionChipStatus() noexcept
|
||||
};
|
||||
}
|
||||
|
||||
bt1035::Bt1035Driver& HardwareBootstrap::bt1035Driver()
|
||||
{
|
||||
return gBt1035;
|
||||
}
|
||||
|
||||
} // namespace hardware
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
#include "core/CompanionChipStatus.hpp"
|
||||
|
||||
#include "bt1035/Bt1035Driver.hpp"
|
||||
|
||||
#include <expected>
|
||||
|
||||
namespace audio {
|
||||
@@ -108,6 +110,20 @@ public:
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static core::CompanionChipStatus companionChipStatus() noexcept;
|
||||
|
||||
/**
|
||||
* @brief bt1035Driver — borrow the BT1035 driver after boot.
|
||||
*
|
||||
* @dname bt1035Driver
|
||||
* @return Reference to the static Bt1035Driver instance.
|
||||
* @pubstate reads static storage initialised by boot().
|
||||
*
|
||||
* Valid only after a successful boot() call in the same process.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-06
|
||||
*/
|
||||
[[nodiscard]] static bt1035::Bt1035Driver& bt1035Driver();
|
||||
};
|
||||
|
||||
} // namespace hardware
|
||||
|
||||
+12
-1
@@ -12,8 +12,10 @@
|
||||
*/
|
||||
|
||||
#include "hardware_bootstrap.hpp"
|
||||
#include "bluetooth/BluetoothService.hpp"
|
||||
#include "net/NetBootstrap.hpp"
|
||||
#include "secure_store/NvsSecureStore.hpp"
|
||||
#include "station/StationService.hpp"
|
||||
#include "tuner/TunerService.hpp"
|
||||
|
||||
#include "esp_log.h"
|
||||
@@ -47,7 +49,7 @@ void heartbeatTask(void* arg)
|
||||
*/
|
||||
extern "C" void app_main()
|
||||
{
|
||||
ESP_LOGI(kTag, "DigiRadio firmware boot — Slice 6");
|
||||
ESP_LOGI(kTag, "DigiRadio firmware boot — Slice 7");
|
||||
|
||||
auto hwResult = hardware::HardwareBootstrap::boot();
|
||||
if (!hwResult) {
|
||||
@@ -60,8 +62,17 @@ extern "C" void app_main()
|
||||
|
||||
static secure_store::NvsSecureStore store;
|
||||
|
||||
static station::StationService stationService(store, tunerService);
|
||||
if (auto loaded = stationService.loadFromStore(); !loaded) {
|
||||
ESP_LOGW(kTag, "station list load failed");
|
||||
}
|
||||
|
||||
static bluetooth::BluetoothService bluetoothService(
|
||||
hardware::HardwareBootstrap::bt1035Driver());
|
||||
|
||||
auto netResult = net::NetBootstrap::start(
|
||||
store, tunerService, hardware::HardwareBootstrap::audioService(),
|
||||
bluetoothService, stationService,
|
||||
hardware::HardwareBootstrap::companionChipStatus());
|
||||
if (!netResult) {
|
||||
ESP_LOGE(kTag, "network bootstrap failed");
|
||||
|
||||
Reference in New Issue
Block a user