From 176cdd9319848272c452b3e2b4ea9e253bff9d2f Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 7 Jul 2026 09:56:44 +0200 Subject: [PATCH] Release fw 0.8.4: doc sync, System UI, BT/FM polish. Align README, manual, and backlog to 0.8.4; add Web UI System tab for OTA/DSP uploads, serial in health header, FM seek down, and BT1035 paired list plus auto-reconnect API. Co-authored-by: Cursor --- Software/README.md | 30 +-- .../core/include/core/BluetoothJson.hpp | 33 ++++ .../components/core/include/core/Bt1035At.hpp | 60 ++++++ .../core/include/core/Bt1035PairedDevice.hpp | 36 ++++ .../core/include/core/TunerJson.hpp | 15 ++ .../components/core/src/BluetoothJson.cpp | 64 ++++++- Software/components/core/src/Bt1035At.cpp | 95 ++++++++++ Software/components/core/src/TunerJson.cpp | 20 ++ .../components/core/test/bt1035_at_test.cpp | 49 +++++ .../components/core/test/tuner_json_test.cpp | 31 ++++ .../bt1035/include/bt1035/Bt1035Driver.hpp | 55 +++++- .../drivers/bt1035/src/Bt1035Driver.cpp | 67 ++++++- .../si4684/include/si4684/Si4684Driver.hpp | 19 ++ .../drivers/si4684/src/Si4684Driver.cpp | 29 +++ .../drivers/si4684/src/Si4684Tuner.cpp | 8 + .../components/net/src/SetupWebServer.cpp | 87 ++++++++- Software/components/net/www/index.html | 174 ++++++++++++++++-- Software/components/net/www/index.html.gz | Bin 7192 -> 8105 bytes .../include/bluetooth/BluetoothService.hpp | 29 +++ .../bluetooth/src/BluetoothService.cpp | 26 +++ Software/docs/TODO.md | 31 +++- Software/docs/manual/ch-api.tex | 54 +++++- Software/docs/manual/ch-bt1035.tex | 11 +- Software/docs/manual/ch-classes.tex | 8 +- Software/docs/manual/ch-si4684.tex | 2 +- Software/docs/manual/manual.tex | 2 +- Software/instructions.md | 15 +- Software/main/hardware_bootstrap.cpp | 4 + 28 files changed, 988 insertions(+), 66 deletions(-) create mode 100644 Software/components/core/include/core/Bt1035PairedDevice.hpp diff --git a/Software/README.md b/Software/README.md index 1f04cdd..0220e76 100644 --- a/Software/README.md +++ b/Software/README.md @@ -2,15 +2,18 @@ Open-source Hi-Fi DAB+/FM receiver firmware for the ESP32-S3. -**Status:** fw **0.8.3** on `main` — NVS + flash encryption (development mode), -tabbed Web UI, `IntegrationService`, RDS/DLS metadata, **13** host tests, **4** CI -jobs. Agent tasks T1–T8 complete; device HIL pending first PCB. +**Status:** fw **0.8.4** on `main` — dual OTA + DSP blob updates, EEPROM identity, +NVS + flash encryption (development mode), tabbed Web UI with **System** uploads, +**15** host tests, **4** CI jobs. Agent tasks T1–T12 complete; device HIL pending +first PCB. -| Area | Shipped in 0.8.3 | +| Area | Shipped in 0.8.4 | |------|------------------| | Boot | Si4684 HOST_LOAD, ADAU1701 RAM program, BT1035 Line-In init | -| Tuner | FM/DAB tune, seek, RSQ, RDS, DAB services + DLS | +| Tuner | FM/DAB tune, seek up/down, RSQ, RDS, DAB services + DLS | | Audio | 6-band EQ, mixer, stereo/bass enhance, NVS profile | +| Updates | ESP32 dual-OTA, ADAU1701 `dsp` partition blob replay | +| Identity | 24AA025E48 EUI-48 → serial, SoftAP/BT name suffix | | Presets | CRUD, reorder, integrated recall + last-preset at boot | | Network | SoftAP/STA, tabbed gzipped SPA, typed JSON REST API | | Security | `initEncryptedStorage()` — see [`docs/security-flash-nvs.md`](docs/security-flash-nvs.md) | @@ -68,7 +71,7 @@ Run from `Software/`: doxygen Doxyfile python3 tools/check-manual-sync.py python3 tools/check_si4684_blobs.py -python3 tools/gzip-www.sh # after editing components/net/www/index.html +tools/gzip-www.sh # after editing components/net/www/index.html ``` CI jobs: `host-tests`, `doxygen`, `manual-sync`, `si4684-blobs` @@ -77,29 +80,34 @@ CI jobs: `host-tests`, `doxygen`, `manual-sync`, `si4684-blobs` ## Web UI Gzipped single-page app at `/` — tabs: **Now** (RDS/DLS), **Radio**, **Presets**, -**Audio** (6-band EQ), **BT**, **Wi‑Fi**. Source: +**Audio** (6-band EQ), **BT**, **Wi‑Fi**, **System** (firmware OTA + DSP upload). +Header shows EEPROM serial from `/api/health`. Source: `components/net/www/index.html` · regenerate embed: `tools/gzip-www.sh`. -## HTTP API (fw 0.8.3) +## HTTP API (fw 0.8.4) | Method | Path | Purpose | |--------|------|---------| -| GET | `/api/health` | Status, firmware version, companion-chip flags | +| GET | `/api/health` | Status, firmware version, serial, companion-chip flags | | POST | `/api/wifi` | Provision STA credentials; reboot on success | | GET | `/api/tuner/status` | Tuner snapshot (DAB/FM, RDS/DLS metadata) | | GET | `/api/tuner/services` | DAB service list for current ensemble | | POST | `/api/tuner/tune` | Tune DAB ensemble or FM frequency | | POST | `/api/tuner/play` | Start DAB service playback | -| POST | `/api/tuner/seek` | FM seek up | +| POST | `/api/tuner/seek` | FM seek up or down (`{"direction":"up\|down"}`) | | GET/PUT | `/api/audio/profile` | Read/apply ADAU1701 mixer + 6-band EQ | | POST | `/api/audio/reset` | Factory-flat audio profile | | POST | `/api/audio/stereo-enhance` | Stereo depth overlay (0–100) | | POST | `/api/audio/bass-enhance` | Bass enhance overlay (0–100) | -| GET | `/api/bluetooth/status` | BT1035 boot, pairing, A2DP state | +| POST | `/api/dsp/program` | Upload ADAU1701 DRAD blob (raw bytes, reboot) | +| POST | `/api/system/ota` | Upload ESP32 firmware image (raw bytes, reboot) | +| GET | `/api/bluetooth/status` | BT1035 boot, pairing, A2DP, name, auto-reconnect | +| GET | `/api/bluetooth/paired` | Paired-device list from module | | POST | `/api/bluetooth/pair` | Enter discoverable mode | | POST | `/api/bluetooth/pair/stop` | Leave discoverable mode | | POST | `/api/bluetooth/disconnect` | Release A2DP session | +| POST | `/api/bluetooth/auto-reconnect` | Set auto-reconnect count (0–15) | | GET | `/api/stations` | List saved presets | | POST | `/api/stations` | Add preset | | POST | `/api/stations/remove` | Remove preset by index | diff --git a/Software/components/core/include/core/BluetoothJson.hpp b/Software/components/core/include/core/BluetoothJson.hpp index b62b719..9adc3d0 100644 --- a/Software/components/core/include/core/BluetoothJson.hpp +++ b/Software/components/core/include/core/BluetoothJson.hpp @@ -13,8 +13,11 @@ #pragma once #include "core/Bt1035At.hpp" +#include "core/Bt1035PairedDevice.hpp" +#include #include +#include namespace core { @@ -32,6 +35,8 @@ struct BluetoothStatus { bool booted; ///< BT1035 driver ready after boot(). bool pairing; ///< Discoverable mode requested by firmware. Bt1035A2dpState a2dpState; ///< Last read A2DP link state. + std::string deviceName; ///< GAP friendly name from AT+NAME. + std::uint8_t autoReconnect; ///< Power-on reconnect count (0 = off). }; /** @@ -61,4 +66,32 @@ struct BluetoothStatus { */ [[nodiscard]] std::string serializeBluetoothErrorJson(const char* reason); +/** + * @brief serializeBluetoothPairedJson — serialise paired-device list. + * + * @dname serializeBluetoothPairedJson + * @param devices Parsed AT+PLIST entries. + * @return JSON object with a devices array. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] std::string serializeBluetoothPairedJson( + const std::vector& devices); + +/** + * @brief parseBluetoothAutoReconnectJson — validate POST body times field. + * + * @dname parseBluetoothAutoReconnectJson + * @param json Request body with \c times 0–15. + * @return Reconnect count on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] std::expected +parseBluetoothAutoReconnectJson(std::string_view json); + } // namespace core diff --git a/Software/components/core/include/core/Bt1035At.hpp b/Software/components/core/include/core/Bt1035At.hpp index a926fd3..1d0ffa4 100644 --- a/Software/components/core/include/core/Bt1035At.hpp +++ b/Software/components/core/include/core/Bt1035At.hpp @@ -12,6 +12,7 @@ */ #pragma once +#include "core/Bt1035PairedDevice.hpp" #include "core/ParseError.hpp" #include @@ -20,6 +21,7 @@ #include #include #include +#include namespace core { @@ -40,6 +42,9 @@ enum class Bt1035AtCommand { PairHidden, ///< AT+PAIR=0 — leave discoverable mode. A2dpStat, ///< AT+A2DPSTAT — read A2DP link state. A2dpDisconnect, ///< AT+A2DPDISC — release current A2DP connection. + QueryName, ///< AT+NAME — read BR/EDR local name (+NAME=). + QueryAutoConn, ///< AT+AUTOCONN — read power-on auto-reconnect count. + QueryPairedList, ///< AT+PLIST — enumerate paired devices (+PLIST=). }; /** @@ -147,4 +152,59 @@ parseBt1035A2dpStatResponse(std::string_view response); */ [[nodiscard]] const char* a2dpStateToken(Bt1035A2dpState state) noexcept; +/** + * @brief buildBt1035SetAutoConnLine — AT+AUTOCONN with reconnect count. + * + * @dname buildBt1035SetAutoConnLine + * @param times 0 off, 1–15 reconnect attempts (Feasycom default 3). + * @return Full AT line including CRLF. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] std::string buildBt1035SetAutoConnLine(std::uint8_t times); + +/** + * @brief parseBt1035NameResponse — extract +NAME= value. + * + * @dname parseBt1035NameResponse + * @param response Full module reply ending in OK. + * @return Device name on success, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] std::expected +parseBt1035NameResponse(std::string_view response); + +/** + * @brief parseBt1035AutoConnResponse — extract +AUTOCONN= value. + * + * @dname parseBt1035AutoConnResponse + * @param response Full module reply ending in OK. + * @return Reconnect count 0–15, or ParseError::MissingField. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] std::expected +parseBt1035AutoConnResponse(std::string_view response); + +/** + * @brief parseBt1035PairedListResponse — parse +PLIST= lines. + * + * @dname parseBt1035PairedListResponse + * @param response Full module reply ending in OK. + * @return Paired devices in index order; empty when none stored. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] std::expected, ParseError> +parseBt1035PairedListResponse(std::string_view response); + } // namespace core diff --git a/Software/components/core/include/core/Bt1035PairedDevice.hpp b/Software/components/core/include/core/Bt1035PairedDevice.hpp new file mode 100644 index 0000000..0f96181 --- /dev/null +++ b/Software/components/core/include/core/Bt1035PairedDevice.hpp @@ -0,0 +1,36 @@ +/** + * @file Bt1035PairedDevice.hpp + * @brief One entry from FSC-BT1035 AT+PLIST paired-record query. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +#include +#include + +namespace core { + +/** + * @brief Bt1035PairedDevice — paired remote from +PLIST= lines. + * + * @dname Bt1035PairedDevice + * @return n/a (type) + * @pubstate Plain DTO parsed from Feasycom BT1035 AT responses. + * + * @author Michele Bigi + * @date 2026-07-07 + */ +struct Bt1035PairedDevice { + std::uint8_t index; ///< Module paired-record index (1–8). + std::string mac; ///< 12-digit hex MAC without separators. + std::string name; ///< UTF-8 friendly name when supplied. +}; + +} // namespace core diff --git a/Software/components/core/include/core/TunerJson.hpp b/Software/components/core/include/core/TunerJson.hpp index 4d11ccb..1ede01f 100644 --- a/Software/components/core/include/core/TunerJson.hpp +++ b/Software/components/core/include/core/TunerJson.hpp @@ -15,6 +15,7 @@ #include "core/FrequencyKHz.hpp" #include "core/ParseError.hpp" #include "core/TunerBand.hpp" +#include "core/SeekDirection.hpp" #include "core/TunerStatus.hpp" #include @@ -128,4 +129,18 @@ struct TunerPlayRequest { [[nodiscard]] std::expected parseTunerPlayJson( std::string_view json); +/** + * @brief parseTunerSeekJson — validate POST /api/tuner/seek body. + * + * @dname parseTunerSeekJson + * @param json Optional body; empty defaults to seek up. + * @return SeekDirection on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ +[[nodiscard]] std::expected parseTunerSeekJson( + std::string_view json); + } // namespace core diff --git a/Software/components/core/src/BluetoothJson.cpp b/Software/components/core/src/BluetoothJson.cpp index 9bb33f7..ff97b04 100644 --- a/Software/components/core/src/BluetoothJson.cpp +++ b/Software/components/core/src/BluetoothJson.cpp @@ -13,16 +13,37 @@ #include "core/BluetoothJson.hpp" +#include #include namespace core { +namespace { + +void appendJsonString(std::ostringstream& out, std::string_view text) +{ + out << '"'; + for (const char ch : text) { + if (ch == '"' || ch == '\\') { + out << '\\'; + } + out << ch; + } + out << '"'; +} + +} // namespace + std::string serializeBluetoothStatusJson(const BluetoothStatus& status) { std::ostringstream out; out << "{\"booted\":" << (status.booted ? "true" : "false") << ",\"pairing\":" << (status.pairing ? "true" : "false") - << ",\"a2dp\":\"" << a2dpStateToken(status.a2dpState) << "\"}"; + << ",\"a2dp\":\"" << a2dpStateToken(status.a2dpState) << "\"" + << ",\"device_name\":"; + appendJsonString(out, status.deviceName); + out << ",\"auto_reconnect\":" + << static_cast(status.autoReconnect) << "}"; return out.str(); } @@ -33,4 +54,45 @@ std::string serializeBluetoothErrorJson(const char* reason) return out.str(); } +std::string serializeBluetoothPairedJson( + const std::vector& devices) +{ + std::ostringstream out; + out << "{\"devices\":["; + for (std::size_t i = 0; i < devices.size(); ++i) { + if (i > 0U) { + out << ','; + } + const Bt1035PairedDevice& device = devices[i]; + out << "{\"index\":" << static_cast(device.index) + << ",\"mac\":"; + appendJsonString(out, device.mac); + out << ",\"name\":"; + appendJsonString(out, device.name); + out << "}"; + } + out << "]}"; + return out.str(); +} + +std::expected +parseBluetoothAutoReconnectJson(std::string_view json) +{ + if (json.find('{') == std::string_view::npos) { + return std::unexpected(ParseError::InvalidJson); + } + const std::string needle = "\"times\":"; + const std::size_t start = json.find(needle); + if (start == std::string_view::npos) { + return std::unexpected(ParseError::MissingField); + } + char* end = nullptr; + const unsigned long raw = + std::strtoul(json.data() + start + needle.size(), &end, 10); + if (end == json.data() + start + needle.size() || raw > 15U) { + return std::unexpected(ParseError::InvalidJson); + } + return static_cast(raw); +} + } // namespace core diff --git a/Software/components/core/src/Bt1035At.cpp b/Software/components/core/src/Bt1035At.cpp index 5f5eb9a..8b5b186 100644 --- a/Software/components/core/src/Bt1035At.cpp +++ b/Software/components/core/src/Bt1035At.cpp @@ -14,6 +14,7 @@ #include "core/Bt1035At.hpp" #include +#include namespace core { @@ -110,10 +111,24 @@ std::string buildBt1035AtLine(Bt1035AtCommand command) return "AT+A2DPSTAT\r\n"; case Bt1035AtCommand::A2dpDisconnect: return "AT+A2DPDISC\r\n"; + case Bt1035AtCommand::QueryName: + return "AT+NAME\r\n"; + case Bt1035AtCommand::QueryAutoConn: + return "AT+AUTOCONN\r\n"; + case Bt1035AtCommand::QueryPairedList: + return "AT+PLIST\r\n"; } return "AT\r\n"; } +std::string buildBt1035SetAutoConnLine(std::uint8_t times) +{ + if (times > 15U) { + times = 15U; + } + return "AT+AUTOCONN=" + std::to_string(times) + "\r\n"; +} + std::array bootInitSequence() noexcept { return std::array{ @@ -179,4 +194,84 @@ const char* a2dpStateToken(Bt1035A2dpState state) noexcept return "unknown"; } +std::expected +parseBt1035NameResponse(std::string_view response) +{ + constexpr std::string_view kPrefix = "+NAME="; + const std::size_t pos = response.find(kPrefix); + if (pos == std::string_view::npos) { + return std::unexpected(ParseError::MissingField); + } + const std::size_t start = pos + kPrefix.size(); + const std::size_t end = response.find_first_of("\r\n", start); + const std::string_view value = + end == std::string_view::npos ? response.substr(start) + : response.substr(start, end - start); + if (value.empty()) { + return std::unexpected(ParseError::MissingField); + } + return std::string(value); +} + +std::expected +parseBt1035AutoConnResponse(std::string_view response) +{ + constexpr std::string_view kPrefix = "+AUTOCONN="; + const std::size_t pos = response.find(kPrefix); + if (pos == std::string_view::npos) { + return std::unexpected(ParseError::MissingField); + } + const std::size_t start = pos + kPrefix.size(); + char* end = nullptr; + const unsigned long raw = + std::strtoul(response.data() + start, &end, 10); + if (end == response.data() + start || raw > 15U) { + return std::unexpected(ParseError::MissingField); + } + return static_cast(raw); +} + +std::expected, ParseError> +parseBt1035PairedListResponse(std::string_view response) +{ + std::vector devices; + constexpr std::string_view kPrefix = "+PLIST="; + std::size_t pos = 0; + while ((pos = response.find(kPrefix, pos)) != std::string_view::npos) { + const std::size_t start = pos + kPrefix.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); + pos = end == std::string_view::npos ? response.size() : end + 1U; + if (line.empty() || line.front() == 'E') { + continue; + } + const std::size_t comma1 = line.find(','); + if (comma1 == std::string_view::npos) { + continue; + } + const std::size_t comma2 = line.find(',', comma1 + 1U); + char* endIdx = nullptr; + const unsigned long index = + std::strtoul(line.data(), &endIdx, 10); + if (endIdx == line.data() || index == 0U || index > 8U) { + continue; + } + Bt1035PairedDevice entry = {}; + entry.index = static_cast(index); + entry.mac = std::string(line.substr(comma1 + 1U, + (comma2 == std::string_view::npos + ? line.size() + : comma2) + - comma1 + - 1U)); + if (comma2 != std::string_view::npos) { + entry.name = std::string(line.substr(comma2 + 1U)); + } + devices.push_back(std::move(entry)); + } + return devices; +} + } // namespace core diff --git a/Software/components/core/src/TunerJson.cpp b/Software/components/core/src/TunerJson.cpp index 96adbef..56266eb 100644 --- a/Software/components/core/src/TunerJson.cpp +++ b/Software/components/core/src/TunerJson.cpp @@ -226,4 +226,24 @@ std::expected parseTunerPlayJson( return req; } +std::expected parseTunerSeekJson( + std::string_view json) +{ + if (json.find('{') == std::string_view::npos) { + return SeekDirection::Up; + } + + const std::string_view direction = extractJsonString(json, "direction"); + if (direction.empty()) { + return SeekDirection::Up; + } + if (direction == "up") { + return SeekDirection::Up; + } + if (direction == "down") { + return SeekDirection::Down; + } + return std::unexpected(ParseError::InvalidJson); +} + } // namespace core diff --git a/Software/components/core/test/bt1035_at_test.cpp b/Software/components/core/test/bt1035_at_test.cpp index b6b80fd..bdc24b1 100644 --- a/Software/components/core/test/bt1035_at_test.cpp +++ b/Software/components/core/test/bt1035_at_test.cpp @@ -12,6 +12,8 @@ */ #include "core/Bt1035At.hpp" +#include "core/Bt1035PairedDevice.hpp" +#include "core/BluetoothJson.hpp" #include #include @@ -74,6 +76,50 @@ namespace { return EXIT_SUCCESS; } +[[nodiscard]] int runNameAutoConnPairedParseTest() +{ + const auto name = + core::parseBt1035NameResponse("+NAME=DigiRadio-A1B2\r\nOK\r\n"); + if (!name || *name != "DigiRadio-A1B2") { + std::cerr << "NAME parse failed\n"; + return EXIT_FAILURE; + } + const auto autoconn = + core::parseBt1035AutoConnResponse("+AUTOCONN=3\r\nOK\r\n"); + if (!autoconn || *autoconn != 3U) { + std::cerr << "AUTOCONN parse failed\n"; + return EXIT_FAILURE; + } + const auto plist = core::parseBt1035PairedListResponse( + "+PLIST=1,001122334455,Phone\r\n" + "+PLIST=2,FFEEDDCCBBAA,\r\n" + "OK\r\n"); + if (!plist || plist->size() != 2U || (*plist)[0U].index != 1U + || (*plist)[0U].mac != "001122334455" + || (*plist)[0U].name != "Phone") { + std::cerr << "PLIST parse failed\n"; + return EXIT_FAILURE; + } + const std::string pairedJson = + core::serializeBluetoothPairedJson(*plist); + if (pairedJson.find("\"index\":1") == std::string::npos + || pairedJson.find("\"mac\":\"001122334455\"") == std::string::npos) { + std::cerr << "paired JSON serialise failed: " << pairedJson << '\n'; + return EXIT_FAILURE; + } + const auto times = + core::parseBluetoothAutoReconnectJson(R"({"times":5})"); + if (!times || *times != 5U) { + std::cerr << "auto-reconnect JSON parse failed\n"; + return EXIT_FAILURE; + } + if (core::buildBt1035SetAutoConnLine(5) != "AT+AUTOCONN=5\r\n") { + std::cerr << "AUTOCONN command line mismatch\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + } // namespace int main() @@ -84,5 +130,8 @@ int main() if (runParseTest() != EXIT_SUCCESS) { return EXIT_FAILURE; } + if (runNameAutoConnPairedParseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } return EXIT_SUCCESS; } diff --git a/Software/components/core/test/tuner_json_test.cpp b/Software/components/core/test/tuner_json_test.cpp index da25697..b94b76c 100644 --- a/Software/components/core/test/tuner_json_test.cpp +++ b/Software/components/core/test/tuner_json_test.cpp @@ -178,6 +178,34 @@ namespace { return EXIT_SUCCESS; } +[[nodiscard]] int runTunerSeekParseTest() +{ + const auto empty = core::parseTunerSeekJson(""); + if (!empty || *empty != core::SeekDirection::Up) { + std::cerr << "empty seek body should default to up\n"; + return EXIT_FAILURE; + } + const auto up = + core::parseTunerSeekJson(R"({"direction":"up"})"); + if (!up || *up != core::SeekDirection::Up) { + std::cerr << "seek up parse failed\n"; + return EXIT_FAILURE; + } + const auto down = + core::parseTunerSeekJson(R"({"direction":"down"})"); + if (!down || *down != core::SeekDirection::Down) { + std::cerr << "seek down parse failed\n"; + return EXIT_FAILURE; + } + const auto bad = + core::parseTunerSeekJson(R"({"direction":"sideways"})"); + if (bad) { + std::cerr << "expected invalid seek direction rejection\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + } // namespace int main() @@ -209,5 +237,8 @@ int main() if (runTunerErrorSerialiseTest() != EXIT_SUCCESS) { return EXIT_FAILURE; } + if (runTunerSeekParseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } return EXIT_SUCCESS; } diff --git a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp index e2dbfb4..b1eebb4 100644 --- a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp +++ b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp @@ -17,7 +17,9 @@ #include "core/Bt1035At.hpp" #include +#include #include +#include namespace bt1035 { @@ -183,11 +185,62 @@ public: [[nodiscard]] std::expected setDeviceName( std::string_view name); + /** + * @brief queryDeviceName — read GAP friendly name (AT+NAME). + * + * @dname queryDeviceName + * @return Module name on success, or Bt1035Error. + * @pubstate writes UART; parses +NAME= from the reply. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected queryDeviceName(); + + /** + * @brief setAutoReconnect — configure power-on reconnect (AT+AUTOCONN). + * + * @dname setAutoReconnect + * @param times 0 off, 1–15 attempts per Feasycom manual. + * @return Ok on success, or Bt1035Error. + * @pubstate writes UART. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected setAutoReconnect( + std::uint8_t times); + + /** + * @brief queryAutoReconnect — read AT+AUTOCONN setting. + * + * @dname queryAutoReconnect + * @return Reconnect count 0–15, or Bt1035Error. + * @pubstate writes UART. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected queryAutoReconnect(); + + /** + * @brief queryPairedList — enumerate paired remotes (AT+PLIST). + * + * @dname queryPairedList + * @return Parsed paired devices, or Bt1035Error. + * @pubstate writes UART; may take longer than a single-line command. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected, Bt1035Error> + queryPairedList(); + private: [[nodiscard]] std::expected ensureBooted() const; [[nodiscard]] std::expected runInitSequence(); [[nodiscard]] std::expected transmitAndCollect( - std::string_view commandLine); + std::string_view commandLine, int timeoutMs = kResponseTimeoutMs); [[nodiscard]] std::expected transmitAndExpectOk( std::string_view commandLine); diff --git a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp index 0bc16bb..5d3723f 100644 --- a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp +++ b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp @@ -22,6 +22,7 @@ #include #include #include +#include namespace bt1035 { @@ -66,7 +67,7 @@ std::expected Bt1035Driver::ensureBooted() const } std::expected Bt1035Driver::transmitAndCollect( - std::string_view commandLine) + std::string_view commandLine, int timeoutMs) { const int written = uart_write_bytes(static_cast(uartPort_), commandLine.data(), @@ -79,7 +80,7 @@ std::expected Bt1035Driver::transmitAndCollect( std::array buffer{}; std::string accumulated; const TickType_t deadline = - xTaskGetTickCount() + pdMS_TO_TICKS(kResponseTimeoutMs); + xTaskGetTickCount() + pdMS_TO_TICKS(timeoutMs); while (xTaskGetTickCount() < deadline) { const int received = uart_read_bytes(static_cast(uartPort_), @@ -175,6 +176,68 @@ std::expected Bt1035Driver::setDeviceName( return transmitAndExpectOk(line); } +std::expected Bt1035Driver::queryDeviceName() +{ + if (auto ready = ensureBooted(); !ready) { + return std::unexpected(ready.error()); + } + auto response = + transmitAndCollect(core::buildBt1035AtLine(core::Bt1035AtCommand::QueryName)); + if (!response) { + return std::unexpected(response.error()); + } + auto parsed = core::parseBt1035NameResponse(*response); + if (!parsed) { + return std::unexpected(Bt1035Error::UnexpectedResponse); + } + return *parsed; +} + +std::expected Bt1035Driver::setAutoReconnect( + std::uint8_t times) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + return transmitAndExpectOk(core::buildBt1035SetAutoConnLine(times)); +} + +std::expected Bt1035Driver::queryAutoReconnect() +{ + if (auto ready = ensureBooted(); !ready) { + return std::unexpected(ready.error()); + } + auto response = transmitAndCollect( + core::buildBt1035AtLine(core::Bt1035AtCommand::QueryAutoConn)); + if (!response) { + return std::unexpected(response.error()); + } + auto parsed = core::parseBt1035AutoConnResponse(*response); + if (!parsed) { + return std::unexpected(Bt1035Error::UnexpectedResponse); + } + return *parsed; +} + +std::expected, Bt1035Error> +Bt1035Driver::queryPairedList() +{ + if (auto ready = ensureBooted(); !ready) { + return std::unexpected(ready.error()); + } + auto response = transmitAndCollect( + core::buildBt1035AtLine(core::Bt1035AtCommand::QueryPairedList), + 4000); + if (!response) { + return std::unexpected(response.error()); + } + auto parsed = core::parseBt1035PairedListResponse(*response); + if (!parsed) { + return std::unexpected(Bt1035Error::UnexpectedResponse); + } + return *parsed; +} + std::expected Bt1035Driver::runInitSequence() { for (const core::Bt1035AtCommand command : core::bootInitSequence()) { diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp index 49557eb..927ce3e 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp @@ -341,6 +341,24 @@ public: std::uint32_t componentId, Si4684DigitalServiceType type = Si4684DigitalServiceType::Audio); + /** + * @brief stopDabService — stop active DAB audio (STOP_DIGITAL_SERVICE). + * + * @dname stopDabService + * @param serviceId Selected service identifier. + * @param componentId Audio component within the service. + * @param type Digital service type (audio by default). + * @return Ok on success, or Si4684Error. + * @pubstate sends STOP_DIGITAL_SERVICE (AN649 opcode 0x82). + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected stopDabService( + std::uint32_t serviceId, + std::uint32_t componentId, + Si4684DigitalServiceType type = Si4684DigitalServiceType::Audio); + private: enum class Command : std::uint8_t { PowerUp = 0x01, @@ -357,6 +375,7 @@ private: FmRdsStatus = 0x34, GetDigitalServiceList = 0x80, StartDigitalService = 0x81, + StopDigitalService = 0x82, GetDigitalServiceData = 0x84, DabTuneFreq = 0xB0, DabDigRadStatus = 0xB2, diff --git a/Software/components/drivers/si4684/src/Si4684Driver.cpp b/Software/components/drivers/si4684/src/Si4684Driver.cpp index 3aaaec6..102f402 100644 --- a/Software/components/drivers/si4684/src/Si4684Driver.cpp +++ b/Software/components/drivers/si4684/src/Si4684Driver.cpp @@ -829,4 +829,33 @@ std::expected Si4684Driver::startDabService( return {}; } +std::expected Si4684Driver::stopDabService( + std::uint32_t serviceId, + std::uint32_t componentId, + Si4684DigitalServiceType type) +{ + if (auto band = ensureBand(Si4684Band::Dab); !band) { + return band; + } + const std::uint8_t args[] = { + static_cast(type), + 0x00U, + 0x00U, + static_cast(serviceId & 0xFFU), + static_cast((serviceId >> 8) & 0xFFU), + static_cast((serviceId >> 16) & 0xFFU), + static_cast(serviceId >> 24), + static_cast(componentId & 0xFFU), + static_cast((componentId >> 8) & 0xFFU), + static_cast((componentId >> 16) & 0xFFU), + static_cast(componentId >> 24), + }; + if (auto cmd = + writeCommand(Command::StopDigitalService, args, sizeof(args)); + !cmd) { + return std::unexpected(Si4684Error::CommandFailed); + } + return {}; +} + } // namespace si4684 diff --git a/Software/components/drivers/si4684/src/Si4684Tuner.cpp b/Software/components/drivers/si4684/src/Si4684Tuner.cpp index 74a5ac3..0919ef3 100644 --- a/Software/components/drivers/si4684/src/Si4684Tuner.cpp +++ b/Software/components/drivers/si4684/src/Si4684Tuner.cpp @@ -153,6 +153,14 @@ std::expected Si4684Tuner::tuneDab( std::expected Si4684Tuner::tuneFm( core::FrequencyKHz frequency) { + if (driver_.loadedBand() == Si4684Band::Dab && lastDabServiceId_ + && lastDabComponentId_) { + (void)driver_.stopDabService(*lastDabServiceId_, *lastDabComponentId_); + lastDabServiceId_.reset(); + lastDabComponentId_.reset(); + dabDynamicLabel_.reset(); + } + if (auto result = driver_.tuneFm(frequency); !result) { return std::unexpected(mapError(result.error())); } diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index a2552ad..40ff5a5 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -60,7 +60,7 @@ namespace net { namespace { constexpr char kTag[] = "SetupWebServer"; -constexpr char kFirmwareVersion[] = "0.8.3"; +constexpr char kFirmwareVersion[] = "0.8.4"; constexpr unsigned kRebootDelaySec = 3; extern const uint8_t www_index_html_gz_start[] asm( @@ -402,12 +402,12 @@ esp_err_t tunerPlayPostHandler(httpd_req_t* req) } /** - * @brief tunerSeekPostHandler — accept POST /api/tuner/seek (FM up). + * @brief tunerSeekPostHandler — accept POST /api/tuner/seek (FM up/down). * * @dname tunerSeekPostHandler * @param req HTTP request handle from esp_http_server. * @return ESP_OK on success, or an esp_err_t error code. - * @pubstate uses route context tuner service with SeekDirection::Up. + * @pubstate uses route context tuner service with parsed SeekDirection. * * @author Michele Bigi * @date 2026-07-06 @@ -420,7 +420,18 @@ esp_err_t tunerSeekPostHandler(httpd_req_t* req) return httpd_resp_send(req, nullptr, 0); } - auto freq = ctx->tuner->seekFm(core::SeekDirection::Up); + std::array body{}; + readRequestBody(req, body); + const auto direction = core::parseTunerSeekJson(std::string_view(body.data())); + if (!direction) { + const std::string json = + core::serializeTunerErrorJson(parseErrorToken(direction.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()); + } + + auto freq = ctx->tuner->seekFm(*direction); if (freq) { const std::string json = std::string("{\"frequency_khz\":") + std::to_string(freq->value()) + "}"; @@ -895,6 +906,58 @@ esp_err_t bluetoothDisconnectPostHandler(httpd_req_t* req) return httpd_resp_send(req, "{\"status\":\"disconnected\"}", 24); } +esp_err_t bluetoothPairedGetHandler(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 devices = ctx->bluetooth->listPaired(); + if (!devices) { + const std::string json = + core::serializeBluetoothErrorJson(bt1035ErrorToken(devices.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::serializeBluetoothPairedJson(*devices); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + +esp_err_t bluetoothAutoReconnectPostHandler(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); + } + + std::array body{}; + readRequestBody(req, body); + const auto times = + core::parseBluetoothAutoReconnectJson(std::string_view(body.data())); + if (!times) { + const std::string json = + core::serializeBluetoothErrorJson(parseErrorToken(times.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 result = ctx->bluetooth->setAutoReconnect(*times); !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\":\"saved\"}", 18); +} + esp_err_t stationsGetHandler(httpd_req_t* req) { auto* ctx = routeContextFrom(req); @@ -1313,6 +1376,22 @@ std::expected SetupWebServer::start( }; httpd_register_uri_handler(server_, &bluetoothDisconnectUri); + const httpd_uri_t bluetoothPairedUri = { + .uri = "/api/bluetooth/paired", + .method = HTTP_GET, + .handler = bluetoothPairedGetHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &bluetoothPairedUri); + + const httpd_uri_t bluetoothAutoReconnectUri = { + .uri = "/api/bluetooth/auto-reconnect", + .method = HTTP_POST, + .handler = bluetoothAutoReconnectPostHandler, + .user_ctx = routeCtx, + }; + httpd_register_uri_handler(server_, &bluetoothAutoReconnectUri); + const httpd_uri_t stationsGetUri = { .uri = "/api/stations", .method = HTTP_GET, diff --git a/Software/components/net/www/index.html b/Software/components/net/www/index.html index e6b900c..f56a232 100644 --- a/Software/components/net/www/index.html +++ b/Software/components/net/www/index.html @@ -131,6 +131,10 @@ color: var(--text); font: inherit; } + input[type="file"] { + padding: var(--space-1); + font-size: var(--text-sm); + } input[type="range"] { padding: 0; accent-color: var(--accent); } button.action { width: 100%; @@ -270,6 +274,7 @@ +
@@ -305,7 +310,8 @@
- + +

@@ -364,13 +370,23 @@

Bluetooth

FSC-BT1035 aptX — pair headphones or speakers.

+
    +
    + + 0 +
    +
    + +
    +
    +

    @@ -387,6 +403,18 @@

    + +