Merge branch 'cursor/igiRadio-ios-app' into HEAD
This commit is contained in:
@@ -47,10 +47,12 @@ public:
|
|||||||
* @brief applyGroup — ingest one RDS group (blocks A–D).
|
* @brief applyGroup — ingest one RDS group (blocks A–D).
|
||||||
*
|
*
|
||||||
* @dname applyGroup
|
* @dname applyGroup
|
||||||
* @param blockA RDS block A (PI code).
|
* @param blockA RDS block A (PI code, currently unused).
|
||||||
* @param blockB RDS block B (group type and address).
|
* @param blockB RDS block B (group type and segment address).
|
||||||
* @param blockC RDS block C (text payload).
|
* @param blockC RDS block C (RT text for group 2 only; group 0's
|
||||||
* @param blockD RDS block D (text payload for RT).
|
* AF list/repeated PI is not text and is ignored).
|
||||||
|
* @param blockD RDS block D (PS text for group 0; RT text for
|
||||||
|
* group 2, per ETSI EN 62106 §3.1.5/§3.1.5.3).
|
||||||
* @pubstate updates PS/RT buffers for group types 0A and 2A.
|
* @pubstate updates PS/RT buffers for group types 0A and 2A.
|
||||||
*
|
*
|
||||||
* @author Michele Bigi
|
* @author Michele Bigi
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ namespace core {
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
constexpr std::string_view kSerialUnknown = "unknown";
|
constexpr std::string_view kSerialUnknown = "unknown";
|
||||||
constexpr std::string_view kSoftApFallback = "DigiRadio-setup";
|
constexpr std::string_view kSoftApFallback = "igiRadio-setup";
|
||||||
constexpr std::string_view kBluetoothFallback = "DigiRadio";
|
constexpr std::string_view kBluetoothFallback = "igiRadio";
|
||||||
constexpr std::string_view kHostnameFallback = "digiradio";
|
constexpr std::string_view kHostnameFallback = "igiradio";
|
||||||
|
|
||||||
[[nodiscard]] std::string prefixed(std::string_view prefix,
|
[[nodiscard]] std::string prefixed(std::string_view prefix,
|
||||||
std::string_view suffix)
|
std::string_view suffix)
|
||||||
@@ -44,9 +44,9 @@ DeviceIdentity DeviceIdentity::fromEui48(Eui48 eui)
|
|||||||
const std::string suffix = eui.shortSuffix();
|
const std::string suffix = eui.shortSuffix();
|
||||||
return DeviceIdentity(eui,
|
return DeviceIdentity(eui,
|
||||||
eui.serialNumber(),
|
eui.serialNumber(),
|
||||||
prefixed("DigiRadio-", suffix),
|
prefixed("igiRadio-", suffix),
|
||||||
prefixed("DigiRadio-", suffix),
|
prefixed("igiRadio-", suffix),
|
||||||
prefixed("digiradio-", suffix));
|
prefixed("igiradio-", suffix));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DeviceIdentity::isKnown() const noexcept
|
bool DeviceIdentity::isKnown() const noexcept
|
||||||
|
|||||||
@@ -35,12 +35,15 @@ void RdsMetadataAccumulator::applyGroup(std::uint16_t blockA,
|
|||||||
static_cast<std::uint8_t>((blockB >> 12U) & 0x0FU);
|
static_cast<std::uint8_t>((blockB >> 12U) & 0x0FU);
|
||||||
|
|
||||||
if (groupType == 0U) {
|
if (groupType == 0U) {
|
||||||
const std::size_t index =
|
// Group type 0 (both 0A and 0B): the two Program Service name
|
||||||
static_cast<std::size_t>((blockB >> 1U) & 0x03U);
|
// characters are always in Block D. Block C differs by version --
|
||||||
|
// 0A carries alternate-frequency codes, 0B repeats the PI code --
|
||||||
|
// but never PS text either way (ETSI EN 62106 §3.1.5).
|
||||||
|
const std::size_t index = static_cast<std::size_t>(blockB & 0x03U);
|
||||||
if (index < kPsSegments) {
|
if (index < kPsSegments) {
|
||||||
psBuffer_[index * 2U] =
|
psBuffer_[index * 2U] =
|
||||||
static_cast<char>((blockC >> 8U) & 0xFFU);
|
static_cast<char>((blockD >> 8U) & 0xFFU);
|
||||||
psBuffer_[index * 2U + 1U] = static_cast<char>(blockC & 0xFFU);
|
psBuffer_[index * 2U + 1U] = static_cast<char>(blockD & 0xFFU);
|
||||||
psSegmentValid_[index] = true;
|
psSegmentValid_[index] = true;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ namespace {
|
|||||||
|
|
||||||
constexpr std::size_t kMaxUrlLength = 200U;
|
constexpr std::size_t kMaxUrlLength = 200U;
|
||||||
constexpr std::string_view kHttpPrefix = "http://";
|
constexpr std::string_view kHttpPrefix = "http://";
|
||||||
|
constexpr std::string_view kHttpsPrefix = "https://";
|
||||||
|
|
||||||
[[nodiscard]] std::string_view extractJsonString(std::string_view json,
|
[[nodiscard]] std::string_view extractJsonString(std::string_view json,
|
||||||
std::string_view key)
|
std::string_view key)
|
||||||
@@ -84,8 +85,9 @@ std::expected<WebRadioConfig, ParseError> parseWebRadioConfigJson(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const std::string_view url = extractJsonString(json, "url");
|
const std::string_view url = extractJsonString(json, "url");
|
||||||
if (url.empty() || url.size() > kMaxUrlLength
|
const bool isHttp = url.substr(0, kHttpPrefix.size()) == kHttpPrefix;
|
||||||
|| url.substr(0, kHttpPrefix.size()) != kHttpPrefix) {
|
const bool isHttps = url.substr(0, kHttpsPrefix.size()) == kHttpsPrefix;
|
||||||
|
if (url.empty() || url.size() > kMaxUrlLength || !(isHttp || isHttps)) {
|
||||||
return std::unexpected(ParseError::InvalidJson);
|
return std::unexpected(ParseError::InvalidJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ add_executable(bt1035_at_test bt1035_at_test.cpp)
|
|||||||
target_link_libraries(bt1035_at_test PRIVATE digiradio_core)
|
target_link_libraries(bt1035_at_test PRIVATE digiradio_core)
|
||||||
add_test(NAME bt1035_at_test COMMAND bt1035_at_test)
|
add_test(NAME bt1035_at_test COMMAND bt1035_at_test)
|
||||||
|
|
||||||
|
add_executable(rds_metadata_accumulator_test rds_metadata_accumulator_test.cpp)
|
||||||
|
target_link_libraries(rds_metadata_accumulator_test PRIVATE digiradio_core)
|
||||||
|
add_test(NAME rds_metadata_accumulator_test COMMAND rds_metadata_accumulator_test)
|
||||||
|
|
||||||
add_executable(broadcast_metadata_test broadcast_metadata_test.cpp)
|
add_executable(broadcast_metadata_test broadcast_metadata_test.cpp)
|
||||||
target_link_libraries(broadcast_metadata_test PRIVATE digiradio_core)
|
target_link_libraries(broadcast_metadata_test PRIVATE digiradio_core)
|
||||||
add_test(NAME broadcast_metadata_test COMMAND broadcast_metadata_test)
|
add_test(NAME broadcast_metadata_test COMMAND broadcast_metadata_test)
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ namespace {
|
|||||||
|
|
||||||
[[nodiscard]] int runRdsProgramNameTest()
|
[[nodiscard]] int runRdsProgramNameTest()
|
||||||
{
|
{
|
||||||
|
// Group type 0, segment address in blockB bits[1:0] (no shift); PS
|
||||||
|
// characters are in blockD, never blockC (ETSI EN 62106 §3.1.5).
|
||||||
core::RdsMetadataAccumulator acc;
|
core::RdsMetadataAccumulator acc;
|
||||||
acc.applyGroup(0U, 0x0000U, 0x5445U, 0U);
|
acc.applyGroup(0U, 0x0000U, 0U, 0x5445U);
|
||||||
acc.applyGroup(0U, 0x0002U, 0x5354U, 0U);
|
acc.applyGroup(0U, 0x0001U, 0U, 0x5354U);
|
||||||
acc.applyGroup(0U, 0x0004U, 0x2020U, 0U);
|
acc.applyGroup(0U, 0x0002U, 0U, 0x2020U);
|
||||||
acc.applyGroup(0U, 0x0006U, 0x2020U, 0U);
|
acc.applyGroup(0U, 0x0003U, 0U, 0x2020U);
|
||||||
|
|
||||||
const auto name = acc.programName();
|
const auto name = acc.programName();
|
||||||
if (!name || name->value() != "TEST") {
|
if (!name || name->value() != "TEST") {
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ namespace {
|
|||||||
if (!expectEqual(identity.serialNumber(), "0004A3123456")) {
|
if (!expectEqual(identity.serialNumber(), "0004A3123456")) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
if (!expectEqual(identity.softApSsid(), "DigiRadio-123456")) {
|
if (!expectEqual(identity.softApSsid(), "igiRadio-123456")) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
if (!expectEqual(identity.bluetoothName(), "DigiRadio-123456")) {
|
if (!expectEqual(identity.bluetoothName(), "igiRadio-123456")) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
if (!expectEqual(identity.hostname(), "digiradio-123456")) {
|
if (!expectEqual(identity.hostname(), "igiradio-123456")) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
return EXIT_SUCCESS;
|
return EXIT_SUCCESS;
|
||||||
@@ -74,13 +74,13 @@ namespace {
|
|||||||
if (!expectEqual(identity.serialNumber(), "unknown")) {
|
if (!expectEqual(identity.serialNumber(), "unknown")) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
if (!expectEqual(identity.softApSsid(), "DigiRadio-setup")) {
|
if (!expectEqual(identity.softApSsid(), "igiRadio-setup")) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
if (!expectEqual(identity.bluetoothName(), "DigiRadio")) {
|
if (!expectEqual(identity.bluetoothName(), "igiRadio")) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
if (!expectEqual(identity.hostname(), "digiradio")) {
|
if (!expectEqual(identity.hostname(), "igiradio")) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
return EXIT_SUCCESS;
|
return EXIT_SUCCESS;
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* @file rds_metadata_accumulator_test.cpp
|
||||||
|
* @brief Host tests for RdsMetadataAccumulator against ETSI EN 62106
|
||||||
|
* group 0B/2A block layouts.
|
||||||
|
*
|
||||||
|
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||||
|
*
|
||||||
|
* Copyright 2026 Michele Bigi
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*
|
||||||
|
* @author Michele Bigi
|
||||||
|
* @date 2026-08-25
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "core/RdsMetadataAccumulator.hpp"
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <iostream>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group type 0B (block B bit 11 set), segment address in bits[1:0]: the two
|
||||||
|
* Program Service characters for that segment live in Block D (high byte
|
||||||
|
* first), never Block C -- Block C for group 0 carries alternate-frequency
|
||||||
|
* codes (0A) or a repeated PI code (0B), not text. This is the exact bug
|
||||||
|
* fixed 2026-08-25 (characters were being read from Block C, which made
|
||||||
|
* every FM station name silently fail to accumulate).
|
||||||
|
*/
|
||||||
|
[[nodiscard]] int runProgramServiceNameTest()
|
||||||
|
{
|
||||||
|
core::RdsMetadataAccumulator acc;
|
||||||
|
|
||||||
|
// "RADIO101" across the 4 PS segments, group type 0B.
|
||||||
|
struct
|
||||||
|
{
|
||||||
|
std::uint16_t segment;
|
||||||
|
char c0;
|
||||||
|
char c1;
|
||||||
|
} segments[] = {
|
||||||
|
{0U, 'R', 'A'},
|
||||||
|
{1U, 'D', 'I'},
|
||||||
|
{2U, 'O', '1'},
|
||||||
|
{3U, '0', '1'},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto &seg : segments) {
|
||||||
|
const std::uint16_t blockB =
|
||||||
|
static_cast<std::uint16_t>(0x0800U | seg.segment);
|
||||||
|
const std::uint16_t blockD = static_cast<std::uint16_t>(
|
||||||
|
(static_cast<std::uint16_t>(seg.c0) << 8) |
|
||||||
|
static_cast<std::uint16_t>(seg.c1));
|
||||||
|
// Block C deliberately holds garbage (a repeated-PI-like value that
|
||||||
|
// is NOT the expected text) to prove the decoder ignores it for
|
||||||
|
// group 0, rather than happening to read the right bytes by luck.
|
||||||
|
acc.applyGroup(0x1234U, blockB, 0xBEEFU, blockD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto name = acc.programName();
|
||||||
|
if (!name) {
|
||||||
|
std::cerr << "expected a program name after 4 PS segments\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
if (name->value() != "RADIO101") {
|
||||||
|
std::cerr << "program name mismatch: got '" << name->value()
|
||||||
|
<< "'\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] int runIncompleteSegmentsTest()
|
||||||
|
{
|
||||||
|
core::RdsMetadataAccumulator acc;
|
||||||
|
acc.applyGroup(0x1234U, 0x0800U, 0xBEEFU, 0x5241U); // segment 0 only
|
||||||
|
if (acc.programName()) {
|
||||||
|
std::cerr << "program name should be absent with 3 segments "
|
||||||
|
"missing\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Group type 2A: RadioText characters split across Block C (2 chars) and
|
||||||
|
* Block D (2 chars) for the same segment -- unlike group 0, Block C really
|
||||||
|
* does carry text here, so this is a regression guard against ever
|
||||||
|
* "fixing" group 2 the same way group 0 needed fixing.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] int runRadiotextTest()
|
||||||
|
{
|
||||||
|
core::RdsMetadataAccumulator acc;
|
||||||
|
const std::uint16_t blockB = 0x2000U; // group type 2, version A, seg 0
|
||||||
|
const std::uint16_t blockC =
|
||||||
|
(static_cast<std::uint16_t>('T') << 8) | static_cast<std::uint16_t>('e');
|
||||||
|
const std::uint16_t blockD =
|
||||||
|
(static_cast<std::uint16_t>('s') << 8) | static_cast<std::uint16_t>('t');
|
||||||
|
acc.applyGroup(0x1234U, blockB, blockC, blockD);
|
||||||
|
|
||||||
|
const auto rt = acc.radiotext();
|
||||||
|
if (!rt) {
|
||||||
|
std::cerr << "expected radiotext after one 2A segment\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
if (rt->value() != "Test") {
|
||||||
|
std::cerr << "radiotext mismatch: got '" << rt->value() << "'\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
if (runProgramServiceNameTest() != EXIT_SUCCESS) {
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
if (runIncompleteSegmentsTest() != EXIT_SUCCESS) {
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
if (runRadiotextTest() != EXIT_SUCCESS) {
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
@@ -40,9 +40,18 @@ namespace {
|
|||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
const auto badScheme = core::parseWebRadioConfigJson(
|
const auto badScheme = core::parseWebRadioConfigJson(
|
||||||
R"({"enabled":true,"url":"https://example.com/x.mp3"})");
|
R"({"enabled":true,"url":"ftp://example.com/x.mp3"})");
|
||||||
if (badScheme) {
|
if (badScheme) {
|
||||||
std::cerr << "non-http scheme should fail\n";
|
std::cerr << "non-http(s) scheme should fail\n";
|
||||||
|
return EXIT_FAILURE;
|
||||||
|
}
|
||||||
|
// Most public internet radio streams are HTTPS-only; rejecting the
|
||||||
|
// scheme outright (as this parser used to) made streaming unusable for
|
||||||
|
// essentially any real station.
|
||||||
|
const auto httpsOk = core::parseWebRadioConfigJson(
|
||||||
|
R"({"enabled":true,"url":"https://example.com/x.mp3"})");
|
||||||
|
if (!httpsOk || httpsOk->url != "https://example.com/x.mp3") {
|
||||||
|
std::cerr << "https url should parse\n";
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
const auto malformed = core::parseWebRadioConfigJson("not json");
|
const auto malformed = core::parseWebRadioConfigJson("not json");
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ constexpr std::uint16_t kSi4684I2sOutEnable = 0x8002U;
|
|||||||
/** Si4684 volume: 0=mute, 63=max (AN649 AUDIO_ANALOG_VOLUME). */
|
/** Si4684 volume: 0=mute, 63=max (AN649 AUDIO_ANALOG_VOLUME). */
|
||||||
constexpr std::uint8_t kSi4684VolumeMax = 63U;
|
constexpr std::uint8_t kSi4684VolumeMax = 63U;
|
||||||
constexpr std::uint16_t kPropFmRdsConfig = 0x3C02U;
|
constexpr std::uint16_t kPropFmRdsConfig = 0x3C02U;
|
||||||
|
/** AN649 §0x3C01 FM_RDS_INTERRUPT_FIFO_COUNT: DEPTH[7:0], groups needed
|
||||||
|
* before RDSFIFOINT (FM_RDS_STATUS RESP4 bit0, our readFmRds() "received"
|
||||||
|
* bit) ever sets. Default 0 disables it permanently regardless of
|
||||||
|
* FM_RDS_CONFIG -- must be nonzero for any RDS group to ever be seen. */
|
||||||
|
constexpr std::uint16_t kPropFmRdsInterruptFifoCount = 0x3C01U;
|
||||||
/** AN649 FM_AUDIO_DE_EMPHASIS (0x3900): 0=75us/US (chip default), 1=50us/
|
/** AN649 FM_AUDIO_DE_EMPHASIS (0x3900): 0=75us/US (chip default), 1=50us/
|
||||||
* Europe, 2=disabled. FM seek band/spacing above is already the European
|
* Europe, 2=disabled. FM seek band/spacing above is already the European
|
||||||
* 87.5-107.9 MHz/100 kHz plan, so the chip must not stay on its 75us/US
|
* 87.5-107.9 MHz/100 kHz plan, so the chip must not stay on its 75us/US
|
||||||
@@ -557,9 +562,22 @@ std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
|
|||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (auto rds = setProperty(kPropFmRdsConfig, 0x0001U); !rds) {
|
// AN649 §0x3C02 FM_RDS_CONFIG: BLETHB[7:6]/BLETHCD[5:4] block-error
|
||||||
|
// thresholds, RDSEN[0]. 0x0001 (thresholds at 0, "no block errors")
|
||||||
|
// rejected almost every real-world group -- any bit error at all
|
||||||
|
// (routine with multipath/noise, even on a strong signal) dropped
|
||||||
|
// the group from the FIFO, so accumulated station names/RadioText
|
||||||
|
// never completed. 0x00A1 keeps both thresholds at the datasheet's
|
||||||
|
// most tolerant recommended setting (2 = "3-5 bit errors detected
|
||||||
|
// and corrected"), still discarding uncorrectable (3) groups.
|
||||||
|
if (auto rds = setProperty(kPropFmRdsConfig, 0x00A1U); !rds) {
|
||||||
return rds;
|
return rds;
|
||||||
}
|
}
|
||||||
|
if (auto rdsFifo =
|
||||||
|
setProperty(kPropFmRdsInterruptFifoCount, 0x0001U);
|
||||||
|
!rdsFifo) {
|
||||||
|
return rdsFifo;
|
||||||
|
}
|
||||||
if (auto deEmph = setProperty(kPropFmAudioDeEmphasis,
|
if (auto deEmph = setProperty(kPropFmAudioDeEmphasis,
|
||||||
kFmAudioDeEmphasisEurope);
|
kFmAudioDeEmphasisEurope);
|
||||||
!deEmph) {
|
!deEmph) {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public:
|
|||||||
* @brief setupDefault — factory for the Slice 1 setup SoftAP.
|
* @brief setupDefault — factory for the Slice 1 setup SoftAP.
|
||||||
*
|
*
|
||||||
* @dname setupDefault
|
* @dname setupDefault
|
||||||
* @return SoftApConfig with SSID DigiRadio-setup.
|
* @return SoftApConfig with SSID igiRadio-setup.
|
||||||
* @pubstate none
|
* @pubstate none
|
||||||
*
|
*
|
||||||
* @author Michele Bigi
|
* @author Michele Bigi
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
namespace net {
|
namespace net {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr std::string_view kSetupSsid = "DigiRadio-setup";
|
constexpr std::string_view kSetupSsid = "igiRadio-setup";
|
||||||
constexpr std::uint8_t kSetupChannel = 1;
|
constexpr std::uint8_t kSetupChannel = 1;
|
||||||
constexpr std::uint8_t kSetupMaxConnections = 4;
|
constexpr std::uint8_t kSetupMaxConnections = 4;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|||||||
@@ -147,23 +147,32 @@ componente di riga, stesso stile, filtrato per banda.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Stile UI — Apple, minimalista ma con una sezione grafica curata
|
## 4bis. RDS (nome stazione FM) — ora funziona davvero, da oggi
|
||||||
|
|
||||||
- Componenti nativi SwiftUI: `Picker` segmented per la sorgente, `Slider` con
|
Fino a oggi il firmware non decodificava **mai** l'RDS (bug a tre livelli,
|
||||||
`.tint()` per volume/bass/stereo, `List`/`Form` in stile Impostazioni per le
|
risolto). Ora `GET /api/tuner/status` e lo scan FM completo possono
|
||||||
stazioni e le opzioni tecniche. Niente controlli custom pesanti o griglie di
|
restituire:
|
||||||
bottoni non standard.
|
|
||||||
- Organizza per tab/sezione logica, non tutto in una schermata:
|
```json
|
||||||
- **Ascolto**: sorgente attiva, volume, stazione corrente.
|
"fm": {
|
||||||
- **Suono**: EQ 6 bande, Bass Boost, Stereo Spread.
|
"frequency_khz": 92100,
|
||||||
- **Stazioni**: lista unificata FM+DAB (vedi §4), con scan.
|
"rssi_dbuv": 57,
|
||||||
- **Bluetooth**: pairing, dispositivo connesso.
|
"snr_db": 40,
|
||||||
- **Diagnostica**: tono di test, dettagli tecnici/versione firmware — non
|
"station_name": "M DUE O",
|
||||||
mescolare con i controlli quotidiani.
|
"radiotext": "...testo libero..."
|
||||||
- Una sezione "grafica" curata è benvenuta (es. una card "Now Playing" con
|
}
|
||||||
sfondo sfumato/blur, animazione leggera sul cambio sorgente). Ora **puoi**
|
```
|
||||||
agganciarla a dati reali di livello audio — vedi §7, l'endpoint VU-meter è
|
|
||||||
disponibile da oggi.
|
`station_name` e `radiotext` sono **opzionali** — compaiono solo dopo qualche
|
||||||
|
secondo di ricezione stabile (l'RDS impiega tempo ad accumularsi), quindi
|
||||||
|
possono mancare subito dopo una sintonizzazione. Mostrali quando presenti
|
||||||
|
(es. nella card "Now Playing" e nella riga della lista stazioni FM),
|
||||||
|
altrimenti mostra la sola frequenza come già fai.
|
||||||
|
|
||||||
|
Se durante l'ascolto normale (non solo durante lo scan) il nome stazione o il
|
||||||
|
radiotext cambiano o compaiono per la prima volta, aggiorna la UI di
|
||||||
|
conseguenza — l'utente ha chiesto esplicitamente un piccolo banner/notifica
|
||||||
|
quando arriva un nuovo nome/messaggio RDS durante la riproduzione.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -196,7 +205,44 @@ uscita), non un'animazione finta.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Checklist di autoverifica prima di considerare il lavoro finito
|
## 8. Scan FM completo — NON è bloccato, è solo lento (86s misurati)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/tuner/scan/full
|
||||||
|
```
|
||||||
|
|
||||||
|
Fa una scansione dell'intera banda FM (fino a 60 canali candidati, con
|
||||||
|
pausa+lettura RDS per ciascuno) e risponde **una sola volta alla fine**,
|
||||||
|
misurato: **~86 secondi** per uno scan completo. Non è un bug, è il tempo
|
||||||
|
reale che serve per farlo bene (RDS incluso).
|
||||||
|
|
||||||
|
**Se l'app usa un timeout HTTP standard (30-60s), questa richiesta scade
|
||||||
|
prima che il firmware finisca** — la request fallisce lato client, ma il
|
||||||
|
firmware nel frattempo continua e completa comunque (il risultato però va
|
||||||
|
perso perché il client non lo aspetta più). Sembra "bloccato", ma non lo è.
|
||||||
|
|
||||||
|
**Azione richiesta**: per questa chiamata specifica, imposta un timeout di
|
||||||
|
almeno **120 secondi** sulla request HTTP, e mostra un indicatore "scansione
|
||||||
|
in corso..." per tutta la durata (non un caricamento breve). `POST
|
||||||
|
/api/tuner/scan` (senza `/full`, per una singola stazione con filtro nome)
|
||||||
|
è invece rapido, timeout normale va bene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8bis. Streaming web radio — bug corretto, ora accetta HTTPS
|
||||||
|
|
||||||
|
`POST /api/streaming {"enabled":true,"url":"..."}` **prima rifiutava
|
||||||
|
categoricamente qualsiasi URL `https://`** (errore `invalid_json`), accettando
|
||||||
|
solo `http://` — dato che quasi tutte le radio via internet reali sono
|
||||||
|
HTTPS-only, questo probabilmente era il motivo per cui "qualsiasi cosa si
|
||||||
|
faccia" dava errore. Corretto oggi: ora accetta sia `http://` che `https://`,
|
||||||
|
e il firmware verifica il certificato TLS con la CA bundle integrata di
|
||||||
|
ESP-IDF. Nessun cambio di contratto per l'app — stessa forma JSON di prima,
|
||||||
|
semplicemente ora funziona anche con URL HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Checklist di autoverifica prima di considerare il lavoro finito
|
||||||
|
|
||||||
- [ ] Cambiare sorgente da Radio a Bluetooth nell'app cambia davvero l'audio
|
- [ ] Cambiare sorgente da Radio a Bluetooth nell'app cambia davvero l'audio
|
||||||
sul dispositivo reale (non solo lo stato locale dell'app).
|
sul dispositivo reale (non solo lo stato locale dell'app).
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Nota per Cursor — Volume master e VU-meter
|
||||||
|
|
||||||
|
Due punti precisi da correggere/implementare nell'app. Segui esattamente i
|
||||||
|
contratti sotto, non improvvisare formati diversi.
|
||||||
|
|
||||||
|
Dispositivo di test: `http://192.168.1.62`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Volume master
|
||||||
|
|
||||||
|
**Endpoint**: fa parte del profilo audio completo, non ha una rotta a sé.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/audio/profile -> legge lo stato attuale (incluso "master")
|
||||||
|
PUT /api/audio/profile -> scrive il profilo COMPLETO
|
||||||
|
```
|
||||||
|
|
||||||
|
Campo:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"master": {"left_db": 0, "right_db": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
Regole obbligatorie:
|
||||||
|
|
||||||
|
1. **Range reale: da -96.0 a +12.0 dB.** Lo slider volume in UI non deve
|
||||||
|
fermarsi a 0 dB — quello è solo "unity gain", non il massimo. Il massimo
|
||||||
|
vero è **+12 dB**. Se oggi lo slider arriva solo a 0, è un limite messo
|
||||||
|
nell'app, va tolto.
|
||||||
|
2. **`PUT /api/audio/profile` sostituisce l'intero oggetto**, non solo il
|
||||||
|
volume. Ogni volta che l'utente muove lo slider del volume, il body della
|
||||||
|
PUT deve contenere ANCHE `active_source`, `eq` (tutte e 6 le bande) ed
|
||||||
|
`enhancements`, con i valori correnti — non solo `{"master": {...}}`.
|
||||||
|
Il modo corretto:
|
||||||
|
- tieni sempre in memoria (o rileggi con GET) lo stato completo del
|
||||||
|
profilo;
|
||||||
|
- quando l'utente cambia il volume, aggiorna SOLO il campo `master` in
|
||||||
|
quello stato locale;
|
||||||
|
- invia l'intero oggetto aggiornato con PUT.
|
||||||
|
3. Normalmente `left_db` e `right_db` vanno impostati **uguali** con un unico
|
||||||
|
slider "Volume" (non serve un secondo controllo per il bilanciamento L/R,
|
||||||
|
a meno che non venga chiesto esplicitamente).
|
||||||
|
4. Valori fuori range (-96/+12) vengono rifiutati dal firmware con errore —
|
||||||
|
clampa lato client prima di inviare.
|
||||||
|
|
||||||
|
Esempio completo di richiesta corretta (cambio solo il volume a -6 dB,
|
||||||
|
tutto il resto invariato):
|
||||||
|
|
||||||
|
```json
|
||||||
|
PUT /api/audio/profile
|
||||||
|
{
|
||||||
|
"active_source": "radio",
|
||||||
|
"master": {"left_db": -6, "right_db": -6},
|
||||||
|
"eq": [
|
||||||
|
{"gain_db": 0, "center_hz": 20, "q": 1.414},
|
||||||
|
{"gain_db": 0, "center_hz": 100, "q": 1},
|
||||||
|
{"gain_db": 0, "center_hz": 400, "q": 1},
|
||||||
|
{"gain_db": 0, "center_hz": 1000, "q": 1},
|
||||||
|
{"gain_db": 0, "center_hz": 3000, "q": 1},
|
||||||
|
{"gain_db": 0, "center_hz": 8000, "q": 1}
|
||||||
|
],
|
||||||
|
"enhancements": {"stereo_level": 0, "bass_level": 0}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. VU-meter
|
||||||
|
|
||||||
|
**Endpoint nuovo, disponibile da oggi**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/audio/levels
|
||||||
|
```
|
||||||
|
|
||||||
|
Risposta (tutti i valori in dBFS, tipicamente negativi, 0 = fondo scala):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"radio_in_left_db": -1.5,
|
||||||
|
"radio_in_right_db": -1.5,
|
||||||
|
"bluetooth_in_left_db": -0.9,
|
||||||
|
"bluetooth_in_right_db": -1.0,
|
||||||
|
"output_left_db": -1.9,
|
||||||
|
"output_right_db": -2.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Regole obbligatorie:
|
||||||
|
|
||||||
|
1. **Il firmware non fa polling né cache** — ogni chiamata GET rilegge live
|
||||||
|
dal DSP in quel preciso istante. Se vuoi un meter che si aggiorna nel
|
||||||
|
tempo, il polling periodico lo devi fare tu lato app.
|
||||||
|
2. **Frequenza consigliata: ogni 200-500 ms**, non più veloce — ogni
|
||||||
|
chiamata impegna il bus I2C del dispositivo per 6 letture sequenziali
|
||||||
|
(una per meter, il chip ha solo 2 registri hardware di cattura).
|
||||||
|
3. **Ferma il polling quando la schermata con i meter non è visibile**
|
||||||
|
(es. `onDisappear` / quando l'utente cambia tab) — non lasciarlo attivo
|
||||||
|
in background, non serve e spreca risorse sul dispositivo.
|
||||||
|
4. `radio_in_*` sono il livello Si4684 (post-compressore), `bluetooth_in_*`
|
||||||
|
il livello ESP32, `output_*` il livello dopo Bass Boost (prima del
|
||||||
|
limiter finale) — utile per capire dove mostrare quale barra.
|
||||||
|
5. Se la risposta HTTP non è 200 (es. 500), mostra i meter come "non
|
||||||
|
disponibili" invece di un valore congelato/stantio.
|
||||||
@@ -11,7 +11,7 @@ idf_component_register(
|
|||||||
"$<$<BOOL:${CONFIG_ESP32_I2S_TEST_TONE}>:esp32_i2s_test_tone.cpp>"
|
"$<$<BOOL:${CONFIG_ESP32_I2S_TEST_TONE}>:esp32_i2s_test_tone.cpp>"
|
||||||
INCLUDE_DIRS "."
|
INCLUDE_DIRS "."
|
||||||
REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration ota eeprom24aa webradio driver
|
REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration ota eeprom24aa webradio driver
|
||||||
PRIV_REQUIRES esp_timer esp_http_client
|
PRIV_REQUIRES esp_timer esp_http_client mbedtls
|
||||||
)
|
)
|
||||||
|
|
||||||
if(CONFIG_TEST_FIRMWARE)
|
if(CONFIG_TEST_FIRMWARE)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "esp32_i2s_sink.hpp"
|
#include "esp32_i2s_sink.hpp"
|
||||||
#include "webradio/WebRadioService.hpp"
|
#include "webradio/WebRadioService.hpp"
|
||||||
|
|
||||||
|
#include "esp_crt_bundle.h"
|
||||||
#include "esp_http_client.h"
|
#include "esp_http_client.h"
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
@@ -48,6 +49,12 @@ struct InputBuffer {
|
|||||||
esp_http_client_config_t cfg{};
|
esp_http_client_config_t cfg{};
|
||||||
cfg.url = url.c_str();
|
cfg.url = url.c_str();
|
||||||
cfg.timeout_ms = kHttpTimeoutMs;
|
cfg.timeout_ms = kHttpTimeoutMs;
|
||||||
|
// Most public internet radio streams are HTTPS-only today; esp_http_client
|
||||||
|
// needs an explicit trust anchor for TLS verification or the handshake
|
||||||
|
// fails outright. CONFIG_MBEDTLS_CERTIFICATE_BUNDLE is already enabled
|
||||||
|
// (sdkconfig), so attach ESP-IDF's built-in CA bundle -- this is a no-op
|
||||||
|
// for plain http:// URLs.
|
||||||
|
cfg.crt_bundle_attach = esp_crt_bundle_attach;
|
||||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||||
if (client == nullptr) {
|
if (client == nullptr) {
|
||||||
ESP_LOGE(kTag, "esp_http_client_init failed");
|
ESP_LOGE(kTag, "esp_http_client_init failed");
|
||||||
|
|||||||
Reference in New Issue
Block a user