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:
+19
-11
@@ -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 T1–T8 complete; device HIL pending first PCB.
|
**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 |
|
| 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**, **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:
|
`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 (0–100) |
|
| POST | `/api/audio/stereo-enhance` | Stereo depth overlay (0–100) |
|
||||||
| POST | `/api/audio/bass-enhance` | Bass enhance 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` | 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 (0–15) |
|
||||||
| 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 0–15.
|
||||||
|
* @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, 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<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 0–15, 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 (1–8).
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, 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<void, Bt1035Error> 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<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()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">Wi‑Fi</button>
|
<button type="button" data-tab="wifi" role="tab">Wi‑Fi</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, 1–15 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
@@ -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 (T1–T8, fw 0.7.1–0.8.3)
|
## Completed agent tasks (T1–T12, fw 0.7.1–0.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.
|
||||||
|
|||||||
@@ -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)
|
||||||
% ------------------------------------------------------------------
|
% ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 T1–T8 complete; device
|
**Firmware on `main`:** **0.8.4** — agent tasks T1–T12 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 |
|
||||||
|
| T9–T12 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
Reference in New Issue
Block a user