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:
@@ -13,8 +13,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/Bt1035At.hpp"
|
||||
#include "core/Bt1035PairedDevice.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace core {
|
||||
|
||||
@@ -32,6 +35,8 @@ struct BluetoothStatus {
|
||||
bool booted; ///< BT1035 driver ready after boot().
|
||||
bool pairing; ///< Discoverable mode requested by firmware.
|
||||
Bt1035A2dpState a2dpState; ///< Last read A2DP link state.
|
||||
std::string deviceName; ///< GAP friendly name from AT+NAME.
|
||||
std::uint8_t autoReconnect; ///< Power-on reconnect count (0 = off).
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -61,4 +66,32 @@ struct BluetoothStatus {
|
||||
*/
|
||||
[[nodiscard]] std::string serializeBluetoothErrorJson(const char* reason);
|
||||
|
||||
/**
|
||||
* @brief serializeBluetoothPairedJson — serialise paired-device list.
|
||||
*
|
||||
* @dname serializeBluetoothPairedJson
|
||||
* @param devices Parsed AT+PLIST entries.
|
||||
* @return JSON object with a devices array.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-07
|
||||
*/
|
||||
[[nodiscard]] std::string serializeBluetoothPairedJson(
|
||||
const std::vector<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
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "core/Bt1035PairedDevice.hpp"
|
||||
#include "core/ParseError.hpp"
|
||||
|
||||
#include <array>
|
||||
@@ -20,6 +21,7 @@
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace core {
|
||||
|
||||
@@ -40,6 +42,9 @@ enum class Bt1035AtCommand {
|
||||
PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
|
||||
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
|
||||
A2dpDisconnect, ///< AT+A2DPDISC — release current A2DP connection.
|
||||
QueryName, ///< AT+NAME — read BR/EDR local name (+NAME=).
|
||||
QueryAutoConn, ///< AT+AUTOCONN — read power-on auto-reconnect count.
|
||||
QueryPairedList, ///< AT+PLIST — enumerate paired devices (+PLIST=).
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -147,4 +152,59 @@ parseBt1035A2dpStatResponse(std::string_view response);
|
||||
*/
|
||||
[[nodiscard]] const char* a2dpStateToken(Bt1035A2dpState state) noexcept;
|
||||
|
||||
/**
|
||||
* @brief buildBt1035SetAutoConnLine — AT+AUTOCONN with reconnect count.
|
||||
*
|
||||
* @dname buildBt1035SetAutoConnLine
|
||||
* @param times 0 off, 1–15 reconnect attempts (Feasycom default 3).
|
||||
* @return Full AT line including CRLF.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-07
|
||||
*/
|
||||
[[nodiscard]] std::string buildBt1035SetAutoConnLine(std::uint8_t times);
|
||||
|
||||
/**
|
||||
* @brief parseBt1035NameResponse — extract +NAME= value.
|
||||
*
|
||||
* @dname parseBt1035NameResponse
|
||||
* @param response Full module reply ending in OK.
|
||||
* @return Device name on success, or ParseError::MissingField.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-07
|
||||
*/
|
||||
[[nodiscard]] std::expected<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
|
||||
|
||||
@@ -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/ParseError.hpp"
|
||||
#include "core/TunerBand.hpp"
|
||||
#include "core/SeekDirection.hpp"
|
||||
#include "core/TunerStatus.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
@@ -128,4 +129,18 @@ struct TunerPlayRequest {
|
||||
[[nodiscard]] std::expected<TunerPlayRequest, ParseError> parseTunerPlayJson(
|
||||
std::string_view json);
|
||||
|
||||
/**
|
||||
* @brief parseTunerSeekJson — validate POST /api/tuner/seek body.
|
||||
*
|
||||
* @dname parseTunerSeekJson
|
||||
* @param json Optional body; empty defaults to seek up.
|
||||
* @return SeekDirection on success, or ParseError.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-07-07
|
||||
*/
|
||||
[[nodiscard]] std::expected<SeekDirection, ParseError> parseTunerSeekJson(
|
||||
std::string_view json);
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
*/
|
||||
|
||||
#include "core/Bt1035At.hpp"
|
||||
#include "core/Bt1035PairedDevice.hpp"
|
||||
#include "core/BluetoothJson.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
@@ -74,6 +76,50 @@ namespace {
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runNameAutoConnPairedParseTest()
|
||||
{
|
||||
const auto name =
|
||||
core::parseBt1035NameResponse("+NAME=DigiRadio-A1B2\r\nOK\r\n");
|
||||
if (!name || *name != "DigiRadio-A1B2") {
|
||||
std::cerr << "NAME parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto autoconn =
|
||||
core::parseBt1035AutoConnResponse("+AUTOCONN=3\r\nOK\r\n");
|
||||
if (!autoconn || *autoconn != 3U) {
|
||||
std::cerr << "AUTOCONN parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto plist = core::parseBt1035PairedListResponse(
|
||||
"+PLIST=1,001122334455,Phone\r\n"
|
||||
"+PLIST=2,FFEEDDCCBBAA,\r\n"
|
||||
"OK\r\n");
|
||||
if (!plist || plist->size() != 2U || (*plist)[0U].index != 1U
|
||||
|| (*plist)[0U].mac != "001122334455"
|
||||
|| (*plist)[0U].name != "Phone") {
|
||||
std::cerr << "PLIST parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::string pairedJson =
|
||||
core::serializeBluetoothPairedJson(*plist);
|
||||
if (pairedJson.find("\"index\":1") == std::string::npos
|
||||
|| pairedJson.find("\"mac\":\"001122334455\"") == std::string::npos) {
|
||||
std::cerr << "paired JSON serialise failed: " << pairedJson << '\n';
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto times =
|
||||
core::parseBluetoothAutoReconnectJson(R"({"times":5})");
|
||||
if (!times || *times != 5U) {
|
||||
std::cerr << "auto-reconnect JSON parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (core::buildBt1035SetAutoConnLine(5) != "AT+AUTOCONN=5\r\n") {
|
||||
std::cerr << "AUTOCONN command line mismatch\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -84,5 +130,8 @@ int main()
|
||||
if (runParseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runNameAutoConnPairedParseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
@@ -178,6 +178,34 @@ namespace {
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
[[nodiscard]] int runTunerSeekParseTest()
|
||||
{
|
||||
const auto empty = core::parseTunerSeekJson("");
|
||||
if (!empty || *empty != core::SeekDirection::Up) {
|
||||
std::cerr << "empty seek body should default to up\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto up =
|
||||
core::parseTunerSeekJson(R"({"direction":"up"})");
|
||||
if (!up || *up != core::SeekDirection::Up) {
|
||||
std::cerr << "seek up parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto down =
|
||||
core::parseTunerSeekJson(R"({"direction":"down"})");
|
||||
if (!down || *down != core::SeekDirection::Down) {
|
||||
std::cerr << "seek down parse failed\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto bad =
|
||||
core::parseTunerSeekJson(R"({"direction":"sideways"})");
|
||||
if (bad) {
|
||||
std::cerr << "expected invalid seek direction rejection\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
@@ -209,5 +237,8 @@ int main()
|
||||
if (runTunerErrorSerialiseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (runTunerSeekParseTest() != EXIT_SUCCESS) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user