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
+63 -1
View File
@@ -13,16 +13,37 @@
#include "core/BluetoothJson.hpp"
#include <cstdlib>
#include <sstream>
namespace core {
namespace {
void appendJsonString(std::ostringstream& out, std::string_view text)
{
out << '"';
for (const char ch : text) {
if (ch == '"' || ch == '\\') {
out << '\\';
}
out << ch;
}
out << '"';
}
} // namespace
std::string serializeBluetoothStatusJson(const BluetoothStatus& status)
{
std::ostringstream out;
out << "{\"booted\":" << (status.booted ? "true" : "false")
<< ",\"pairing\":" << (status.pairing ? "true" : "false")
<< ",\"a2dp\":\"" << a2dpStateToken(status.a2dpState) << "\"}";
<< ",\"a2dp\":\"" << a2dpStateToken(status.a2dpState) << "\""
<< ",\"device_name\":";
appendJsonString(out, status.deviceName);
out << ",\"auto_reconnect\":"
<< static_cast<unsigned>(status.autoReconnect) << "}";
return out.str();
}
@@ -33,4 +54,45 @@ std::string serializeBluetoothErrorJson(const char* reason)
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
+95
View File
@@ -14,6 +14,7 @@
#include "core/Bt1035At.hpp"
#include <cstdlib>
#include <vector>
namespace core {
@@ -110,10 +111,24 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
return "AT+A2DPSTAT\r\n";
case Bt1035AtCommand::A2dpDisconnect:
return "AT+A2DPDISC\r\n";
case Bt1035AtCommand::QueryName:
return "AT+NAME\r\n";
case Bt1035AtCommand::QueryAutoConn:
return "AT+AUTOCONN\r\n";
case Bt1035AtCommand::QueryPairedList:
return "AT+PLIST\r\n";
}
return "AT\r\n";
}
std::string buildBt1035SetAutoConnLine(std::uint8_t times)
{
if (times > 15U) {
times = 15U;
}
return "AT+AUTOCONN=" + std::to_string(times) + "\r\n";
}
std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noexcept
{
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
@@ -179,4 +194,84 @@ const char* a2dpStateToken(Bt1035A2dpState state) noexcept
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
@@ -226,4 +226,24 @@ std::expected<TunerPlayRequest, ParseError> parseTunerPlayJson(
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