From d587032a0a3ab2c26fa483c2bf59f27c5741b026 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 25 Aug 2026 23:58:37 +0200 Subject: [PATCH 1/3] Fix FM RDS: station name/RadioText never decoded, three stacked bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/tuner/status and the full FM band scan always returned an empty station_name, on every frequency, regardless of signal quality -- confirmed live on multiple >35 dB SNR channels before this fix. Three independent problems, found by reading the Si4684 datasheet (AN649 Rev.2.0) instead of guessing further: 1. FM_RDS_CONFIG (property 0x3C02) was enabled (RDSEN=1) but with both block-error thresholds at 0 ("no block errors"), so almost any real-world RDS group -- routine with ordinary multipath/noise, even on a strong signal -- got rejected from the FIFO outright. Raised to the datasheet's most tolerant recommended setting (2, "3-5 bit errors detected and corrected"). 2. FM_RDS_INTERRUPT_FIFO_COUNT (property 0x3C01) was never set at all, defaulting to 0 -- which the datasheet states disables RDSFIFOINT permanently. Si4684Driver::readFmRds()'s "received" flag reads exactly that bit (FM_RDS_STATUS RESP4 bit 0), so it could never be true, and Si4684Tuner::refreshStatus()'s RDS poll loop broke out on its first iteration every single time, before ever touching the accumulator. Set to 1 (fire as soon as one group is queued). 3. The real bug, once groups actually started arriving: RdsMetadataAccumulator::applyGroup() read the two Program Service name characters from Block C and computed the segment index as (blockB >> 1) & 0x3. Per ETSI EN 62106 §3.1.5, PS characters for group type 0 (both 0A and 0B) are always in Block D -- Block C holds alternate-frequency codes (0A) or a repeated PI code (0B), never text -- and the segment address is blockB bits[1:0] with no shift. This function had no dedicated test before now, which is how a wrong block/bit pair could ship unnoticed: the old test fixture encoded the same bug in its "expected" input. Confirmed live after all three fixes: station_name and radiotext both populate with stable, plausible content across repeated reads (not noise) on a real broadcast signal. Co-Authored-By: Claude Sonnet 5 --- .../include/core/RdsMetadataAccumulator.hpp | 10 +- .../core/src/RdsMetadataAccumulator.cpp | 11 +- Software/components/core/test/CMakeLists.txt | 4 + .../core/test/broadcast_metadata_test.cpp | 10 +- .../test/rds_metadata_accumulator_test.cpp | 126 ++++++++++++++++++ .../drivers/si4684/src/Si4684Driver.cpp | 20 ++- 6 files changed, 168 insertions(+), 13 deletions(-) create mode 100644 Software/components/core/test/rds_metadata_accumulator_test.cpp diff --git a/Software/components/core/include/core/RdsMetadataAccumulator.hpp b/Software/components/core/include/core/RdsMetadataAccumulator.hpp index 3126817..b78e955 100644 --- a/Software/components/core/include/core/RdsMetadataAccumulator.hpp +++ b/Software/components/core/include/core/RdsMetadataAccumulator.hpp @@ -47,10 +47,12 @@ public: * @brief applyGroup — ingest one RDS group (blocks A–D). * * @dname applyGroup - * @param blockA RDS block A (PI code). - * @param blockB RDS block B (group type and address). - * @param blockC RDS block C (text payload). - * @param blockD RDS block D (text payload for RT). + * @param blockA RDS block A (PI code, currently unused). + * @param blockB RDS block B (group type and segment address). + * @param blockC RDS block C (RT text for group 2 only; group 0's + * 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. * * @author Michele Bigi diff --git a/Software/components/core/src/RdsMetadataAccumulator.cpp b/Software/components/core/src/RdsMetadataAccumulator.cpp index 8ef84b2..9df17eb 100644 --- a/Software/components/core/src/RdsMetadataAccumulator.cpp +++ b/Software/components/core/src/RdsMetadataAccumulator.cpp @@ -35,12 +35,15 @@ void RdsMetadataAccumulator::applyGroup(std::uint16_t blockA, static_cast((blockB >> 12U) & 0x0FU); if (groupType == 0U) { - const std::size_t index = - static_cast((blockB >> 1U) & 0x03U); + // Group type 0 (both 0A and 0B): the two Program Service name + // 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(blockB & 0x03U); if (index < kPsSegments) { psBuffer_[index * 2U] = - static_cast((blockC >> 8U) & 0xFFU); - psBuffer_[index * 2U + 1U] = static_cast(blockC & 0xFFU); + static_cast((blockD >> 8U) & 0xFFU); + psBuffer_[index * 2U + 1U] = static_cast(blockD & 0xFFU); psSegmentValid_[index] = true; } return; diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index b9dbbc6..785f35f 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -129,6 +129,10 @@ add_executable(bt1035_at_test bt1035_at_test.cpp) target_link_libraries(bt1035_at_test PRIVATE digiradio_core) 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) target_link_libraries(broadcast_metadata_test PRIVATE digiradio_core) add_test(NAME broadcast_metadata_test COMMAND broadcast_metadata_test) diff --git a/Software/components/core/test/broadcast_metadata_test.cpp b/Software/components/core/test/broadcast_metadata_test.cpp index fca2b6e..6be4c9d 100644 --- a/Software/components/core/test/broadcast_metadata_test.cpp +++ b/Software/components/core/test/broadcast_metadata_test.cpp @@ -34,11 +34,13 @@ namespace { [[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; - acc.applyGroup(0U, 0x0000U, 0x5445U, 0U); - acc.applyGroup(0U, 0x0002U, 0x5354U, 0U); - acc.applyGroup(0U, 0x0004U, 0x2020U, 0U); - acc.applyGroup(0U, 0x0006U, 0x2020U, 0U); + acc.applyGroup(0U, 0x0000U, 0U, 0x5445U); + acc.applyGroup(0U, 0x0001U, 0U, 0x5354U); + acc.applyGroup(0U, 0x0002U, 0U, 0x2020U); + acc.applyGroup(0U, 0x0003U, 0U, 0x2020U); const auto name = acc.programName(); if (!name || name->value() != "TEST") { diff --git a/Software/components/core/test/rds_metadata_accumulator_test.cpp b/Software/components/core/test/rds_metadata_accumulator_test.cpp new file mode 100644 index 0000000..6542c00 --- /dev/null +++ b/Software/components/core/test/rds_metadata_accumulator_test.cpp @@ -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 +#include + +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(0x0800U | seg.segment); + const std::uint16_t blockD = static_cast( + (static_cast(seg.c0) << 8) | + static_cast(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('T') << 8) | static_cast('e'); + const std::uint16_t blockD = + (static_cast('s') << 8) | static_cast('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; +} diff --git a/Software/components/drivers/si4684/src/Si4684Driver.cpp b/Software/components/drivers/si4684/src/Si4684Driver.cpp index 5934402..4ff7390 100644 --- a/Software/components/drivers/si4684/src/Si4684Driver.cpp +++ b/Software/components/drivers/si4684/src/Si4684Driver.cpp @@ -66,6 +66,11 @@ constexpr std::uint16_t kSi4684I2sOutEnable = 0x8002U; /** Si4684 volume: 0=mute, 63=max (AN649 AUDIO_ANALOG_VOLUME). */ constexpr std::uint8_t kSi4684VolumeMax = 63U; 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/ * 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 @@ -557,9 +562,22 @@ std::expected Si4684Driver::configureAfterBoot( 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; } + if (auto rdsFifo = + setProperty(kPropFmRdsInterruptFifoCount, 0x0001U); + !rdsFifo) { + return rdsFifo; + } if (auto deEmph = setProperty(kPropFmAudioDeEmphasis, kFmAudioDeEmphasisEurope); !deEmph) { From 012d2a626721ff3c39497643fd06807ed98504d0 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Wed, 26 Aug 2026 00:06:21 +0200 Subject: [PATCH 2/3] Rename device identity from DigiRadio to igiRadio SoftAP SSID, Bluetooth name, and mDNS hostname now use the "igiRadio" prefix (igiRadio-, igiradio-.local), matching the iOS app's name instead of the firmware project's repo name. Confirmed live: igiradio-CC4DB4.local resolves, the old digiradio-CC4DB4.local no longer does. Also adds the app-side briefs/notes accumulated today (active_source, volume contract, VU-meter polling, RDS availability) as a single up-to-date file to hand to Cursor. Co-Authored-By: Claude Sonnet 5 --- .../components/core/src/DeviceIdentity.cpp | 12 +- .../core/test/device_identity_test.cpp | 12 +- .../net/include/net/SoftApConfig.hpp | 2 +- Software/components/net/src/SoftApConfig.cpp | 2 +- Software/docs/app-cursor-brief-2026-08-25.md | 29 +++++ .../cursor-note-volume-vumeter-2026-08-25.md | 105 ++++++++++++++++++ 6 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 Software/docs/cursor-note-volume-vumeter-2026-08-25.md diff --git a/Software/components/core/src/DeviceIdentity.cpp b/Software/components/core/src/DeviceIdentity.cpp index f629c82..94ff514 100644 --- a/Software/components/core/src/DeviceIdentity.cpp +++ b/Software/components/core/src/DeviceIdentity.cpp @@ -18,9 +18,9 @@ namespace core { namespace { constexpr std::string_view kSerialUnknown = "unknown"; -constexpr std::string_view kSoftApFallback = "DigiRadio-setup"; -constexpr std::string_view kBluetoothFallback = "DigiRadio"; -constexpr std::string_view kHostnameFallback = "digiradio"; +constexpr std::string_view kSoftApFallback = "igiRadio-setup"; +constexpr std::string_view kBluetoothFallback = "igiRadio"; +constexpr std::string_view kHostnameFallback = "igiradio"; [[nodiscard]] std::string prefixed(std::string_view prefix, std::string_view suffix) @@ -44,9 +44,9 @@ DeviceIdentity DeviceIdentity::fromEui48(Eui48 eui) const std::string suffix = eui.shortSuffix(); return DeviceIdentity(eui, eui.serialNumber(), - prefixed("DigiRadio-", suffix), - prefixed("DigiRadio-", suffix), - prefixed("digiradio-", suffix)); + prefixed("igiRadio-", suffix), + prefixed("igiRadio-", suffix), + prefixed("igiradio-", suffix)); } bool DeviceIdentity::isKnown() const noexcept diff --git a/Software/components/core/test/device_identity_test.cpp b/Software/components/core/test/device_identity_test.cpp index 1dd165c..61574dc 100644 --- a/Software/components/core/test/device_identity_test.cpp +++ b/Software/components/core/test/device_identity_test.cpp @@ -52,13 +52,13 @@ namespace { if (!expectEqual(identity.serialNumber(), "0004A3123456")) { return EXIT_FAILURE; } - if (!expectEqual(identity.softApSsid(), "DigiRadio-123456")) { + if (!expectEqual(identity.softApSsid(), "igiRadio-123456")) { return EXIT_FAILURE; } - if (!expectEqual(identity.bluetoothName(), "DigiRadio-123456")) { + if (!expectEqual(identity.bluetoothName(), "igiRadio-123456")) { return EXIT_FAILURE; } - if (!expectEqual(identity.hostname(), "digiradio-123456")) { + if (!expectEqual(identity.hostname(), "igiradio-123456")) { return EXIT_FAILURE; } return EXIT_SUCCESS; @@ -74,13 +74,13 @@ namespace { if (!expectEqual(identity.serialNumber(), "unknown")) { return EXIT_FAILURE; } - if (!expectEqual(identity.softApSsid(), "DigiRadio-setup")) { + if (!expectEqual(identity.softApSsid(), "igiRadio-setup")) { return EXIT_FAILURE; } - if (!expectEqual(identity.bluetoothName(), "DigiRadio")) { + if (!expectEqual(identity.bluetoothName(), "igiRadio")) { return EXIT_FAILURE; } - if (!expectEqual(identity.hostname(), "digiradio")) { + if (!expectEqual(identity.hostname(), "igiradio")) { return EXIT_FAILURE; } return EXIT_SUCCESS; diff --git a/Software/components/net/include/net/SoftApConfig.hpp b/Software/components/net/include/net/SoftApConfig.hpp index 7774ecf..528292b 100644 --- a/Software/components/net/include/net/SoftApConfig.hpp +++ b/Software/components/net/include/net/SoftApConfig.hpp @@ -41,7 +41,7 @@ public: * @brief setupDefault — factory for the Slice 1 setup SoftAP. * * @dname setupDefault - * @return SoftApConfig with SSID DigiRadio-setup. + * @return SoftApConfig with SSID igiRadio-setup. * @pubstate none * * @author Michele Bigi diff --git a/Software/components/net/src/SoftApConfig.cpp b/Software/components/net/src/SoftApConfig.cpp index 4cd18bc..a6ad303 100644 --- a/Software/components/net/src/SoftApConfig.cpp +++ b/Software/components/net/src/SoftApConfig.cpp @@ -21,7 +21,7 @@ namespace net { 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 kSetupMaxConnections = 4; } // namespace diff --git a/Software/docs/app-cursor-brief-2026-08-25.md b/Software/docs/app-cursor-brief-2026-08-25.md index 3cf5dc8..ec6a1e5 100644 --- a/Software/docs/app-cursor-brief-2026-08-25.md +++ b/Software/docs/app-cursor-brief-2026-08-25.md @@ -147,6 +147,35 @@ componente di riga, stesso stile, filtrato per banda. --- +## 4bis. RDS (nome stazione FM) — ora funziona davvero, da oggi + +Fino a oggi il firmware non decodificava **mai** l'RDS (bug a tre livelli, +risolto). Ora `GET /api/tuner/status` e lo scan FM completo possono +restituire: + +```json +"fm": { + "frequency_khz": 92100, + "rssi_dbuv": 57, + "snr_db": 40, + "station_name": "M DUE O", + "radiotext": "...testo libero..." +} +``` + +`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. + +--- + ## 5. Stile UI — Apple, minimalista ma con una sezione grafica curata - Componenti nativi SwiftUI: `Picker` segmented per la sorgente, `Slider` con diff --git a/Software/docs/cursor-note-volume-vumeter-2026-08-25.md b/Software/docs/cursor-note-volume-vumeter-2026-08-25.md new file mode 100644 index 0000000..834300e --- /dev/null +++ b/Software/docs/cursor-note-volume-vumeter-2026-08-25.md @@ -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. From b46dcea698dda778c043aee737dba705d72d9792 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Wed, 26 Aug 2026 00:27:02 +0200 Subject: [PATCH 3/3] Fix web radio streaming: HTTPS URLs were rejected outright POST /api/streaming's JSON parser only accepted "http://" URLs, rejecting any "https://" URL with invalid_json before ever attempting a connection. Nearly every real internet radio stream today is HTTPS- only, so this made the feature fail for essentially any station a user would actually try. Two changes were needed together: the parser now accepts both http:// and https://, and web_radio_stream.cpp's esp_http_client now attaches ESP-IDF's built-in CA certificate bundle (crt_bundle_attach) so the TLS handshake actually verifies -- CONFIG_MBEDTLS_CERTIFICATE_BUNDLE was already enabled in sdkconfig but never wired up here. Requires adding mbedtls to main's PRIV_REQUIRES (esp_crt_bundle.h lives there). Confirmed live: an https:// stream URL now gets accepted by the API and produces a real HTTP response (404, from a guessed-wrong path) -- before this fix it never reached the network at all. Co-Authored-By: Claude Sonnet 5 --- Software/components/core/src/WebRadioJson.cpp | 6 +- .../core/test/web_radio_json_test.cpp | 13 +++- Software/docs/app-cursor-brief-2026-08-25.md | 59 ++++++++++++------- Software/main/CMakeLists.txt | 2 +- Software/main/web_radio_stream.cpp | 7 +++ 5 files changed, 61 insertions(+), 26 deletions(-) diff --git a/Software/components/core/src/WebRadioJson.cpp b/Software/components/core/src/WebRadioJson.cpp index 5f4c870..3a94b3e 100644 --- a/Software/components/core/src/WebRadioJson.cpp +++ b/Software/components/core/src/WebRadioJson.cpp @@ -19,6 +19,7 @@ namespace { constexpr std::size_t kMaxUrlLength = 200U; constexpr std::string_view kHttpPrefix = "http://"; +constexpr std::string_view kHttpsPrefix = "https://"; [[nodiscard]] std::string_view extractJsonString(std::string_view json, std::string_view key) @@ -84,8 +85,9 @@ std::expected parseWebRadioConfigJson( } const std::string_view url = extractJsonString(json, "url"); - if (url.empty() || url.size() > kMaxUrlLength - || url.substr(0, kHttpPrefix.size()) != kHttpPrefix) { + const bool isHttp = 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); } diff --git a/Software/components/core/test/web_radio_json_test.cpp b/Software/components/core/test/web_radio_json_test.cpp index 891a111..9507fc6 100644 --- a/Software/components/core/test/web_radio_json_test.cpp +++ b/Software/components/core/test/web_radio_json_test.cpp @@ -40,9 +40,18 @@ namespace { return EXIT_FAILURE; } 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) { - 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; } const auto malformed = core::parseWebRadioConfigJson("not json"); diff --git a/Software/docs/app-cursor-brief-2026-08-25.md b/Software/docs/app-cursor-brief-2026-08-25.md index ec6a1e5..c7ecc05 100644 --- a/Software/docs/app-cursor-brief-2026-08-25.md +++ b/Software/docs/app-cursor-brief-2026-08-25.md @@ -176,26 +176,6 @@ quando arriva un nuovo nome/messaggio RDS durante la riproduzione. --- -## 5. Stile UI — Apple, minimalista ma con una sezione grafica curata - -- Componenti nativi SwiftUI: `Picker` segmented per la sorgente, `Slider` con - `.tint()` per volume/bass/stereo, `List`/`Form` in stile Impostazioni per le - stazioni e le opzioni tecniche. Niente controlli custom pesanti o griglie di - bottoni non standard. -- Organizza per tab/sezione logica, non tutto in una schermata: - - **Ascolto**: sorgente attiva, volume, stazione corrente. - - **Suono**: EQ 6 bande, Bass Boost, Stereo Spread. - - **Stazioni**: lista unificata FM+DAB (vedi §4), con scan. - - **Bluetooth**: pairing, dispositivo connesso. - - **Diagnostica**: tono di test, dettagli tecnici/versione firmware — non - mescolare con i controlli quotidiani. -- 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. - ---- - ## 7. VU-meter — nuovo, disponibile da oggi ``` @@ -225,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 sul dispositivo reale (non solo lo stato locale dell'app). diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index a46d76e..2793976 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -11,7 +11,7 @@ idf_component_register( "$<$:esp32_i2s_test_tone.cpp>" INCLUDE_DIRS "." 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) diff --git a/Software/main/web_radio_stream.cpp b/Software/main/web_radio_stream.cpp index 4f68a26..3fa874b 100644 --- a/Software/main/web_radio_stream.cpp +++ b/Software/main/web_radio_stream.cpp @@ -13,6 +13,7 @@ #include "esp32_i2s_sink.hpp" #include "webradio/WebRadioService.hpp" +#include "esp_crt_bundle.h" #include "esp_http_client.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -48,6 +49,12 @@ struct InputBuffer { esp_http_client_config_t cfg{}; cfg.url = url.c_str(); 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); if (client == nullptr) { ESP_LOGE(kTag, "esp_http_client_init failed");