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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 09:56:44 +02:00
co-authored by Cursor
parent b9ed8a2dcd
commit 176cdd9319
28 changed files with 988 additions and 66 deletions
+19 -11
View File
@@ -2,15 +2,18 @@
Open-source Hi-Fi DAB+/FM receiver firmware for the ESP32-S3. Open-source Hi-Fi DAB+/FM receiver firmware for the ESP32-S3.
**Status:** fw **0.8.3** on `main`NVS + flash encryption (development mode), **Status:** fw **0.8.4** on `main`dual OTA + DSP blob updates, EEPROM identity,
tabbed Web UI, `IntegrationService`, RDS/DLS metadata, **13** host tests, **4** CI NVS + flash encryption (development mode), tabbed Web UI with **System** uploads,
jobs. Agent tasks T1T8 complete; device HIL pending first PCB. **15** host tests, **4** CI jobs. Agent tasks T1T12 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 | | 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 | | 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 | | Presets | CRUD, reorder, integrated recall + last-preset at boot |
| Network | SoftAP/STA, tabbed gzipped SPA, typed JSON REST API | | Network | SoftAP/STA, tabbed gzipped SPA, typed JSON REST API |
| Security | `initEncryptedStorage()` — see [`docs/security-flash-nvs.md`](docs/security-flash-nvs.md) | | Security | `initEncryptedStorage()` — see [`docs/security-flash-nvs.md`](docs/security-flash-nvs.md) |
@@ -68,7 +71,7 @@ Run from `Software/`:
doxygen Doxyfile doxygen Doxyfile
python3 tools/check-manual-sync.py python3 tools/check-manual-sync.py
python3 tools/check_si4684_blobs.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` CI jobs: `host-tests`, `doxygen`, `manual-sync`, `si4684-blobs`
@@ -77,29 +80,34 @@ CI jobs: `host-tests`, `doxygen`, `manual-sync`, `si4684-blobs`
## Web UI ## Web UI
Gzipped single-page app at `/` — tabs: **Now** (RDS/DLS), **Radio**, **Presets**, Gzipped single-page app at `/` — tabs: **Now** (RDS/DLS), **Radio**, **Presets**,
**Audio** (6-band EQ), **BT**, **WiFi**. Source: **Audio** (6-band EQ), **BT**, **WiFi**, **System** (firmware OTA + DSP upload).
Header shows EEPROM serial from `/api/health`. Source:
`components/net/www/index.html` · regenerate embed: `components/net/www/index.html` · regenerate embed:
`tools/gzip-www.sh`. `tools/gzip-www.sh`.
## HTTP API (fw 0.8.3) ## HTTP API (fw 0.8.4)
| Method | Path | Purpose | | 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 | | POST | `/api/wifi` | Provision STA credentials; reboot on success |
| GET | `/api/tuner/status` | Tuner snapshot (DAB/FM, RDS/DLS metadata) | | GET | `/api/tuner/status` | Tuner snapshot (DAB/FM, RDS/DLS metadata) |
| GET | `/api/tuner/services` | DAB service list for current ensemble | | GET | `/api/tuner/services` | DAB service list for current ensemble |
| POST | `/api/tuner/tune` | Tune DAB ensemble or FM frequency | | POST | `/api/tuner/tune` | Tune DAB ensemble or FM frequency |
| POST | `/api/tuner/play` | Start DAB service playback | | 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 | | GET/PUT | `/api/audio/profile` | Read/apply ADAU1701 mixer + 6-band EQ |
| POST | `/api/audio/reset` | Factory-flat audio profile | | POST | `/api/audio/reset` | Factory-flat audio profile |
| POST | `/api/audio/stereo-enhance` | Stereo depth overlay (0100) | | POST | `/api/audio/stereo-enhance` | Stereo depth overlay (0100) |
| POST | `/api/audio/bass-enhance` | Bass enhance overlay (0100) | | POST | `/api/audio/bass-enhance` | Bass enhance overlay (0100) |
| 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` | Enter discoverable mode |
| POST | `/api/bluetooth/pair/stop` | Leave discoverable mode | | POST | `/api/bluetooth/pair/stop` | Leave discoverable mode |
| POST | `/api/bluetooth/disconnect` | Release A2DP session | | POST | `/api/bluetooth/disconnect` | Release A2DP session |
| POST | `/api/bluetooth/auto-reconnect` | Set auto-reconnect count (015) |
| GET | `/api/stations` | List saved presets | | GET | `/api/stations` | List saved presets |
| POST | `/api/stations` | Add preset | | POST | `/api/stations` | Add preset |
| POST | `/api/stations/remove` | Remove preset by index | | POST | `/api/stations/remove` | Remove preset by index |
@@ -13,8 +13,11 @@
#pragma once #pragma once
#include "core/Bt1035At.hpp" #include "core/Bt1035At.hpp"
#include "core/Bt1035PairedDevice.hpp"
#include <cstdint>
#include <string> #include <string>
#include <vector>
namespace core { namespace core {
@@ -32,6 +35,8 @@ struct BluetoothStatus {
bool booted; ///< BT1035 driver ready after boot(). bool booted; ///< BT1035 driver ready after boot().
bool pairing; ///< Discoverable mode requested by firmware. bool pairing; ///< Discoverable mode requested by firmware.
Bt1035A2dpState a2dpState; ///< Last read A2DP link state. 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); [[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<Bt1035PairedDevice>& devices);
/**
* @brief parseBluetoothAutoReconnectJson — validate POST body times field.
*
* @dname parseBluetoothAutoReconnectJson
* @param json Request body with \c times 015.
* @return Reconnect count on success, or ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-07
*/
[[nodiscard]] std::expected<std::uint8_t, ParseError>
parseBluetoothAutoReconnectJson(std::string_view json);
} // namespace core } // namespace core
@@ -12,6 +12,7 @@
*/ */
#pragma once #pragma once
#include "core/Bt1035PairedDevice.hpp"
#include "core/ParseError.hpp" #include "core/ParseError.hpp"
#include <array> #include <array>
@@ -20,6 +21,7 @@
#include <expected> #include <expected>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector>
namespace core { namespace core {
@@ -40,6 +42,9 @@ enum class Bt1035AtCommand {
PairHidden, ///< AT+PAIR=0 — leave discoverable mode. PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state. A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
A2dpDisconnect, ///< AT+A2DPDISC — release current A2DP connection. 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; [[nodiscard]] const char* a2dpStateToken(Bt1035A2dpState state) noexcept;
/**
* @brief buildBt1035SetAutoConnLine — AT+AUTOCONN with reconnect count.
*
* @dname buildBt1035SetAutoConnLine
* @param times 0 off, 115 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<std::string, ParseError>
parseBt1035NameResponse(std::string_view response);
/**
* @brief parseBt1035AutoConnResponse — extract +AUTOCONN= value.
*
* @dname parseBt1035AutoConnResponse
* @param response Full module reply ending in OK.
* @return Reconnect count 015, or ParseError::MissingField.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-07-07
*/
[[nodiscard]] std::expected<std::uint8_t, ParseError>
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<std::vector<Bt1035PairedDevice>, ParseError>
parseBt1035PairedListResponse(std::string_view response);
} // namespace core } // namespace core
@@ -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 <cstdint>
#include <string>
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 (18).
std::string mac; ///< 12-digit hex MAC without separators.
std::string name; ///< UTF-8 friendly name when supplied.
};
} // namespace core
@@ -15,6 +15,7 @@
#include "core/FrequencyKHz.hpp" #include "core/FrequencyKHz.hpp"
#include "core/ParseError.hpp" #include "core/ParseError.hpp"
#include "core/TunerBand.hpp" #include "core/TunerBand.hpp"
#include "core/SeekDirection.hpp"
#include "core/TunerStatus.hpp" #include "core/TunerStatus.hpp"
#include <cstdint> #include <cstdint>
@@ -128,4 +129,18 @@ struct TunerPlayRequest {
[[nodiscard]] std::expected<TunerPlayRequest, ParseError> parseTunerPlayJson( [[nodiscard]] std::expected<TunerPlayRequest, ParseError> parseTunerPlayJson(
std::string_view json); 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<SeekDirection, ParseError> parseTunerSeekJson(
std::string_view json);
} // namespace core } // namespace core
+63 -1
View File
@@ -13,16 +13,37 @@
#include "core/BluetoothJson.hpp" #include "core/BluetoothJson.hpp"
#include <cstdlib>
#include <sstream> #include <sstream>
namespace core { 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::string serializeBluetoothStatusJson(const BluetoothStatus& status)
{ {
std::ostringstream out; std::ostringstream out;
out << "{\"booted\":" << (status.booted ? "true" : "false") out << "{\"booted\":" << (status.booted ? "true" : "false")
<< ",\"pairing\":" << (status.pairing ? "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<unsigned>(status.autoReconnect) << "}";
return out.str(); return out.str();
} }
@@ -33,4 +54,45 @@ std::string serializeBluetoothErrorJson(const char* reason)
return out.str(); return out.str();
} }
std::string serializeBluetoothPairedJson(
const std::vector<Bt1035PairedDevice>& 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<unsigned>(device.index)
<< ",\"mac\":";
appendJsonString(out, device.mac);
out << ",\"name\":";
appendJsonString(out, device.name);
out << "}";
}
out << "]}";
return out.str();
}
std::expected<std::uint8_t, ParseError>
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<std::uint8_t>(raw);
}
} // namespace core } // namespace core
+95
View File
@@ -14,6 +14,7 @@
#include "core/Bt1035At.hpp" #include "core/Bt1035At.hpp"
#include <cstdlib> #include <cstdlib>
#include <vector>
namespace core { namespace core {
@@ -110,10 +111,24 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
return "AT+A2DPSTAT\r\n"; return "AT+A2DPSTAT\r\n";
case Bt1035AtCommand::A2dpDisconnect: case Bt1035AtCommand::A2dpDisconnect:
return "AT+A2DPDISC\r\n"; 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"; 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<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noexcept std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noexcept
{ {
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{ return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
@@ -179,4 +194,84 @@ const char* a2dpStateToken(Bt1035A2dpState state) noexcept
return "unknown"; return "unknown";
} }
std::expected<std::string, ParseError>
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<std::uint8_t, ParseError>
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<std::uint8_t>(raw);
}
std::expected<std::vector<Bt1035PairedDevice>, ParseError>
parseBt1035PairedListResponse(std::string_view response)
{
std::vector<Bt1035PairedDevice> 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<std::uint8_t>(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 } // namespace core
@@ -226,4 +226,24 @@ std::expected<TunerPlayRequest, ParseError> parseTunerPlayJson(
return req; return req;
} }
std::expected<SeekDirection, ParseError> 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 } // namespace core
@@ -12,6 +12,8 @@
*/ */
#include "core/Bt1035At.hpp" #include "core/Bt1035At.hpp"
#include "core/Bt1035PairedDevice.hpp"
#include "core/BluetoothJson.hpp"
#include <cstdlib> #include <cstdlib>
#include <iostream> #include <iostream>
@@ -74,6 +76,50 @@ namespace {
return EXIT_SUCCESS; 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 } // namespace
int main() int main()
@@ -84,5 +130,8 @@ int main()
if (runParseTest() != EXIT_SUCCESS) { if (runParseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
if (runNameAutoConnPairedParseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }
@@ -178,6 +178,34 @@ namespace {
return EXIT_SUCCESS; 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 } // namespace
int main() int main()
@@ -209,5 +237,8 @@ int main()
if (runTunerErrorSerialiseTest() != EXIT_SUCCESS) { if (runTunerErrorSerialiseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
if (runTunerSeekParseTest() != EXIT_SUCCESS) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS; return EXIT_SUCCESS;
} }
@@ -17,7 +17,9 @@
#include "core/Bt1035At.hpp" #include "core/Bt1035At.hpp"
#include <expected> #include <expected>
#include <string>
#include <string_view> #include <string_view>
#include <vector>
namespace bt1035 { namespace bt1035 {
@@ -183,11 +185,62 @@ public:
[[nodiscard]] std::expected<void, Bt1035Error> setDeviceName( [[nodiscard]] std::expected<void, Bt1035Error> setDeviceName(
std::string_view name); 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<std::string, Bt1035Error> queryDeviceName();
/**
* @brief setAutoReconnect — configure power-on reconnect (AT+AUTOCONN).
*
* @dname setAutoReconnect
* @param times 0 off, 115 attempts per Feasycom manual.
* @return Ok on success, or Bt1035Error.
* @pubstate writes UART.
*
* @author Michele Bigi
* @date 2026-07-07
*/
[[nodiscard]] std::expected<void, Bt1035Error> setAutoReconnect(
std::uint8_t times);
/**
* @brief queryAutoReconnect — read AT+AUTOCONN setting.
*
* @dname queryAutoReconnect
* @return Reconnect count 015, or Bt1035Error.
* @pubstate writes UART.
*
* @author Michele Bigi
* @date 2026-07-07
*/
[[nodiscard]] std::expected<std::uint8_t, Bt1035Error> 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<std::vector<core::Bt1035PairedDevice>, Bt1035Error>
queryPairedList();
private: private:
[[nodiscard]] std::expected<void, Bt1035Error> ensureBooted() const; [[nodiscard]] std::expected<void, Bt1035Error> ensureBooted() const;
[[nodiscard]] std::expected<void, Bt1035Error> runInitSequence(); [[nodiscard]] std::expected<void, Bt1035Error> runInitSequence();
[[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollect( [[nodiscard]] std::expected<std::string, Bt1035Error> transmitAndCollect(
std::string_view commandLine); std::string_view commandLine, int timeoutMs = kResponseTimeoutMs);
[[nodiscard]] std::expected<void, Bt1035Error> transmitAndExpectOk( [[nodiscard]] std::expected<void, Bt1035Error> transmitAndExpectOk(
std::string_view commandLine); std::string_view commandLine);
@@ -22,6 +22,7 @@
#include <array> #include <array>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector>
namespace bt1035 { namespace bt1035 {
@@ -66,7 +67,7 @@ std::expected<void, Bt1035Error> Bt1035Driver::ensureBooted() const
} }
std::expected<std::string, Bt1035Error> Bt1035Driver::transmitAndCollect( std::expected<std::string, Bt1035Error> Bt1035Driver::transmitAndCollect(
std::string_view commandLine) std::string_view commandLine, int timeoutMs)
{ {
const int written = uart_write_bytes(static_cast<uart_port_t>(uartPort_), const int written = uart_write_bytes(static_cast<uart_port_t>(uartPort_),
commandLine.data(), commandLine.data(),
@@ -79,7 +80,7 @@ std::expected<std::string, Bt1035Error> Bt1035Driver::transmitAndCollect(
std::array<char, 128> buffer{}; std::array<char, 128> buffer{};
std::string accumulated; std::string accumulated;
const TickType_t deadline = const TickType_t deadline =
xTaskGetTickCount() + pdMS_TO_TICKS(kResponseTimeoutMs); xTaskGetTickCount() + pdMS_TO_TICKS(timeoutMs);
while (xTaskGetTickCount() < deadline) { while (xTaskGetTickCount() < deadline) {
const int received = uart_read_bytes(static_cast<uart_port_t>(uartPort_), const int received = uart_read_bytes(static_cast<uart_port_t>(uartPort_),
@@ -175,6 +176,68 @@ std::expected<void, Bt1035Error> Bt1035Driver::setDeviceName(
return transmitAndExpectOk(line); return transmitAndExpectOk(line);
} }
std::expected<std::string, Bt1035Error> 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<void, Bt1035Error> Bt1035Driver::setAutoReconnect(
std::uint8_t times)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
return transmitAndExpectOk(core::buildBt1035SetAutoConnLine(times));
}
std::expected<std::uint8_t, Bt1035Error> 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<std::vector<core::Bt1035PairedDevice>, 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<void, Bt1035Error> Bt1035Driver::runInitSequence() std::expected<void, Bt1035Error> Bt1035Driver::runInitSequence()
{ {
for (const core::Bt1035AtCommand command : core::bootInitSequence()) { for (const core::Bt1035AtCommand command : core::bootInitSequence()) {
@@ -341,6 +341,24 @@ public:
std::uint32_t componentId, std::uint32_t componentId,
Si4684DigitalServiceType type = Si4684DigitalServiceType::Audio); 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<void, Si4684Error> stopDabService(
std::uint32_t serviceId,
std::uint32_t componentId,
Si4684DigitalServiceType type = Si4684DigitalServiceType::Audio);
private: private:
enum class Command : std::uint8_t { enum class Command : std::uint8_t {
PowerUp = 0x01, PowerUp = 0x01,
@@ -357,6 +375,7 @@ private:
FmRdsStatus = 0x34, FmRdsStatus = 0x34,
GetDigitalServiceList = 0x80, GetDigitalServiceList = 0x80,
StartDigitalService = 0x81, StartDigitalService = 0x81,
StopDigitalService = 0x82,
GetDigitalServiceData = 0x84, GetDigitalServiceData = 0x84,
DabTuneFreq = 0xB0, DabTuneFreq = 0xB0,
DabDigRadStatus = 0xB2, DabDigRadStatus = 0xB2,
@@ -829,4 +829,33 @@ std::expected<void, Si4684Error> Si4684Driver::startDabService(
return {}; return {};
} }
std::expected<void, Si4684Error> 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<std::uint8_t>(type),
0x00U,
0x00U,
static_cast<std::uint8_t>(serviceId & 0xFFU),
static_cast<std::uint8_t>((serviceId >> 8) & 0xFFU),
static_cast<std::uint8_t>((serviceId >> 16) & 0xFFU),
static_cast<std::uint8_t>(serviceId >> 24),
static_cast<std::uint8_t>(componentId & 0xFFU),
static_cast<std::uint8_t>((componentId >> 8) & 0xFFU),
static_cast<std::uint8_t>((componentId >> 16) & 0xFFU),
static_cast<std::uint8_t>(componentId >> 24),
};
if (auto cmd =
writeCommand(Command::StopDigitalService, args, sizeof(args));
!cmd) {
return std::unexpected(Si4684Error::CommandFailed);
}
return {};
}
} // namespace si4684 } // namespace si4684
@@ -153,6 +153,14 @@ std::expected<void, core::TunerError> Si4684Tuner::tuneDab(
std::expected<void, core::TunerError> Si4684Tuner::tuneFm( std::expected<void, core::TunerError> Si4684Tuner::tuneFm(
core::FrequencyKHz frequency) 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) { if (auto result = driver_.tuneFm(frequency); !result) {
return std::unexpected(mapError(result.error())); return std::unexpected(mapError(result.error()));
} }
+83 -4
View File
@@ -60,7 +60,7 @@ namespace net {
namespace { namespace {
constexpr char kTag[] = "SetupWebServer"; constexpr char kTag[] = "SetupWebServer";
constexpr char kFirmwareVersion[] = "0.8.3"; constexpr char kFirmwareVersion[] = "0.8.4";
constexpr unsigned kRebootDelaySec = 3; constexpr unsigned kRebootDelaySec = 3;
extern const uint8_t www_index_html_gz_start[] asm( 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 * @dname tunerSeekPostHandler
* @param req HTTP request handle from esp_http_server. * @param req HTTP request handle from esp_http_server.
* @return ESP_OK on success, or an esp_err_t error code. * @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 * @author Michele Bigi
* @date 2026-07-06 * @date 2026-07-06
@@ -420,7 +420,18 @@ esp_err_t tunerSeekPostHandler(httpd_req_t* req)
return httpd_resp_send(req, nullptr, 0); return httpd_resp_send(req, nullptr, 0);
} }
auto freq = ctx->tuner->seekFm(core::SeekDirection::Up); std::array<char, 128> 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) { if (freq) {
const std::string json = std::string("{\"frequency_khz\":") const std::string json = std::string("{\"frequency_khz\":")
+ std::to_string(freq->value()) + "}"; + 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); 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<char, 128> 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) esp_err_t stationsGetHandler(httpd_req_t* req)
{ {
auto* ctx = routeContextFrom(req); auto* ctx = routeContextFrom(req);
@@ -1313,6 +1376,22 @@ std::expected<void, NetError> SetupWebServer::start(
}; };
httpd_register_uri_handler(server_, &bluetoothDisconnectUri); 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 = { const httpd_uri_t stationsGetUri = {
.uri = "/api/stations", .uri = "/api/stations",
.method = HTTP_GET, .method = HTTP_GET,
+157 -17
View File
@@ -131,6 +131,10 @@
color: var(--text); color: var(--text);
font: inherit; font: inherit;
} }
input[type="file"] {
padding: var(--space-1);
font-size: var(--text-sm);
}
input[type="range"] { padding: 0; accent-color: var(--accent); } input[type="range"] { padding: 0; accent-color: var(--accent); }
button.action { button.action {
width: 100%; width: 100%;
@@ -270,6 +274,7 @@
<button type="button" data-tab="audio" role="tab">Audio</button> <button type="button" data-tab="audio" role="tab">Audio</button>
<button type="button" data-tab="bt" role="tab">BT</button> <button type="button" data-tab="bt" role="tab">BT</button>
<button type="button" data-tab="wifi" role="tab">WiFi</button> <button type="button" data-tab="wifi" role="tab">WiFi</button>
<button type="button" data-tab="system" role="tab">System</button>
</nav> </nav>
<section id="panel-now" class="panel active" role="tabpanel"> <section id="panel-now" class="panel active" role="tabpanel">
@@ -305,7 +310,8 @@
<input id="fm-khz" type="number" min="64000" max="108000" step="100" value="101500"> <input id="fm-khz" type="number" min="64000" max="108000" step="100" value="101500">
<button type="button" class="action" id="tune-fm">Tune FM</button> <button type="button" class="action" id="tune-fm">Tune FM</button>
<div class="row"> <div class="row">
<button type="button" class="action secondary" id="seek-fm">Seek up</button> <button type="button" class="action secondary" id="seek-fm-up">Seek up</button>
<button type="button" class="action secondary" id="seek-fm-down">Seek down</button>
</div> </div>
</div> </div>
<p class="msg" id="tuner-msg" aria-live="polite"></p> <p class="msg" id="tuner-msg" aria-live="polite"></p>
@@ -364,13 +370,23 @@
<h2>Bluetooth</h2> <h2>Bluetooth</h2>
<p class="lede">FSC-BT1035 aptX — pair headphones or speakers.</p> <p class="lede">FSC-BT1035 aptX — pair headphones or speakers.</p>
<div class="stat-grid" id="bt-stats"></div> <div class="stat-grid" id="bt-stats"></div>
<ul class="list" id="bt-paired-list"></ul>
<div class="slider-row">
<label for="bt-auto-reconnect">Auto-reconnect</label>
<output id="bt-auto-reconnect-out">0</output>
</div>
<input id="bt-auto-reconnect" type="range" min="0" max="15" step="1" value="0">
<div class="row"> <div class="row">
<button type="button" class="action" id="bt-refresh">Refresh</button> <button type="button" class="action" id="bt-refresh">Refresh</button>
<button type="button" class="action secondary" id="bt-pair">Pair</button> <button type="button" class="action secondary" id="bt-pair">Pair</button>
</div> </div>
<div class="row"> <div class="row">
<button type="button" class="action secondary" id="bt-load-paired">Paired list</button>
<button type="button" class="action secondary" id="bt-stop-pair">Stop pairing</button> <button type="button" class="action secondary" id="bt-stop-pair">Stop pairing</button>
</div>
<div class="row">
<button type="button" class="action secondary" id="bt-disconnect">Disconnect</button> <button type="button" class="action secondary" id="bt-disconnect">Disconnect</button>
<button type="button" class="action secondary" id="bt-save-reconnect">Save reconnect</button>
</div> </div>
<p class="msg" id="bt-msg" aria-live="polite"></p> <p class="msg" id="bt-msg" aria-live="polite"></p>
</section> </section>
@@ -387,6 +403,18 @@
</form> </form>
<p class="msg" id="wifi-msg" aria-live="polite"></p> <p class="msg" id="wifi-msg" aria-live="polite"></p>
</section> </section>
<section id="panel-system" class="panel" role="tabpanel" hidden>
<h2>System</h2>
<p class="lede">Firmware and ADAU1701 program updates. Device reboots after a successful upload.</p>
<label for="ota-file">Firmware image (.bin)</label>
<input id="ota-file" type="file" accept=".bin,application/octet-stream">
<button type="button" class="action" id="ota-upload">Upload firmware OTA</button>
<label for="dsp-file" style="margin-top: var(--space-2);">ADAU1701 program (DRAD blob)</label>
<input id="dsp-file" type="file" accept=".bin,.dsp,application/octet-stream">
<button type="button" class="action secondary" id="dsp-upload">Upload DSP program</button>
<p class="msg" id="system-msg" aria-live="polite"></p>
</section>
</div> </div>
<script> <script>
@@ -412,12 +440,53 @@
function api(path, opts) { function api(path, opts) {
opts = opts || {}; opts = opts || {};
return fetch(path, opts).then(function (r) { return fetch(path, opts).then(function (r) {
return r.json().then(function (d) { if (r.status === 204 || r.headers.get("content-length") === "0") {
return { ok: r.ok, status: r.status, d: {} };
}
return r.text().then(function (text) {
var d = {};
if (text) {
try { d = JSON.parse(text); } catch (e) { d = { raw: text }; }
}
return { ok: r.ok, status: r.status, d: d }; return { ok: r.ok, status: r.status, d: d };
}); });
}); });
} }
function uploadBinary(path, file) {
return fetch(path, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: file
}).then(function (r) {
return r.text().then(function (text) {
var d = {};
if (text) {
try { d = JSON.parse(text); } catch (e) { d = { raw: text }; }
}
return { ok: r.ok, status: r.status, d: d };
});
});
}
function seekFm(direction) {
api("/api/tuner/seek", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ direction: direction })
})
.then(function (res) {
if (res.ok && res.d.frequency_khz != null) {
$("fm-khz").value = res.d.frequency_khz;
showMsg($("tuner-msg"), "Seeked to " + res.d.frequency_khz + " kHz.", true);
refreshTunerStatus();
} else {
showMsg($("tuner-msg"), "Seek failed: " + (res.d.reason || res.d.error || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Seek failed.", false); });
}
function setTab(name) { function setTab(name) {
document.querySelectorAll("nav.tabs button").forEach(function (btn) { document.querySelectorAll("nav.tabs button").forEach(function (btn) {
var on = btn.getAttribute("data-tab") === name; var on = btn.getAttribute("data-tab") === name;
@@ -621,13 +690,19 @@
} }
var rows = [ var rows = [
["Booted", s.booted ? "yes" : "no"], ["Booted", s.booted ? "yes" : "no"],
["Name", s.device_name || "—"],
["Pairing", s.pairing ? "yes" : "no"], ["Pairing", s.pairing ? "yes" : "no"],
["A2DP", s.a2dp || "—"] ["A2DP", s.a2dp || "—"],
["Auto-reconnect", s.auto_reconnect != null ? s.auto_reconnect : "—"]
]; ];
$("bt-stats").innerHTML = rows.map(function (pair) { $("bt-stats").innerHTML = rows.map(function (pair) {
return '<div class="stat"><span>' + pair[0] + return '<div class="stat"><span>' + pair[0] +
'</span>' + pair[1] + "</div>"; '</span>' + pair[1] + "</div>";
}).join(""); }).join("");
if (s.auto_reconnect != null) {
$("bt-auto-reconnect").value = s.auto_reconnect;
$("bt-auto-reconnect-out").textContent = s.auto_reconnect;
}
} }
function refreshBluetooth() { function refreshBluetooth() {
@@ -740,11 +815,36 @@
}); });
} }
function loadBluetoothPaired() {
return api("/api/bluetooth/paired")
.then(function (res) {
var list = $("bt-paired-list");
list.innerHTML = "";
if (!res.ok || !res.d.devices) {
showMsg($("bt-msg"), "Paired list failed.", false);
return;
}
res.d.devices.forEach(function (device) {
var li = document.createElement("li");
var label = document.createElement("span");
label.className = "label";
label.textContent = device.index + " · " + device.mac +
(device.name ? " · " + device.name : "");
li.appendChild(label);
list.appendChild(li);
});
showMsg($("bt-msg"), res.d.devices.length + " paired device(s).", true);
})
.catch(function () { showMsg($("bt-msg"), "Paired list failed.", false); });
}
api("/api/health") api("/api/health")
.then(function (res) { .then(function (res) {
if (res.ok) { if (res.ok) {
var serial = res.d.serialNumber || "unknown";
$("health-text").innerHTML = $("health-text").innerHTML =
'Status <code>' + res.d.status + "</code> · FW <code>" + res.d.fw + "</code>"; 'Status <code>' + res.d.status + "</code> · FW <code>" + res.d.fw +
"</code> · SN <code>" + serial + "</code>";
renderChips(res.d.chips); renderChips(res.d.chips);
} else { } else {
$("health-text").textContent = "Health check failed."; $("health-text").textContent = "Health check failed.";
@@ -820,19 +920,8 @@
.catch(function () { showMsg($("tuner-msg"), "Tune failed.", false); }); .catch(function () { showMsg($("tuner-msg"), "Tune failed.", false); });
}); });
$("seek-fm").addEventListener("click", function () { $("seek-fm-up").addEventListener("click", function () { seekFm("up"); });
api("/api/tuner/seek", { method: "POST" }) $("seek-fm-down").addEventListener("click", function () { seekFm("down"); });
.then(function (res) {
if (res.ok && res.d.frequency_khz != null) {
$("fm-khz").value = res.d.frequency_khz;
showMsg($("tuner-msg"), "Seeked to " + res.d.frequency_khz + " kHz.", true);
refreshTunerStatus();
} else {
showMsg($("tuner-msg"), "Seek failed: " + (res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg($("tuner-msg"), "Seek failed.", false); });
});
$("load-services").addEventListener("click", function () { $("load-services").addEventListener("click", function () {
var list = $("service-list"); var list = $("service-list");
@@ -952,6 +1041,57 @@
if (res.ok) refreshBluetooth(); if (res.ok) refreshBluetooth();
}); });
}); });
$("bt-load-paired").addEventListener("click", loadBluetoothPaired);
bindSlider("bt-auto-reconnect", "bt-auto-reconnect-out");
$("bt-save-reconnect").addEventListener("click", function () {
var level = parseInt($("bt-auto-reconnect").value, 10);
api("/api/bluetooth/auto-reconnect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ times: level })
}).then(function (res) {
showMsg($("bt-msg"), res.ok ? "Auto-reconnect saved." : "Save failed.", res.ok);
if (res.ok) refreshBluetooth();
});
});
$("ota-upload").addEventListener("click", function () {
var file = $("ota-file").files[0];
var msg = $("system-msg");
if (!file) {
showMsg(msg, "Choose a firmware .bin file.", false);
return;
}
showMsg(msg, "Uploading firmware…", true);
uploadBinary("/api/system/ota", file)
.then(function (res) {
if (res.ok && res.d.status === "stored") {
showMsg(msg, "Firmware stored. Rebooting in " + res.d.reboot_sec + "s…", true);
} else {
showMsg(msg, "OTA failed: " + (res.d.error || res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "OTA upload failed.", false); });
});
$("dsp-upload").addEventListener("click", function () {
var file = $("dsp-file").files[0];
var msg = $("system-msg");
if (!file) {
showMsg(msg, "Choose a DRAD blob.", false);
return;
}
showMsg(msg, "Uploading DSP program…", true);
uploadBinary("/api/dsp/program", file)
.then(function (res) {
if (res.ok && res.d.status === "stored") {
showMsg(msg, "DSP program stored. Rebooting in " + res.d.reboot_sec + "s…", true);
} else {
showMsg(msg, "DSP upload failed: " + (res.d.error || res.d.reason || "unknown"), false);
}
})
.catch(function () { showMsg(msg, "DSP upload failed.", false); });
});
$("save-preset").addEventListener("click", function () { $("save-preset").addEventListener("click", function () {
var name = $("preset-name").value.trim(); var name = $("preset-name").value.trim();
Binary file not shown.
@@ -17,6 +17,7 @@
#include "core/BluetoothJson.hpp" #include "core/BluetoothJson.hpp"
#include <expected> #include <expected>
#include <vector>
namespace bluetooth { namespace bluetooth {
@@ -94,6 +95,34 @@ public:
*/ */
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> disconnect(); [[nodiscard]] std::expected<void, bt1035::Bt1035Error> disconnect();
/**
* @brief listPaired — read paired remotes from the module.
*
* @dname listPaired
* @return Device list on success, or Bt1035Error.
* @pubstate queries AT+PLIST via the driver.
*
* @author Michele Bigi
* @date 2026-07-07
*/
[[nodiscard]] std::expected<std::vector<core::Bt1035PairedDevice>,
bt1035::Bt1035Error>
listPaired();
/**
* @brief setAutoReconnect — configure power-on reconnect attempts.
*
* @dname setAutoReconnect
* @param times 0 off, 115 per Feasycom manual.
* @return Ok on success, or Bt1035Error.
* @pubstate writes AT+AUTOCONN via the driver.
*
* @author Michele Bigi
* @date 2026-07-07
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setAutoReconnect(
std::uint8_t times);
private: private:
bt1035::Bt1035Driver& driver_; bt1035::Bt1035Driver& driver_;
bool pairingActive_; bool pairingActive_;
@@ -28,12 +28,26 @@ BluetoothService::refreshStatus()
.booted = driver_.isBooted(), .booted = driver_.isBooted(),
.pairing = pairingActive_, .pairing = pairingActive_,
.a2dpState = core::Bt1035A2dpState::Standby, .a2dpState = core::Bt1035A2dpState::Standby,
.deviceName = {},
.autoReconnect = 0U,
}; };
if (!status.booted) { if (!status.booted) {
return status; return status;
} }
if (auto name = driver_.queryDeviceName(); name) {
status.deviceName = std::move(*name);
} else {
return std::unexpected(name.error());
}
if (auto reconnect = driver_.queryAutoReconnect(); reconnect) {
status.autoReconnect = *reconnect;
} else {
return std::unexpected(reconnect.error());
}
auto a2dp = driver_.queryA2dpState(); auto a2dp = driver_.queryA2dpState();
if (!a2dp) { if (!a2dp) {
return std::unexpected(a2dp.error()); return std::unexpected(a2dp.error());
@@ -65,4 +79,16 @@ std::expected<void, bt1035::Bt1035Error> BluetoothService::disconnect()
return driver_.disconnectA2dp(); return driver_.disconnectA2dp();
} }
std::expected<std::vector<core::Bt1035PairedDevice>, bt1035::Bt1035Error>
BluetoothService::listPaired()
{
return driver_.queryPairedList();
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::setAutoReconnect(
std::uint8_t times)
{
return driver_.setAutoReconnect(times);
}
} // namespace bluetooth } // namespace bluetooth
+23 -8
View File
@@ -3,8 +3,9 @@
Agent task list and hardware-in-the-loop backlog. Working directory for all Agent task list and hardware-in-the-loop backlog. Working directory for all
commands is `Software/`. commands is `Software/`.
**Current firmware:** `0.8.3`NVS + flash encryption (dev mode), tabbed Web **Current firmware:** `0.8.4`dual OTA + DSP blob updates, EEPROM identity,
UI, integration service, RDS/DLS metadata, CI gate (4 jobs). NVS + flash encryption (dev mode), tabbed Web UI with System uploads, CI gate
(4 jobs).
**Before writing code, read `AGENTS.md`, `.cursor/rules/`, and **Before writing code, read `AGENTS.md`, `.cursor/rules/`, and
`instructions.md`.** Definition of Done: Apache header, doc blocks, `instructions.md`.** Definition of Done: Apache header, doc blocks,
@@ -13,7 +14,7 @@ UI, integration service, RDS/DLS metadata, CI gate (4 jobs).
--- ---
## Completed agent tasks (T1T8, fw 0.7.10.8.3) ## Completed agent tasks (T1T12, fw 0.7.10.8.4)
| Task | Version | Summary | | Task | Version | Summary |
|------|---------|---------| |------|---------|---------|
@@ -22,9 +23,13 @@ UI, integration service, RDS/DLS metadata, CI gate (4 jobs).
| **T3** | 0.7.2 | Preset reorder API/UI, DAB playing ids in status | | **T3** | 0.7.2 | Preset reorder API/UI, DAB playing ids in status |
| **T4** | 0.8.0 | RDS/DLS broadcast metadata | | **T4** | 0.8.0 | RDS/DLS broadcast metadata |
| **T5** | 0.8.1 | `IntegrationService` — startup, preset recall, last-preset NVS | | **T5** | 0.8.1 | `IntegrationService` — startup, preset recall, last-preset NVS |
| **T6** | 0.8.2 | Tabbed configuration Web UI (full REST coverage) | | **T6** | 0.8.2 | Tabbed configuration Web UI (REST coverage) |
| **T7** | 0.8.2 | Si4684 blob policy — gitignore, docs, `check_si4684_blobs.py` | | **T7** | 0.8.2 | Si4684 blob policy — gitignore, docs, `check_si4684_blobs.py` |
| **T8** | 0.8.3 | NVS + flash encryption — `initEncryptedStorage`, security docs | | **T8** | 0.8.3 | NVS + flash encryption — `initEncryptedStorage`, security docs |
| **T9** | 0.8.4 | Dual-OTA partition table + `dsp` blob slot, rollback Kconfig |
| **T10** | 0.8.4 | EEPROM EUI-48 identity — SoftAP/BT/hostname/serial |
| **T11** | 0.8.4 | Updatable ADAU1701 program — `POST /api/dsp/program`, DRAD blob |
| **T12** | 0.8.4 | ESP32 OTA — `POST /api/system/ota`, rollback confirm on boot |
Also landed (not numbered): BT1035 pairing (`BluetoothService`), station presets Also landed (not numbered): BT1035 pairing (`BluetoothService`), station presets
(fw 0.7.0), companion-chip boot (Slice 3), ADAU1701 runtime (Slice 5). (fw 0.7.0), companion-chip boot (Slice 3), ADAU1701 runtime (Slice 5).
@@ -44,7 +49,12 @@ reboot, presets and `last_preset` survive power cycle.
Si4684 DAB/FM tune, ADAU1701 profile apply, BT1035 A2DP to headphones, Si4684 DAB/FM tune, ADAU1701 profile apply, BT1035 A2DP to headphones,
now-playing metadata in UI and `/api/tuner/status`. now-playing metadata in UI and `/api/tuner/status`.
### H3. Production flash encryption (optional) ### H3. OTA and DSP program update (on hardware)
Push a known-good `.bin` via `POST /api/system/ota`, confirm rollback after a
deliberately bad image. Upload a DRAD blob via `POST /api/dsp/program` and
verify ADAU replay after reboot.
### H4. Production flash encryption (optional)
After H1 passes, trial build with `sdkconfig.defaults.production` overlay on After H1 passes, trial build with `sdkconfig.defaults.production` overlay on
a sacrificial unit; confirm RELEASE mode policy before shipping. a sacrificial unit; confirm RELEASE mode policy before shipping.
@@ -52,9 +62,13 @@ a sacrificial unit; confirm RELEASE mode policy before shipping.
## Open firmware polish (non-blocking) ## Open firmware polish (non-blocking)
- BT1035: device name, paired-device list, auto-reconnect AT (driver stubs open). Done in fw 0.8.4 unless noted:
- Si4684: optional commands (STOP_DIGITAL_SERVICE, ensemble info) if product needs them.
- FM seek down (API today is seek-up only). - FM seek down `POST /api/tuner/seek` with `{"direction":"down"}`.
- BT1035 — query/set name, paired list (`AT+PLIST`), auto-reconnect
(`AT+AUTOCONN`) per Feasycom BT1035 manual.
- Si4684 — `STOP_DIGITAL_SERVICE` (0x82) before FM band switch when DAB
audio is active; ensemble metrics remain via `DAB_DIGRAD_STATUS` in status.
--- ---
@@ -76,5 +90,6 @@ After editing the web UI: `tools/gzip-www.sh`.
- Extend existing patterns (`AudioProfile` / `IAudioProfileStore` shape). - Extend existing patterns (`AudioProfile` / `IAudioProfileStore` shape).
- Never invent Si4684 opcodes — cite AN649. - Never invent Si4684 opcodes — cite AN649.
- Never invent BT1035 AT strings — cite Feasycom BT1035 programming guide.
- One logical change per commit; 50/72 messages. - One logical change per commit; 50/72 messages.
- Update `ch-classes.tex` / `ch-api.tex` when public API or HTTP changes. - Update `ch-classes.tex` / `ch-api.tex` when public API or HTTP changes.
+45 -9
View File
@@ -6,7 +6,7 @@ implemented in \texttt{SetupWebServer}. Request bodies are parsed into
domain types in the pure core (\texttt{components/core}) before any domain types in the pure core (\texttt{components/core}) before any
persistence or driver call. Exact C++ signatures live in the generated persistence or driver call. Exact C++ signatures live in the generated
Doxygen output under \texttt{docs/api/}; this chapter documents the Doxygen output under \texttt{docs/api/}; this chapter documents the
wire protocol and behaviour as shipped in firmware~0.8.3. wire protocol and behaviour as shipped in firmware~0.8.4.
\section{Transport and reachability} \section{Transport and reachability}
@@ -36,7 +36,7 @@ Returns a health-check DTO serialised by
\begin{drnote}[Response schema] \begin{drnote}[Response schema]
\begin{drcode}[JSON] \begin{drcode}[JSON]
{"status":"ok","fw":"0.8.3","serialNumber":"0004A3123456", {"status":"ok","fw":"0.8.4","serialNumber":"0004A3123456",
"chips":{"si4684":true,"adau1701":true,"bt1035":true}} "chips":{"si4684":true,"adau1701":true,"bt1035":true}}
\end{drcode} \end{drcode}
\begin{itemize} \begin{itemize}
@@ -180,9 +180,19 @@ Success response: \texttt{\{"status":"playing"\}}. HTTP status:
\subsection{\texttt{POST /api/tuner/seek}} \subsection{\texttt{POST /api/tuner/seek}}
\label{sec:api-tuner-seek} \label{sec:api-tuner-seek}
Seeks FM upward (no request body). Returns Seeks FM in the requested direction. Optional JSON body parsed by
\texttt{\{"frequency\_khz":...\}} on success. HTTP status: \textbf{200 OK}; \texttt{core::parseTunerSeekJson()}; an empty body defaults to upward seek.
\textbf{409} on seek failure. Returns \texttt{\{"frequency\_khz":...\}} on success. HTTP status:
\textbf{200 OK}; \textbf{400} for invalid \texttt{direction}; \textbf{409} on
seek failure.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"direction":"up"}
\end{drcode}
Use \texttt{"down"} for downward seek. Omit the body or send \texttt{{}}
for upward seek (backward compatible).
\end{drnote}
\subsection{\texttt{GET /api/audio/profile}} \subsection{\texttt{GET /api/audio/profile}}
\label{sec:api-audio-profile-get} \label{sec:api-audio-profile-get}
@@ -309,13 +319,14 @@ Adjusts bass emphasis via a PEQ overlay on bands 1--2 (100\,Hz /
\subsection{\texttt{GET /api/bluetooth/status}} \subsection{\texttt{GET /api/bluetooth/status}}
\label{sec:api-bluetooth-status} \label{sec:api-bluetooth-status}
Returns BT1035 boot flag, whether discoverable mode was requested, and the Returns BT1035 boot flag, whether discoverable mode was requested, the
last read A2DP state. Serialised by last read A2DP state, module friendly name, and auto-reconnect count.
\texttt{core::serializeBluetoothStatusJson()}. Serialised by \texttt{core::serializeBluetoothStatusJson()}.
\begin{drnote}[Response schema] \begin{drnote}[Response schema]
\begin{drcode}[JSON] \begin{drcode}[JSON]
{"booted":true,"pairing":false,"a2dp":"standby"} {"booted":true,"pairing":false,"a2dp":"standby",
"device_name":"DigiRadio-A1B2","auto_reconnect":3}
\end{drcode} \end{drcode}
\end{drnote} \end{drnote}
@@ -336,6 +347,31 @@ Leaves discoverable mode (\texttt{AT+PAIR=0}). Success:
Releases the current A2DP session (\texttt{AT+A2DPDISC}). Releases the current A2DP session (\texttt{AT+A2DPDISC}).
\subsection{\texttt{GET /api/bluetooth/paired}}
\label{sec:api-bluetooth-paired}
Returns paired remotes from \texttt{AT+PLIST}, serialised by
\texttt{core::serializeBluetoothPairedJson()}.
\begin{drnote}[Response schema]
\begin{drcode}[JSON]
{"devices":[{"index":1,"mac":"001122334455","name":"Phone"}]}
\end{drcode}
\end{drnote}
\subsection{\texttt{POST /api/bluetooth/auto-reconnect}}
\label{sec:api-bluetooth-auto-reconnect}
Sets the module auto-reconnect retry count (\texttt{AT+AUTOCONN=0..15}).
Body parsed by \texttt{core::parseBluetoothAutoReconnectJson()}.
\begin{drnote}[Request schema]
\begin{drcode}[JSON]
{"times":3}
\end{drcode}
Success: \texttt{\{"status":"saved"\}}.
\end{drnote}
% ------------------------------------------------------------------ % ------------------------------------------------------------------
% Station presets (Slice 4) % Station presets (Slice 4)
% ------------------------------------------------------------------ % ------------------------------------------------------------------
+9 -2
View File
@@ -153,6 +153,9 @@ updating \texttt{core::Bt1035AtCommand}, the manual, and a host test.
\texttt{PairHidden} & \texttt{AT+PAIR=0} & Leave discoverable mode \\ \texttt{PairHidden} & \texttt{AT+PAIR=0} & Leave discoverable mode \\
\texttt{A2dpStat} & \texttt{AT+A2DPSTAT} & Read link state \\ \texttt{A2dpStat} & \texttt{AT+A2DPSTAT} & Read link state \\
\texttt{A2dpDisconnect} & \texttt{AT+A2DPDISC} & Release A2DP session \\ \texttt{A2dpDisconnect} & \texttt{AT+A2DPDISC} & Release A2DP session \\
\texttt{QueryName} & \texttt{AT+NAME} & Read module friendly name \\
\texttt{QueryAutoConn} & \texttt{AT+AUTOCONN} & Read auto-reconnect count \\
\texttt{QueryPairedList} & \texttt{AT+PLIST} & List paired remotes \\
\bottomrule \bottomrule
\end{tabular} \end{tabular}
\caption{Enumerated AT commands (\texttt{core::Bt1035AtCommand}). Boot \caption{Enumerated AT commands (\texttt{core::Bt1035AtCommand}). Boot
@@ -179,6 +182,10 @@ updating \texttt{core::Bt1035AtCommand}, the manual, and a host test.
\texttt{leavePairingMode()} & \texttt{AT+PAIR=0} \\ \texttt{leavePairingMode()} & \texttt{AT+PAIR=0} \\
\texttt{queryA2dpState()} & \texttt{AT+A2DPSTAT}, parse \texttt{+A2DPSTAT=} \\ \texttt{queryA2dpState()} & \texttt{AT+A2DPSTAT}, parse \texttt{+A2DPSTAT=} \\
\texttt{disconnectA2dp()} & \texttt{AT+A2DPDISC} \\ \texttt{disconnectA2dp()} & \texttt{AT+A2DPDISC} \\
\texttt{queryDeviceName()} & \texttt{AT+NAME}, parse \texttt{+NAME=} \\
\texttt{queryAutoReconnect()} & \texttt{AT+AUTOCONN}, parse \texttt{+AUTOCONN=} \\
\texttt{setAutoReconnect(times)} & \texttt{AT+AUTOCONN=n} (0--15) \\
\texttt{queryPairedList()} & \texttt{AT+PLIST}, parse \texttt{+PLIST=} lines \\
\bottomrule \bottomrule
\end{tabular} \end{tabular}
\caption{Public driver API.} \caption{Public driver API.}
@@ -240,8 +247,8 @@ if (auto r = bt.sendCommand(core::Bt1035AtCommand::AuxLineIn); !r) {
\label{sec:bt1035-reading} \label{sec:bt1035-reading}
\begin{itemize} \begin{itemize}
\item Feasycom FSC-BT1035 AT command manual (vendor) --- full command set \item Feasycom FSC-BT1035 AT command manual (vendor) --- full command set;
for name, paired-device list, and reconnect not yet wrapped by firmware. firmware wraps name, paired list, and auto-reconnect for the Web UI.
\item Chapter~\ref{ch:hardware} --- pin map and I\textsuperscript{2}S routing. \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 Chapter~\ref{ch:adau1701} --- DSP output that feeds the module.
\item \texttt{components/core/test/bt1035\_at\_test.cpp} --- init sequence test. \item \texttt{components/core/test/bt1035\_at\_test.cpp} --- init sequence test.
+7 -1
View File
@@ -16,7 +16,7 @@ added in the same change that introduces the class. A tooling check keeps
this chapter in step with the code, so it is always current. this chapter in step with the code, so it is always current.
\end{drnote} \end{drnote}
The class reference tracks firmware~0.8.3 on \texttt{main}. Public classes The class reference tracks firmware~0.8.4 on \texttt{main}. Public classes
are grouped by layer: domain core, application services, and hardware drivers. are grouped by layer: domain core, application services, and hardware drivers.
% ------------------------------------------------------------------ % ------------------------------------------------------------------
@@ -154,6 +154,12 @@ are included. Wrong-band calls return \texttt{Si4684Error::WrongBand}.
Register-level opcodes remain private; see \texttt{Si4684Types.hpp} for status Register-level opcodes remain private; see \texttt{Si4684Types.hpp} for status
DTOs. Full DAB/FM tuning guide: Chapter~\ref{ch:si4684}. DTOs. Full DAB/FM tuning guide: Chapter~\ref{ch:si4684}.
\section{Bt1035PairedDevice}\label{cls:Bt1035PairedDevice}
Plain DTO for one \texttt{+PLIST=} line from the FSC-BT1035: paired-record
index (1--8), 12-digit MAC, and optional friendly name. Parsed by
\texttt{core::parseBt1035PairedListResponse()} and serialised on
\texttt{GET /api/bluetooth/paired}.
\section{Bt1035Driver}\label{cls:Bt1035Driver} \section{Bt1035Driver}\label{cls:Bt1035Driver}
UART driver for the FSC-BT1035 (Chapter~\ref{ch:bt1035}). \texttt{boot()} UART driver for the FSC-BT1035 (Chapter~\ref{ch:bt1035}). \texttt{boot()}
pulses RESET\#, opens UART2 with RTS/CTS, and runs pulses RESET\#, opens UART2 with RTS/CTS, and runs
+1 -1
View File
@@ -319,7 +319,7 @@ The setup UI and REST clients use \texttt{tuner::TunerService}, which wraps
FM & \texttt{\{"band":"fm","frequency\_khz":64000..108000\}} \\ FM & \texttt{\{"band":"fm","frequency\_khz":64000..108000\}} \\
\texttt{GET /api/tuner/services} & DAB only & Programme list for ensemble \\ \texttt{GET /api/tuner/services} & DAB only & Programme list for ensemble \\
\texttt{POST /api/tuner/play} & DAB only & Start \texttt{service\_id}/\texttt{component\_id} \\ \texttt{POST /api/tuner/play} & DAB only & Start \texttt{service\_id}/\texttt{component\_id} \\
\texttt{POST /api/tuner/seek} & FM only & Seek up, return new kHz \\ \texttt{POST /api/tuner/seek} & FM only & Seek up/down, return new kHz \\
\texttt{GET /api/tuner/status} & both & Locked state, RSQ or DIGRAD \\ \texttt{GET /api/tuner/status} & both & Locked state, RSQ or DIGRAD \\
\bottomrule \bottomrule
\end{tabular} \end{tabular}
+1 -1
View File
@@ -30,7 +30,7 @@
\vfill \vfill
{\color{drInk}\large Michele Bigi\par} {\color{drInk}\large Michele Bigi\par}
\vspace{2mm} \vspace{2mm}
{\color{drGray}Firmware 0.8.3 \quad\textbullet\quad 2026\par} {\color{drGray}Firmware 0.8.4 \quad\textbullet\quad 2026\par}
\vspace{2mm} \vspace{2mm}
{\color{drGray}Hardware: CERN-OHL-S v2 \quad\textbullet\quad Firmware: Apache-2.0\par} {\color{drGray}Hardware: CERN-OHL-S v2 \quad\textbullet\quad Firmware: Apache-2.0\par}
\vspace{2mm} \vspace{2mm}
+7 -8
View File
@@ -4,8 +4,8 @@ Read this together with `AGENTS.md` and everything under
`.cursor/rules/`. Those define *how* to write code; this file defines `.cursor/rules/`. Those define *how* to write code; this file defines
*what we are building* and the current state on `main`. *what we are building* and the current state on `main`.
**Firmware on `main`:** **0.8.3** all agent tasks T1T8 complete; device **Firmware on `main`:** **0.8.4** — agent tasks T1T12 complete; device HIL
HIL pending PCB arrival. pending PCB arrival.
## What DigiRadio is ## What DigiRadio is
@@ -46,17 +46,16 @@ Repository: https://github.com/manvalan/DigiRadio
| 4 Station presets | Done (0.7.0) | NVS `station_list`, full `/api/stations/*` | | 4 Station presets | Done (0.7.0) | NVS `station_list`, full `/api/stations/*` |
| 5 ADAU1701 runtime | Done | EQ, mixer, enhancements, audio API | | 5 ADAU1701 runtime | Done | EQ, mixer, enhancements, audio API |
| 6 Si4684 tuning | Done | FM/DAB tune, seek, RSQ, RDS, DAB services/DLS | | 6 Si4684 tuning | Done | FM/DAB tune, seek, RSQ, RDS, DAB services/DLS |
| 7 BT1035 | Mostly done | Pairing, A2DP stat/disconnect; name/plist AT open | | 7 BT1035 | Done (0.8.4) | Pairing, A2DP, name/plist/auto-reconnect AT |
| 8 Integration | Done (0.8.1) | `IntegrationService`, last-preset NVS | | 8 Integration | Done (0.8.1) | `IntegrationService`, last-preset NVS |
| T6 Web UI | Done (0.8.2) | Tabbed SPA, all REST endpoints | | T6 Web UI | Done (0.8.4) | Tabbed SPA + System tab (OTA/DSP upload) |
| T7 Si4684 blobs | Done (0.8.2) | Local-only `.bin`, CI policy check | | T7 Si4684 blobs | Done (0.8.2) | Local-only `.bin`, CI policy check |
| T8 NVS encryption | Done (0.8.3) | `initEncryptedStorage`; HIL when PCB ready | | T8 NVS encryption | Done (0.8.3) | `initEncryptedStorage`; HIL when PCB ready |
| T9T12 Platform | Done (0.8.4) | Dual OTA, EEPROM identity, DSP + firmware OTA |
Next work: **hardware-in-the-loop** (`docs/TODO.md` § P4), not new features Next work: **hardware-in-the-loop** (`docs/TODO.md` § P4), not new features
unless the user requests them. unless the user requests them.
## Working agreement
- **Blockers first** — state risks before solutions. - **Blockers first** — state risks before solutions.
- **One vertical slice at a time** — `main` always builds; host tests green. - **One vertical slice at a time** — `main` always builds; host tests green.
- Apache header + Doxygen doc blocks on every file/class/method. - Apache header + Doxygen doc blocks on every file/class/method.
@@ -66,8 +65,8 @@ unless the user requests them.
## Slice 1 — Walking skeleton (complete) ## Slice 1 — Walking skeleton (complete)
- ESP-IDF `esp32s3`, C++23, `components/core` host-testable. - ESP-IDF `esp32s3`, C++23, `components/core` host-testable.
- SoftAP `DigiRadio-setup`, gzipped page, `GET /api/health`. - SoftAP `DigiRadio-<suffix>` (or setup fallback), gzipped page, `GET /api/health`.
- Current health JSON includes `fw` (today **0.8.3**) and companion-chip flags. - Health JSON includes `fw` (today **0.8.4**), `serialNumber`, companion-chip flags.
## Slice 2 — Secure store + Wi-Fi STA (complete) ## Slice 2 — Secure store + Wi-Fi STA (complete)
+4
View File
@@ -133,6 +133,10 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
ESP_LOGW(kTag, "BT1035 device name set failed"); ESP_LOGW(kTag, "BT1035 device name set failed");
} }
if (auto reconnectResult = gBt1035.setAutoReconnect(3U); !reconnectResult) {
ESP_LOGW(kTag, "BT1035 auto-reconnect set failed");
}
gReady = true; gReady = true;
ESP_LOGI(kTag, "companion chips ready"); ESP_LOGI(kTag, "companion chips ready");
return {}; return {};