Merge cursor/igiRadio-ios-app into main

# Conflicts:
#	Software/components/core/include/core/TunerJson.hpp
#	Software/components/core/src/TunerJson.cpp
#	Software/components/drivers/bt1035/src/Bt1035Driver.cpp
#	Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp
#	Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp
#	Software/components/net/include/net/SetupWebServer.hpp
#	Software/components/net/src/SetupWebServer.cpp
#	Software/components/services/tuner/include/tuner/TunerService.hpp
#	Software/components/services/tuner/src/TunerService.cpp
#	Software/docs/TODO.md
#	Software/docs/si4684-rf-investigation-report.md
#	Software/instructions.md
#	Software/main/antenna_calibration.cpp
#	Software/main/hardware_bootstrap.cpp
#	Software/main/hardware_bootstrap.hpp
#	Software/main/main.cpp
This commit is contained in:
2026-08-24 23:32:38 +02:00
98 changed files with 9197 additions and 234 deletions
+2
View File
@@ -13,6 +13,8 @@
![Hardware License](https://img.shields.io/badge/hardware-CERN--OHL--S-lightgrey)
[![Firmware License: Apache 2.0](https://img.shields.io/badge/Firmware%20License-Apache%202.0-blue.svg)](Software/LICENSE)
![DigiRadio PCB](docs/images/digiradio-pcb.jpg)
</div>
---
@@ -0,0 +1,35 @@
# APP_ARCHITECTURE — igiRadio
## Stack
```
SwiftUI Views
↓ @Observable ViewModels
DigiRadioService (protocol)
RealDigiRadioService ──→ HTTPDigiRadioClient (URLSession)
MockDigiRadioService ──→ dati in-memory
BLEProvisioningService (solo setup WiFi)
↓ CoreBluetooth + protocomm (ESP Security1)
```
## Principi
1. Le View **non** chiamano URLSession o CoreBluetooth direttamente.
2. Un solo `DigiRadioState` osservabile aggiornato dal service.
3. Errori tipizzati (`DigiRadioError`).
4. Dependency injection via `AppEnvironment` in `igiRadioApp`.
## Connessione
| Fase | Meccanismo |
|------|------------|
| Prima configurazione | BLE provisioning **oppure** join SoftAP + POST /api/wifi |
| Uso normale | mDNS `digiradio-xxxxxx.local` o IP manuale |
| Base URL | `http://{host}/api/...` |
## Polling vs push
- Nessuna notifica BLE documentata per stato tuner.
- `TunerViewModel` può pollare `GET /api/tuner/status` a intervallo configurabile quando la schermata radio è attiva.
@@ -0,0 +1,45 @@
# BLE_PROTOCOL — igiRadio
## Riepilogo
**DigiRadio non espone un protocollo BLE GATT documentato per il controllo radio/audio.**
Il BLE dell'ESP32-S3 è usato **solo** per il provisioning WiFi (setup iniziale).
---
## BLE WiFi Provisioning (documentato)
| Parametro | Valore |
|-----------|--------|
| Stack | ESP-IDF `wifi_provisioning` + `scheme_ble` |
| Sicurezza | protocomm **Security1** |
| Proof of possession | **Serial number** dispositivo (EUI-48 da EEPROM) |
| Nome advertising | `DigiRadio-<suffix>` (es. `DigiRadio-CC4DB4`) |
| Riferimento firmware | `components/net/src/BleProvisioning.cpp` |
| Riferimento manuale | `ch-api.tex` §BLE provisioning |
### Flusso
1. Dispositivo in setup mode (no credenziali WiFi salvate)
2. Advertise BLE con nome = SoftAP SSID
3. App iOS invia credenziali WiFi via protocomm (Security1 + PoP)
4. Firmware valida, salva in NVS, reboot in STA
5. App passa a controllo via **HTTP** su LAN
### Implementazione igiRadio
- Usare **CoreBluetooth** solo per questa fase
- Compatibile con app generiche "ESP BLE Provisioning" (stesso protocollo Espressif)
- PoP richiesto dall'utente: serial da etichetta o da schermata setup SoftAP
### UNKNOWN
- UUID servizio/caratteristica custom DigiRadio per controllo: **non documentati**
- Notifiche BLE stato tuner: **non documentate**
---
## Controllo dispositivo (post-provisioning)
Vedi `APP_ARCHITECTURE.md` — trasporto **HTTP REST** porta 80.
@@ -0,0 +1,58 @@
# DEVELOPMENT — igiRadio
## Requisiti
- macOS con **Xcode 15+** (testato Xcode 26.6)
- iOS 17+ deployment target
- Dispositivo DigiRadio su stessa rete WiFi (STA) o in setup SoftAP
## Aprire il progetto
```bash
open /Users/michelebigi/Documents/Develop/DigiRadio/Software/APP/igiRadio/igiRadio.xcodeproj
```
Rigenerare il progetto (se necessario):
```bash
cd Software/APP/igiRadio
python3 Scripts/generate_xcodeproj.py
```
## Build e test da terminale
```bash
cd Software/APP/igiRadio
xcodebuild -scheme igiRadio -destination 'platform=iOS Simulator,name=iPhone 17' build test
```
## Scheme
- **igiRadio** — app principale
- **igiRadioTests** — unit test protocollo e state
## Mock vs reale
In **DEBUG**, `AppEnvironment` avvia in modalità mock (`MockDigiRadioService`).
Disattivare in **Connessione → Modalità demo (Mock)** e inserire l'host HTTP reale.
## Test su hardware
1. Provisioning WiFi (BLE discovery in-app, o app ESP BLE Provisioning; oppure SoftAP `http://192.168.4.1`)
2. In app: Impostazioni → Connessione → host `http://digiradio-<suffix>.local`
3. Verificare `GET /api/health`
## Documentazione firmware
`/Users/michelebigi/Documents/Develop/DigiRadio/Software/docs/manual/ch-api.tex`
## Architettura runtime
```
SwiftUI → ViewModel → DigiRadioService
├── RealDigiRadioService → HTTPDigiRadioClient (REST JSON)
└── MockDigiRadioService
BLEProvisioningService (CoreBluetooth, solo discovery setup)
```
Il controllo tuner/audio **non** usa GATT proprietario — vedi `BLE_PROTOCOL.md`.
@@ -0,0 +1,17 @@
# DEVICE_STATE — DigiRadioState
Modello centrale in `Models/DigiRadioState.swift`.
## Sezioni
| Sezione | Campi documentati |
|---------|-------------------|
| `connection` | host, isConnected, lastError |
| `health` | status, firmware, serialNumber, chips |
| `tuner` | band, locked, volume, fm?, dab? |
| `audio` | mixer, master, eq, enhancements |
| `bluetooth` | booted, pairing, a2dpState, deviceName, speaker |
| `stations` | [Station] |
| `streaming` | enabled, url |
Campi non documentati nell'API **non** sono presenti nel modello.
@@ -0,0 +1,151 @@
# PROJECT_ANALYSIS — DigiRadio (fonte: documentazione firmware)
**Data analisi:** 2026-08-18
**Fonte primaria:** `Software/docs/manual/` (non modificata)
**Firmware di riferimento:** 0.8.5+
---
## 1. Documenti analizzati
| File | Contenuto |
|------|-----------|
| `ch-intro.tex` | Introduzione prodotto |
| `ch-hardware.tex` | PCB, pinout, catena audio |
| `ch-firmware.tex` | Architettura firmware ESP32-S3 |
| `ch-si4684.tex` | Tuner FM/DAB, tune, seek, RDS, DLS |
| `ch-adau1701.tex` | DSP SigmaStudio, mixer, EQ, safeload |
| `ch-bt1035.tex` | Modulo BT classic A2DP (UART AT), I2S da ADAU |
| `ch-sigmastudio.tex` | Design SigmaStudio |
| `ch-api.tex` | **HTTP REST API** (protocollo app) |
| `ch-classes.tex` | Classi firmware |
| `ch-build.tex` | Build/flash |
| `ch-licensing.tex` | Licenze |
Documentazione aggiuntiva letta: `Software/CLAUDE.md`, `Software/docs/security-flash-nvs.md`, `BleProvisioning.cpp`.
---
## 2. Distinzione critica: BLE vs controllo
### Controllo dispositivo (tuner, audio, preset, BT config)
**Trasporto documentato:** HTTP JSON REST su **TCP porta 80** (WiFi).
- Setup mode: SoftAP `DigiRadio-<suffix>``http://192.168.4.1/`
- STA mode: stessa API su IP LAN o **mDNS** `digiradio-<suffix>.local`
- **NON esiste** un protocollo GATT proprietario documentato per tuner/audio/preset.
### BLE (ESP32-S3 onboard)
**Solo provisioning WiFi** in setup mode (`ch-api.tex` §BLE provisioning):
- Stack: ESP-IDF `wifi_provisioning` + `scheme_ble`
- Sicurezza: **Security1** (protocomm)
- **Proof of possession (PoP):** serial number dispositivo (stesso valore di `GET /api/health``serialNumber`)
- **Nome servizio BLE:** SSID SoftAP = `DigiRadio-<suffix>` (es. `DigiRadio-CC4DB4`)
- Su successo: credenziali salvate in NVS, reboot in STA
- **Non è un endpoint HTTP** — nessuna REST call
### Bluetooth Audio (streaming verso speaker esterno)
**Modulo separato FSC-BT1035** (classic BR/EDR A2DP), controllato dal firmware via **UART AT**, esposto all'app solo tramite **HTTP** `/api/bluetooth/*`.
L'iPhone **non** si collega in A2DP al DigiRadio per ascoltare: il DigiRadio invia audio al Bose/Speaker via BT1035.
---
## 3. Architettura hardware (sintesi)
```
Si4684 (SPI) ──I2S──► ADAU1701 (I2S master 48 kHz) ──I2S──► BT1035 ──A2DP──► Speaker
ESP32-S3 (WiFi/BLE, HTTP server, opz. I2S test)
```
Sorgenti audio nel DSP: **Si4684** (radio), **ESP32 I2S** (stream locale / phone push).
---
## 4. Endpoint HTTP documentati (controllo app)
| Metodo | Path | Funzione |
|--------|------|----------|
| GET | `/api/health` | Stato, fw, serial, chip boot |
| POST | `/api/wifi` | Provisioning STA (reboot) |
| POST | `/api/wifi/scan` | Scan reti vicine |
| GET | `/api/tuner/status` | Stato tuner FM/DAB |
| GET | `/api/tuner/services` | Lista servizi DAB |
| POST | `/api/tuner/tune` | Sintonia FM/DAB |
| POST | `/api/tuner/play` | Play servizio DAB |
| POST | `/api/tuner/seek` | Seek FM up/down |
| POST | `/api/tuner/scan` | Scan singola stazione |
| POST | `/api/tuner/scan/full` | Scan completo banda FM |
| GET/PUT | `/api/audio/profile` | Profilo mixer/EQ/master |
| POST | `/api/audio/reset` | Reset profilo factory |
| POST | `/api/audio/stereo-enhance` | Enhancement stereo 0100 |
| POST | `/api/audio/bass-enhance` | Enhancement bass 0100 |
| POST | `/api/audio/beep` | Beep diagnostico ADAU |
| GET | `/api/dsp/params` | Tabella parametri SigmaStudio |
| PUT | `/api/dsp/param` | Scrittura cella raw (live) |
| POST | `/api/dsp/program` | Upload blob DSP (reboot) |
| POST | `/api/system/ota` | OTA firmware ESP32 (reboot) |
| GET/POST | `/api/streaming` | Web radio MP3 URL |
| PUT | `/api/stream/phone` | Push PCM stereo 48 kHz da phone |
| GET | `/api/bluetooth/status` | Stato BT1035 |
| POST | `/api/bluetooth/pair` | Modalità discoverable |
| POST | `/api/bluetooth/pair/stop` | Esci pairing |
| POST | `/api/bluetooth/disconnect` | Disconnect A2DP |
| GET | `/api/bluetooth/paired` | Lista paired |
| POST | `/api/bluetooth/scan` | Scan speaker vicini |
| POST | `/api/bluetooth/connect` | Connect A2DP + opz. save |
| GET/POST/DELETE | `/api/bluetooth/speaker` | Speaker default |
| POST | `/api/bluetooth/reconnect` | Reconnect manuale |
| POST | `/api/bluetooth/auto-reconnect` | Retry count 015 |
| GET | `/api/stations` | Lista preset |
| POST | `/api/stations` | Aggiungi preset |
| POST | `/api/stations/remove` | Rimuovi preset |
| POST | `/api/stations/reorder` | Riordina preset |
| POST | `/api/stations/tune` | Richiama preset |
---
## 5. Funzionalità per schermata app
| Area | Supportato (documentato) | Note |
|------|--------------------------|------|
| Connessione WiFi | ✅ | BLE prov. + SoftAP + POST /api/wifi |
| FM tune/seek/scan | ✅ | RSSI, SNR, RDS PS/RT |
| DAB tune/play/services | ✅ | DLS, ensemble, service list |
| Preset | ✅ | CRUD + reorder + recall |
| Volume/mixer/EQ | ✅ | Via audio profile |
| DSP raw params | ✅ | Escape hatch tecnico |
| BT speaker pairing | ✅ | Via HTTP → BT1035 AT |
| OTA firmware | ✅ | POST /api/system/ota |
| OTA DSP | ✅ | POST /api/dsp/program |
| Phone audio stream | ✅ | PUT /api/stream/phone |
| Web radio stream | ✅ | POST /api/streaming |
| Artwork album | ❌ | **UNKNOWN** — non in API |
| Batteria dispositivo | ❌ | Alimentato da rete |
| AUX/USB come sorgente UI | ❌ | Non esposto in API |
| Notifiche push BLE stato | ❌ | Nessun GATT documentato |
---
## 6. Implicazioni per igiRadio
1. **DigiRadioService** → implementazione **HTTP REST** (URLSession), non GATT custom.
2. **BLE layer** → solo **WiFi provisioning** (ESP protocomm Security1 + PoP = serial).
3. **MockDigiRadioService** → stessa interfaccia, dati fittizi.
4. **Discovery** → mDNS `.local`, IP manuale, o join SoftAP in setup.
5. **Real-time** → polling selettivo su `/api/tuner/status` quando in radio; nessuna notifica BLE documentata.
---
## 7. UNKNOWN — specification required
| Voce | Motivo |
|------|--------|
| UUID GATT controllo tuner/audio | Non documentati — controllo è HTTP |
| Artwork/metadata streaming | Non in API tuner |
| Protocol version field | Solo `fw` in health |
| Hardware revision API | Solo `serialNumber` + chip flags |
@@ -0,0 +1,31 @@
# UI_ARCHITECTURE — igiRadio
## iPhone
`TabView` con tab: Home, Radio, Presets, Audio, Settings.
## iPad
`NavigationSplitView`:
- Sidebar: Home, FM, DAB, Presets, Bluetooth, Audio, Settings
- Detail: contenuto selezionato
## Schermate
| Schermata | ViewModel |
|-----------|-----------|
| Connection | `ConnectionViewModel` |
| Home | `HomeViewModel` |
| FM | `FMViewModel` |
| DAB | `DABViewModel` |
| Presets | `PresetsViewModel` |
| Audio | `AudioViewModel` |
| Bluetooth | `BluetoothViewModel` |
| Settings | `SettingsViewModel` |
| Device Info | `DeviceViewModel` |
| Diagnostics | `DiagnosticsViewModel` |
| Firmware | `FirmwareViewModel` |
## Design System
`Components/DesignSystem/` — colori semantici, tipografia, card, slider, status dot.
@@ -0,0 +1,421 @@
#!/usr/bin/env python3
"""Generate igiRadio.xcodeproj using PBXFileSystemSynchronizedRootGroup."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PROJECT = "igiRadio"
BUNDLE = "com.digiradio.igiRadio"
def uid(n):
# 24-char hex IDs (stable)
import hashlib
return hashlib.md5(f"igiRadio-{n}".encode()).hexdigest()[:24].upper()
IDS = {k: uid(k) for k in [
"project", "main", "products", "app", "test", "app_product", "test_product",
"sources", "resources", "fw_app", "fw_test", "test_sources", "test_fw",
"sync_app", "sync_tests", "sync_exception", "proj_cfg", "app_cfg", "test_cfg",
"dbg_proj", "rel_proj", "dbg_app", "rel_app", "dbg_test", "rel_test",
"proxy", "dep",
]}
pbx = f'''// !$*UTF8*$!
{{
\tarchiveVersion = 1;
\tclasses = {{
\t}};
\tobjectVersion = 77;
\tobjects = {{
/* Begin PBXBuildFile section */
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
\t\t{IDS["proxy"]} /* PBXContainerItemProxy */ = {{
\t\t\tisa = PBXContainerItemProxy;
\t\t\tcontainerPortal = {IDS["project"]} /* Project object */;
\t\t\tproxyType = 1;
\t\t\tremoteGlobalIDString = {IDS["app"]};
\t\t\tremoteInfo = {PROJECT};
\t\t}};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
\t\t{IDS["app_product"]} /* {PROJECT}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT}.app; sourceTree = BUILT_PRODUCTS_DIR; }};
\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = {PROJECT}Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }};
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */ = {{
\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;
\t\t\tmembershipExceptions = (
\t\t\t\tResources/Info.plist,
\t\t\t);
\t\t\ttarget = {IDS["app"]} /* {PROJECT} */;
\t\t}};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
\t\t{IDS["sync_app"]} /* {PROJECT} */ = {{
\t\t\tisa = PBXFileSystemSynchronizedRootGroup;
\t\t\texceptions = (
\t\t\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */,
\t\t\t);
\t\t\tpath = {PROJECT};
\t\t\tsourceTree = "<group>";
\t\t}};
\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */ = {{
\t\t\tisa = PBXFileSystemSynchronizedRootGroup;
\t\t\tpath = Tests/{PROJECT}Tests;
\t\t\tsourceTree = "<group>";
\t\t}};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
\t\t{IDS["fw_app"]} /* Frameworks */ = {{
\t\t\tisa = PBXFrameworksBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
\t\t{IDS["test_fw"]} /* Frameworks */ = {{
\t\t\tisa = PBXFrameworksBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
\t\t{IDS["products"]} /* Products */ = {{
\t\t\tisa = PBXGroup;
\t\t\tchildren = (
\t\t\t\t{IDS["app_product"]} /* {PROJECT}.app */,
\t\t\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */,
\t\t\t);
\t\t\tname = Products;
\t\t\tsourceTree = "<group>";
\t\t}};
\t\t{IDS["main"]} = {{
\t\t\tisa = PBXGroup;
\t\t\tchildren = (
\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */,
\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */,
\t\t\t\t{IDS["products"]} /* Products */,
\t\t\t);
\t\t\tsourceTree = "<group>";
\t\t}};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
\t\t{IDS["app"]} /* {PROJECT} */ = {{
\t\t\tisa = PBXNativeTarget;
\t\t\tbuildConfigurationList = {IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */;
\t\t\tbuildPhases = (
\t\t\t\t{IDS["sources"]} /* Sources */,
\t\t\t\t{IDS["fw_app"]} /* Frameworks */,
\t\t\t\t{IDS["resources"]} /* Resources */,
\t\t\t);
\t\t\tbuildRules = (
\t\t\t);
\t\t\tdependencies = (
\t\t\t);
\t\t\tfileSystemSynchronizedGroups = (
\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */,
\t\t\t);
\t\t\tname = {PROJECT};
\t\t\tproductName = {PROJECT};
\t\t\tproductReference = {IDS["app_product"]} /* {PROJECT}.app */;
\t\t\tproductType = "com.apple.product-type.application";
\t\t}};
\t\t{IDS["test"]} /* {PROJECT}Tests */ = {{
\t\t\tisa = PBXNativeTarget;
\t\t\tbuildConfigurationList = {IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */;
\t\t\tbuildPhases = (
\t\t\t\t{IDS["test_sources"]} /* Sources */,
\t\t\t\t{IDS["test_fw"]} /* Frameworks */,
\t\t\t);
\t\t\tbuildRules = (
\t\t\t);
\t\t\tdependencies = (
\t\t\t\t{IDS["dep"]} /* PBXTargetDependency */,
\t\t\t);
\t\t\tfileSystemSynchronizedGroups = (
\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */,
\t\t\t);
\t\t\tname = {PROJECT}Tests;
\t\t\tproductName = {PROJECT}Tests;
\t\t\tproductReference = {IDS["test_product"]} /* {PROJECT}Tests.xctest */;
\t\t\tproductType = "com.apple.product-type.bundle.unit-test";
\t\t}};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
\t\t{IDS["project"]} /* Project object */ = {{
\t\t\tisa = PBXProject;
\t\t\tattributes = {{
\t\t\t\tBuildIndependentTargetsInParallel = 1;
\t\t\t\tLastSwiftUpdateCheck = 2600;
\t\t\t\tLastUpgradeCheck = 2600;
\t\t\t\tTargetAttributes = {{
\t\t\t\t\t{IDS["app"]} = {{
\t\t\t\t\t\tCreatedOnToolsVersion = 26.0;
\t\t\t\t\t}};
\t\t\t\t\t{IDS["test"]} = {{
\t\t\t\t\t\tCreatedOnToolsVersion = 26.0;
\t\t\t\t\t\tTestTargetID = {IDS["app"]};
\t\t\t\t\t}};
\t\t\t\t}};
\t\t\t}};
\t\t\tbuildConfigurationList = {IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */;
\t\t\tcompatibilityVersion = "Xcode 16.0";
\t\t\tdevelopmentRegion = en;
\t\t\thasScannedForEncodings = 0;
\t\t\tknownRegions = (
\t\t\t\ten,
\t\t\t\tBase,
\t\t\t);
\t\t\tmainGroup = {IDS["main"]};
\t\t\tproductRefGroup = {IDS["products"]} /* Products */;
\t\t\tprojectDirPath = "";
\t\t\tprojectRoot = "";
\t\t\ttargets = (
\t\t\t\t{IDS["app"]} /* {PROJECT} */,
\t\t\t\t{IDS["test"]} /* {PROJECT}Tests */,
\t\t\t);
\t\t}};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
\t\t{IDS["resources"]} /* Resources */ = {{
\t\t\tisa = PBXResourcesBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
\t\t{IDS["sources"]} /* Sources */ = {{
\t\t\tisa = PBXSourcesBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
\t\t{IDS["test_sources"]} /* Sources */ = {{
\t\t\tisa = PBXSourcesBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
\t\t{IDS["dep"]} /* PBXTargetDependency */ = {{
\t\t\tisa = PBXTargetDependency;
\t\t\ttarget = {IDS["app"]} /* {PROJECT} */;
\t\t\ttargetProxy = {IDS["proxy"]} /* PBXContainerItemProxy */;
\t\t}};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
\t\t{IDS["dbg_proj"]} /* Debug */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;
\t\t\t\tCLANG_ENABLE_MODULES = YES;
\t\t\t\tCOPY_PHASE_STRIP = NO;
\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;
\t\t\t\tENABLE_TESTABILITY = YES;
\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
\t\t\t\tONLY_ACTIVE_ARCH = YES;
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = "-Onone";
\t\t\t}};
\t\t\tname = Debug;
\t\t}};
\t\t{IDS["rel_proj"]} /* Release */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;
\t\t\t\tCLANG_ENABLE_MODULES = YES;
\t\t\t\tCOPY_PHASE_STRIP = NO;
\t\t\t\tDEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
\t\t\t\tENABLE_NS_ASSERTIONS = NO;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;
\t\t\t\tVALIDATE_PRODUCT = YES;
\t\t\t}};
\t\t\tname = Release;
\t\t}};
\t\t{IDS["dbg_app"]} /* Debug */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
\t\t\t\tCODE_SIGN_STYLE = Automatic;
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
\t\t\t\tENABLE_PREVIEWS = YES;
\t\t\t\tGENERATE_INFOPLIST_FILE = NO;
\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (
\t\t\t\t\t"$(inherited)",
\t\t\t\t\t"@executable_path/Frameworks",
\t\t\t\t);
\t\t\t\tMARKETING_VERSION = 1.0;
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE};
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;
\t\t\t\tSWIFT_VERSION = 5.0;
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
\t\t\t}};
\t\t\tname = Debug;
\t\t}};
\t\t{IDS["rel_app"]} /* Release */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
\t\t\t\tCODE_SIGN_STYLE = Automatic;
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
\t\t\t\tENABLE_PREVIEWS = YES;
\t\t\t\tGENERATE_INFOPLIST_FILE = NO;
\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (
\t\t\t\t\t"$(inherited)",
\t\t\t\t\t"@executable_path/Frameworks",
\t\t\t\t);
\t\t\t\tMARKETING_VERSION = 1.0;
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE};
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;
\t\t\t\tSWIFT_VERSION = 5.0;
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
\t\t\t}};
\t\t\tname = Release;
\t\t}};
\t\t{IDS["dbg_test"]} /* Debug */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)";
\t\t\t\tCODE_SIGN_STYLE = Automatic;
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
\t\t\t\tGENERATE_INFOPLIST_FILE = YES;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tMARKETING_VERSION = 1.0;
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests;
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;
\t\t\t\tSWIFT_VERSION = 5.0;
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}";
\t\t\t}};
\t\t\tname = Debug;
\t\t}};
\t\t{IDS["rel_test"]} /* Release */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)";
\t\t\t\tCODE_SIGN_STYLE = Automatic;
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
\t\t\t\tGENERATE_INFOPLIST_FILE = YES;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tMARKETING_VERSION = 1.0;
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests;
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;
\t\t\t\tSWIFT_VERSION = 5.0;
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}";
\t\t\t}};
\t\t\tname = Release;
\t\t}};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
\t\t{IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */ = {{
\t\t\tisa = XCConfigurationList;
\t\t\tbuildConfigurations = (
\t\t\t\t{IDS["dbg_proj"]} /* Debug */,
\t\t\t\t{IDS["rel_proj"]} /* Release */,
\t\t\t);
\t\t\tdefaultConfigurationIsVisible = 0;
\t\t\tdefaultConfigurationName = Release;
\t\t}};
\t\t{IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */ = {{
\t\t\tisa = XCConfigurationList;
\t\t\tbuildConfigurations = (
\t\t\t\t{IDS["dbg_app"]} /* Debug */,
\t\t\t\t{IDS["rel_app"]} /* Release */,
\t\t\t);
\t\t\tdefaultConfigurationIsVisible = 0;
\t\t\tdefaultConfigurationName = Release;
\t\t}};
\t\t{IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */ = {{
\t\t\tisa = XCConfigurationList;
\t\t\tbuildConfigurations = (
\t\t\t\t{IDS["dbg_test"]} /* Debug */,
\t\t\t\t{IDS["rel_test"]} /* Release */,
\t\t\t);
\t\t\tdefaultConfigurationIsVisible = 0;
\t\t\tdefaultConfigurationName = Release;
\t\t}};
/* End XCConfigurationList section */
\t}};
\trootObject = {IDS["project"]} /* Project object */;
}}
'''
out = ROOT / f"{PROJECT}.xcodeproj" / "project.pbxproj"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(pbx)
scheme_dir = ROOT / f"{PROJECT}.xcodeproj" / "xcshareddata" / "xcschemes"
scheme_dir.mkdir(parents=True, exist_ok=True)
scheme = f'''<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="2600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['app']}" BuildableName="{PROJECT}.app" BlueprintName="{PROJECT}" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['test']}" BuildableName="{PROJECT}Tests.xctest" BlueprintName="{PROJECT}Tests" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['app']}" BuildableName="{PROJECT}.app" BlueprintName="{PROJECT}" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
</Scheme>
'''
(scheme_dir / f"{PROJECT}.xcscheme").write_text(scheme)
print(f"Wrote {out}")
if __name__ == "__main__":
pass
@@ -0,0 +1,65 @@
import XCTest
@testable import igiRadio
final class HTTPDecoderTests: XCTestCase {
func testDecodeHealth() throws {
let json = """
{"status":"ok","fw":"0.8.5","serialNumber":"CC4DB4MOCK01",
"chips":{"si4684":true,"adau1701":true,"bt1035":true}}
""".data(using: .utf8)!
let health = try JSONDecoder.api.decode(HealthResponse.self, from: json)
XCTAssertEqual(health.status, "ok")
XCTAssertEqual(health.fw, "0.8.5")
XCTAssertEqual(health.serialNumber, "CC4DB4MOCK01")
XCTAssertTrue(health.chips.si4684)
}
func testDecodeTunerFM() throws {
let json = """
{"booted":true,"band":"fm","locked":true,"volume":50,
"fm":{"frequency_khz":102300,"rssi_dbuv":48,"snr_db":20,"stereo":true,
"station_name":"Radio Test","radiotext":"Hello"},
"dab":null}
""".data(using: .utf8)!
let tuner = try JSONDecoder.api.decode(TunerStatusResponse.self, from: json)
XCTAssertEqual(tuner.band, "fm")
XCTAssertEqual(tuner.fm?.frequencyKhz, 102300)
XCTAssertEqual(tuner.fm?.stationName, "Radio Test")
}
func testDecodeAudioProfile() throws {
let json = """
{"mixer":{"si4684_left_db":0,"si4684_right_db":0,"esp32_left_db":0,"esp32_right_db":0,
"mix_left_db":0,"mix_right_db":0},
"master":{"left_db":10,"right_db":10},
"eq":[{"gain_db":0,"center_hz":40,"q":1.414}],
"enhancements":{"stereo_level":5,"bass_level":7}}
""".data(using: .utf8)!
let profile = try JSONDecoder.api.decode(AudioProfileDTO.self, from: json)
XCTAssertEqual(profile.master.leftDb, 10)
XCTAssertEqual(profile.enhancements.bassLevel, 7)
}
func testA2DPStateMapping() {
XCTAssertEqual(A2DPState(apiValue: "streaming"), .streaming)
XCTAssertEqual(A2DPState(apiValue: "unknown-value"), .unknown)
}
func testBLENameToHost() {
XCTAssertEqual(
DigiRadioDiscoveryService.httpHost(fromBLEName: "DigiRadio-CC4DB4"),
"http://digiradio-cc4db4.local"
)
XCTAssertNil(DigiRadioDiscoveryService.httpHost(fromBLEName: "OtherDevice"))
}
}
final class MockDigiRadioServiceTests: XCTestCase {
func testMockConnectAndTune() async throws {
let mock = MockDigiRadioService()
try await mock.connect(host: "mock.local")
XCTAssertTrue(mock.state.connection.isConnected)
try await mock.tuneFM(frequencyKhz: 101_500)
XCTAssertEqual(mock.state.tuner.fm?.frequencyKhz, 101_500)
}
}
@@ -0,0 +1,349 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 71;
objects = {
/* Begin PBXContainerItemProxy section */
C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D7ACBDA7D90DDE7BBA02A41B /* Project object */;
proxyType = 1;
remoteGlobalIDString = 0938FB30D2E421C09D31973A;
remoteInfo = igiRadio;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
366935DDA7048CE6717FCB93 /* igiRadio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = igiRadio.app; sourceTree = BUILT_PRODUCTS_DIR; };
97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = igiRadioTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Resources/Info.plist,
);
target = 0938FB30D2E421C09D31973A /* igiRadio */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
693436FA1780106DA05D06E3 /* Tests/igiRadioTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests/igiRadioTests; sourceTree = "<group>"; };
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = igiRadio; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
0A8032441559F0A3B8F103F8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
71F357EA3EEC95DAEFCF7345 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
2761999F42D5E1910DD2B679 /* Products */ = {
isa = PBXGroup;
children = (
366935DDA7048CE6717FCB93 /* igiRadio.app */,
97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
C5D6659C8B3150288BA78279 = {
isa = PBXGroup;
children = (
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */,
693436FA1780106DA05D06E3 /* Tests/igiRadioTests */,
2761999F42D5E1910DD2B679 /* Products */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
0938FB30D2E421C09D31973A /* igiRadio */ = {
isa = PBXNativeTarget;
buildConfigurationList = ECA92BA517002122145704D1 /* Build configuration list for PBXNativeTarget "igiRadio" */;
buildPhases = (
DF1C5CA20CABFD7B8DA40E94 /* Sources */,
71F357EA3EEC95DAEFCF7345 /* Frameworks */,
3C8EE57839841131D4276B21 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */,
);
name = igiRadio;
productName = igiRadio;
productReference = 366935DDA7048CE6717FCB93 /* igiRadio.app */;
productType = "com.apple.product-type.application";
};
48A9EB97D44FF615ADCA031A /* igiRadioTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */;
buildPhases = (
F994E2BFA3D135FCD316A95C /* Sources */,
0A8032441559F0A3B8F103F8 /* Frameworks */,
);
buildRules = (
);
dependencies = (
0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
693436FA1780106DA05D06E3 /* Tests/igiRadioTests */,
);
name = igiRadioTests;
productName = igiRadioTests;
productReference = 97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
D7ACBDA7D90DDE7BBA02A41B /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
0938FB30D2E421C09D31973A = {
CreatedOnToolsVersion = 26.0;
};
48A9EB97D44FF615ADCA031A = {
CreatedOnToolsVersion = 26.0;
TestTargetID = 0938FB30D2E421C09D31973A;
};
};
};
buildConfigurationList = C8A2C2BD01CBC6B9DFC6F199 /* Build configuration list for PBXProject "igiRadio" */;
compatibilityVersion = "Xcode 16.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = C5D6659C8B3150288BA78279;
productRefGroup = 2761999F42D5E1910DD2B679 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
0938FB30D2E421C09D31973A /* igiRadio */,
48A9EB97D44FF615ADCA031A /* igiRadioTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3C8EE57839841131D4276B21 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
DF1C5CA20CABFD7B8DA40E94 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
F994E2BFA3D135FCD316A95C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 0938FB30D2E421C09D31973A /* igiRadio */;
targetProxy = C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
54274B7C1572A50D5BE0DEA8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
73C8BF7223EFFA7108969F77 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = igiRadio/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadio;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
90735310944459417946066C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadioTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/igiRadio.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/igiRadio";
};
name = Debug;
};
E6AD029DFF2D9078F77BDCBA /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadioTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/igiRadio.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/igiRadio";
};
name = Release;
};
FA1BD134133059F9163AC34F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = igiRadio/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadio;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
FDBD275B398C4976FD6BC80D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_TESTABILITY = YES;
GCC_OPTIMIZATION_LEVEL = 0;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
90735310944459417946066C /* Debug */,
E6AD029DFF2D9078F77BDCBA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C8A2C2BD01CBC6B9DFC6F199 /* Build configuration list for PBXProject "igiRadio" */ = {
isa = XCConfigurationList;
buildConfigurations = (
FDBD275B398C4976FD6BC80D /* Debug */,
54274B7C1572A50D5BE0DEA8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
ECA92BA517002122145704D1 /* Build configuration list for PBXNativeTarget "igiRadio" */ = {
isa = XCConfigurationList;
buildConfigurations = (
73C8BF7223EFFA7108969F77 /* Debug */,
FA1BD134133059F9163AC34F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = D7ACBDA7D90DDE7BBA02A41B /* Project object */;
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="2600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="0938FB30D2E421C09D31973A" BuildableName="igiRadio.app" BlueprintName="igiRadio" ReferencedContainer="container:igiRadio.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="48A9EB97D44FF615ADCA031A" BuildableName="igiRadioTests.xctest" BlueprintName="igiRadioTests" ReferencedContainer="container:igiRadio.xcodeproj"/>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="0938FB30D2E421C09D31973A" BuildableName="igiRadio.app" BlueprintName="igiRadio" ReferencedContainer="container:igiRadio.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
</Scheme>
@@ -0,0 +1,30 @@
import Foundation
import Observation
/// Dependency container for igiRadio.
@Observable
final class AppEnvironment {
var useMockDevice: Bool
private(set) var digiRadio: any DigiRadioService
var state: DigiRadioState {
digiRadio.state
}
init(useMockDevice: Bool = {
#if DEBUG
true
#else
false
#endif
}()) {
self.useMockDevice = useMockDevice
self.digiRadio = useMockDevice ? MockDigiRadioService() : RealDigiRadioService()
}
func setUseMock(_ enabled: Bool) {
guard useMockDevice != enabled else { return }
useMockDevice = enabled
digiRadio = enabled ? MockDigiRadioService() : RealDigiRadioService()
}
}
@@ -0,0 +1,20 @@
import SwiftUI
@main
struct igiRadioApp: App {
@State private var environment = AppEnvironment()
var body: some Scene {
WindowGroup {
RootView()
.environment(environment)
.task {
#if DEBUG
if environment.useMockDevice, !environment.state.connection.isConnected {
try? await environment.digiRadio.connect(host: "mock.local")
}
#endif
}
}
}
}
@@ -0,0 +1,433 @@
import SwiftUI
// MARK: - Vertical fader (mixer channels)
struct IGIVerticalFader: View {
var label: String
var icon: String
@Binding var valueDb: Double
var range: ClosedRange<Double> = -40 ... 12
var onCommit: () -> Void
private let trackHeight: CGFloat = 160
var body: some View {
VStack(spacing: IGITheme.spacingS) {
Text(formattedDb)
.font(.caption.monospacedDigit().weight(.semibold))
.foregroundStyle(valueDb > 0 ? IGITheme.accent : .secondary)
.contentTransition(.numericText())
.animation(.snappy, value: valueDb)
GeometryReader { geo in
let h = geo.size.height
ZStack(alignment: .bottom) {
Capsule()
.fill(Color.primary.opacity(0.08))
Capsule()
.fill(
LinearGradient(
colors: [IGITheme.accent.opacity(0.35), IGITheme.accent],
startPoint: .bottom,
endPoint: .top
)
)
.frame(height: fillHeight(total: h))
}
.overlay(alignment: .top) {
Circle()
.fill(.background)
.shadow(color: .black.opacity(0.18), radius: 4, y: 2)
.overlay(Circle().stroke(IGITheme.accent.opacity(0.5), lineWidth: 2))
.frame(width: 28, height: 28)
.offset(y: thumbOffset(total: h))
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
valueDb = db(from: drag.location.y, height: h)
}
.onEnded { _ in onCommit() }
)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(label), \(formattedDb)")
.accessibilityAdjustableAction { direction in
let step = 0.5
switch direction {
case .increment: valueDb = min(range.upperBound, valueDb + step)
case .decrement: valueDb = max(range.lowerBound, valueDb - step)
@unknown default: break
}
onCommit()
}
}
.frame(height: trackHeight)
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.secondary)
Text(label)
.font(.caption2.weight(.medium))
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(width: 52)
}
.frame(width: 64)
}
private var formattedDb: String {
valueDb >= 0 ? String(format: "+%.1f", valueDb) : String(format: "%.1f", valueDb)
}
private func normalized(_ db: Double) -> Double {
(db - range.lowerBound) / (range.upperBound - range.lowerBound)
}
private func fillHeight(total: CGFloat) -> CGFloat {
max(4, total * normalized(valueDb))
}
private func thumbOffset(total: CGFloat) -> CGFloat {
let travel = total - 28
return travel * (1 - normalized(valueDb))
}
private func db(from y: CGFloat, height: CGFloat) -> Double {
let clamped = max(0, min(height, y))
let norm = 1 - (clamped / height)
let raw = range.lowerBound + norm * (range.upperBound - range.lowerBound)
return (raw * 2).rounded() / 2
}
}
// MARK: - Mixer channel group
struct IGIMixerChannelGroup: View {
var title: String
var subtitle: String
var systemImage: String
@Binding var leftDb: Double
@Binding var rightDb: Double
var onCommit: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
HStack(spacing: IGITheme.spacingS) {
Image(systemName: systemImage)
.font(.title3)
.foregroundStyle(IGITheme.accent)
.frame(width: 36, height: 36)
.background(IGITheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline)
Text(subtitle).font(.caption).foregroundStyle(.secondary)
}
}
HStack(spacing: IGITheme.spacingL) {
Spacer()
IGIVerticalFader(label: "Sinistra", icon: "l.circle", valueDb: $leftDb, onCommit: onCommit)
IGIVerticalFader(label: "Destra", icon: "r.circle", valueDb: $rightDb, onCommit: onCommit)
Spacer()
}
}
.igiAudioCard()
}
}
// MARK: - Graphic equalizer
struct IGIGraphicEqualizer: View {
@Binding var bands: [EQBandState]
var onCommit: () -> Void
private let gainRange: ClosedRange<Double> = -12 ... 12
private let barMaxHeight: CGFloat = 120
var body: some View {
VStack(spacing: IGITheme.spacingM) {
// Curve preview
GeometryReader { geo in
let w = geo.size.width
let h = geo.size.height
let sorted = bands.sorted { $0.centerHz < $1.centerHz }
if sorted.count >= 2 {
Path { path in
for (i, band) in sorted.enumerated() {
let x = w * CGFloat(i) / CGFloat(max(1, sorted.count - 1))
let y = h - normalizedGain(band.gainDb) * h
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
else { path.addLine(to: CGPoint(x: x, y: y)) }
}
}
.stroke(
LinearGradient(colors: [IGITheme.accent, .purple], startPoint: .leading, endPoint: .trailing),
style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)
)
}
}
.frame(height: 48)
.padding(.horizontal, IGITheme.spacingS)
HStack(alignment: .bottom, spacing: IGITheme.spacingS) {
ForEach($bands) { $band in
EQBar(
centerHz: band.centerHz,
gainDb: $band.gainDb,
maxHeight: barMaxHeight,
range: gainRange,
onCommit: onCommit
)
}
}
.frame(maxWidth: .infinity)
HStack {
Text("-12 dB").font(.caption2).foregroundStyle(.secondary)
Spacer()
Text("0").font(.caption2.weight(.medium))
Spacer()
Text("+12 dB").font(.caption2).foregroundStyle(.secondary)
}
}
.igiAudioCard()
}
private func normalizedGain(_ db: Double) -> CGFloat {
CGFloat((db - gainRange.lowerBound) / (gainRange.upperBound - gainRange.lowerBound))
}
}
private struct EQBar: View {
var centerHz: Double
@Binding var gainDb: Double
var maxHeight: CGFloat
var range: ClosedRange<Double>
var onCommit: () -> Void
var body: some View {
VStack(spacing: 6) {
Text(gainLabel)
.font(.system(size: 10, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(gainDb == 0 ? .secondary : IGITheme.accent)
.frame(height: 14)
GeometryReader { geo in
let h = geo.size.height
let mid = h * 0.5
ZStack(alignment: .bottom) {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary.opacity(0.06))
if gainDb >= 0 {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(barGradient)
.frame(height: max(4, normalized(gainDb) * mid))
.offset(y: -mid)
} else {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(barGradient)
.frame(height: max(4, normalized(abs(gainDb)) * mid))
.offset(y: -(mid - max(4, normalized(abs(gainDb)) * mid)))
}
}
.overlay {
Rectangle()
.fill(Color.primary.opacity(0.2))
.frame(height: 1)
.position(x: geo.size.width / 2, y: mid)
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
gainDb = db(from: drag.location.y, height: h)
}
.onEnded { _ in onCommit() }
)
}
.frame(height: maxHeight)
Text(freqLabel)
.font(.system(size: 9, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Banda \(freqLabel)")
.accessibilityValue(gainLabel)
}
private var gainLabel: String {
gainDb >= 0 ? String(format: "+%.1f", gainDb) : String(format: "%.1f", gainDb)
}
private var freqLabel: String {
if centerHz >= 1000 {
return String(format: "%.0fk", centerHz / 1000)
}
return String(format: "%.0f", centerHz)
}
private var barGradient: LinearGradient {
if gainDb >= 0 {
return LinearGradient(colors: [IGITheme.accent.opacity(0.5), IGITheme.accent], startPoint: .bottom, endPoint: .top)
}
return LinearGradient(colors: [.orange.opacity(0.4), .orange], startPoint: .top, endPoint: .bottom)
}
private func normalized(_ db: Double) -> CGFloat {
CGFloat(db / range.upperBound)
}
private func db(from y: CGFloat, height: CGFloat) -> Double {
let clamped = max(0, min(height, y))
let norm = 1 - (clamped / height)
let raw = range.lowerBound + norm * (range.upperBound - range.lowerBound)
return (raw * 2).rounded() / 2
}
}
// MARK: - Enhancement arc dial
struct IGIEnhancementDial: View {
var title: String
var systemImage: String
@Binding var level: Double
var onCommit: () -> Void
@State private var dragOrigin: Double?
var body: some View {
VStack(spacing: IGITheme.spacingS) {
ZStack {
Circle()
.stroke(Color.primary.opacity(0.08), lineWidth: 10)
Circle()
.trim(from: 0, to: level / 100)
.stroke(
AngularGradient(colors: [IGITheme.accent.opacity(0.4), IGITheme.accent], center: .center),
style: StrokeStyle(lineWidth: 10, lineCap: .round)
)
.rotationEffect(.degrees(-90))
VStack(spacing: 2) {
Image(systemName: systemImage)
.font(.title3)
.foregroundStyle(IGITheme.accent)
Text("\(Int(level))")
.font(.title2.weight(.bold).monospacedDigit())
}
}
.frame(width: 88, height: 88)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
if dragOrigin == nil { dragOrigin = level }
let base = dragOrigin ?? level
level = max(0, min(100, base - drag.translation.height / 2))
}
.onEnded { _ in
dragOrigin = nil
onCommit()
}
)
.accessibilityLabel(title)
.accessibilityValue("\(Int(level)) su cento")
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Master volume hero
struct IGIMasterVolumeHero: View {
@Binding var leftDb: Double
@Binding var rightDb: Double
var onCommit: () -> Void
private var linkedDb: Binding<Double> {
Binding(
get: { (leftDb + rightDb) / 2 },
set: { newValue in
leftDb = newValue
rightDb = newValue
}
)
}
var body: some View {
VStack(spacing: IGITheme.spacingM) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Volume master")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
Text("\(Int(linkedDb.wrappedValue))")
.font(.system(size: 48, weight: .bold, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText())
}
Spacer()
Image(systemName: "speaker.wave.3.fill")
.font(.largeTitle)
.foregroundStyle(IGITheme.accent.gradient)
.symbolEffect(.variableColor.iterative, options: .repeating, value: linkedDb.wrappedValue)
}
Slider(value: linkedDb, in: 0 ... 100, step: 1) { editing in
if !editing { onCommit() }
}
.tint(IGITheme.accent)
HStack(spacing: IGITheme.spacingL) {
stereoBalance(label: "L", value: $leftDb)
stereoBalance(label: "R", value: $rightDb)
}
}
.igiAudioCard()
}
@ViewBuilder
private func stereoBalance(label: String, value: Binding<Double>) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text("Canale \(label)")
.font(.caption2)
.foregroundStyle(.secondary)
HStack {
Text(label).font(.caption.weight(.bold))
Slider(value: value, in: 0 ... 100, step: 1) { editing in
if !editing { onCommit() }
}
.tint(.secondary)
}
}
}
}
// MARK: - Card style
extension View {
func igiAudioCard() -> some View {
igiPremiumCard()
}
}
enum AudioPanel: String, CaseIterable, Identifiable {
case mixer = "Mixer"
case enhance = "FX"
var id: String { rawValue }
var icon: String {
switch self {
case .mixer: "slider.vertical.3"
case .enhance: "sparkles"
}
}
}
@@ -0,0 +1,150 @@
import SwiftUI
enum IGITheme {
static let cornerRadius: CGFloat = 16
static let cardRadius: CGFloat = 20
static let spacingXS: CGFloat = 4
static let spacingS: CGFloat = 8
static let spacingM: CGFloat = 16
static let spacingL: CGFloat = 24
static let spacingXL: CGFloat = 32
static let accent = Color.accentColor
static let cardBackground = Color(.secondarySystemGroupedBackground)
static let screenBackground = Color(.systemGroupedBackground)
}
struct IGICard<Content: View>: View {
@ViewBuilder var content: () -> Content
var body: some View {
content()
.padding(IGITheme.spacingM)
.background(IGITheme.cardBackground, in: RoundedRectangle(cornerRadius: IGITheme.cardRadius, style: .continuous))
}
}
struct IGIStatusDot: View {
var isConnected: Bool
var body: some View {
Circle()
.fill(isConnected ? Color.green : Color.orange)
.frame(width: 10, height: 10)
.accessibilityLabel(isConnected ? "Connesso" : "Non connesso")
}
}
struct IGIPrimaryButton: View {
var title: String
var systemImage: String?
var action: () -> Void
var body: some View {
Button(action: action) {
Label {
Text(title)
.fontWeight(.semibold)
} icon: {
if let systemImage {
Image(systemName: systemImage)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
struct IGISignalBar: View {
var level: Double
var body: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule().fill(Color.secondary.opacity(0.2))
Capsule()
.fill(Color.accentColor.gradient)
.frame(width: proxy.size.width * max(0, min(1, level)))
}
}
.frame(height: 8)
.accessibilityLabel("Segnale")
.accessibilityValue("\(Int(level * 100)) percento")
}
}
struct IGISectionHeader: View {
var title: String
var body: some View {
Text(title)
.font(.title3.weight(.semibold))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct IGIEmptyState: View {
var title: String
var message: String
var systemImage: String
var body: some View {
VStack(spacing: IGITheme.spacingM) {
Image(systemName: systemImage)
.font(.system(size: 44))
.foregroundStyle(.secondary)
Text(title).font(.title3.weight(.semibold))
Text(message)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(IGITheme.spacingXL)
}
}
struct IGIVolumeSlider: View {
@Binding var value: Double
var onEditingEnded: ((Double) -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
HStack {
Image(systemName: "speaker.fill")
Slider(value: $value, in: 0 ... 100, step: 1) { editing in
if !editing { onEditingEnded?(value) }
}
Image(systemName: "speaker.wave.3.fill")
}
Text("Volume \(Int(value))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
struct IGIScanningIndicator: View {
@State private var rotation: Double = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
ZStack {
Circle()
.stroke(Color.accentColor.opacity(0.2), lineWidth: 4)
Circle()
.trim(from: 0, to: 0.28)
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(rotation))
}
.frame(width: 56, height: 56)
.onAppear {
guard !reduceMotion else { return }
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotation = 360
}
}
.accessibilityLabel("Ricerca in corso")
}
}
@@ -0,0 +1,270 @@
import SwiftUI
// MARK: - Shared premium surface
extension View {
func igiPremiumCard() -> some View {
self
.padding(IGITheme.spacingM)
.background {
RoundedRectangle(cornerRadius: IGITheme.cardRadius, style: .continuous)
.fill(.ultraThinMaterial)
.shadow(color: .black.opacity(0.06), radius: 12, y: 4)
}
}
}
struct IGIHeroBackground: View {
var body: some View {
LinearGradient(
colors: [
IGITheme.screenBackground,
IGITheme.accent.opacity(0.08),
IGITheme.screenBackground
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
}
}
// MARK: - Transport
struct IGITransportCluster: View {
var onPrevious: () -> Void
var onPlay: () -> Void
var onNext: () -> Void
var body: some View {
HStack(spacing: IGITheme.spacingL) {
transportButton(icon: "backward.fill", size: 56, prominent: false, action: onPrevious)
transportButton(icon: "play.fill", size: 76, prominent: true, action: onPlay)
transportButton(icon: "forward.fill", size: 56, prominent: false, action: onNext)
}
.frame(maxWidth: .infinity)
}
@ViewBuilder
private func transportButton(icon: String, size: CGFloat, prominent: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(prominent ? .largeTitle : .title2)
.foregroundStyle(prominent ? .white : .primary)
.frame(width: size, height: size)
.background {
if prominent {
Circle().fill(IGITheme.accent.gradient)
.shadow(color: IGITheme.accent.opacity(0.35), radius: 12, y: 6)
} else {
Circle().fill(.ultraThinMaterial)
}
}
}
.buttonStyle(.plain)
}
}
// MARK: - Band badge
struct IGIBandBadge: View {
var band: TunerBand
var locked: Bool
var body: some View {
HStack(spacing: 6) {
Image(systemName: band == .fm ? "dot.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right")
Text(band.rawValue.uppercased())
.fontWeight(.bold)
if locked {
Image(systemName: "lock.fill")
.font(.caption2)
}
}
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(IGITheme.accent.opacity(0.15), in: Capsule())
.foregroundStyle(IGITheme.accent)
}
}
// MARK: - Preset chip
struct IGIPresetChip: View {
var name: String
var subtitle: String
var action: () -> Void
var body: some View {
Button(action: action) {
VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(subtitle)
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(minWidth: 110, alignment: .leading)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.primary.opacity(0.06))
)
}
.buttonStyle(.plain)
}
}
// MARK: - Metric bar
struct IGIMetricBar: View {
var label: String
var value: String
var level: Double
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(label).font(.caption).foregroundStyle(.secondary)
Spacer()
Text(value).font(.caption.monospacedDigit().weight(.medium))
}
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(0.08))
Capsule()
.fill(IGITheme.accent.gradient)
.frame(width: geo.size.width * max(0, min(1, level)))
}
}
.frame(height: 6)
}
}
}
// MARK: - FM frequency hero
struct IGIFrequencyHero: View {
@Binding var frequencyMHz: Double
var stationName: String?
var isTuning: Bool
var onTune: () -> Void
var body: some View {
VStack(spacing: IGITheme.spacingL) {
if let stationName, !stationName.isEmpty {
Text(stationName)
.font(.title3.weight(.semibold))
.foregroundStyle(IGITheme.accent)
.lineLimit(1)
}
ZStack {
Circle()
.stroke(Color.primary.opacity(0.06), lineWidth: 20)
Circle()
.trim(from: 0, to: normalizedFrequency)
.stroke(
AngularGradient(colors: [IGITheme.accent.opacity(0.3), IGITheme.accent], center: .center),
style: StrokeStyle(lineWidth: 20, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.animation(.snappy, value: frequencyMHz)
VStack(spacing: 4) {
Text(String(format: "%.2f", frequencyMHz))
.font(.system(size: 44, weight: .bold, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText())
Text("MHz")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.frame(width: 220, height: 220)
Slider(value: $frequencyMHz, in: 64 ... 108, step: 0.05)
.tint(IGITheme.accent)
HStack(spacing: IGITheme.spacingM) {
stepButton("0.1") { frequencyMHz = max(64, frequencyMHz - 0.1) }
Button(action: onTune) {
Group {
if isTuning {
ProgressView().tint(.white)
} else {
Text("Sintonizza")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.disabled(isTuning)
stepButton("+0.1") { frequencyMHz = min(108, frequencyMHz + 0.1) }
}
}
.igiPremiumCard()
}
private var normalizedFrequency: Double {
(frequencyMHz - 64) / (108 - 64)
}
@ViewBuilder
private func stepButton(_ title: String, action: @escaping () -> Void) -> some View {
Button(title, action: action)
.font(.subheadline.weight(.semibold).monospacedDigit())
.frame(width: 56, height: 48)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
.buttonStyle(.plain)
}
}
// MARK: - Scan result row
struct IGIScanResultRow: View {
var title: String
var frequencyMHz: Double
var rssi: Int?
var action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: IGITheme.spacingM) {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(IGITheme.accent.opacity(0.12))
.frame(width: 44, height: 44)
Image(systemName: "radio.fill")
.foregroundStyle(IGITheme.accent)
}
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline).lineLimit(1)
Text(String(format: "%.2f MHz", frequencyMHz))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let rssi {
Text("\(rssi)")
.font(.caption.monospacedDigit().weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.primary.opacity(0.06), in: Capsule())
}
Image(systemName: "play.circle.fill")
.font(.title2)
.foregroundStyle(IGITheme.accent)
}
.padding(IGITheme.spacingS)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
}
}
@@ -0,0 +1,264 @@
import Foundation
import Observation
@Observable
final class MockDigiRadioService: DigiRadioService {
private(set) var state = DigiRadioState()
init() {
seed()
}
func connect(host: String) async throws {
try await delay()
state.connection.host = host
state.connection.isConnected = true
state.connection.lastError = nil
}
func disconnect() {
state.connection = ConnectionState()
seed()
}
func refreshHealth() async throws {
try await delay()
}
func refreshTunerStatus() async throws {
try await delay()
}
func refreshAudioProfile() async throws {
try await delay()
}
func refreshBluetoothStatus() async throws {
try await delay()
}
func refreshStations() async throws {
try await delay()
}
func refreshStreaming() async throws {
try await delay()
}
func setStreaming(enabled: Bool, url: String) async throws {
try await delay()
state.streaming = StreamingState(enabled: enabled, url: url)
}
func tuneFM(frequencyKhz: Int) async throws {
try await delay()
state.tuner.band = .fm
state.tuner.locked = true
state.tuner.fm = FMTunerState(
frequencyKhz: frequencyKhz,
rssiDbuv: 48,
snrDb: 22,
stereo: true,
stationName: "Radio Mock",
radiotext: "igiRadio demo mode"
)
}
func tuneDAB(freqIndex: Int) async throws {
try await delay()
state.tuner.band = .dab
state.tuner.locked = true
state.tuner.dab = DABTunerState(
freqIndex: freqIndex,
ficQuality: 78,
cnrDb: 24,
playingServiceId: 101,
playingComponentId: 1,
dynamicLabel: "DAB Mock Ensemble"
)
}
func playDAB(serviceId: Int, componentId: Int) async throws {
try await delay()
state.tuner.dab?.playingServiceId = serviceId
state.tuner.dab?.playingComponentId = componentId
state.tuner.dab?.dynamicLabel = "Now playing mock service"
}
func seekFM(direction: String) async throws {
try await delay()
guard var fm = state.tuner.fm else { return }
let step = direction == "down" ? -100 : 100
fm.frequencyKhz = max(64_000, min(108_000, fm.frequencyKhz + step))
state.tuner.fm = fm
}
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse {
try await delay(1.2)
return TunerScanResponse(
status: "found",
band: "fm",
steps: 8,
frequencyKhz: 101_500,
stationName: name ?? "Mock FM",
freqIndex: nil,
serviceId: nil,
componentId: nil
)
}
func scanFullFM() async throws -> [FMScanHit] {
try await delay(1.5)
return [
FMScanHit(frequencyKhz: 97_500, rssiDbuv: 55, snrDb: 20, stationName: "Mock R1"),
FMScanHit(frequencyKhz: 101_500, rssiDbuv: 48, snrDb: 18, stationName: "Mock R2"),
FMScanHit(frequencyKhz: 102_300, rssiDbuv: 51, snrDb: 19, stationName: "Radio Italia")
]
}
func setVolume(_ volume: Int) async throws {
try await delay()
let clamped = max(0, min(100, volume))
state.tuner.volume = clamped
state.audio.master.leftDb = Double(clamped)
state.audio.master.rightDb = Double(clamped)
}
func listDABServices() async throws -> [DABService] {
try await delay()
return [
DABService(serviceId: 101, componentId: 1, label: "Mock Radio 1"),
DABService(serviceId: 102, componentId: 1, label: "Mock Radio 2"),
DABService(serviceId: 103, componentId: 1, label: "Mock Jazz")
]
}
func saveStation(_ station: Station) async throws {
try await delay()
state.stations.append(station)
}
func removeStation(at index: Int) async throws {
try await delay()
guard state.stations.indices.contains(index) else { return }
state.stations.remove(at: index)
}
func reorderStation(from: Int, to: Int) async throws {
try await delay()
guard state.stations.indices.contains(from), state.stations.indices.contains(to) else { return }
let item = state.stations.remove(at: from)
state.stations.insert(item, at: to)
}
func tuneStation(at index: Int) async throws {
try await delay()
guard state.stations.indices.contains(index) else { return }
let station = state.stations[index]
switch station.band {
case .fm:
if let freq = station.fmFrequencyKhz { try await tuneFM(frequencyKhz: freq) }
case .dab:
if let idx = station.dabFreqIndex { try await tuneDAB(freqIndex: idx) }
if let sid = station.dabServiceId, let cid = station.dabComponentId {
try await playDAB(serviceId: sid, componentId: cid)
}
}
}
func applyAudioProfile(_ profile: AudioProfileDTO) async throws {
try await delay()
state.audio.mixer = profile.mixer
state.audio.master = profile.master
state.audio.eq = profile.eq
state.audio.enhancements = profile.enhancements
}
func setStereoEnhance(level: Int) async throws {
try await delay()
state.audio.enhancements.stereoLevel = level
}
func setBassEnhance(level: Int) async throws {
try await delay()
state.audio.enhancements.bassLevel = level
}
func resetAudio() async throws {
try await delay()
state.audio = AudioState()
}
func scanBluetooth(seconds: Int) async throws {
try await delay(0.8)
state.bluetooth.nearbyDevices = [
BluetoothDevice(index: 0, mac: "AA:BB:CC:DD:EE:FF", name: "Bose Solo II", rssiDbm: -52),
BluetoothDevice(index: 1, mac: "11:22:33:44:55:66", name: "Living Room", rssiDbm: -68)
]
}
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
try await delay()
state.bluetooth.a2dpState = .streaming
state.bluetooth.deviceName = name ?? "Speaker"
if save {
state.bluetooth.savedSpeaker = SavedSpeaker(mac: mac, name: name ?? "Speaker")
}
}
func reconnectBluetooth() async throws {
try await delay()
state.bluetooth.a2dpState = .streaming
}
private func delay(_ seconds: Double = 0.35) async throws {
try await Task.sleep(for: .seconds(seconds))
}
private func seed() {
state.health = HealthState(
status: "ok",
firmware: "0.8.5-mock",
serialNumber: "CC4DB4MOCK01",
chips: ChipHealth(si4684: true, adau1701: true, bt1035: true)
)
state.tuner = TunerState(
booted: true,
band: .fm,
locked: true,
volume: 42,
fm: FMTunerState(
frequencyKhz: 102_300,
rssiDbuv: 51,
snrDb: 19,
stereo: true,
stationName: "Radio Italia",
radiotext: "Mock mode — nessun hardware collegato"
),
dab: nil
)
state.audio = AudioState(
mixer: MixerState(),
master: MasterVolumeState(leftDb: 42, rightDb: 42),
eq: (0 ..< 6).map { i in
EQBandState(index: i, gainDb: 0, centerHz: [40, 120, 400, 1000, 3000, 10000][i], q: 1.414)
},
enhancements: EnhancementsState(stereoLevel: 20, bassLevel: 15)
)
state.bluetooth = BluetoothState(
booted: true,
pairing: false,
a2dpState: .streaming,
deviceName: "Bose Solo II",
autoReconnect: 5,
savedSpeaker: SavedSpeaker(mac: "BC:87:FA:E6:9D:6E", name: "Bose Solo II")
)
state.stations = [
Station(name: "Radio Italia", band: .fm, fmFrequencyKhz: 102_300),
Station(name: "RDS", band: .fm, fmFrequencyKhz: 97_500)
]
state.streaming = StreamingState(enabled: false, url: "")
state.connection.isConnected = true
state.connection.host = "digiradio-mock.local"
}
}
@@ -0,0 +1,75 @@
import Foundation
/// Audio profile: EQ + enhancements (stored on device via PUT /api/audio/profile).
struct AudioProfileTemplate: Identifiable, Codable, Equatable, Hashable {
var id: String
var name: String
var subtitle: String
var systemImage: String
var isBuiltIn: Bool
var eq: [EQBandState]
var enhancements: EnhancementsState
static let bandCenters: [Double] = [40, 120, 400, 1000, 3000, 10_000]
static func eqBands(gains: [Double]) -> [EQBandState] {
zip(bandCenters, gains).enumerated().map { index, pair in
EQBandState(index: index, gainDb: pair.1, centerHz: pair.0, q: 1.414)
}
}
func profileDTO(mixer: MixerState, master: MasterVolumeState) -> AudioProfileDTO {
AudioProfileDTO(mixer: mixer, master: master, eq: eq, enhancements: enhancements)
}
func duplicatedAsUser(named name: String) -> AudioProfileTemplate {
AudioProfileTemplate(
id: UUID().uuidString,
name: name,
subtitle: "Profilo personalizzato",
systemImage: "slider.horizontal.3",
isBuiltIn: false,
eq: eq,
enhancements: enhancements
)
}
}
enum AudioProfileLibrary {
static let builtIn: [AudioProfileTemplate] = [
template(id: "builtin.flat", name: "Piatto", subtitle: "Risposta neutra", icon: "minus", gains: [0, 0, 0, 0, 0, 0], stereo: 0, bass: 0),
template(id: "builtin.vocal", name: "Voci", subtitle: "Parlato e podcast", icon: "person.wave.2", gains: [-2, -1, 3, 4, 2, 0], stereo: 15, bass: 5),
template(id: "builtin.bass", name: "Bassi", subtitle: "Più corpo e profondità", icon: "waveform.path", gains: [6, 4, 1, 0, -1, -2], stereo: 10, bass: 35),
template(id: "builtin.treble", name: "Alti", subtitle: "Dettaglio e aria", icon: "sparkles", gains: [-2, -1, 0, 2, 4, 5], stereo: 25, bass: 0),
template(id: "builtin.rock", name: "Rock", subtitle: "Energia V-shape", icon: "guitars", gains: [5, 3, -1, 1, 4, 5], stereo: 30, bass: 25),
template(id: "builtin.jazz", name: "Jazz", subtitle: "Caldo e naturale", icon: "music.quarternote.3", gains: [3, 2, 1, 2, 1, 0], stereo: 20, bass: 15),
template(id: "builtin.classical", name: "Classica", subtitle: "Equilibrio dinamico", icon: "hifispeaker.2", gains: [2, 1, 0, 0, 2, 3], stereo: 15, bass: 5),
template(id: "builtin.lounge", name: "Lounge", subtitle: "Ascolto rilassato", icon: "moon.stars", gains: [2, 1, 0, -1, -2, -3], stereo: 10, bass: 20),
template(id: "builtin.fm", name: "Radio FM", subtitle: "Voce in evidenza", icon: "radio", gains: [-1, 0, 2, 3, 2, 1], stereo: 20, bass: 10)
]
private static func template(
id: String, name: String, subtitle: String, icon: String,
gains: [Double], stereo: Int, bass: Int
) -> AudioProfileTemplate {
AudioProfileTemplate(
id: id,
name: name,
subtitle: subtitle,
systemImage: icon,
isBuiltIn: true,
eq: AudioProfileTemplate.eqBands(gains: gains),
enhancements: EnhancementsState(stereoLevel: stereo, bassLevel: bass)
)
}
static func matchActive(_ profile: AudioProfileTemplate, eq: [EQBandState], enhancements: EnhancementsState) -> Bool {
guard eq.count == profile.eq.count else { return false }
let sortedEq = eq.sorted { $0.centerHz < $1.centerHz }
let sortedProfile = profile.eq.sorted { $0.centerHz < $1.centerHz }
let eqMatch = zip(sortedEq, sortedProfile).allSatisfy { abs($0.gainDb - $1.gainDb) < 0.6 }
return eqMatch
&& profile.enhancements.stereoLevel == enhancements.stereoLevel
&& profile.enhancements.bassLevel == enhancements.bassLevel
}
}
@@ -0,0 +1,245 @@
import Foundation
/// Central device snapshot only documented API fields.
struct DigiRadioState: Equatable, Sendable {
var connection = ConnectionState()
var health = HealthState()
var tuner = TunerState()
var audio = AudioState()
var bluetooth = BluetoothState()
var stations: [Station] = []
var streaming = StreamingState()
}
struct ConnectionState: Equatable, Sendable {
var host: String = ""
var isConnected = false
var isConnecting = false
var lastError: String?
}
struct HealthState: Equatable, Sendable {
var status: String = ""
var firmware: String = ""
var serialNumber: String = ""
var chips = ChipHealth()
}
struct ChipHealth: Equatable, Sendable {
var si4684 = false
var adau1701 = false
var bt1035 = false
}
struct TunerState: Equatable, Sendable {
var booted = false
var band: TunerBand = .fm
var locked = false
var volume: Int = 0
var fm: FMTunerState?
var dab: DABTunerState?
}
enum TunerBand: String, Codable, Sendable, CaseIterable {
case fm
case dab
}
struct FMTunerState: Equatable, Sendable, Codable {
var frequencyKhz: Int
var rssiDbuv: Int?
var snrDb: Int?
var stereo: Bool?
var stationName: String?
var radiotext: String?
enum CodingKeys: String, CodingKey {
case frequencyKhz = "frequency_khz"
case rssiDbuv = "rssi_dbuv"
case snrDb = "snr_db"
case stereo
case stationName = "station_name"
case radiotext
}
}
struct DABTunerState: Equatable, Sendable, Codable {
var freqIndex: Int
var ficQuality: Int?
var cnrDb: Int?
var playingServiceId: Int?
var playingComponentId: Int?
var dynamicLabel: String?
enum CodingKeys: String, CodingKey {
case freqIndex = "freq_index"
case ficQuality = "fic_quality"
case cnrDb = "cnr_db"
case playingServiceId = "playing_service_id"
case playingComponentId = "playing_component_id"
case dynamicLabel = "dynamic_label"
}
}
struct AudioState: Equatable, Sendable {
var mixer = MixerState()
var master = MasterVolumeState()
var eq: [EQBandState] = []
var enhancements = EnhancementsState()
}
struct MixerState: Equatable, Sendable, Codable {
var si4684LeftDb: Double = 0
var si4684RightDb: Double = 0
var esp32LeftDb: Double = 0
var esp32RightDb: Double = 0
var mixLeftDb: Double = 0
var mixRightDb: Double = 0
enum CodingKeys: String, CodingKey {
case si4684LeftDb = "si4684_left_db"
case si4684RightDb = "si4684_right_db"
case esp32LeftDb = "esp32_left_db"
case esp32RightDb = "esp32_right_db"
case mixLeftDb = "mix_left_db"
case mixRightDb = "mix_right_db"
}
}
struct MasterVolumeState: Equatable, Sendable, Codable {
var leftDb: Double = 0
var rightDb: Double = 0
enum CodingKeys: String, CodingKey {
case leftDb = "left_db"
case rightDb = "right_db"
}
}
struct EQBandState: Equatable, Hashable, Sendable, Codable, Identifiable {
var id: Int { index }
var index: Int
var gainDb: Double
var centerHz: Double
var q: Double
enum CodingKeys: String, CodingKey {
case gainDb = "gain_db"
case centerHz = "center_hz"
case q
}
init(index: Int, gainDb: Double, centerHz: Double, q: Double) {
self.index = index
self.gainDb = gainDb
self.centerHz = centerHz
self.q = q
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
index = 0
gainDb = try container.decode(Double.self, forKey: .gainDb)
centerHz = try container.decode(Double.self, forKey: .centerHz)
q = try container.decode(Double.self, forKey: .q)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(gainDb, forKey: .gainDb)
try container.encode(centerHz, forKey: .centerHz)
try container.encode(q, forKey: .q)
}
}
struct EnhancementsState: Equatable, Hashable, Sendable, Codable {
var stereoLevel: Int = 0
var bassLevel: Int = 0
enum CodingKeys: String, CodingKey {
case stereoLevel = "stereo_level"
case bassLevel = "bass_level"
}
}
struct BluetoothState: Equatable, Sendable {
var booted = false
var pairing = false
var a2dpState: A2DPState = .unknown
var deviceName: String = ""
var autoReconnect: Int = 0
var savedSpeaker: SavedSpeaker?
var nearbyDevices: [BluetoothDevice] = []
var pairedDevices: [BluetoothPairedDevice] = []
}
enum A2DPState: String, Sendable {
case standby, connecting, connected, streaming, paused, unknown
init(apiValue: String) {
switch apiValue.lowercased() {
case "standby": self = .standby
case "connecting": self = .connecting
case "connected": self = .connected
case "streaming": self = .streaming
case "paused": self = .paused
default: self = .unknown
}
}
}
struct SavedSpeaker: Equatable, Sendable {
var mac: String
var name: String
}
struct BluetoothDevice: Equatable, Sendable, Identifiable {
var id: String { mac }
var index: Int
var mac: String
var name: String
var rssiDbm: Int
}
struct BluetoothPairedDevice: Equatable, Sendable, Identifiable {
var id: String { mac }
var index: Int
var mac: String
var name: String
}
struct Station: Equatable, Sendable, Codable, Identifiable {
var id: String { "\(band.rawValue)-\(name)-\(fmFrequencyKhz ?? dabFreqIndex ?? 0)" }
var name: String
var band: TunerBand
var dabFreqIndex: Int?
var dabServiceId: Int?
var dabComponentId: Int?
var fmFrequencyKhz: Int?
enum CodingKeys: String, CodingKey {
case name, band
case dabFreqIndex = "dab_freq_index"
case dabServiceId = "dab_service_id"
case dabComponentId = "dab_component_id"
case fmFrequencyKhz = "fm_frequency_khz"
}
}
struct StreamingState: Equatable, Sendable, Codable {
var enabled = false
var url: String = ""
}
struct DABService: Equatable, Sendable, Identifiable, Codable {
var id: String { "\(serviceId)-\(componentId)" }
var serviceId: Int
var componentId: Int
var label: String
enum CodingKeys: String, CodingKey {
case serviceId = "service_id"
case componentId = "component_id"
case label
}
}
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.984",
"green" : "0.447",
"red" : "0.227"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.584",
"red" : "0.376"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>igiRadio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>igiRadio usa Bluetooth per trovare DigiRadio in setup mode e configurare il WiFi.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>igiRadio si connette a DigiRadio sulla rete locale per controllare radio e audio.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,20 @@
import Foundation
/// Persists user-created audio profiles on the iPhone (firmware stores one active profile only).
enum AudioProfileStore {
private static let key = "igiRadio.userAudioProfiles"
static func loadUserProfiles() -> [AudioProfileTemplate] {
guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
return (try? JSONDecoder().decode([AudioProfileTemplate].self, from: data)) ?? []
}
static func saveUserProfiles(_ profiles: [AudioProfileTemplate]) {
guard let data = try? JSONEncoder().encode(profiles) else { return }
UserDefaults.standard.set(data, forKey: key)
}
static func allProfiles() -> [AudioProfileTemplate] {
AudioProfileLibrary.builtIn + loadUserProfiles()
}
}
@@ -0,0 +1,90 @@
import AVFoundation
import Foundation
/// Decodes local audio files to interleaved PCM s16le stereo @ 48 kHz for PUT /api/stream/phone.
enum LocalAudioDecoder {
static let outputSampleRate: Double = 48_000
static let chunkFrames: AVAudioFrameCount = 2048
static func pcmChunks(from fileURL: URL) -> AsyncThrowingStream<Data, Error> {
AsyncThrowingStream { continuation in
Task {
do {
let accessed = fileURL.startAccessingSecurityScopedResource()
defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } }
let inputFile = try AVAudioFile(forReading: fileURL)
guard let outputFormat = AVAudioFormat(
commonFormat: .pcmFormatInt16,
sampleRate: outputSampleRate,
channels: 2,
interleaved: true
) else {
throw PhoneStreamError.formatUnsupported
}
guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: outputFormat) else {
throw PhoneStreamError.formatUnsupported
}
let inputBuffer = AVAudioPCMBuffer(
pcmFormat: inputFile.processingFormat,
frameCapacity: chunkFrames
)!
let outputBuffer = AVAudioPCMBuffer(
pcmFormat: outputFormat,
frameCapacity: chunkFrames
)!
while inputFile.framePosition < inputFile.length {
try Task.checkCancellation()
try inputFile.read(into: inputBuffer, frameCount: chunkFrames)
if inputBuffer.frameLength == 0 { break }
var inputProvided = false
var convertError: NSError?
let status = converter.convert(to: outputBuffer, error: &convertError) { _, outStatus in
if inputProvided {
outStatus.pointee = .noDataNow
return nil
}
inputProvided = true
outStatus.pointee = .haveData
return inputBuffer
}
if status == .error {
throw convertError ?? PhoneStreamError.decodeFailed
}
if outputBuffer.frameLength == 0 { continue }
let byteCount = Int(outputBuffer.frameLength) * Int(outputFormat.streamDescription.pointee.mBytesPerFrame)
guard let channelData = outputBuffer.int16ChannelData else { continue }
continuation.yield(Data(bytes: channelData[0], count: byteCount))
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}
enum PhoneStreamError: LocalizedError {
case notConnected
case formatUnsupported
case decodeFailed
case deviceRejected(Int, String?)
case transport(Error)
var errorDescription: String? {
switch self {
case .notConnected: return "DigiRadio non connesso."
case .formatUnsupported: return "Formato audio non supportato."
case .decodeFailed: return "Impossibile decodificare il file."
case let .deviceRejected(code, body): return "Device HTTP \(code): \(body ?? "")"
case let .transport(error): return error.localizedDescription
}
}
}
@@ -0,0 +1,152 @@
import Foundation
import Network
import OSLog
/// Streams PCM to DigiRadio via documented PUT /api/stream/phone (chunked HTTP/1.1).
actor PhonePCMStreamService {
static let shared = PhonePCMStreamService()
private(set) var isStreaming = false
private(set) var statusMessage = ""
private var streamTask: Task<Void, Never>?
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "PhoneStream")
func start(fileURL: URL, connectionHost: String) {
stop()
let host = Self.parseHost(connectionHost)
guard !host.isEmpty else {
statusMessage = "Host non valido"
return
}
isStreaming = true
statusMessage = "Avvio stream…"
streamTask = Task {
do {
try await stream(fileURL: fileURL, host: host)
statusMessage = "Stream completato"
isStreaming = false
} catch is CancellationError {
statusMessage = "Stream interrotto"
isStreaming = false
} catch {
statusMessage = error.localizedDescription
isStreaming = false
logger.error("Phone stream failed: \(error.localizedDescription, privacy: .public)")
}
}
}
func stop() {
streamTask?.cancel()
streamTask = nil
isStreaming = false
}
private func stream(fileURL: URL, host: String) async throws {
let chunks = LocalAudioDecoder.pcmChunks(from: fileURL)
try await sendChunkedPUT(host: host, chunks: chunks)
}
private func sendChunkedPUT(host: String, chunks: AsyncThrowingStream<Data, Error>) async throws {
let connection = NWConnection(host: NWEndpoint.Host(host), port: 80, using: .tcp)
connection.start(queue: .global(qos: .userInitiated))
try await waitUntilReady(connection)
let header = """
PUT /api/stream/phone HTTP/1.1\r
Host: \(host)\r
Transfer-Encoding: chunked\r
Connection: close\r
Content-Type: application/octet-stream\r
\r
"""
try await send(connection, Data(header.utf8))
for try await chunk in chunks {
try Task.checkCancellation()
try await sendChunk(connection, chunk)
statusMessage = "Streaming…"
}
try await send(connection, Data("0\r\n\r\n".utf8))
_ = try await readResponse(connection)
connection.cancel()
}
private func waitUntilReady(_ connection: NWConnection) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
var finished = false
connection.stateUpdateHandler = { state in
guard !finished else { return }
switch state {
case .ready:
finished = true
continuation.resume()
case let .failed(error):
finished = true
continuation.resume(throwing: PhoneStreamError.transport(error))
default:
break
}
}
}
}
private func send(_ connection: NWConnection, _ data: Data) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { error in
if let error {
continuation.resume(throwing: PhoneStreamError.transport(error))
} else {
continuation.resume()
}
})
}
}
private func sendChunk(_ connection: NWConnection, _ chunk: Data) async throws {
var payload = Data()
payload.append(contentsOf: "\(String(chunk.count, radix: 16, uppercase: false))\r\n".utf8)
payload.append(chunk)
payload.append(contentsOf: "\r\n".utf8)
try await send(connection, payload)
}
private func readResponse(_ connection: NWConnection) async throws -> Int {
try await withCheckedThrowingContinuation { continuation in
connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, _, error in
if let error {
continuation.resume(throwing: PhoneStreamError.transport(error))
return
}
guard let data, let text = String(data: data, encoding: .utf8) else {
continuation.resume(returning: 200)
return
}
let statusLine = text.split(separator: "\r\n").first.map(String.init) ?? ""
if statusLine.contains("409") {
continuation.resume(throwing: PhoneStreamError.deviceRejected(409, "Stream occupato (web radio attiva?)"))
} else if statusLine.contains("503") {
continuation.resume(throwing: PhoneStreamError.deviceRejected(503, "Sink I2S non disponibile"))
} else if statusLine.contains("200") {
continuation.resume(returning: 200)
} else if let code = Int(statusLine.split(separator: " ").dropFirst().first ?? "") {
continuation.resume(throwing: PhoneStreamError.deviceRejected(code, statusLine))
} else {
continuation.resume(returning: 200)
}
}
}
}
private static func parseHost(_ raw: String) -> String {
var h = raw.trimmingCharacters(in: .whitespacesAndNewlines)
h = h.replacingOccurrences(of: "http://", with: "")
h = h.replacingOccurrences(of: "https://", with: "")
if let slash = h.firstIndex(of: "/") {
h = String(h[..<slash])
}
return h
}
}
@@ -0,0 +1,73 @@
import CoreBluetooth
import Foundation
import Observation
import OSLog
/// Discovers DigiRadio BLE provisioning endpoints (ESP-IDF wifi_provisioning / scheme_ble).
/// Full credential exchange uses Espressif protocomm Security1 (PoP = device serial).
/// This service performs discovery only; credential provisioning UI is documented in BLE_PROTOCOL.md.
@Observable
final class BLEProvisioningService: NSObject {
enum Phase: Equatable {
case idle
case scanning
case found(name: String, identifier: UUID)
case unsupported
case error(String)
}
private(set) var phase: Phase = .idle
private(set) var discoveredDevices: [(name: String, id: UUID, rssi: Int)] = []
private var central: CBCentralManager?
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "BLE")
override init() {
super.init()
central = CBCentralManager(delegate: self, queue: .main)
}
func startScan() {
guard let central else { return }
discoveredDevices = []
phase = .scanning
guard central.state == .poweredOn else {
phase = .error("Bluetooth non disponibile")
return
}
central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
logger.info("BLE scan started")
}
func stopScan() {
central?.stopScan()
if case .scanning = phase {
phase = discoveredDevices.isEmpty ? .idle : .found(name: discoveredDevices[0].name, identifier: discoveredDevices[0].id)
}
}
}
extension BLEProvisioningService: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state != .poweredOn, case .scanning = phase {
phase = .error("Bluetooth spento o non autorizzato")
central.stopScan()
}
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
guard name.hasPrefix("DigiRadio") else { return }
let entry = (name: name, id: peripheral.identifier, rssi: RSSI.intValue)
if !discoveredDevices.contains(where: { $0.id == entry.id }) {
discoveredDevices.append(entry)
logger.debug("Found \(name, privacy: .public) RSSI \(RSSI.intValue)")
}
phase = .found(name: name, identifier: peripheral.identifier)
}
}
@@ -0,0 +1,97 @@
import Foundation
import Observation
import OSLog
/// Maps BLE setup names (DigiRadio-XXXX) to HTTP hosts and verifies reachability.
@Observable
final class DigiRadioDiscoveryService {
struct DiscoveredDevice: Identifiable, Equatable {
var id: String { host }
var name: String
var host: String
var isReachable: Bool
var firmware: String?
}
private(set) var devices: [DiscoveredDevice] = []
private(set) var isSearching = false
private let ble = BLEProvisioningService()
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "Discovery")
private var probeTasks: [Task<Void, Never>] = []
func start() {
guard !isSearching else { return }
isSearching = true
devices = []
ble.startScan()
logger.info("Discovery started")
}
func stop() {
isSearching = false
ble.stopScan()
probeTasks.forEach { $0.cancel() }
probeTasks = []
}
func refreshFromBLE() {
let candidates = ble.discoveredDevices.compactMap { entry -> DiscoveredDevice? in
guard let host = Self.httpHost(fromBLEName: entry.name) else { return nil }
return DiscoveredDevice(name: entry.name, host: host, isReachable: false, firmware: nil)
}
for candidate in candidates {
guard !devices.contains(where: { $0.host == candidate.host }) else { continue }
devices.append(candidate)
probe(host: candidate.host)
}
}
func addManualHost(_ raw: String) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
var host = trimmed
if !host.hasPrefix("http://") && !host.hasPrefix("https://") {
host = "http://\(host)"
}
guard let url = URL(string: host), let schemeHost = url.host else { return }
let normalized = "http://\(schemeHost)"
guard !devices.contains(where: { $0.host == normalized }) else { return }
let name = schemeHost
devices.append(DiscoveredDevice(name: name, host: normalized, isReachable: false, firmware: nil))
probe(host: normalized)
}
static func httpHost(fromBLEName name: String) -> String? {
// SoftAP / BLE name: DigiRadio-CC4DB4 digiradio-cc4db4.local
guard name.hasPrefix("DigiRadio-") else { return nil }
let suffix = String(name.dropFirst("DigiRadio-".count)).lowercased()
guard !suffix.isEmpty else { return nil }
return "http://digiradio-\(suffix).local"
}
private func probe(host: String) {
let task = Task {
let client = HTTPDigiRadioClient()
do {
try client.setHost(host)
let health = try await client.fetchHealth()
await MainActor.run {
if let index = devices.firstIndex(where: { $0.host == host }) {
devices[index].isReachable = health.status == "ok"
devices[index].firmware = health.fw
}
}
} catch {
await MainActor.run {
if let index = devices.firstIndex(where: { $0.host == host }) {
devices[index].isReachable = false
}
}
logger.debug("Probe failed for \(host, privacy: .public): \(error.localizedDescription)")
}
}
probeTasks.append(task)
}
}
@@ -0,0 +1,76 @@
import Foundation
enum DigiRadioError: LocalizedError, Equatable {
case notConnected
case invalidHost
case httpStatus(Int, String?)
case decodingFailed
case network(Error)
var errorDescription: String? {
switch self {
case .notConnected: return "Non connesso a DigiRadio."
case .invalidHost: return "Host non valido."
case let .httpStatus(code, reason): return "HTTP \(code): \(reason ?? "errore")"
case .decodingFailed: return "Risposta non valida dal dispositivo."
case let .network(error): return error.localizedDescription
}
}
static func == (lhs: DigiRadioError, rhs: DigiRadioError) -> Bool {
switch (lhs, rhs) {
case (.notConnected, .notConnected), (.invalidHost, .invalidHost), (.decodingFailed, .decodingFailed):
return true
case let (.httpStatus(a, b), .httpStatus(c, d)):
return a == c && b == d
default:
return false
}
}
}
/// High-level API for controlling DigiRadio (HTTP REST per firmware docs).
protocol DigiRadioService: AnyObject {
var state: DigiRadioState { get }
func connect(host: String) async throws
func disconnect()
func refreshHealth() async throws
func refreshTunerStatus() async throws
func refreshAudioProfile() async throws
func refreshBluetoothStatus() async throws
func refreshStations() async throws
func refreshStreaming() async throws
func setStreaming(enabled: Bool, url: String) async throws
func tuneFM(frequencyKhz: Int) async throws
func tuneDAB(freqIndex: Int) async throws
func playDAB(serviceId: Int, componentId: Int) async throws
func seekFM(direction: String) async throws
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse
func scanFullFM() async throws -> [FMScanHit]
func setVolume(_ volume: Int) async throws
func listDABServices() async throws -> [DABService]
func saveStation(_ station: Station) async throws
func removeStation(at index: Int) async throws
func reorderStation(from: Int, to: Int) async throws
func tuneStation(at index: Int) async throws
func applyAudioProfile(_ profile: AudioProfileDTO) async throws
func setStereoEnhance(level: Int) async throws
func setBassEnhance(level: Int) async throws
func resetAudio() async throws
func scanBluetooth(seconds: Int) async throws
func connectBluetooth(mac: String, name: String?, save: Bool) async throws
func reconnectBluetooth() async throws
}
/// Wire DTO for PUT /api/audio/profile
struct AudioProfileDTO: Codable, Equatable, Sendable {
var mixer: MixerState
var master: MasterVolumeState
var eq: [EQBandState]
var enhancements: EnhancementsState
}
@@ -0,0 +1,197 @@
import Foundation
import Observation
import OSLog
@Observable
final class RealDigiRadioService: DigiRadioService {
private(set) var state = DigiRadioState()
private let client = HTTPDigiRadioClient()
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "Device")
func connect(host: String) async throws {
state.connection.isConnecting = true
state.connection.lastError = nil
defer { state.connection.isConnecting = false }
try client.setHost(host)
state.connection.host = host
try await refreshHealth()
state.connection.isConnected = true
logger.info("Connected to \(host, privacy: .public)")
}
func disconnect() {
client.clearHost()
state.connection = ConnectionState()
}
func refreshHealth() async throws {
let health = try await client.fetchHealth()
state.health.status = health.status
state.health.firmware = health.fw
state.health.serialNumber = health.serialNumber
state.health.chips = ChipHealth(
si4684: health.chips.si4684,
adau1701: health.chips.adau1701,
bt1035: health.chips.bt1035
)
}
func refreshTunerStatus() async throws {
let tuner = try await client.fetchTunerStatus()
applyTuner(tuner)
}
func refreshAudioProfile() async throws {
let profile = try await client.fetchAudioProfile()
state.audio.mixer = profile.mixer
state.audio.master = profile.master
state.audio.eq = profile.eq.enumerated().map { index, band in
EQBandState(index: index, gainDb: band.gainDb, centerHz: band.centerHz, q: band.q)
}
state.audio.enhancements = profile.enhancements
}
func refreshBluetoothStatus() async throws {
let bt = try await client.fetchBluetoothStatus()
state.bluetooth.booted = bt.booted
state.bluetooth.pairing = bt.pairing
state.bluetooth.a2dpState = A2DPState(apiValue: bt.a2dp)
state.bluetooth.deviceName = bt.deviceName
state.bluetooth.autoReconnect = bt.autoReconnect
let speaker = try await client.fetchSavedSpeaker()
if speaker.configured, let mac = speaker.mac, let name = speaker.name {
state.bluetooth.savedSpeaker = SavedSpeaker(mac: mac, name: name)
} else {
state.bluetooth.savedSpeaker = nil
}
}
func refreshStations() async throws {
let list = try await client.fetchStations()
state.stations = list.stations
}
func refreshStreaming() async throws {
state.streaming = try await client.fetchStreaming()
}
func setStreaming(enabled: Bool, url: String) async throws {
let config = StreamingState(enabled: enabled, url: url)
state.streaming = try await client.setStreaming(config)
}
func tuneFM(frequencyKhz: Int) async throws {
let tuner = try await client.tune(body: TuneRequest(band: "fm", freqIndex: nil, frequencyKhz: frequencyKhz))
applyTuner(tuner)
}
func tuneDAB(freqIndex: Int) async throws {
let tuner = try await client.tune(body: TuneRequest(band: "dab", freqIndex: freqIndex, frequencyKhz: nil))
applyTuner(tuner)
}
func playDAB(serviceId: Int, componentId: Int) async throws {
try await client.playDAB(body: PlayRequest(serviceId: serviceId, componentId: componentId))
try await refreshTunerStatus()
}
func seekFM(direction: String) async throws {
_ = try await client.seekFM(direction: direction)
try await refreshTunerStatus()
}
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse {
try await client.scanStation(body: TunerScanRequest(band: "fm", maxSteps: maxSteps, name: name))
}
func scanFullFM() async throws -> [FMScanHit] {
let response = try await client.scanFullFM()
return response.stations
}
func setVolume(_ volume: Int) async throws {
var profile = try await client.fetchAudioProfile()
let clamped = max(0, min(100, volume))
profile.master.leftDb = Double(clamped)
profile.master.rightDb = Double(clamped)
try await client.putAudioProfile(profile)
state.tuner.volume = clamped
state.audio.master = profile.master
}
func listDABServices() async throws -> [DABService] {
let response = try await client.fetchDABServices()
return response.services
}
func saveStation(_ station: Station) async throws {
try await client.addStation(station)
try await refreshStations()
}
func removeStation(at index: Int) async throws {
try await client.removeStation(index: index)
try await refreshStations()
}
func reorderStation(from: Int, to: Int) async throws {
try await client.reorderStation(from: from, to: to)
try await refreshStations()
}
func tuneStation(at index: Int) async throws {
try await client.tuneStation(index: index)
try await refreshTunerStatus()
}
func applyAudioProfile(_ profile: AudioProfileDTO) async throws {
try await client.putAudioProfile(profile)
state.audio.mixer = profile.mixer
state.audio.master = profile.master
state.audio.eq = profile.eq
state.audio.enhancements = profile.enhancements
}
func setStereoEnhance(level: Int) async throws {
try await client.setEnhance(path: "/api/audio/stereo-enhance", level: level)
state.audio.enhancements.stereoLevel = level
}
func setBassEnhance(level: Int) async throws {
try await client.setEnhance(path: "/api/audio/bass-enhance", level: level)
state.audio.enhancements.bassLevel = level
}
func resetAudio() async throws {
try await client.resetAudio()
try await refreshAudioProfile()
}
func scanBluetooth(seconds: Int) async throws {
let response = try await client.scanBluetooth(seconds: seconds)
state.bluetooth.nearbyDevices = response.devices.map {
BluetoothDevice(index: $0.index, mac: $0.mac, name: $0.name, rssiDbm: $0.rssiDbm)
}
}
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
try await client.connectBluetooth(mac: mac, name: name, save: save)
try await refreshBluetoothStatus()
}
func reconnectBluetooth() async throws {
try await client.reconnectBluetooth()
try await refreshBluetoothStatus()
}
private func applyTuner(_ tuner: TunerStatusResponse) {
state.tuner.booted = tuner.booted
state.tuner.band = TunerBand(rawValue: tuner.band) ?? .fm
state.tuner.locked = tuner.locked
state.tuner.volume = tuner.volume
state.tuner.fm = tuner.fm
state.tuner.dab = tuner.dab
}
}
@@ -0,0 +1,415 @@
import Foundation
import OSLog
/// HTTP REST client for DigiRadio firmware API (ch-api.tex).
final class HTTPDigiRadioClient {
private let session: URLSession
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "HTTP")
private var baseURL: URL?
init(session: URLSession = .shared) {
self.session = session
}
func setHost(_ host: String) throws {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw DigiRadioError.invalidHost }
var normalized = trimmed
if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
normalized = "http://\(normalized)"
}
guard let url = URL(string: normalized) else { throw DigiRadioError.invalidHost }
baseURL = url
}
func clearHost() {
baseURL = nil
}
var isConfigured: Bool { baseURL != nil }
// MARK: - Health
func fetchHealth() async throws -> HealthResponse {
try await get("/api/health")
}
// MARK: - Tuner
func fetchTunerStatus() async throws -> TunerStatusResponse {
try await get("/api/tuner/status")
}
func fetchDABServices() async throws -> DABServicesResponse {
try await get("/api/tuner/services")
}
func tune(body: TuneRequest) async throws -> TunerStatusResponse {
try await post("/api/tuner/tune", body: body)
}
func playDAB(body: PlayRequest) async throws {
let _: StatusResponse = try await post("/api/tuner/play", body: body)
}
func seekFM(direction: String) async throws -> SeekResponse {
try await post("/api/tuner/seek", body: SeekRequest(direction: direction))
}
func scanStation(body: TunerScanRequest) async throws -> TunerScanResponse {
try await post("/api/tuner/scan", body: body)
}
func scanFullFM() async throws -> FMScanFullResponse {
try await post("/api/tuner/scan/full", body: EmptyBody())
}
// MARK: - Audio
func fetchAudioProfile() async throws -> AudioProfileDTO {
try await get("/api/audio/profile")
}
func putAudioProfile(_ profile: AudioProfileDTO) async throws {
let _: StatusResponse = try await put("/api/audio/profile", body: profile)
}
func resetAudio() async throws {
let _: StatusResponse = try await post("/api/audio/reset", body: EmptyBody())
}
func setEnhance(path: String, level: Int) async throws {
let _: StatusResponse = try await post(path, body: EnhanceRequest(level: level))
}
// MARK: - Bluetooth
func fetchBluetoothStatus() async throws -> BluetoothStatusResponse {
try await get("/api/bluetooth/status")
}
func fetchSavedSpeaker() async throws -> SavedSpeakerResponse {
try await get("/api/bluetooth/speaker")
}
func scanBluetooth(seconds: Int) async throws -> BluetoothScanResponse {
try await post("/api/bluetooth/scan", body: ScanRequest(seconds: seconds))
}
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
let _: StatusResponse = try await post(
"/api/bluetooth/connect",
body: ConnectRequest(mac: mac, name: name, save: save)
)
}
func reconnectBluetooth() async throws {
let _: StatusResponse = try await post("/api/bluetooth/reconnect", body: EmptyBody())
}
// MARK: - Stations
func fetchStations() async throws -> StationListResponse {
try await get("/api/stations")
}
func addStation(_ station: Station) async throws {
let _: StatusResponse = try await post("/api/stations", body: station)
}
func removeStation(index: Int) async throws {
let _: StatusResponse = try await post("/api/stations/remove", body: IndexRequest(index: index))
}
func reorderStation(from: Int, to: Int) async throws {
let _: StatusResponse = try await post(
"/api/stations/reorder",
body: ReorderRequest(from: from, to: to)
)
}
func tuneStation(index: Int) async throws {
let _: StatusResponse = try await post("/api/stations/tune", body: IndexRequest(index: index))
}
// MARK: - Streaming
func fetchStreaming() async throws -> StreamingState {
try await get("/api/streaming")
}
func setStreaming(_ config: StreamingState) async throws -> StreamingState {
try await post("/api/streaming", body: config)
}
// MARK: - HTTP helpers
private func url(for path: String) throws -> URL {
guard let base = baseURL else { throw DigiRadioError.notConnected }
guard let url = URL(string: path, relativeTo: base) else { throw DigiRadioError.invalidHost }
return url
}
private func get<T: Decodable>(_ path: String) async throws -> T {
let url = try url(for: path)
var request = URLRequest(url: url)
request.httpMethod = "GET"
return try await perform(request)
}
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let url = try url(for: path)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder.api.encode(body)
return try await perform(request)
}
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
let url = try url(for: path)
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder.api.encode(body)
return try await perform(request)
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
logger.debug("HTTP \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "")")
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw DigiRadioError.decodingFailed
}
guard (200 ... 299).contains(http.statusCode) else {
let reason = String(data: data, encoding: .utf8)
throw DigiRadioError.httpStatus(http.statusCode, reason)
}
do {
return try JSONDecoder.api.decode(T.self, from: data)
} catch {
logger.error("Decode failed: \(error.localizedDescription)")
throw DigiRadioError.decodingFailed
}
} catch let error as DigiRadioError {
throw error
} catch {
throw DigiRadioError.network(error)
}
}
}
// MARK: - API DTOs
struct EmptyBody: Codable {}
struct HealthResponse: Codable {
var status: String
var fw: String
var serialNumber: String
var chips: ChipHealthResponse
}
struct ChipHealthResponse: Codable {
var si4684: Bool
var adau1701: Bool
var bt1035: Bool
}
struct TunerStatusResponse: Codable {
var booted: Bool
var band: String
var locked: Bool
var volume: Int
var fm: FMTunerState?
var dab: DABTunerState?
enum CodingKeys: String, CodingKey {
case booted, band, locked, volume, fm, dab
}
}
extension TunerStatusResponse {
func dabDecoded() -> DABTunerState? {
guard let dab else { return nil }
return DABTunerState(
freqIndex: dab.freqIndex,
ficQuality: dab.ficQuality,
cnrDb: dab.cnrDb,
playingServiceId: dab.playingServiceId,
playingComponentId: dab.playingComponentId,
dynamicLabel: dab.dynamicLabel
)
}
}
struct DABServicesResponse: Codable {
var services: [DABService]
}
struct TuneRequest: Codable {
var band: String
var freqIndex: Int?
var frequencyKhz: Int?
enum CodingKeys: String, CodingKey {
case band
case freqIndex = "freq_index"
case frequencyKhz = "frequency_khz"
}
}
struct PlayRequest: Codable {
var serviceId: Int
var componentId: Int
enum CodingKeys: String, CodingKey {
case serviceId = "service_id"
case componentId = "component_id"
}
}
struct SeekRequest: Codable {
var direction: String
}
struct SeekResponse: Codable {
var frequencyKhz: Int?
enum CodingKeys: String, CodingKey {
case frequencyKhz = "frequency_khz"
}
}
struct EnhanceRequest: Codable {
var level: Int
}
struct StatusResponse: Codable {
var status: String
}
struct BluetoothStatusResponse: Codable {
var booted: Bool
var pairing: Bool
var a2dp: String
var deviceName: String
var autoReconnect: Int
enum CodingKeys: String, CodingKey {
case booted, pairing, a2dp
case deviceName = "device_name"
case autoReconnect = "auto_reconnect"
}
}
struct SavedSpeakerResponse: Codable {
var configured: Bool
var mac: String?
var name: String?
}
struct ScanRequest: Codable {
var seconds: Int
}
struct BluetoothScanResponse: Codable {
var devices: [BluetoothScanDevice]
}
struct BluetoothScanDevice: Codable {
var index: Int
var mac: String
var name: String
var rssiDbm: Int
enum CodingKeys: String, CodingKey {
case index, mac, name
case rssiDbm = "rssi_dbm"
}
}
struct ConnectRequest: Codable {
var mac: String
var name: String?
var save: Bool?
}
struct StationListResponse: Codable {
var stations: [Station]
}
struct IndexRequest: Codable {
var index: Int
}
struct ReorderRequest: Codable {
var from: Int
var to: Int
}
struct TunerScanRequest: Codable {
var band: String
var maxSteps: Int?
var name: String?
enum CodingKeys: String, CodingKey {
case band
case maxSteps = "max_steps"
case name
}
}
struct TunerScanResponse: Codable {
var status: String
var band: String?
var steps: Int?
var frequencyKhz: Int?
var stationName: String?
var freqIndex: Int?
var serviceId: Int?
var componentId: Int?
enum CodingKeys: String, CodingKey {
case status, band, steps
case frequencyKhz = "frequency_khz"
case stationName = "station_name"
case freqIndex = "freq_index"
case serviceId = "service_id"
case componentId = "component_id"
}
}
struct FMScanHit: Codable, Identifiable, Equatable {
var id: Int { frequencyKhz }
var frequencyKhz: Int
var rssiDbuv: Int?
var snrDb: Int?
var stationName: String?
enum CodingKeys: String, CodingKey {
case frequencyKhz = "frequency_khz"
case rssiDbuv = "rssi_dbuv"
case snrDb = "snr_db"
case stationName = "station_name"
}
}
struct FMScanFullResponse: Codable {
var stations: [FMScanHit]
}
extension JSONEncoder {
static let api: JSONEncoder = {
let e = JSONEncoder()
return e
}()
}
extension JSONDecoder {
static let api: JSONDecoder = {
let d = JSONDecoder()
return d
}()
}
@@ -0,0 +1,51 @@
import Foundation
import Observation
@Observable
final class AudioProfileEditorViewModel {
var profile: AudioProfileTemplate
private let service: any DigiRadioService
private let onSave: (AudioProfileTemplate) -> Void
private var applyTask: Task<Void, Never>?
var isSaving = false
var isNewProfile: Bool
init(
profile: AudioProfileTemplate,
service: any DigiRadioService,
isNewProfile: Bool,
onSave: @escaping (AudioProfileTemplate) -> Void
) {
self.profile = profile
self.service = service
self.isNewProfile = isNewProfile
self.onSave = onSave
}
func schedulePreviewApply() {
guard !profile.isBuiltIn else { return }
applyTask?.cancel()
applyTask = Task {
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
await applyToDevice(persistUserCopy: false)
}
}
func applyToDevice(persistUserCopy: Bool) async {
isSaving = true
defer { isSaving = false }
let audio = service.state.audio
let dto = profile.profileDTO(mixer: audio.mixer, master: audio.master)
try? await service.applyAudioProfile(dto)
if persistUserCopy, !profile.isBuiltIn {
onSave(profile)
}
}
func saveUserProfile() {
guard !profile.isBuiltIn else { return }
onSave(profile)
}
}
@@ -0,0 +1,94 @@
import Foundation
import Observation
@Observable
final class AudioProfilesViewModel {
private let service: any DigiRadioService
var builtIn: [AudioProfileTemplate] = AudioProfileLibrary.builtIn
var userProfiles: [AudioProfileTemplate] = []
var activeProfileID: String?
var isLoading = false
var isApplying = false
var errorMessage: String?
init(service: any DigiRadioService) {
self.service = service
reloadUserProfiles()
}
func reloadUserProfiles() {
userProfiles = AudioProfileStore.loadUserProfiles()
}
func load() async {
isLoading = true
defer { isLoading = false }
try? await service.refreshAudioProfile()
detectActiveProfile()
}
func detectActiveProfile() {
let audio = service.state.audio
activeProfileID = AudioProfileStore.allProfiles().first {
AudioProfileLibrary.matchActive($0, eq: audio.eq, enhancements: audio.enhancements)
}?.id
}
func apply(_ profile: AudioProfileTemplate) async {
isApplying = true
errorMessage = nil
defer { isApplying = false }
do {
let audio = service.state.audio
let dto = profile.profileDTO(mixer: audio.mixer, master: audio.master)
try await service.applyAudioProfile(dto)
activeProfileID = profile.id
} catch {
errorMessage = error.localizedDescription
}
}
func deleteUserProfile(_ profile: AudioProfileTemplate) {
guard !profile.isBuiltIn else { return }
userProfiles.removeAll { $0.id == profile.id }
AudioProfileStore.saveUserProfiles(userProfiles)
if activeProfileID == profile.id { activeProfileID = nil }
}
func saveUserProfile(_ profile: AudioProfileTemplate) {
var copy = profile
copy.isBuiltIn = false
if let index = userProfiles.firstIndex(where: { $0.id == copy.id }) {
userProfiles[index] = copy
} else {
userProfiles.append(copy)
}
AudioProfileStore.saveUserProfiles(userProfiles)
}
func duplicateAsUser(from profile: AudioProfileTemplate, name: String) -> AudioProfileTemplate {
AudioProfileTemplate(
id: UUID().uuidString,
name: name,
subtitle: "Profilo personalizzato",
systemImage: "slider.horizontal.3",
isBuiltIn: false,
eq: profile.eq,
enhancements: profile.enhancements
)
}
func profileFromDevice(name: String) -> AudioProfileTemplate {
let audio = service.state.audio
return AudioProfileTemplate(
id: UUID().uuidString,
name: name,
subtitle: "Creato dal dispositivo",
systemImage: "waveform.path.ecg",
isBuiltIn: false,
eq: audio.eq.isEmpty ? AudioProfileLibrary.builtIn[0].eq : audio.eq,
enhancements: audio.enhancements
)
}
}
@@ -0,0 +1,75 @@
import Foundation
import Observation
@Observable
final class AudioViewModel {
private let service: any DigiRadioService
private var applyTask: Task<Void, Never>?
var panel: AudioPanel = .mixer
var mixer = MixerState()
var master = MasterVolumeState()
var eqBands: [EQBandState] = []
var stereoLevel: Double = 0
var bassLevel: Double = 0
var isLoading = false
var isSaving = false
init(service: any DigiRadioService) {
self.service = service
}
func load() async {
isLoading = true
defer { isLoading = false }
try? await service.refreshAudioProfile()
syncFromDevice()
}
func syncFromDevice() {
let audio = service.state.audio
mixer = audio.mixer
master = audio.master
eqBands = audio.eq
stereoLevel = Double(audio.enhancements.stereoLevel)
bassLevel = Double(audio.enhancements.bassLevel)
if master.leftDb == 0 && master.rightDb == 0 {
let vol = Double(service.state.tuner.volume)
master = MasterVolumeState(leftDb: vol, rightDb: vol)
}
}
func scheduleApply() {
applyTask?.cancel()
applyTask = Task {
try? await Task.sleep(for: .milliseconds(350))
guard !Task.isCancelled else { return }
await applyNow()
}
}
func applyNow() async {
isSaving = true
defer { isSaving = false }
let profile = AudioProfileDTO(
mixer: mixer,
master: master,
eq: eqBands,
enhancements: EnhancementsState(stereoLevel: Int(stereoLevel), bassLevel: Int(bassLevel))
)
try? await service.applyAudioProfile(profile)
try? await service.setVolume(Int((master.leftDb + master.rightDb) / 2))
syncFromDevice()
}
func reset() async {
try? await service.resetAudio()
syncFromDevice()
}
func applyEnhancements() async {
try? await service.setStereoEnhance(level: Int(stereoLevel))
try? await service.setBassEnhance(level: Int(bassLevel))
syncFromDevice()
}
}
@@ -0,0 +1,67 @@
import Foundation
import Observation
@Observable
final class ConnectionViewModel {
private let environment: AppEnvironment
var hostInput = "digiradio-cc4db4.local"
var isBusy = false
var errorMessage: String?
let discovery = DigiRadioDiscoveryService()
init(environment: AppEnvironment) {
self.environment = environment
if environment.useMockDevice {
hostInput = "mock.local"
}
}
var state: DigiRadioState { environment.state }
func connect() async {
isBusy = true
errorMessage = nil
defer { isBusy = false }
do {
try await environment.digiRadio.connect(host: hostInput)
} catch {
errorMessage = error.localizedDescription
}
}
func connect(to host: String) async {
hostInput = host.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "")
await connect()
}
func disconnect() {
environment.digiRadio.disconnect()
}
func toggleMock(_ enabled: Bool) {
environment.setUseMock(enabled)
if enabled {
hostInput = "mock.local"
discovery.stop()
Task { try? await environment.digiRadio.connect(host: hostInput) }
} else {
environment.digiRadio.disconnect()
}
}
func startDiscovery() {
discovery.start()
discovery.addManualHost(hostInput)
}
func stopDiscovery() {
discovery.stop()
}
func pollDiscovery() {
discovery.refreshFromBLE()
if !hostInput.isEmpty {
discovery.addManualHost(hostInput)
}
}
}
@@ -0,0 +1,52 @@
import Foundation
import Observation
@Observable
final class HomeViewModel {
private let service: any DigiRadioService
private var refreshTask: Task<Void, Never>?
init(service: any DigiRadioService) {
self.service = service
}
var state: DigiRadioState { service.state }
func onAppear() {
refreshTask?.cancel()
refreshTask = Task {
await refreshAll()
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(3))
guard state.connection.isConnected else { continue }
try? await service.refreshTunerStatus()
}
}
}
func onDisappear() {
refreshTask?.cancel()
refreshTask = nil
}
func refreshAll() async {
guard state.connection.isConnected else { return }
try? await service.refreshHealth()
try? await service.refreshTunerStatus()
try? await service.refreshAudioProfile()
try? await service.refreshBluetoothStatus()
try? await service.refreshStations()
}
func setVolume(_ value: Double) async {
try? await service.setVolume(Int(value))
}
func seekFM(_ direction: String) async {
try? await service.seekFM(direction: direction)
}
func tunePreset(at index: Int) async {
try? await service.tuneStation(at: index)
}
}
@@ -0,0 +1,184 @@
import Foundation
import Observation
import UniformTypeIdentifiers
enum StreamSource: String, CaseIterable, Identifiable {
case webRadio = "Radio web"
case urlContent = "URL contenuto"
case localFile = "File iPhone"
var id: String { rawValue }
var subtitle: String {
switch self {
case .webRadio: "MP3 HTTP sul DigiRadio (decoder ESP32)"
case .urlContent: "Podcast, playlist o stream personalizzato"
case .localFile: "File audio decodificato e inviato dal telefono"
}
}
var icon: String {
switch self {
case .webRadio: "antenna.radiowaves.left.and.right"
case .urlContent: "link"
case .localFile: "music.note.list"
}
}
}
struct StreamPreset: Identifiable {
var id: String { name }
var name: String
var url: String
}
enum StreamPresets {
/// Default from firmware WebRadioConfig.hpp (documented example).
static let catalog: [StreamPreset] = [
StreamPreset(name: "Radio Monte Carlo", url: "http://edge.radiomontecarlo.net/RMC.mp3"),
StreamPreset(name: "Esempio MP3", url: "http://stream.example.com/radio.mp3")
]
}
@Observable
final class StreamingViewModel {
private let service: any DigiRadioService
var source: StreamSource = .webRadio
var enabled = false
var url = StreamPresets.catalog[0].url
var isLoading = false
var isSaving = false
var errorMessage: String?
var selectedFileName = ""
var selectedFileURL: URL?
var phoneStreamActive = false
var phoneStreamStatus = ""
private var statusPollTask: Task<Void, Never>?
init(service: any DigiRadioService) {
self.service = service
}
deinit {
statusPollTask?.cancel()
}
var connectionHost: String {
service.state.connection.host
}
var isConnected: Bool {
service.state.connection.isConnected
}
func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
try await service.refreshStreaming()
syncFromDevice()
} catch {
errorMessage = error.localizedDescription
}
startStatusPolling()
}
func selectPreset(_ preset: StreamPreset) {
url = preset.url
source = .webRadio
}
func normalizedHTTPURL() -> String? {
var trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if !trimmed.hasPrefix("http://") {
if trimmed.hasPrefix("https://") {
errorMessage = "Il firmware accetta solo URL http:// (non https)."
return nil
}
trimmed = "http://\(trimmed)"
}
return trimmed
}
func playWebStream() async {
guard let normalized = normalizedHTTPURL() else { return }
url = normalized
await apply(enabled: true)
}
func stopWebStream() async {
await apply(enabled: false)
}
func apply(enabled: Bool? = nil) async {
guard let normalized = normalizedHTTPURL() else { return }
isSaving = true
errorMessage = nil
defer { isSaving = false }
let targetEnabled = enabled ?? self.enabled
do {
try await service.setStreaming(enabled: targetEnabled, url: normalized)
syncFromDevice()
} catch {
errorMessage = error.localizedDescription
}
}
func pickFile(_ url: URL) {
selectedFileURL = url
selectedFileName = url.lastPathComponent
source = .localFile
errorMessage = nil
}
func playLocalFile() async {
guard isConnected, let fileURL = selectedFileURL else {
errorMessage = "Seleziona un file e connetti DigiRadio."
return
}
errorMessage = nil
await stopWebStreamSilently()
await PhonePCMStreamService.shared.start(fileURL: fileURL, connectionHost: connectionHost)
}
func stopLocalFile() async {
await PhonePCMStreamService.shared.stop()
phoneStreamActive = false
phoneStreamStatus = "Fermo"
}
func stopPolling() {
statusPollTask?.cancel()
statusPollTask = nil
}
private func stopWebStreamSilently() async {
try? await service.setStreaming(enabled: false, url: url)
syncFromDevice()
}
private func syncFromDevice() {
enabled = service.state.streaming.enabled
if !service.state.streaming.url.isEmpty {
url = service.state.streaming.url
}
}
private func startStatusPolling() {
statusPollTask?.cancel()
statusPollTask = Task { @MainActor in
while !Task.isCancelled {
let streamer = PhonePCMStreamService.shared
phoneStreamActive = await streamer.isStreaming
let status = await streamer.statusMessage
if !status.isEmpty { phoneStreamStatus = status }
try? await Task.sleep(for: .milliseconds(500))
}
}
}
}
@@ -0,0 +1,118 @@
import SwiftUI
struct AudioProfileEditorView: View {
@Environment(AppEnvironment.self) private var environment
@Environment(\.dismiss) private var dismiss
@State private var viewModel: AudioProfileEditorViewModel?
let profile: AudioProfileTemplate
let isNewProfile: Bool
let onSave: (AudioProfileTemplate) -> Void
var body: some View {
let vm = viewModel ?? AudioProfileEditorViewModel(
profile: profile,
service: environment.digiRadio,
isNewProfile: isNewProfile,
onSave: onSave
)
ScrollView {
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
if !vm.profile.isBuiltIn {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
Text("Nome profilo")
.font(.headline)
TextField("Nome", text: Binding(
get: { vm.profile.name },
set: { vm.profile.name = $0 }
))
.padding()
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
}
.igiPremiumCard()
}
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("Equalizzatore")
.font(.title3.weight(.bold))
IGIGraphicEqualizer(
bands: Binding(
get: { vm.profile.eq },
set: { vm.profile.eq = $0 }
),
onCommit: { vm.schedulePreviewApply() }
)
}
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("Enhancements")
.font(.headline)
HStack(spacing: IGITheme.spacingL) {
IGIEnhancementDial(
title: "Stereo",
systemImage: "circle.lefthalf.filled",
level: Binding(
get: { Double(vm.profile.enhancements.stereoLevel) },
set: { vm.profile.enhancements.stereoLevel = Int($0) }
),
onCommit: { vm.schedulePreviewApply() }
)
IGIEnhancementDial(
title: "Bass",
systemImage: "waveform.path",
level: Binding(
get: { Double(vm.profile.enhancements.bassLevel) },
set: { vm.profile.enhancements.bassLevel = Int($0) }
),
onCommit: { vm.schedulePreviewApply() }
)
}
}
.igiPremiumCard()
if !vm.profile.isBuiltIn {
Button {
vm.saveUserProfile()
Task { await vm.applyToDevice(persistUserCopy: true) }
dismiss()
} label: {
Label("Salva profilo", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
}
Button {
Task { await vm.applyToDevice(persistUserCopy: false) }
} label: {
Label("Applica a DigiRadio", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
if vm.profile.isBuiltIn {
Button {
let duplicate = vm.profile.duplicatedAsUser(named: "\(vm.profile.name) custom")
onSave(duplicate)
dismiss()
} label: {
Label("Salva come profilo personale", systemImage: "plus.square.on.square")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
}
}
.padding(IGITheme.spacingM)
}
.background(IGIHeroBackground())
.navigationTitle(vm.profile.isBuiltIn ? vm.profile.name : "Modifica profilo")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if viewModel == nil { viewModel = vm }
}
}
}
@@ -0,0 +1,190 @@
import SwiftUI
struct AudioProfilesView: View {
@Environment(AppEnvironment.self) private var environment
@State private var viewModel: AudioProfilesViewModel?
@State private var editorProfile: AudioProfileTemplate?
@State private var editorIsNew = false
@State private var showNewProfileSheet = false
@State private var newProfileName = ""
var body: some View {
let vm = viewModel ?? AudioProfilesViewModel(service: environment.digiRadio)
ScrollView {
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
activeBanner(vm)
profileSection(title: "Predefiniti", profiles: vm.builtIn, vm: vm, allowDelete: false, onDelete: nil)
profileSection(title: "I tuoi profili", profiles: vm.userProfiles, vm: vm, allowDelete: true, onDelete: { vm.deleteUserProfile($0) })
if vm.userProfiles.isEmpty {
Text("Crea un profilo personalizzato partendo da un preset o dal suono attuale del device.")
.font(.caption)
.foregroundStyle(.secondary)
.igiPremiumCard()
}
HStack(spacing: IGITheme.spacingM) {
Button {
newProfileName = "Il mio profilo"
showNewProfileSheet = true
} label: {
Label("Nuovo profilo", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
NavigationLink {
AudioView()
} label: {
Label("Mixer", systemImage: "slider.vertical.3")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
}
}
.padding(IGITheme.spacingM)
}
.background(IGIHeroBackground())
.navigationTitle("Profilo audio")
.navigationBarTitleDisplayMode(.large)
.navigationDestination(item: $editorProfile) { profile in
AudioProfileEditorView(
profile: profile,
isNewProfile: editorIsNew,
onSave: { saved in
vm.saveUserProfile(saved)
vm.reloadUserProfiles()
vm.activeProfileID = saved.id
}
)
}
.alert("Nuovo profilo", isPresented: $showNewProfileSheet) {
TextField("Nome", text: $newProfileName)
Button("Annulla", role: .cancel) {}
Button("Crea") {
let profile = vm.profileFromDevice(name: newProfileName.isEmpty ? "Il mio profilo" : newProfileName)
editorIsNew = true
editorProfile = profile
}
} message: {
Text("Parte dal profilo attualmente sul DigiRadio.")
}
.onAppear {
if viewModel == nil { viewModel = vm }
Task { await vm.load() }
}
}
@ViewBuilder
private func activeBanner(_ vm: AudioProfilesViewModel) -> some View {
HStack(spacing: IGITheme.spacingM) {
Image(systemName: "checkmark.seal.fill")
.font(.title2)
.foregroundStyle(IGITheme.accent)
VStack(alignment: .leading, spacing: 2) {
Text("Profilo attivo")
.font(.caption)
.foregroundStyle(.secondary)
Text(vm.builtIn.first(where: { $0.id == vm.activeProfileID })?.name
?? vm.userProfiles.first(where: { $0.id == vm.activeProfileID })?.name
?? "Personalizzato / device")
.font(.headline)
}
Spacer()
if vm.isApplying { ProgressView() }
}
.igiPremiumCard()
}
@ViewBuilder
private func profileSection(
title: String,
profiles: [AudioProfileTemplate],
vm: AudioProfilesViewModel,
allowDelete: Bool,
onDelete: ((AudioProfileTemplate) -> Void)?
) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text(title)
.font(.title3.weight(.bold))
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: IGITheme.spacingS)], spacing: IGITheme.spacingS) {
ForEach(profiles) { profile in
AudioProfileCard(
profile: profile,
isActive: vm.activeProfileID == profile.id,
allowDelete: allowDelete
) {
Task { await vm.apply(profile) }
} onEdit: {
editorIsNew = false
editorProfile = profile
} onDelete: {
onDelete?(profile)
}
}
}
}
.igiPremiumCard()
}
}
private struct AudioProfileCard: View {
var profile: AudioProfileTemplate
var isActive: Bool
var allowDelete: Bool
var onApply: () -> Void
var onEdit: () -> Void
var onDelete: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
HStack {
Image(systemName: profile.systemImage)
.foregroundStyle(isActive ? .white : IGITheme.accent)
Spacer()
if isActive {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white)
}
}
Text(profile.name)
.font(.headline)
.foregroundStyle(isActive ? .white : .primary)
Text(profile.subtitle)
.font(.caption2)
.foregroundStyle(isActive ? .white.opacity(0.85) : .secondary)
.lineLimit(2)
HStack(spacing: IGITheme.spacingS) {
Button("Applica", action: onApply)
.font(.caption.weight(.semibold))
.buttonStyle(.borderedProminent)
.tint(isActive ? .white : IGITheme.accent)
Button("Modifica", action: onEdit)
.font(.caption.weight(.semibold))
.buttonStyle(.bordered)
.tint(isActive ? .white : .primary)
}
.padding(.top, 4)
}
.padding(IGITheme.spacingM)
.background(
isActive
? AnyShapeStyle(IGITheme.accent.gradient)
: AnyShapeStyle(Color.primary.opacity(0.05)),
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
)
.contextMenu {
if allowDelete {
Button(role: .destructive, action: onDelete) {
Label("Elimina", systemImage: "trash")
}
}
}
}
}
@@ -0,0 +1,199 @@
import SwiftUI
struct AudioView: View {
@Environment(AppEnvironment.self) private var environment
@State private var viewModel: AudioViewModel?
var body: some View {
let vm = viewModel ?? AudioViewModel(service: environment.digiRadio)
ScrollView {
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
header(vm)
if vm.isLoading {
ProgressView("Caricamento profilo audio…")
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
} else {
IGIMasterVolumeHero(
leftDb: Binding(
get: { vm.master.leftDb },
set: { vm.master.leftDb = $0; vm.scheduleApply() }
),
rightDb: Binding(
get: { vm.master.rightDb },
set: { vm.master.rightDb = $0; vm.scheduleApply() }
),
onCommit: { vm.scheduleApply() }
)
panelPicker(vm)
switch vm.panel {
case .mixer:
mixerPanel(vm)
case .enhance:
enhancePanel(vm)
}
resetButton(vm)
}
}
.padding(IGITheme.spacingM)
}
.background(
LinearGradient(
colors: [
IGITheme.screenBackground,
IGITheme.accent.opacity(0.06),
IGITheme.screenBackground
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
)
.navigationTitle("Audio")
.navigationBarTitleDisplayMode(.large)
.onAppear {
if viewModel == nil { viewModel = vm }
Task { await vm.load() }
}
}
@ViewBuilder
private func header(_ vm: AudioViewModel) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("ADAU1701")
.font(.subheadline.weight(.semibold))
.foregroundStyle(IGITheme.accent)
Text("Mixer · EQ · DSP")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if vm.isSaving {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Salvataggio")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
@ViewBuilder
private func panelPicker(_ vm: AudioViewModel) -> some View {
HStack(spacing: IGITheme.spacingS) {
ForEach(AudioPanel.allCases) { panel in
Button {
withAnimation(.snappy) { vm.panel = panel }
} label: {
Label(panel.rawValue, systemImage: panel.icon)
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(
vm.panel == panel
? AnyShapeStyle(IGITheme.accent.gradient)
: AnyShapeStyle(Color.primary.opacity(0.06)),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.foregroundStyle(vm.panel == panel ? .white : .primary)
}
.buttonStyle(.plain)
}
}
}
@ViewBuilder
private func mixerPanel(_ vm: AudioViewModel) -> some View {
VStack(spacing: IGITheme.spacingM) {
IGIMixerChannelGroup(
title: "Radio Si4684",
subtitle: "FM / DAB — ingresso tuner",
systemImage: "antenna.radiowaves.left.and.right",
leftDb: Binding(
get: { vm.mixer.si4684LeftDb },
set: { vm.mixer.si4684LeftDb = $0 }
),
rightDb: Binding(
get: { vm.mixer.si4684RightDb },
set: { vm.mixer.si4684RightDb = $0 }
),
onCommit: { vm.scheduleApply() }
)
IGIMixerChannelGroup(
title: "Stream ESP32",
subtitle: "Web radio / phone push",
systemImage: "waveform",
leftDb: Binding(
get: { vm.mixer.esp32LeftDb },
set: { vm.mixer.esp32LeftDb = $0 }
),
rightDb: Binding(
get: { vm.mixer.esp32RightDb },
set: { vm.mixer.esp32RightDb = $0 }
),
onCommit: { vm.scheduleApply() }
)
IGIMixerChannelGroup(
title: "Mix bus",
subtitle: "Uscita mixer ADAU",
systemImage: "speaker.wave.2.fill",
leftDb: Binding(
get: { vm.mixer.mixLeftDb },
set: { vm.mixer.mixLeftDb = $0 }
),
rightDb: Binding(
get: { vm.mixer.mixRightDb },
set: { vm.mixer.mixRightDb = $0 }
),
onCommit: { vm.scheduleApply() }
)
}
}
@ViewBuilder
private func enhancePanel(_ vm: AudioViewModel) -> some View {
HStack(spacing: IGITheme.spacingL) {
IGIEnhancementDial(
title: "Stereo",
systemImage: "circle.lefthalf.filled",
level: Binding(
get: { vm.stereoLevel },
set: { vm.stereoLevel = $0 }
),
onCommit: { Task { await vm.applyEnhancements() } }
)
IGIEnhancementDial(
title: "Bass",
systemImage: "waveform.path",
level: Binding(
get: { vm.bassLevel },
set: { vm.bassLevel = $0 }
),
onCommit: { Task { await vm.applyEnhancements() } }
)
}
.igiAudioCard()
}
@ViewBuilder
private func resetButton(_ vm: AudioViewModel) -> some View {
Button(role: .destructive) {
Task { await vm.reset() }
} label: {
Label("Ripristina profilo audio", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
.padding(.top, IGITheme.spacingS)
}
}
@@ -0,0 +1,62 @@
import SwiftUI
struct BLEProvisioningView: View {
@State private var service = BLEProvisioningService()
var body: some View {
List {
Section("Ricerca") {
switch service.phase {
case .idle:
Text("Premi Avvia per cercare dispositivi DigiRadio in setup mode.")
.foregroundStyle(.secondary)
case .scanning:
HStack {
IGIScanningIndicator().frame(width: 28, height: 28)
Text("Cerca DigiRadio...")
}
case let .found(name, _):
Label("Trovato: \(name)", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
case .unsupported:
Text("Provisioning BLE non ancora integrato in igiRadio.")
case let .error(message):
Text(message).foregroundStyle(.red)
}
}
if !service.discoveredDevices.isEmpty {
Section("Dispositivi") {
ForEach(service.discoveredDevices, id: \.id) { device in
VStack(alignment: .leading) {
Text(device.name).font(.headline)
Text("RSSI \(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
}
}
}
}
Section {
Button("Avvia scansione") { service.startScan() }
Button("Ferma scansione") { service.stopScan() }
}
Section("Istruzioni") {
Text("1. Metti DigiRadio in setup mode (SoftAP DigiRadio-<suffix>).")
Text("2. Il dispositivo espone anche BLE provisioning ESP-IDF.")
Text("3. Proof of Possession (PoP) = serial number del dispositivo.")
Text("4. Dopo il join WiFi, usa la connessione HTTP in igiRadio.")
}
.font(.footnote)
.foregroundStyle(.secondary)
Section {
Text("UNKNOWN — specification required: implementazione completa protocomm Security1 in-app (attualmente consigliata app ESP BLE Provisioning di Espressif).")
.font(.footnote)
.foregroundStyle(.orange)
}
}
.navigationTitle("BLE Provisioning")
.onDisappear { service.stopScan() }
}
}
@@ -0,0 +1,73 @@
import SwiftUI
struct BluetoothView: View {
@Environment(AppEnvironment.self) private var environment
@State private var isScanning = false
var body: some View {
let bt = environment.state.bluetooth
Form {
Section("Stato speaker (BT1035)") {
LabeledContent("Modulo", value: bt.booted ? "Attivo" : "Non pronto")
LabeledContent("A2DP", value: bt.a2dpState.rawValue.capitalized)
LabeledContent("Dispositivo", value: bt.deviceName.isEmpty ? "" : bt.deviceName)
if let speaker = bt.savedSpeaker {
LabeledContent("Speaker salvato", value: speaker.name)
Text(speaker.mac).font(.caption).foregroundStyle(.secondary)
}
}
Section("Azioni") {
Button(isScanning ? "Scansione..." : "Scansiona speaker vicini") {
Task { await scan() }
}
.disabled(isScanning)
Button("Riconnetti speaker salvato") {
Task { try? await environment.digiRadio.reconnectBluetooth() }
}
}
if !bt.nearbyDevices.isEmpty {
Section("Dispositivi trovati") {
ForEach(bt.nearbyDevices) { device in
HStack {
VStack(alignment: .leading) {
Text(device.name).font(.headline)
Text(device.mac).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(device.rssiDbm) dBm")
.font(.caption)
.foregroundStyle(.secondary)
Button("Connetti") {
Task {
try? await environment.digiRadio.connectBluetooth(
mac: device.mac,
name: device.name,
save: true
)
}
}
.buttonStyle(.borderedProminent)
}
}
}
}
Section {
Text("Questa sezione configura il modulo Bluetooth classic (BT1035) che invia audio A2DP verso un altoparlante esterno. È distinta dalla connessione BLE usata solo per il provisioning WiFi.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Bluetooth Audio")
.onAppear { Task { try? await environment.digiRadio.refreshBluetoothStatus() } }
}
private func scan() async {
isScanning = true
defer { isScanning = false }
try? await environment.digiRadio.scanBluetooth(seconds: 8)
}
}
@@ -0,0 +1,144 @@
import SwiftUI
struct ConnectionView: View {
@Environment(AppEnvironment.self) private var environment
@State private var viewModel: ConnectionViewModel?
@State private var discoveryTimer: Timer?
var body: some View {
let vm = viewModel ?? ConnectionViewModel(environment: environment)
let state = environment.state
Form {
Section("Dispositivo") {
Toggle("Modalità demo (Mock)", isOn: Binding(
get: { environment.useMockDevice },
set: { vm.toggleMock($0) }
))
}
if !environment.useMockDevice {
Section("Ricerca in rete") {
if vm.discovery.isSearching {
HStack {
IGIScanningIndicator().frame(width: 28, height: 28)
Text("Cerca DigiRadio...")
}
Button("Ferma ricerca") { stopDiscovery(vm) }
} else {
Button("Cerca dispositivi") { startDiscovery(vm) }
}
if vm.discovery.devices.isEmpty {
Text("Avvia la ricerca per trovare DigiRadio via BLE (setup) o verificare l'host inserito.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(vm.discovery.devices) { device in
Button {
Task { await vm.connect(to: device.host) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(device.name).font(.headline)
Text(device.host).font(.caption).foregroundStyle(.secondary)
if let fw = device.firmware {
Text("FW \(fw)").font(.caption2).foregroundStyle(.secondary)
}
}
Spacer()
if device.isReachable {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
} else {
Image(systemName: "questionmark.circle").foregroundStyle(.orange)
}
}
}
.disabled(vm.isBusy)
}
}
}
}
Section("Connessione HTTP") {
TextField("Host", text: Binding(
get: { vm.hostInput },
set: { vm.hostInput = $0 }
))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
if state.connection.isConnected {
LabeledContent("Stato", value: "Connesso")
LabeledContent("Host", value: state.connection.host)
}
if let error = vm.errorMessage ?? state.connection.lastError {
Text(error).foregroundStyle(.red)
}
}
Section {
if state.connection.isConnected {
Button("Disconnetti", role: .destructive) {
vm.disconnect()
}
} else {
Button {
Task { await vm.connect() }
} label: {
if vm.isBusy {
HStack {
IGIScanningIndicator().frame(width: 24, height: 24)
Text("Connessione...")
}
} else {
Text("Connetti")
}
}
.disabled(vm.isBusy)
}
}
Section("Provisioning WiFi (BLE)") {
NavigationLink("Configura WiFi via BLE") {
BLEProvisioningView()
}
Text("DigiRadio espone BLE solo per il provisioning WiFi. Il nome BLE (es. DigiRadio-CC4DB4) corrisponde all'host HTTP digiradio-cc4db4.local.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Suggerimenti host") {
Text("• Setup mode: http://192.168.4.1")
Text("• STA mode: http://digiradio-<suffix>.local")
Text("• Oppure IP LAN del dispositivo")
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.navigationTitle("Connessione")
.onAppear {
if viewModel == nil { viewModel = vm }
if environment.useMockDevice && !state.connection.isConnected {
Task { await vm.connect() }
}
}
.onDisappear { stopDiscovery(vm) }
}
private func startDiscovery(_ vm: ConnectionViewModel) {
vm.startDiscovery()
discoveryTimer?.invalidate()
discoveryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
vm.pollDiscovery()
}
}
private func stopDiscovery(_ vm: ConnectionViewModel) {
discoveryTimer?.invalidate()
discoveryTimer = nil
vm.stopDiscovery()
}
}
@@ -0,0 +1,77 @@
import SwiftUI
struct DABRadioView: View {
@Environment(AppEnvironment.self) private var environment
@State private var freqIndex: Double = 12
@State private var services: [DABService] = []
@State private var loadError: String?
var body: some View {
let dab = environment.state.tuner.dab
Form {
Section("Ensemble") {
Stepper("Freq index: \(Int(freqIndex))", value: Binding(
get: { Int(freqIndex) },
set: { freqIndex = Double($0) }
), in: 0 ... 37)
Button("Sintonizza ensemble") {
Task { try? await environment.digiRadio.tuneDAB(freqIndex: Int(freqIndex)) }
}
}
if let dab {
Section("Stato DAB") {
LabeledContent("Freq index", value: "\(dab.freqIndex)")
if let fic = dab.ficQuality { LabeledContent("FIC quality", value: "\(fic)") }
if let cnr = dab.cnrDb { LabeledContent("CNR", value: "\(cnr) dB") }
if let label = dab.dynamicLabel { LabeledContent("DLS", value: label) }
}
}
Section("Servizi") {
if let loadError {
Text(loadError).foregroundStyle(.red)
}
if services.isEmpty {
Text("Nessun servizio caricato")
.foregroundStyle(.secondary)
} else {
ForEach(services) { service in
Button {
Task {
try? await environment.digiRadio.playDAB(
serviceId: service.serviceId,
componentId: service.componentId
)
}
} label: {
VStack(alignment: .leading) {
Text(service.label).font(.headline)
Text("SID \(service.serviceId) · CID \(service.componentId)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Button("Carica servizi") { Task { await loadServices() } }
}
}
.navigationTitle("DAB")
.onAppear {
if let idx = environment.state.tuner.dab?.freqIndex {
freqIndex = Double(idx)
}
Task { try? await environment.digiRadio.refreshTunerStatus() }
}
}
private func loadServices() async {
loadError = nil
do {
services = try await environment.digiRadio.listDABServices()
} catch {
loadError = error.localizedDescription
}
}
}
@@ -0,0 +1,202 @@
import SwiftUI
struct FMRadioView: View {
@Environment(AppEnvironment.self) private var environment
@State private var frequencyMHz: Double = 102.3
@State private var isTuning = false
@State private var isScanning = false
@State private var scanResults: [FMScanHit] = []
@State private var scanError: String?
var body: some View {
let fm = environment.state.tuner.fm
ScrollView {
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
IGIFrequencyHero(
frequencyMHz: $frequencyMHz,
stationName: fm?.stationName,
isTuning: isTuning,
onTune: { Task { await tune() } }
)
if let fm {
signalCard(fm)
if let ps = fm.stationName, !ps.isEmpty {
rdsCard(fm, ps: ps)
}
}
seekSection
scanSection
}
.padding(IGITheme.spacingM)
}
.background(IGIHeroBackground())
.navigationTitle("FM")
.navigationBarTitleDisplayMode(.large)
.onAppear {
if let khz = environment.state.tuner.fm?.frequencyKhz {
frequencyMHz = Double(khz) / 1000.0
}
Task { try? await environment.digiRadio.refreshTunerStatus() }
}
.onChange(of: environment.state.tuner.fm?.frequencyKhz) { _, newValue in
if let khz = newValue {
withAnimation(.snappy) {
frequencyMHz = Double(khz) / 1000.0
}
}
}
}
@ViewBuilder
private func signalCard(_ fm: FMTunerState) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("Segnale")
.font(.headline)
if let rssi = fm.rssiDbuv {
IGIMetricBar(label: "RSSI", value: "\(rssi) dBµV", level: Double(rssi) / 80.0)
}
if let snr = fm.snrDb {
IGIMetricBar(label: "SNR", value: "\(snr) dB", level: Double(snr) / 40.0)
}
if let stereo = fm.stereo {
HStack {
Text("Stereo")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(stereo ? "" : "Mono")
.font(.caption.weight(.semibold))
.foregroundStyle(stereo ? .green : .secondary)
}
}
}
.igiPremiumCard()
}
@ViewBuilder
private func rdsCard(_ fm: FMTunerState, ps: String) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
HStack {
Image(systemName: "text.bubble.fill")
.foregroundStyle(IGITheme.accent)
Text("RDS")
.font(.headline)
}
Text(ps)
.font(.title3.weight(.semibold))
if let rt = fm.radiotext, !rt.isEmpty {
Text(rt)
.font(.subheadline)
.foregroundStyle(.secondary)
.italic()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.igiPremiumCard()
}
private var seekSection: some View {
HStack(spacing: IGITheme.spacingM) {
Button {
Task { try? await environment.digiRadio.seekFM(direction: "down") }
} label: {
Label("Giù", systemImage: "chevron.down")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
Button {
Task { try? await environment.digiRadio.seekFM(direction: "up") }
} label: {
Label("Su", systemImage: "chevron.up")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
}
}
@ViewBuilder
private var scanSection: some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("Scan")
.font(.headline)
if isScanning {
HStack(spacing: IGITheme.spacingS) {
IGIScanningIndicator().frame(width: 28, height: 28)
Text("Scansione in corso…")
.foregroundStyle(.secondary)
}
}
if let scanError {
Text(scanError).font(.caption).foregroundStyle(.red)
}
HStack(spacing: IGITheme.spacingS) {
Button("Prossima stazione") { Task { await scanNext() } }
.buttonStyle(.borderedProminent)
.disabled(isScanning)
Button("Banda completa") { Task { await scanFull() } }
.buttonStyle(.bordered)
.disabled(isScanning)
}
if !scanResults.isEmpty {
VStack(spacing: IGITheme.spacingS) {
ForEach(scanResults) { hit in
IGIScanResultRow(
title: hit.stationName ?? "Stazione FM",
frequencyMHz: Double(hit.frequencyKhz) / 1000,
rssi: hit.rssiDbuv
) {
Task { try? await environment.digiRadio.tuneFM(frequencyKhz: hit.frequencyKhz) }
}
}
}
}
}
.igiPremiumCard()
}
private func tune() async {
isTuning = true
defer { isTuning = false }
let khz = Int((frequencyMHz * 1000).rounded())
try? await environment.digiRadio.tuneFM(frequencyKhz: khz)
}
private func scanNext() async {
isScanning = true
scanError = nil
defer { isScanning = false }
do {
let result = try await environment.digiRadio.scanFM(maxSteps: 45, name: nil)
if result.status == "found", let khz = result.frequencyKhz {
try? await environment.digiRadio.tuneFM(frequencyKhz: khz)
} else {
scanError = "Nessuna stazione trovata"
}
} catch {
scanError = error.localizedDescription
}
}
private func scanFull() async {
isScanning = true
scanError = nil
defer { isScanning = false }
do {
scanResults = try await environment.digiRadio.scanFullFM()
if scanResults.isEmpty {
scanError = "Nessuna stazione nella banda"
}
} catch {
scanError = error.localizedDescription
}
}
}
@@ -0,0 +1,292 @@
import SwiftUI
struct HomeView: View {
@Environment(AppEnvironment.self) private var environment
@State private var viewModel: HomeViewModel?
@State private var volume: Double = 42
var body: some View {
let state = environment.state
ScrollView {
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
header(state: state)
nowPlayingCard(state: state)
streamCard(state: state)
quickLinks
IGITransportCluster(
onPrevious: { Task { await viewModel?.seekFM("down") } },
onPlay: { Task { await viewModel?.refreshAll() } },
onNext: { Task { await viewModel?.seekFM("up") } }
)
volumeSection
presetsSection(state: state)
}
.padding(IGITheme.spacingM)
}
.background(IGIHeroBackground())
.navigationTitle("igiRadio")
.navigationBarTitleDisplayMode(.large)
.safeAreaInset(edge: .top) {
if !state.connection.isConnected {
connectionBanner
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
ConnectionView()
} label: {
Label(state.connection.isConnected ? "Connesso" : "Connetti", systemImage: "link")
}
}
}
.onAppear {
if viewModel == nil {
viewModel = HomeViewModel(service: environment.digiRadio)
}
viewModel?.onAppear()
volume = Double(state.tuner.volume)
Task { try? await environment.digiRadio.refreshStreaming() }
}
.onDisappear { viewModel?.onDisappear() }
.onChange(of: environment.state.tuner.volume) { _, newValue in
volume = Double(newValue)
}
}
private var connectionBanner: some View {
NavigationLink {
ConnectionView()
} label: {
HStack {
Image(systemName: "wifi.exclamationmark")
Text("DigiRadio non connesso — tocca per collegare")
.font(.subheadline.weight(.medium))
Spacer()
Image(systemName: "chevron.right")
}
.padding(.horizontal, IGITheme.spacingM)
.padding(.vertical, 10)
.background(.orange.opacity(0.15))
}
.buttonStyle(.plain)
}
@ViewBuilder
private func header(state: DigiRadioState) -> some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 8) {
Text("DigiRadio")
.font(.title.weight(.bold))
HStack(spacing: 8) {
IGIStatusDot(isConnected: state.connection.isConnected)
Text(state.connection.isConnected ? "Connesso" : "Non connesso")
.font(.subheadline)
.foregroundStyle(.secondary)
}
IGIBandBadge(band: state.tuner.band, locked: state.tuner.locked)
}
Spacer()
if let rssi = state.tuner.fm?.rssiDbuv {
VStack(alignment: .trailing, spacing: 6) {
Text("Segnale")
.font(.caption)
.foregroundStyle(.secondary)
IGISignalBar(level: Double(rssi) / 80.0)
.frame(width: 100)
}
}
}
}
@ViewBuilder
private func nowPlayingCard(state: DigiRadioState) -> some View {
VStack(spacing: IGITheme.spacingM) {
ZStack {
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(
LinearGradient(
colors: [
IGITheme.accent.opacity(0.45),
.purple.opacity(0.35),
IGITheme.accent.opacity(0.25)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(height: 200)
.overlay {
Circle()
.fill(.white.opacity(0.08))
.frame(width: 160, height: 160)
.blur(radius: 2)
}
Image(systemName: state.tuner.band == .fm ? "radio.fill" : "antenna.radiowaves.left.and.right")
.font(.system(size: 56))
.foregroundStyle(.white.opacity(0.95))
.symbolEffect(.pulse, options: .repeating, value: state.tuner.locked)
}
VStack(spacing: 6) {
Text(primaryTitle(state: state))
.font(.title2.weight(.bold))
.multilineTextAlignment(.center)
.contentTransition(.interpolate)
Text(secondaryLine(state: state))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
}
}
.igiPremiumCard()
.animation(.snappy, value: state.tuner.fm?.frequencyKhz)
.animation(.snappy, value: state.tuner.fm?.stationName)
}
@ViewBuilder
private func streamCard(state: DigiRadioState) -> some View {
NavigationLink {
StreamingView()
} label: {
HStack(spacing: IGITheme.spacingM) {
ZStack {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(state.streaming.enabled ? IGITheme.accent.opacity(0.2) : Color.secondary.opacity(0.12))
.frame(width: 52, height: 52)
Image(systemName: state.streaming.enabled ? "dot.radiowaves.forward" : "dot.radiowaves.forward.slash")
.font(.title2)
.foregroundStyle(state.streaming.enabled ? IGITheme.accent : .secondary)
}
VStack(alignment: .leading, spacing: 4) {
Text("Web radio stream")
.font(.headline)
if state.streaming.enabled, !state.streaming.url.isEmpty {
Text(state.streaming.url)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("Nessuno stream attivo")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
.igiPremiumCard()
}
.buttonStyle(.plain)
}
private var quickLinks: some View {
HStack(spacing: IGITheme.spacingS) {
NavigationLink {
FMRadioView()
} label: {
quickLinkLabel("FM", icon: "dot.radiowaves.left.and.right")
}
NavigationLink {
DABRadioView()
} label: {
quickLinkLabel("DAB", icon: "antenna.radiowaves.left.and.right")
}
NavigationLink {
AudioProfilesView()
} label: {
quickLinkLabel("Profilo", icon: "waveform.path.ecg")
}
NavigationLink {
StreamingView()
} label: {
quickLinkLabel("Stream", icon: "dot.radiowaves.forward")
}
}
}
@ViewBuilder
private func quickLinkLabel(_ title: String, icon: String) -> some View {
Label(title, systemImage: icon)
.font(.caption.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var volumeSection: some View {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
HStack {
Text("Volume")
.font(.headline)
Spacer()
Text("\(Int(volume))")
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(IGITheme.accent)
.contentTransition(.numericText())
}
IGIVolumeSlider(value: $volume) { value in
Task { await viewModel?.setVolume(value) }
}
}
.igiPremiumCard()
}
@ViewBuilder
private func presetsSection(state: DigiRadioState) -> some View {
IGISectionHeader(title: "Preset")
if state.stations.isEmpty {
IGIEmptyState(
title: "Nessun preset",
message: "Salva le tue stazioni preferite dalla schermata Preset.",
systemImage: "star"
)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: IGITheme.spacingS) {
ForEach(Array(state.stations.enumerated()), id: \.offset) { index, station in
IGIPresetChip(
name: station.name,
subtitle: station.band.rawValue.uppercased()
) {
Task { await viewModel?.tunePreset(at: index) }
}
}
}
}
}
}
private func primaryTitle(state: DigiRadioState) -> String {
switch state.tuner.band {
case .fm:
if let name = state.tuner.fm?.stationName, !name.isEmpty { return name }
if let khz = state.tuner.fm?.frequencyKhz {
return String(format: "%.2f MHz", Double(khz) / 1000.0)
}
return "FM"
case .dab:
return state.tuner.dab?.dynamicLabel ?? "DAB"
}
}
private func secondaryLine(state: DigiRadioState) -> String {
switch state.tuner.band {
case .fm:
if let khz = state.tuner.fm?.frequencyKhz {
let freq = String(format: "%.2f MHz", Double(khz) / 1000.0)
if let rt = state.tuner.fm?.radiotext, !rt.isEmpty { return "\(freq) · \(rt)" }
return freq
}
return state.tuner.fm?.radiotext ?? "FM Radio"
case .dab:
if let cnr = state.tuner.dab?.cnrDb {
return "CNR \(cnr) dB"
}
return "Digital Radio"
}
}
}
@@ -0,0 +1,86 @@
import SwiftUI
struct PresetsView: View {
@Environment(AppEnvironment.self) private var environment
@State private var newName = ""
@State private var newBand: TunerBand = .fm
@State private var newFMKhz = "102300"
var body: some View {
List {
Section {
ForEach(Array(environment.state.stations.enumerated()), id: \.offset) { index, station in
HStack {
VStack(alignment: .leading) {
Text(station.name).font(.headline)
Text(detail(for: station)).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button("Play") {
Task { try? await environment.digiRadio.tuneStation(at: index) }
}
.buttonStyle(.bordered)
}
}
.onDelete { offsets in
for index in offsets {
Task { try? await environment.digiRadio.removeStation(at: index) }
}
}
.onMove { from, to in
guard let source = from.first else { return }
Task { try? await environment.digiRadio.reorderStation(from: source, to: to) }
}
}
Section("Aggiungi preset") {
TextField("Nome", text: $newName)
Picker("Banda", selection: $newBand) {
ForEach(TunerBand.allCases, id: \.self) { band in
Text(band.rawValue.uppercased()).tag(band)
}
}
if newBand == .fm {
TextField("Frequenza kHz", text: $newFMKhz)
.keyboardType(.numberPad)
}
Button("Salva") { Task { await savePreset() } }
.disabled(newName.isEmpty)
}
}
.navigationTitle("Preset")
.toolbar { EditButton() }
.onAppear { Task { try? await environment.digiRadio.refreshStations() } }
}
private func detail(for station: Station) -> String {
switch station.band {
case .fm:
if let khz = station.fmFrequencyKhz {
return String(format: "FM %.2f MHz", Double(khz) / 1000)
}
return "FM"
case .dab:
return "DAB index \(station.dabFreqIndex ?? 0)"
}
}
private func savePreset() async {
let station: Station
switch newBand {
case .fm:
guard let khz = Int(newFMKhz) else { return }
station = Station(name: newName, band: .fm, fmFrequencyKhz: khz)
case .dab:
station = Station(
name: newName,
band: .dab,
dabFreqIndex: Int(environment.state.tuner.dab?.freqIndex ?? 0),
dabServiceId: environment.state.tuner.dab?.playingServiceId,
dabComponentId: environment.state.tuner.dab?.playingComponentId
)
}
try? await environment.digiRadio.saveStation(station)
newName = ""
}
}
@@ -0,0 +1,104 @@
import SwiftUI
struct RootView: View {
@Environment(AppEnvironment.self) private var environment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
Group {
if horizontalSizeClass == .regular {
IPadRootView()
} else {
IPhoneRootView()
}
}
.tint(IGITheme.accent)
}
}
private struct IPhoneRootView: View {
@Environment(AppEnvironment.self) private var environment
var body: some View {
TabView {
NavigationStack { HomeView() }
.tabItem { Label("Home", systemImage: "house.fill") }
NavigationStack { FMRadioView() }
.tabItem { Label("FM", systemImage: "dot.radiowaves.left.and.right") }
NavigationStack { DABRadioView() }
.tabItem { Label("DAB", systemImage: "antenna.radiowaves.left.and.right") }
NavigationStack { StreamingView() }
.tabItem { Label("Stream", systemImage: "dot.radiowaves.forward") }
NavigationStack { PresetsView() }
.tabItem { Label("Preset", systemImage: "star.fill") }
NavigationStack { AudioProfilesView() }
.tabItem { Label("Profilo", systemImage: "waveform.path.ecg") }
NavigationStack { SettingsRootView() }
.tabItem { Label("Impostazioni", systemImage: "gearshape.fill") }
}
}
}
private struct IPadRootView: View {
@State private var selection: SidebarItem? = .home
var body: some View {
NavigationSplitView {
List(SidebarItem.allCases, selection: $selection) { item in
NavigationLink(value: item) {
Label(item.title, systemImage: item.systemImage)
}
}
.navigationTitle("igiRadio")
} detail: {
switch selection ?? .home {
case .home: HomeView()
case .fm: FMRadioView()
case .dab: DABRadioView()
case .stream: StreamingView()
case .bluetooth: BluetoothView()
case .audioProfiles: AudioProfilesView()
case .presets: PresetsView()
case .settings: SettingsRootView()
}
}
}
}
enum SidebarItem: String, CaseIterable, Identifiable {
case home, fm, dab, stream, bluetooth, audioProfiles, presets, settings
var id: String { rawValue }
var title: String {
switch self {
case .home: "Home"
case .fm: "FM"
case .dab: "DAB"
case .stream: "Stream"
case .bluetooth: "Bluetooth"
case .audioProfiles: "Profilo audio"
case .presets: "Preset"
case .settings: "Impostazioni"
}
}
var systemImage: String {
switch self {
case .home: "house.fill"
case .fm: "dot.radiowaves.left.and.right"
case .dab: "antenna.radiowaves.left.and.right"
case .stream: "dot.radiowaves.forward"
case .bluetooth: "dot.radiowaves.left.and.right"
case .audioProfiles: "waveform.path.ecg"
case .presets: "star.fill"
case .settings: "gearshape.fill"
}
}
}
@@ -0,0 +1,110 @@
import SwiftUI
struct SettingsRootView: View {
var body: some View {
List {
Section("DigiRadio") {
NavigationLink("Connessione") { ConnectionView() }
NavigationLink("Informazioni dispositivo") { DeviceInfoView() }
NavigationLink("Diagnostica") { DiagnosticsView() }
NavigationLink("Firmware") { FirmwareView() }
}
Section("Radio") {
NavigationLink("FM") { FMRadioView() }
NavigationLink("DAB") { DABRadioView() }
NavigationLink("Web radio stream") { StreamingView() }
NavigationLink("Preset") { PresetsView() }
}
Section("Audio") {
NavigationLink {
AudioProfilesView()
} label: {
Label("Profili audio", systemImage: "waveform.path.ecg")
}
NavigationLink {
AudioView()
} label: {
Label("Mixer & volume", systemImage: "slider.vertical.3")
}
NavigationLink("Bluetooth speaker") { BluetoothView() }
}
}
.navigationTitle("Impostazioni")
}
}
struct DeviceInfoView: View {
@Environment(AppEnvironment.self) private var environment
var body: some View {
let health = environment.state.health
let chips = health.chips
Form {
LabeledContent("Nome", value: "DigiRadio")
LabeledContent("Firmware", value: health.firmware.isEmpty ? "" : health.firmware)
LabeledContent("Serial number", value: health.serialNumber.isEmpty ? "" : health.serialNumber)
LabeledContent("Stato", value: health.status.isEmpty ? "" : health.status)
Section("Chip") {
LabeledContent("Si4684", value: chips.si4684 ? "OK" : "")
LabeledContent("ADAU1701", value: chips.adau1701 ? "OK" : "")
LabeledContent("BT1035", value: chips.bt1035 ? "OK" : "")
}
Section("Connessione") {
LabeledContent("Host", value: environment.state.connection.host.isEmpty ? "" : environment.state.connection.host)
LabeledContent("HTTP", value: environment.state.connection.isConnected ? "Connesso" : "Non connesso")
}
}
.navigationTitle("Dispositivo")
.onAppear { Task { try? await environment.digiRadio.refreshHealth() } }
}
}
struct DiagnosticsView: View {
@Environment(AppEnvironment.self) private var environment
var body: some View {
Form {
Section("Tuner") {
LabeledContent("Booted", value: environment.state.tuner.booted ? "" : "No")
LabeledContent("Banda", value: environment.state.tuner.band.rawValue.uppercased())
LabeledContent("Locked", value: environment.state.tuner.locked ? "" : "No")
}
Section("Bluetooth") {
LabeledContent("A2DP", value: environment.state.bluetooth.a2dpState.rawValue)
LabeledContent("Pairing", value: environment.state.bluetooth.pairing ? "" : "No")
}
Section {
Button("Aggiorna snapshot") {
Task {
try? await environment.digiRadio.refreshHealth()
try? await environment.digiRadio.refreshTunerStatus()
try? await environment.digiRadio.refreshBluetoothStatus()
}
}
}
}
.navigationTitle("Diagnostica")
}
}
struct FirmwareView: View {
@Environment(AppEnvironment.self) private var environment
var body: some View {
Form {
Section("Versione corrente") {
LabeledContent("Firmware", value: environment.state.health.firmware.isEmpty ? "" : environment.state.health.firmware)
}
Section("Aggiornamento OTA") {
Text("L'aggiornamento firmware è supportato via POST /api/system/ota con un file .bin ESP-IDF valido. L'interfaccia di upload file sarà aggiunta in una versione successiva.")
.font(.footnote)
.foregroundStyle(.secondary)
Text("UNKNOWN — specification required: progress reporting durante OTA stream.")
.font(.footnote)
.foregroundStyle(.orange)
}
}
.navigationTitle("Firmware")
.onAppear { Task { try? await environment.digiRadio.refreshHealth() } }
}
}
@@ -0,0 +1,235 @@
import SwiftUI
import UniformTypeIdentifiers
struct StreamingView: View {
@Environment(AppEnvironment.self) private var environment
@State private var viewModel: StreamingViewModel?
@State private var showFilePicker = false
var body: some View {
let vm = viewModel ?? StreamingViewModel(service: environment.digiRadio)
ScrollView {
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
sourcePicker(vm)
statusHero(vm)
switch vm.source {
case .webRadio:
webRadioSection(vm)
case .urlContent:
urlContentSection(vm)
case .localFile:
localFileSection(vm)
}
if let error = vm.errorMessage {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.igiPremiumCard()
}
}
.padding(IGITheme.spacingM)
}
.background(IGIHeroBackground())
.navigationTitle("Stream")
.navigationBarTitleDisplayMode(.large)
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [.audio, .mp3, .mpeg4Audio, .wav, .aiff],
allowsMultipleSelection: false
) { result in
if case let .success(urls) = result, let url = urls.first {
vm.pickFile(url)
}
}
.onAppear {
if viewModel == nil { viewModel = vm }
Task { await vm.load() }
}
.onDisappear {
viewModel?.stopPolling()
}
}
@ViewBuilder
private func sourcePicker(_ vm: StreamingViewModel) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
Text("Sorgente")
.font(.headline)
ForEach(StreamSource.allCases) { item in
Button {
withAnimation(.snappy) { vm.source = item }
} label: {
HStack(spacing: IGITheme.spacingM) {
Image(systemName: item.icon)
.frame(width: 28)
.foregroundStyle(vm.source == item ? .white : IGITheme.accent)
VStack(alignment: .leading, spacing: 2) {
Text(item.rawValue).font(.subheadline.weight(.semibold))
Text(item.subtitle).font(.caption2).foregroundStyle(vm.source == item ? .white.opacity(0.85) : .secondary)
}
Spacer()
if vm.source == item {
Image(systemName: "checkmark.circle.fill")
}
}
.padding(IGITheme.spacingM)
.background(
vm.source == item
? AnyShapeStyle(IGITheme.accent.gradient)
: AnyShapeStyle(Color.primary.opacity(0.05)),
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
)
.foregroundStyle(vm.source == item ? .white : .primary)
}
.buttonStyle(.plain)
}
}
.igiPremiumCard()
}
@ViewBuilder
private func statusHero(_ vm: StreamingViewModel) -> some View {
let active = vm.source == .localFile ? vm.phoneStreamActive : vm.enabled
VStack(spacing: IGITheme.spacingM) {
Image(systemName: active ? "dot.radiowaves.forward" : "pause.circle")
.font(.system(size: 48))
.foregroundStyle(active ? IGITheme.accent : .secondary)
.symbolEffect(.pulse, options: .repeating, value: active)
Text(active ? "In riproduzione" : "Fermo")
.font(.title3.weight(.bold))
Text(statusSubtitle(vm))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.igiPremiumCard()
}
private func statusSubtitle(_ vm: StreamingViewModel) -> String {
switch vm.source {
case .webRadio, .urlContent:
return vm.enabled ? vm.url : "Nessuno stream web attivo"
case .localFile:
return vm.phoneStreamStatus.isEmpty ? "Nessun file in invio" : vm.phoneStreamStatus
}
}
@ViewBuilder
private func webRadioSection(_ vm: StreamingViewModel) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("Stazioni")
.font(.headline)
ForEach(StreamPresets.catalog) { preset in
Button {
vm.selectPreset(preset)
} label: {
HStack {
VStack(alignment: .leading) {
Text(preset.name).font(.headline)
Text(preset.url).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
Image(systemName: "play.circle.fill").font(.title2)
}
}
.buttonStyle(.plain)
if preset.id != StreamPresets.catalog.last?.id {
Divider()
}
}
}
.igiPremiumCard()
streamURLControls(vm, playLabel: "Play radio")
}
@ViewBuilder
private func urlContentSection(_ vm: StreamingViewModel) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
Text("Contenuto via URL")
.font(.headline)
Text("Il DigiRadio scarica e decodifica MP3 HTTP sul device. L'URL deve iniziare con http:// (max 200 caratteri).")
.font(.caption)
.foregroundStyle(.secondary)
}
.igiPremiumCard()
streamURLControls(vm, playLabel: "Play contenuto")
}
@ViewBuilder
private func streamURLControls(_ vm: StreamingViewModel, playLabel: String) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
TextField("http://…", text: Binding(get: { vm.url }, set: { vm.url = $0 }))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding()
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
HStack(spacing: IGITheme.spacingM) {
Button { Task { await vm.playWebStream() } } label: {
Label(playLabel, systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.disabled(vm.isSaving || !vm.isConnected)
Button { Task { await vm.stopWebStream() } } label: {
Label("Stop", systemImage: "stop.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
.disabled(vm.isSaving)
}
}
.igiPremiumCard()
}
@ViewBuilder
private func localFileSection(_ vm: StreamingViewModel) -> some View {
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
Text("File sul telefono")
.font(.headline)
Text("L'iPhone decodifica il file e invia PCM stereo 48 kHz a PUT /api/stream/phone. Ferma prima eventuali stream web sul device.")
.font(.caption)
.foregroundStyle(.secondary)
Button {
showFilePicker = true
} label: {
Label(vm.selectedFileName.isEmpty ? "Scegli file audio" : vm.selectedFileName, systemImage: "folder")
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
HStack(spacing: IGITheme.spacingM) {
Button { Task { await vm.playLocalFile() } } label: {
Label("Invia a DigiRadio", systemImage: "arrow.up.circle.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.disabled(!vm.isConnected || vm.selectedFileURL == nil || vm.phoneStreamActive)
Button { Task { await vm.stopLocalFile() } } label: {
Label("Stop", systemImage: "stop.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.bordered)
.disabled(!vm.phoneStreamActive)
}
}
.igiPremiumCard()
}
}
@@ -49,12 +49,16 @@ void sigma_studio_unlock(void);
/**
* @brief Write a contiguous register/data block to the ADAU1701.
*
* Retries each I2C chunk up to 3 times on NACK, matching the reliability
* already applied to the runtime safeload path (see sigma_i2c_write).
*
* @param devAddress SigmaStudio device address (0x68 write addr).
* @param address 16-bit target address in DSP memory map.
* @param length Payload length in bytes (may exceed 255).
* @param pData Payload bytes.
* @return 0 on success, non-zero if any chunk failed after retries.
*/
void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
int SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
unsigned int address,
unsigned int length,
ADI_REG_TYPE* pData);
@@ -69,6 +73,22 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
*/
int sigma_i2c_read(unsigned int reg, unsigned char* data, unsigned int length);
/**
* @brief Read back a previously written block and compare it byte-for-byte.
*
* Diagnostic aid for boot-time DSP program replay: a chunk that ACKed but
* still landed wrong (or a register that doesn't hold the value it was
* given) shows up here even though SIGMA_WRITE_REGISTER_BLOCK reported
* success. Read-only — never aborts anything by itself, callers decide.
*
* @param address 16-bit address the block was written to.
* @param expected Bytes that were written.
* @param length Payload length in bytes (may exceed 255).
* @return 0 if the read-back matches, non-zero on mismatch or read failure.
*/
int sigma_verify_block(unsigned int address, const ADI_REG_TYPE* expected,
unsigned int length);
/** Safeload data register base (0x0810..0x0814). */
#define ADAU1701_SAFELOAD_DATA_BASE 0x0810U
/** Safeload address register base (0x0815..0x0819). */
@@ -112,6 +112,34 @@ struct BluetoothConnectRequest {
[[nodiscard]] std::expected<std::uint8_t, ParseError>
parseBluetoothAutoReconnectJson(std::string_view json);
/**
* @brief parseBluetoothA2dpCodecConfigJson — validate POST codec_mask field.
*
* @dname parseBluetoothA2dpCodecConfigJson
* @param json Request body with \c codec_mask 063 (see AT+A2DPCFG bits).
* @return Bitmask on success, or ParseError.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-24
*/
[[nodiscard]] std::expected<std::uint8_t, ParseError>
parseBluetoothA2dpCodecConfigJson(std::string_view json);
/**
* @brief serializeBluetoothA2dpCodecJson — negotiated codec for HTTP.
*
* @dname serializeBluetoothA2dpCodecJson
* @param codec Parsed negotiated codec (AT+A2DPENC reply).
* @return JSON object \c {"codec":"..."}.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-24
*/
[[nodiscard]] std::string serializeBluetoothA2dpCodecJson(
Bt1035A2dpCodec codec);
/**
* @brief serializeBluetoothScanJson — serialise scan result list.
*
@@ -40,7 +40,8 @@ enum class Bt1035AtCommand {
Reset, ///< AT+RESET — software reset (best-effort, not gated on OK).
Ping, ///< AT — link check.
I2sMode, ///< AT+AUXCFG=3 — I2S input from ADAU1701 (mandatory).
I2sSlave48k24, ///< AT+I2SCFG=35 — I2S slave 48 kHz 24-bit (§5.1.4).
I2sSlave48k32, ///< AT+I2SCFG=67 — I2S slave 48 kHz 32-bit (§5.1.4).
A2dpCodecConfig, ///< AT+A2DPCFG=1 — enable AAC alongside mandatory SBC (§5.3.4).
PairDiscoverable, ///< AT+PAIR=1 — enter BR/EDR/BLE discoverable mode.
PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
@@ -105,16 +106,40 @@ enum class Bt1035AtResponseKind {
};
/** Number of commands in bootInitSequence(). */
inline constexpr std::size_t kBt1035BootInitCommandCount = 3U;
inline constexpr std::size_t kBt1035BootInitCommandCount = 4U;
/**
* Feasycom programming guide §5.1.4: I2S slave, 48 kHz, 24-bit — matches the
* ADAU1701 serial output word length (SigmaStudio Hardware Configuration).
* Bit field: BIT[0]=enable(1), BIT[1]=slave(1), BIT[2]=FS 48kHz(0),
* BIT[3]=left justified(0), BIT[4]=data 1-bit delay(0), BIT[5:6]=24-bit(01)
* -> 1 + 2 + 32 = 35.
* Feasycom programming guide §5.3.4: AT+A2DPCFG bit field enables optional
* codecs on top of the mandatory baseline SBC (BIT0=AAC, BIT1=aptX,
* BIT2=aptX-LL, BIT3=aptX-HD, BIT4=aptX-Adaptive, BIT5=LDAC; BT1035 does
* not support BT806's FastStream). Never sent before 2026-08-24, so every
* A2DP link negotiated SBC only regardless of what the paired speaker
* supports. Value 1 = AAC only, matching the guide's own worked example
* -- picked over a wider bitmask because AAC is the most broadly
* supported optional codec on consumer speakers (native on iOS) and this
* is the datasheet-validated example, not an untested combination.
*/
inline constexpr std::uint8_t kBt1035I2sSlave48k24Param = 35U;
inline constexpr std::uint8_t kBt1035A2dpCodecConfigParam = 1U;
/**
* Feasycom programming guide §5.1.4: I2S slave, 48 kHz, 32-bit -> value 67.
* This is one of only two configurations the guide validates with explicit
* bit-clock math (the other being value 3, 16-bit); 24-bit (35, used here
* until 2026-08-24) is not a documented example. Matches the ADAU1701's
* actual physical output framing: the Serial Output Control Register
* (0x081E, R15_BCLK_FREQ/R15_LRCLK_FREQ fields in the compiled SigmaStudio
* export) fixes BCLK=3.072 MHz / LRCLK=48 kHz regardless of the OWL
* "word length" field -- i.e. a 64-BCLK-cycle (32-bit) frame is what's
* physically on the wire no matter what OWL says, so 67 is the value that
* matches reality; 35 (24-bit) silently regressed in commit 6f7b6dd
* (2026-08-08, an unrelated large feature commit) and was found to
* contradict every other citation of this value in the repo
* (instructions.md, README.md, CLAUDE.md, AGENTS.md, docs/TODO.md).
* Bit field: BIT[0]=enable(1), BIT[1]=slave(1), BIT[2]=FS 48kHz(0),
* BIT[3]=left justified(0), BIT[4]=data 1-bit delay(0), BIT[5:6]=32-bit(10)
* -> 1 + 2 + 64 = 67.
*/
inline constexpr std::uint8_t kBt1035I2sSlave48k32Param = 67U;
/**
* @brief buildBt1035AtLine — serialise a command with CRLF terminator.
@@ -133,7 +158,7 @@ inline constexpr std::uint8_t kBt1035I2sSlave48k24Param = 35U;
* @brief bootInitSequence — mandatory bring-up commands in order.
*
* @dname bootInitSequence
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k24 (I2SCFG=35).
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k32 (I2SCFG=67).
* @pubstate none
*
* @author Michele Bigi
@@ -210,6 +235,23 @@ parseBt1035A2dpEncoderResponse(std::string_view response);
*/
[[nodiscard]] const char* a2dpCodecToken(Bt1035A2dpCodec codec) noexcept;
/**
* @brief buildBt1035A2dpCodecConfigLine — AT+A2DPCFG with runtime bitmask.
*
* @dname buildBt1035A2dpCodecConfigLine
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
* BIT4=aptX-Adaptive, BIT5=LDAC (§5.3.4); clamped to 0-63.
* 0 disables every optional codec (SBC-only baseline).
* @return Full AT line including CRLF.
* @pubstate Takes effect on the next A2DP negotiation, not an already
* streaming link — the peer must reconnect for the change to
* apply.
*
* @author Michele Bigi
* @date 2026-08-24
*/
[[nodiscard]] std::string buildBt1035A2dpCodecConfigLine(std::uint8_t bitmask);
/**
* @brief buildBt1035SetAutoConnLine — AT+AUTOCONN with reconnect count.
*
@@ -86,6 +86,10 @@ public:
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @param antCap Front-end antenna varactor override (0-128).
* 0 = automatic; other values force a specific
* varactor setting, for antenna calibration
* sweeps. See tuneFm's antCap doc for details.
* @return Ok on success, or WrongBand / TuneFailed / NotBooted.
* @pubstate writes last tune target in the adapter.
*
@@ -93,7 +97,7 @@ public:
* @date 2026-07-06
*/
[[nodiscard]] virtual std::expected<void, TunerError> tuneDab(
std::uint8_t freqIndex) = 0;
std::uint8_t freqIndex, std::uint8_t antCap = 0U) = 0;
/**
* @brief tuneFm — tune to an FM centre frequency.
@@ -41,8 +41,9 @@ struct TunerTuneRequest {
TunerBand band; ///< Target band (Dab or Fm).
std::uint8_t dabFreqIndex; ///< Band III ensemble index (037) when band is Dab.
std::optional<FrequencyKHz> fmFrequency; ///< FM centre frequency when band is Fm.
std::optional<std::uint8_t> antCap; ///< FM antenna varactor override (0128),
///< for calibration sweeps; ignored for Dab.
std::optional<std::uint8_t> antCap; ///< Antenna varactor override (0128),
///< for calibration sweeps; applies to
///< whichever band is being tuned.
};
/**
@@ -241,19 +242,75 @@ struct TunerFmScannedStation {
[[nodiscard]] std::string serializeTunerFmBandScanJson(
const std::vector<TunerFmScannedStation>& stations);
/**
* @brief AntennaCalibrationRequest — parsed POST
* /api/tuner/calibrate-antenna body.
*
* @dname AntennaCalibrationRequest
* @return n/a (type)
* @pubstate Plain DTO filled by parseAntennaCalibrationJson at the HTTP
* boundary.
*
* @author Michele Bigi
* @date 2026-08-20
*/
struct AntennaCalibrationRequest {
TunerBand band; ///< Which band's ANTCAP default this saves.
std::uint8_t antCap; ///< Value to persist (0-128).
};
/**
* @brief parseAntennaCalibrationJson — validate POST
* /api/tuner/calibrate-antenna body.
*
* @dname parseAntennaCalibrationJson
* @param json Untrusted request body from the HTTP handler.
* @return ANTCAP value (0-128) on success, or a ParseError.
* @return Band + ANTCAP value (0-128) on success, or a ParseError.
* `band` defaults to Fm when the field is omitted, preserving
* the original FM-only request shape.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-19
*/
[[nodiscard]] std::expected<std::uint8_t, ParseError>
[[nodiscard]] std::expected<AntennaCalibrationRequest, ParseError>
parseAntennaCalibrationJson(std::string_view json);
/**
* @brief XtalCalibrationRequest — parsed POST
* /api/tuner/xtal-calibrate body.
*
* @dname XtalCalibrationRequest
* @return n/a (type)
* @pubstate Plain DTO filled by parseXtalCalibrationJson at the HTTP
* boundary. Diagnostic-only: live Si4684 crystal parameter
* recalibration, no ESP32 restart required.
*
* @author Michele Bigi
* @date 2026-08-23
*/
struct XtalCalibrationRequest {
std::uint8_t ibias; ///< POWER_UP ARG3 IBIAS (0-127).
std::uint8_t ctun; ///< POWER_UP ARG8 CTUN (0-63).
std::uint32_t xtalFreqHz; ///< POWER_UP ARG4-7 XTAL_FREQ in Hz.
};
/**
* @brief parseXtalCalibrationJson — validate POST
* /api/tuner/xtal-calibrate body.
*
* @dname parseXtalCalibrationJson
* @param json Untrusted request body from the HTTP handler.
* @return Crystal parameters on success, or a ParseError. `ibias` and
* `ctun` default to the values already loaded at boot when
* omitted (unusual to omit, but harmless); `xtal_freq_hz`
* defaults to the nominal 19,200,000 Hz.
* @pubstate none
*
* @author Michele Bigi
* @date 2026-08-23
*/
[[nodiscard]] std::expected<XtalCalibrationRequest, ParseError>
parseXtalCalibrationJson(std::string_view json);
} // namespace core
@@ -63,6 +63,11 @@ struct TunerStatus {
std::optional<FrequencyKHz> fmChipReadFrequency; ///< FM_RSQ READFREQ (may lag commanded).
std::optional<std::int8_t> fmRssiDbuV; ///< FM RSSI in dBµV.
std::optional<std::int8_t> fmSnrDb; ///< FM SNR in dB.
/** AN649 FM_RSQ_STATUS FREQOFF, units of 2 PPM. Crystal calibration
* signal: real broadcast carriers are GPS/rubidium-locked, so a
* nonzero reading here reflects the local XTAL_FREQ/CTUN reference
* error, not the station. */
std::optional<std::int8_t> fmFreqOffBppm;
std::optional<bool> fmStereo; ///< FM stereo pilot detected.
std::optional<BroadcastLabel> fmStationName; ///< FM RDS program service name.
std::optional<BroadcastLabel> fmRadiotext; ///< FM RDS radiotext (RT).
@@ -58,15 +58,24 @@ namespace {
std::string_view key,
bool& out)
{
const std::string trueNeedle =
std::string("\"") + std::string(key) + "\":true";
const std::string falseNeedle =
std::string("\"") + std::string(key) + "\":false";
if (json.find(trueNeedle) != std::string_view::npos) {
const std::string needle =
std::string("\"") + std::string(key) + "\":";
const std::size_t needlePos = json.find(needle);
if (needlePos == std::string_view::npos) {
return false;
}
std::size_t start = needlePos + needle.size();
while (start < json.size()
&& (json[start] == ' ' || json[start] == '\t'
|| json[start] == '\r' || json[start] == '\n')) {
++start;
}
const std::string_view rest = json.substr(start);
if (rest.starts_with("true")) {
out = true;
return true;
}
if (json.find(falseNeedle) != std::string_view::npos) {
if (rest.starts_with("false")) {
out = false;
return true;
}
+79 -16
View File
@@ -96,6 +96,33 @@ parseBluetoothAutoReconnectJson(std::string_view json)
return static_cast<std::uint8_t>(raw);
}
std::expected<std::uint8_t, ParseError>
parseBluetoothA2dpCodecConfigJson(std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const std::string needle = "\"codec_mask\":";
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 > 63U) {
return std::unexpected(ParseError::InvalidJson);
}
return static_cast<std::uint8_t>(raw);
}
std::string serializeBluetoothA2dpCodecJson(Bt1035A2dpCodec codec)
{
std::ostringstream out;
out << "{\"codec\":\"" << a2dpCodecToken(codec) << "\"}";
return out.str();
}
std::string serializeBluetoothScanJson(
const std::vector<Bt1035ScannedDevice>& devices)
{
@@ -123,12 +150,21 @@ parseBluetoothConnectJson(std::string_view json)
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
const std::string needle = "\"mac\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
const std::string needle = "\"mac\":";
const std::size_t needlePos = json.find(needle);
if (needlePos == std::string_view::npos) {
return std::unexpected(ParseError::MissingField);
}
const std::size_t valueStart = start + needle.size();
std::size_t start = needlePos + needle.size();
while (start < json.size()
&& (json[start] == ' ' || json[start] == '\t'
|| json[start] == '\r' || json[start] == '\n')) {
++start;
}
if (start >= json.size() || json[start] != '"') {
return std::unexpected(ParseError::InvalidJson);
}
const std::size_t valueStart = start + 1U;
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
@@ -156,17 +192,35 @@ BluetoothConnectRequest parseBluetoothConnectRequest(std::string_view json)
if (auto mac = parseBluetoothConnectJson(json); mac) {
request.mac = std::move(*mac);
}
const std::string nameNeedle = "\"name\":\"";
const std::size_t nameStart = json.find(nameNeedle);
if (nameStart != std::string_view::npos) {
const std::size_t valueStart = nameStart + nameNeedle.size();
const std::string nameNeedle = "\"name\":";
const std::size_t nameNeedlePos = json.find(nameNeedle);
if (nameNeedlePos != std::string_view::npos) {
std::size_t nameStart = nameNeedlePos + nameNeedle.size();
while (nameStart < json.size()
&& (json[nameStart] == ' ' || json[nameStart] == '\t'
|| json[nameStart] == '\r' || json[nameStart] == '\n')) {
++nameStart;
}
if (nameStart < json.size() && json[nameStart] == '"') {
const std::size_t valueStart = nameStart + 1U;
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd != std::string_view::npos) {
request.name.assign(json.substr(valueStart, valueEnd - valueStart));
request.name.assign(
json.substr(valueStart, valueEnd - valueStart));
}
}
request.save = json.find("\"save\":true") != std::string_view::npos
|| json.find("\"save\": true") != std::string_view::npos;
}
const std::string saveNeedle = "\"save\":";
const std::size_t saveNeedlePos = json.find(saveNeedle);
if (saveNeedlePos != std::string_view::npos) {
std::size_t saveStart = saveNeedlePos + saveNeedle.size();
while (saveStart < json.size()
&& (json[saveStart] == ' ' || json[saveStart] == '\t'
|| json[saveStart] == '\r' || json[saveStart] == '\n')) {
++saveStart;
}
request.save = json.substr(saveStart).starts_with("true");
}
return request;
}
@@ -192,13 +246,22 @@ parseBluetoothSpeakerJson(std::string_view json)
return std::unexpected(mac.error());
}
BtSpeakerTarget target{.mac = *mac, .name = {}};
const std::string nameNeedle = "\"name\":\"";
const std::size_t nameStart = json.find(nameNeedle);
if (nameStart != std::string_view::npos) {
const std::size_t valueStart = nameStart + nameNeedle.size();
const std::string nameNeedle = "\"name\":";
const std::size_t nameNeedlePos = json.find(nameNeedle);
if (nameNeedlePos != std::string_view::npos) {
std::size_t nameStart = nameNeedlePos + nameNeedle.size();
while (nameStart < json.size()
&& (json[nameStart] == ' ' || json[nameStart] == '\t'
|| json[nameStart] == '\r' || json[nameStart] == '\n')) {
++nameStart;
}
if (nameStart < json.size() && json[nameStart] == '"') {
const std::size_t valueStart = nameStart + 1U;
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd != std::string_view::npos) {
target.name.assign(json.substr(valueStart, valueEnd - valueStart));
target.name.assign(
json.substr(valueStart, valueEnd - valueStart));
}
}
}
return target;
+14 -3
View File
@@ -106,8 +106,10 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
return "AT\r\n";
case Bt1035AtCommand::I2sMode:
return "AT+AUXCFG=3\r\n";
case Bt1035AtCommand::I2sSlave48k24:
return "AT+I2SCFG=35\r\n";
case Bt1035AtCommand::I2sSlave48k32:
return "AT+I2SCFG=67\r\n";
case Bt1035AtCommand::A2dpCodecConfig:
return buildBt1035A2dpCodecConfigLine(kBt1035A2dpCodecConfigParam);
case Bt1035AtCommand::PairDiscoverable:
return "AT+PAIR=1\r\n";
case Bt1035AtCommand::PairHidden:
@@ -128,6 +130,14 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
return "AT\r\n";
}
std::string buildBt1035A2dpCodecConfigLine(std::uint8_t bitmask)
{
if (bitmask > 63U) {
bitmask = 63U;
}
return "AT+A2DPCFG=" + std::to_string(bitmask) + "\r\n";
}
std::string buildBt1035SetAutoConnLine(std::uint8_t times)
{
if (times > 15U) {
@@ -150,7 +160,8 @@ std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noex
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
Bt1035AtCommand::Ping,
Bt1035AtCommand::I2sMode,
Bt1035AtCommand::I2sSlave48k24,
Bt1035AtCommand::I2sSlave48k32,
Bt1035AtCommand::A2dpCodecConfig,
};
}
+13 -4
View File
@@ -24,12 +24,21 @@ namespace {
std::string_view key)
{
const std::string needle =
std::string("\"") + std::string(key) + "\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
std::string("\"") + std::string(key) + "\":";
const std::size_t needlePos = json.find(needle);
if (needlePos == std::string_view::npos) {
return {};
}
const std::size_t valueStart = start + needle.size();
std::size_t start = needlePos + needle.size();
while (start < json.size()
&& (json[start] == ' ' || json[start] == '\t'
|| json[start] == '\r' || json[start] == '\n')) {
++start;
}
if (start >= json.size() || json[start] != '"') {
return {};
}
const std::size_t valueStart = start + 1U;
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return {};
@@ -24,12 +24,21 @@ namespace {
std::string_view key)
{
const std::string needle =
std::string("\"") + std::string(key) + "\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
std::string("\"") + std::string(key) + "\":";
const std::size_t needlePos = json.find(needle);
if (needlePos == std::string_view::npos) {
return {};
}
const std::size_t valueStart = start + needle.size();
std::size_t start = needlePos + needle.size();
while (start < json.size()
&& (json[start] == ' ' || json[start] == '\t'
|| json[start] == '\r' || json[start] == '\n')) {
++start;
}
if (start >= json.size() || json[start] != '"') {
return {};
}
const std::size_t valueStart = start + 1U;
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return {};
+71 -10
View File
@@ -24,12 +24,21 @@ namespace {
std::string_view key)
{
const std::string needle =
std::string("\"") + std::string(key) + "\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
std::string("\"") + std::string(key) + "\":";
const std::size_t needlePos = json.find(needle);
if (needlePos == std::string_view::npos) {
return {};
}
const std::size_t valueStart = start + needle.size();
std::size_t start = needlePos + needle.size();
while (start < json.size()
&& (json[start] == ' ' || json[start] == '\t'
|| json[start] == '\r' || json[start] == '\n')) {
++start;
}
if (start >= json.size() || json[start] != '"') {
return {};
}
const std::size_t valueStart = start + 1U;
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return {};
@@ -129,6 +138,10 @@ std::string serializeTunerStatusJson(const TunerStatus& status)
if (status.fmSnrDb) {
out << ",\"snr_db\":" << static_cast<int>(*status.fmSnrDb);
}
if (status.fmFreqOffBppm) {
out << ",\"freqoff_ppm\":"
<< (static_cast<int>(*status.fmFreqOffBppm) * 2);
}
if (status.fmStereo) {
out << ",\"stereo\":" << (*status.fmStereo ? "true" : "false");
}
@@ -200,13 +213,13 @@ std::expected<TunerTuneRequest, ParseError> parseTunerTuneJson(
return std::unexpected(freq.error());
}
req.fmFrequency = *freq;
} else {
return std::unexpected(ParseError::InvalidJson);
}
unsigned long antCap = 0U;
if (extractJsonUint(json, "antcap", antCap) && antCap <= 128U) {
req.antCap = static_cast<std::uint8_t>(antCap);
}
} else {
return std::unexpected(ParseError::InvalidJson);
}
return req;
}
@@ -340,8 +353,8 @@ std::string serializeTunerFmBandScanJson(
return out.str();
}
std::expected<std::uint8_t, ParseError> parseAntennaCalibrationJson(
std::string_view json)
std::expected<AntennaCalibrationRequest, ParseError>
parseAntennaCalibrationJson(std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
@@ -350,7 +363,55 @@ std::expected<std::uint8_t, ParseError> parseAntennaCalibrationJson(
if (!extractJsonUint(json, "antcap", antCap) || antCap > 128U) {
return std::unexpected(ParseError::MissingField);
}
return static_cast<std::uint8_t>(antCap);
AntennaCalibrationRequest req = {};
req.antCap = static_cast<std::uint8_t>(antCap);
const std::string_view band = extractJsonString(json, "band");
if (band == "dab") {
req.band = TunerBand::Dab;
} else if (band.empty() || band == "fm") {
req.band = TunerBand::Fm;
} else {
return std::unexpected(ParseError::InvalidJson);
}
return req;
}
std::expected<XtalCalibrationRequest, ParseError>
parseXtalCalibrationJson(std::string_view json)
{
if (json.find('{') == std::string_view::npos) {
return std::unexpected(ParseError::InvalidJson);
}
// Defaults match Si4684Driver::boot()'s own defaults -- a request that
// only wants to change one parameter can omit the others.
XtalCalibrationRequest req = {};
req.ibias = 72U;
req.ctun = 31U;
req.xtalFreqHz = 19200000U;
unsigned long ibias = 0U;
if (extractJsonUint(json, "ibias", ibias)) {
if (ibias > 127U) {
return std::unexpected(ParseError::InvalidJson);
}
req.ibias = static_cast<std::uint8_t>(ibias);
}
unsigned long ctun = 0U;
if (extractJsonUint(json, "ctun", ctun)) {
if (ctun > 63U) {
return std::unexpected(ParseError::InvalidJson);
}
req.ctun = static_cast<std::uint8_t>(ctun);
}
unsigned long xtalFreqHz = 0U;
if (extractJsonUint(json, "xtal_freq_hz", xtalFreqHz)) {
req.xtalFreqHz = static_cast<std::uint32_t>(xtalFreqHz);
}
return req;
}
} // namespace core
+28 -10
View File
@@ -24,12 +24,21 @@ constexpr std::string_view kHttpPrefix = "http://";
std::string_view key)
{
const std::string needle =
std::string("\"") + std::string(key) + "\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
std::string("\"") + std::string(key) + "\":";
const std::size_t needlePos = json.find(needle);
if (needlePos == std::string_view::npos) {
return {};
}
const std::size_t valueStart = start + needle.size();
std::size_t start = needlePos + needle.size();
while (start < json.size()
&& (json[start] == ' ' || json[start] == '\t'
|| json[start] == '\r' || json[start] == '\n')) {
++start;
}
if (start >= json.size() || json[start] != '"') {
return {};
}
const std::size_t valueStart = start + 1U;
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return {};
@@ -40,15 +49,24 @@ constexpr std::string_view kHttpPrefix = "http://";
[[nodiscard]] bool extractJsonBool(std::string_view json,
std::string_view key, bool& out)
{
const std::string trueNeedle =
std::string("\"") + std::string(key) + "\":true";
const std::string falseNeedle =
std::string("\"") + std::string(key) + "\":false";
if (json.find(trueNeedle) != std::string_view::npos) {
const std::string needle =
std::string("\"") + std::string(key) + "\":";
const std::size_t needlePos = json.find(needle);
if (needlePos == std::string_view::npos) {
return false;
}
std::size_t start = needlePos + needle.size();
while (start < json.size()
&& (json[start] == ' ' || json[start] == '\t'
|| json[start] == '\r' || json[start] == '\n')) {
++start;
}
const std::string_view rest = json.substr(start);
if (rest.starts_with("true")) {
out = true;
return true;
}
if (json.find(falseNeedle) != std::string_view::npos) {
if (rest.starts_with("false")) {
out = false;
return true;
}
@@ -37,12 +37,21 @@ namespace {
[[nodiscard]] std::string_view extractJsonString(std::string_view json,
std::string_view key)
{
const std::string needle = std::string("\"") + std::string(key) + "\":\"";
const std::size_t start = json.find(needle);
if (start == std::string_view::npos) {
const std::string needle = std::string("\"") + std::string(key) + "\":";
const std::size_t needlePos = json.find(needle);
if (needlePos == std::string_view::npos) {
return {};
}
const std::size_t valueStart = start + needle.size();
std::size_t start = needlePos + needle.size();
while (start < json.size()
&& (json[start] == ' ' || json[start] == '\t'
|| json[start] == '\r' || json[start] == '\n')) {
++start;
}
if (start >= json.size() || json[start] != '"') {
return {};
}
const std::size_t valueStart = start + 1U;
const std::size_t valueEnd = json.find('"', valueStart);
if (valueEnd == std::string_view::npos) {
return {};
@@ -33,7 +33,7 @@ namespace {
std::cerr << "I2S mode must be in init sequence\n";
return EXIT_FAILURE;
}
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k24) {
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k32) {
std::cerr << "I2SCFG must be in init sequence\n";
return EXIT_FAILURE;
}
@@ -44,9 +44,19 @@ namespace {
return EXIT_FAILURE;
}
const std::string i2sCfg =
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k24);
if (i2sCfg != "AT+I2SCFG=35\r\n") {
std::cerr << "I2SCFG=35 command line mismatch\n";
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k32);
if (i2sCfg != "AT+I2SCFG=67\r\n") {
std::cerr << "I2SCFG=67 command line mismatch\n";
return EXIT_FAILURE;
}
if (sequence[3U] != core::Bt1035AtCommand::A2dpCodecConfig) {
std::cerr << "A2DPCFG must be in init sequence\n";
return EXIT_FAILURE;
}
const std::string a2dpCfg =
core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpCodecConfig);
if (a2dpCfg != "AT+A2DPCFG=1\r\n") {
std::cerr << "A2DPCFG=1 command line mismatch\n";
return EXIT_FAILURE;
}
const std::string reset =
@@ -111,7 +111,7 @@ public:
}
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
std::uint8_t) override
std::uint8_t, std::uint8_t) override
{
return {};
}
@@ -53,7 +53,7 @@ public:
}
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
std::uint8_t) override
std::uint8_t, std::uint8_t) override
{
return {};
}
@@ -68,6 +68,7 @@ namespace adau1701
{
const unsigned char deviceAddr =
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
std::size_t index = 0U;
for (const core::RegisterWrite &write : program.writes())
{
const auto data = write.data();
@@ -75,12 +76,35 @@ namespace adau1701
{
return std::unexpected(Adau1701Error::DownloadFailed);
}
SIGMA_WRITE_REGISTER_BLOCK(
deviceAddr,
write.address(),
static_cast<unsigned int>(data.size()),
const_cast<ADI_REG_TYPE *>(
reinterpret_cast<const ADI_REG_TYPE *>(data.data())));
auto *bytes = const_cast<ADI_REG_TYPE *>(
reinterpret_cast<const ADI_REG_TYPE *>(data.data()));
const auto length = static_cast<unsigned int>(data.size());
if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, write.address(), length,
bytes) != 0)
{
ESP_LOGE(kTag,
"program write #%u failed (addr=0x%04X len=%u) "
"after retries",
static_cast<unsigned>(index),
static_cast<unsigned>(write.address()),
static_cast<unsigned>(length));
return std::unexpected(Adau1701Error::DownloadFailed);
}
// Diagnostic only: an ACKed write that still reads back wrong
// would otherwise be invisible (see the SIGMA_WRITE_REGISTER_BLOCK
// comment in SigmaStudioFW.c). Logged, not fatal -- some
// addresses in this range may be self-clearing/status bits
// that legitimately don't read back what was written.
if (sigma_verify_block(write.address(), bytes, length) != 0)
{
ESP_LOGW(kTag,
"program write #%u read-back mismatch (addr=0x%04X "
"len=%u) -- ACKed but did not land as written",
static_cast<unsigned>(index),
static_cast<unsigned>(write.address()),
static_cast<unsigned>(length));
}
++index;
}
return {};
}
@@ -158,19 +182,137 @@ namespace adau1701
return replay;
}
// SerialInputRegister (0x081F) override: bit3 IBP=1, matching the
// BCLK edge the Si4684's I2S output actually changes data on
// (compiled DSP program default is 0x00 = IBP=0, which produced
// pure static on a strong locked signal). ILP=1 was also tried
// (0x18) and made it worse (pure white noise again) — IBP alone
// (0x08) is the correct override, confirmed live: real, recognizable
// music instead of static/noise on a locked FM station.
// Diagnostic (2026-08-24, extended from band-0-only): log EVERY EQ
// band's live Param RAM contents (bands 0-5, 5 coefficients each --
// B0,B1,B2,A0,A1 per paramAddrEqBandBase's 5-word stride). Band 0
// is the fixed high-pass that applyEq() always skips (whatever
// landed at program-load time plays permanently); bands 1-5 are
// runtime-safeloaded whenever the audio profile has a nonzero
// gain, but with the factory-default flat profile (all gains 0)
// they should read back as the identity biquad (B0=0x00800000=1.0,
// rest 0) written by designFlatEq(). A single steady test tone at
// one frequency cannot reveal a bad coefficient elsewhere in a
// band's response curve -- reading the actual RAM contents is the
// only way to confirm what's really there, not what the software
// believes it wrote. Decode as 5.23 (28-bit, matches the ADAU1701's
// real Param RAM format, NOT a naive 32-bit Q8.23 -- see the
// 2026-08-23 band-0 false alarm in
// docs/si4684-rf-investigation-report.md for why that distinction
// matters).
for (unsigned band = 0U; band < 6U; ++band)
{
const unsigned char deviceAddr =
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
ADI_REG_TYPE serialInFix = 0x08U;
SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U,
&serialInFix);
const unsigned baseAddr = paramAddrEqBandBase(
static_cast<std::uint8_t>(band));
for (unsigned i = 0U; i < 5U; ++i)
{
unsigned char raw[4U] = {0U, 0U, 0U, 0U};
if (sigma_i2c_read(baseAddr + i, raw, sizeof(raw)) == 0)
{
std::int32_t fixpoint =
static_cast<std::int32_t>(
(static_cast<std::uint32_t>(raw[0]) << 24) |
(static_cast<std::uint32_t>(raw[1]) << 16) |
(static_cast<std::uint32_t>(raw[2]) << 8) |
static_cast<std::uint32_t>(raw[3]));
// Sign-extend from bit 27 (28-bit/5.23 format).
if ((fixpoint & (1 << 27)) != 0)
{
fixpoint -= (1 << 28);
}
ESP_LOGI(kTag,
"EQ band%u param[%u] addr=0x%04X raw=0x%08X "
"value=%f",
band, i, baseAddr + i,
static_cast<unsigned>(
(static_cast<std::uint32_t>(raw[0]) << 24)
| (static_cast<std::uint32_t>(raw[1]) << 16)
| (static_cast<std::uint32_t>(raw[2]) << 8)
| static_cast<std::uint32_t>(raw[3])),
static_cast<double>(fixpoint) /
static_cast<double>(1U << 23));
}
else
{
ESP_LOGW(kTag, "EQ band%u param[%u] read-back failed",
band, i);
}
}
}
// SerialInputRegister (0x081F) IBP override -- REMOVED 2026-08-24,
// CONFIRMED LIVE as the root cause of the multi-day hiss/
// unintelligible-speech investigation. History: on 2026-08-16
// (commit 6974095) this was set to IBP=1 (0x08) alongside a
// SEPARATE, simultaneous fix to Si4684's PIN_CONFIG_ENABLE (which
// had been forcing the chip's analog DAC fallback instead of real
// I2S output). Both fixes landed in the same commit and were
// tested together: "static" became "music", credited to IBP=1.
// But SerialInputRegister is a SINGLE register shared by every
// SDATA_INx pin on the ADAU1701 (confirmed against the datasheet
// 2026-08-24: one INPUT_BCLK/INPUT_LRCLK clock pair serves all
// four SDATA_INx pins) -- so the override also applied to the
// ESP32 leg, not just Si4684's. The PIN_CONFIG_ENABLE fix alone
// was what turned static into music (Si4684 finally sending valid
// I2S data at all); IBP=1 had never been validated in isolation
// and was in fact marginal/wrong for both legs. With IBP left at
// the compiled default (0x00, IBP=0) and PIN_CONFIG_ENABLE
// independently correct, live listening confirmed clean audio on
// both the Si4684 (DAB) and ESP32 (web radio) paths -- the hiss
// is gone. Left commented out below rather than deleted, in case
// a future hardware revision needs it revisited.
//
// {
// const unsigned char deviceAddr =
// static_cast<unsigned char>(pins_.i2cAddr7 << 1);
// ADI_REG_TYPE serialInFix = 0x08U;
// if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U,
// &serialInFix) != 0)
// {
// ESP_LOGE(kTag, "SerialInputRegister override failed after retries");
// return std::unexpected(Adau1701Error::DownloadFailed);
// }
// if (sigma_verify_block(0x081FU, &serialInFix, 1U) != 0)
// {
// ESP_LOGW(kTag,
// "SerialInputRegister read-back mismatch -- ACKed "
// "but did not land as 0x08");
// }
// }
// Limiter1/Limiter2 threshold override, 2026-08-24: compiled
// program ships both at 0x00800000 = 1.0 linear = 0 dBFS (an
// RMS-detecting limiter, per Analog Devices' own SigmaStudio
// Limiter cell docs), with zero headroom anywhere upstream (every
// mixer/EQ/master-volume gain in the compiled program is unity).
// A quiet synthetic test tone (well under 0 dBFS RMS) never
// engaged it and sounded clean; real loudness-normalized FM/DAB/
// streamed program content sits close to 0 dBFS RMS routinely,
// triggering continuous gain-reduction ("pumping" per ADI's own
// docs) heard as exactly the hiss/unintelligible-speech symptom
// under investigation. Pulling both thresholds down to -6 dBFS
// gives real margin without being so conservative it can't be
// heard whether this was the mechanism.
{
constexpr float kLimiterThresholdDb = -6.0F;
constexpr float kLimiterThresholdLinear = 0.50118723F; // 10^(-6/20)
const std::int32_t thresholdFixpoint =
core::floatToFixpoint823(kLimiterThresholdLinear);
if (auto lim1 = safeloadFixpoint(
static_cast<unsigned>(ADDR_LIMITER1_THRESHOLD),
thresholdFixpoint);
!lim1)
{
ESP_LOGW(kTag, "Limiter1 threshold override failed");
}
if (auto lim2 = safeloadFixpoint(
static_cast<unsigned>(ADDR_LIMITER2_THRESHOLD),
thresholdFixpoint);
!lim2)
{
ESP_LOGW(kTag, "Limiter2 threshold override failed");
}
ESP_LOGI(kTag, "Limiter1/2 threshold set to %.1f dBFS",
static_cast<double>(kLimiterThresholdDb));
}
booted_ = true;
@@ -14,12 +14,14 @@
#include "SigmaStudioFW.h"
#include "driver/i2c_master.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include <string.h>
static const char* kTag = "SigmaStudioFW";
static i2c_master_dev_handle_t s_dev = NULL;
static SemaphoreHandle_t s_lock = NULL;
@@ -73,14 +75,45 @@ static unsigned int sigmaWordSize(unsigned int address)
return 4U;
}
void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
/*
* The boot-time program/param replay was previously fire-and-forget: the
* i2c_master_transmit result was discarded outright, so a single transient
* NACK anywhere in the hundreds of chunked writes that make up a DSP
* program load silently left that one chunk (a gain, a filter, a mux)
* at its power-on-reset RAM contents instead of the SigmaStudio-designed
* value -- indistinguishable at the time from a correct load, but capable
* of producing exactly a persistent, localized audio artifact rather than
* gross silence. Give it the same retry-on-NACK reliability the runtime
* safeload path already got in sigma_i2c_write (see its comment) and make
* failure observable instead of silent.
*/
static int sigmaTransmitChunk(unsigned int addr, const ADI_REG_TYPE* payload,
unsigned int chunkBytes)
{
unsigned char buf[2U + 64U];
buf[0] = (unsigned char)((addr >> 8) & 0xFFU);
buf[1] = (unsigned char)(addr & 0xFFU);
memcpy(buf + 2U, payload, chunkBytes);
static const int kMaxAttempts = 3;
for (int attempt = 0; attempt < kMaxAttempts; ++attempt) {
if (i2c_master_transmit(s_dev, buf, (size_t)(2U + chunkBytes), 1000) ==
ESP_OK) {
return 0;
}
vTaskDelay(pdMS_TO_TICKS(2));
}
return -1;
}
int SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
unsigned int address,
unsigned int length,
ADI_REG_TYPE* pData)
{
(void)devAddress;
if (s_dev == NULL || pData == NULL || length == 0U) {
return;
return -1;
}
enum { kChunkBytesMax = 64U };
@@ -96,15 +129,48 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
const unsigned int chunk =
remaining > chunkBytes ? chunkBytes : remaining;
const unsigned int words = chunk / wordSize;
unsigned char buf[2U + kChunkBytesMax];
buf[0] = (unsigned char)((addr >> 8) & 0xFFU);
buf[1] = (unsigned char)(addr & 0xFFU);
memcpy(buf + 2U, cursor, chunk);
i2c_master_transmit(s_dev, buf, (size_t)(2U + chunk), 1000);
if (sigmaTransmitChunk(addr, cursor, chunk) != 0) {
return -1;
}
addr += words;
cursor += chunk;
remaining -= chunk;
}
return 0;
}
int sigma_verify_block(unsigned int address, const ADI_REG_TYPE* expected,
unsigned int length)
{
if (s_dev == NULL || expected == NULL || length == 0U) {
return -1;
}
enum { kChunkBytesMax = 64U };
const unsigned int wordSize = sigmaWordSize(address);
const unsigned int wordsPerChunk = kChunkBytesMax / wordSize;
const unsigned int chunkBytes = wordsPerChunk * wordSize;
unsigned int addr = address;
unsigned int remaining = length;
const ADI_REG_TYPE* cursor = expected;
while (remaining > 0U) {
const unsigned int chunk =
remaining > chunkBytes ? chunkBytes : remaining;
const unsigned int words = chunk / wordSize;
unsigned char readBack[kChunkBytesMax];
if (sigma_i2c_read(addr, readBack, chunk) != 0) {
return -1;
}
if (memcmp(readBack, cursor, chunk) != 0) {
return -1;
}
addr += words;
cursor += chunk;
remaining -= chunk;
}
return 0;
}
int sigma_i2c_read(unsigned int reg, unsigned char* data, unsigned int length)
@@ -198,6 +264,37 @@ int sigma_safeload_param(unsigned int paramAddr, int fixpoint)
return sigma_safeload_block(1U, addrs, values);
}
/*
* Diagnostic only, mirrors the boot-replay read-back in
* SIGMA_WRITE_REGISTER_BLOCK: a safeload can ACK every transaction and
* still not land as intended (address/data written to the wrong Param RAM
* slot, a stale value left from a previous session's committed-but-later-
* garbled write, etc). This is the runtime path applied on every EQ/gain
* change and on the stored-profile replay at boot -- unlike the one-time
* program load, it was previously unverified. Log-only: some callers pass
* addresses this can't independently confirm are readable Param RAM (vs.
* a write-only control register), so a mismatch here is a strong signal,
* not a hard boot/apply-time failure.
*/
static void sigmaVerifyParam(unsigned int paramAddr, int fixpoint)
{
unsigned char readBack[4U];
if (sigma_i2c_read(paramAddr, readBack, sizeof(readBack)) != 0) {
ESP_LOGW(kTag, "safeload verify: read-back failed for param 0x%04X",
paramAddr);
return;
}
const int actual = (int)(((unsigned int)readBack[0] << 24) |
((unsigned int)readBack[1] << 16) |
((unsigned int)readBack[2] << 8) |
(unsigned int)readBack[3]);
if (actual != fixpoint) {
ESP_LOGW(kTag,
"safeload verify: param 0x%04X mismatch, wrote %d read %d",
paramAddr, fixpoint, actual);
}
}
int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs,
const int* fixpoints)
{
@@ -218,7 +315,14 @@ int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs,
}
}
return sigma_trigger_safeload();
if (sigma_trigger_safeload() != 0) {
return -1;
}
for (unsigned char i = 0U; i < count; ++i) {
sigmaVerifyParam(paramAddrs[i], fixpoints[i]);
}
return 0;
}
int sigma_safeload_raw_block(unsigned char count, const unsigned int* paramAddrs,
@@ -37,8 +37,23 @@ namespace bt1035 {
struct Bt1035Pins {
int uartTx; ///< ESP32 TX -> module RX.
int uartRx; ///< ESP32 RX <- module TX.
int resetGpio; ///< Module RESET (active level per schematic).
int sysCtlGpio; ///< SYS_CTL (optional module enable).
int resetGpio; ///< Module RESET# (pin 8), active-low. Driven (not
///< floating, since 2026-08-23): held LOW together with
///< SYS_CTRL past the datasheet §4.8 Reset Protection
///< timeout (~1.8 s) to force a genuine power-down on
///< every resetAndInitOnce() attempt, then released HIGH
///< before SYS_CTRL's power-up pulse (§4.7).
int sysCtlGpio; ///< SYS_CTL (pin 34), active-high. Driven LOW/HIGH on
///< every resetAndInitOnce() call, together with
///< resetGpio, to force a real power-cycle each retry.
int ctsGpio; ///< Host->module UART_CTS (module pin 15). Diagnostic
///< only (2026-08-22): read-only floating input, never
///< driven — this driver does not implement hardware
///< flow control. See boot()'s comment.
int rtsGpio; ///< Module UART_RTS/PIO2 (module pin 16), factory
///< default function is PA_MUTE, not flow control
///< (Feasycom programming guide). Diagnostic only
///< (2026-08-22): read-only floating input.
};
/**
@@ -238,6 +253,24 @@ public:
*/
[[nodiscard]] std::expected<std::uint8_t, Bt1035Error> queryAutoReconnect();
/**
* @brief setA2dpCodecConfig — enable optional A2DP codecs (AT+A2DPCFG).
*
* @dname setA2dpCodecConfig
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
* BIT4=aptX-Adaptive, BIT5=LDAC (§5.3.4); 0 forces
* the mandatory SBC-only baseline.
* @return Ok on success, or Bt1035Error. Only affects the *next* A2DP
* negotiation — an already-connected peer keeps its current
* codec until it reconnects (see disconnectA2dp()).
* @pubstate writes UART.
*
* @author Michele Bigi
* @date 2026-08-24
*/
[[nodiscard]] std::expected<void, Bt1035Error> setA2dpCodecConfig(
std::uint8_t bitmask);
/**
* @brief queryPairedList — enumerate paired remotes (AT+PLIST).
*
@@ -36,21 +36,39 @@ constexpr int kBaudRate = 115200;
constexpr int kUartRxBuffer = 4096;
constexpr int kUartTxBuffer = 256;
constexpr int kResponseTimeoutMs = 2000;
constexpr int kPostResetMs = 500;
constexpr int kPostUartMs = 100;
/** Feasycom BT1035 programming user guide §2.2 (pin 34 SYS_CTRL): "Delay
* 100ms, pull high". */
constexpr int kSysCtlLeadInMs = 100;
/** Margin beyond the datasheet's own >20ms SYS_CTRL-assertion-to-power-up
* minimum (§4.7), for regulator/crystal settling before RESET releases. */
constexpr int kSysCtlSettleMs = 50;
/** Observed intermittently: the module sometimes needs a second RESET#
* pulse to come up (power-up timing jitter between cold/warm boots) —
* a single attempt with no retry was found to explain sporadic total
* boot failures ("no spontaneous UART bytes" -> "AT init failed") on
* otherwise-identical hardware/wiring. */
constexpr int kBootAttempts = 3;
constexpr int kBootRetryDelayMs = 300;
/** Datasheet §4.7: "From the OFF state, SYS_CTRL must be asserted for
* >20 ms to start power up." */
constexpr int kSysCtlAssertMs = 20;
/** Datasheet §4.8: "Reset Protection timeout (typically greater than
* ~1.8 s) causes the device to power down if VCHG is not present and
* SYS_CTRL is low." RESET# and SYS_CTRL are held asserted/low together for
* comfortably longer than that before each power-up, to guarantee a
* genuine full power-down rather than a pulse too short for the module's
* own protection timer to act on — see resetAndInitOnce()'s comment for
* why this now runs on every attempt, not just the first. */
constexpr int kSysCtlDeassertMs = 2500;
/** Measured live (2026-08-20, power/wiring confirmed sound with a
* multimeter — VBAT_IN/SYS_CTRL/1.8V_OUT/VDD_IO all correct, TX/RX pins
* verified via continuity): the module's spontaneous boot banner
* (+VER=FSC-BT1035,..., +DEVSTAT=1) doesn't appear until ~18.5s after
* RESET# releases — full BT stack init, not just the internal regulator.
* The previous 3500ms wait here was never enough for the module to say
* anything, so every prior boot attempt cut power and restarted before
* the module could finish booting even once.
*
* boot() makes exactly one resetAndInitOnce() attempt per call (matching
* fd9d4ae's validated 5/5-clean-boot design): the BT1035 datasheet's own
* "Reset Protection timeout (typically >1.8s)" means a second
* SYS_CTRL/RESET pulse fired shortly after a failed attempt would not
* reliably reach a clean power-off state before repowering — an internal
* retry loop here risks re-interrupting the module mid bring-up, the
* same class of bug fixed in fd9d4ae (redundant AT+RESET). Retries now
* live one layer up, in hardware::bt1035RetryTask
* (main/hardware_bootstrap.cpp), which only re-invokes a full, clean
* boot() call — never re-pulses the pins faster than a whole boot cycle
* apart. */
constexpr int kBootBannerWaitMs = 25000;
void flushUartRx(int uartPort) noexcept
{
@@ -62,15 +80,52 @@ void flushUartRx(int uartPort) noexcept
}
}
// Diagnostic only: some BT1035 firmware prints an unsolicited boot banner on
// UART right after the hardware RESET# pulse. Capturing it (or its absence)
// tells us whether the UART link is electrically alive independent of the
// AT command layer.
// Diagnostic only, run if the single boot attempt fails at 115200 (the
// datasheet's own default). AT+BAUD persists across RESET#/SYS_CTRL power
// cycles (programming guide §5.1.3), so a stray manual AT+BAUD or
// AT+RESTORE sent during earlier interactive testing could have left the
// module listening at a different rate than our fixed assumption — this
// sweep tells us if that's what's happening instead of guessing.
constexpr std::array<int, 8> kBaudProbeCandidates = {
9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600};
void probeBaudRates(int uartPort) noexcept
{
ESP_LOGW(kTag, "115200 unresponsive — sweeping baud rates");
for (const int baud : kBaudProbeCandidates) {
if (uart_set_baudrate(static_cast<uart_port_t>(uartPort), baud)
!= ESP_OK) {
continue;
}
flushUartRx(uartPort);
uart_write_bytes(static_cast<uart_port_t>(uartPort), "AT\r\n", 4);
std::array<char, 64> buf{};
const int n = uart_read_bytes(static_cast<uart_port_t>(uartPort),
buf.data(), buf.size(),
pdMS_TO_TICKS(500));
if (n > 0) {
ESP_LOGW(kTag, "baud probe: module responded at %d baud (%d bytes)",
baud, n);
ESP_LOG_BUFFER_HEX(kTag, buf.data(), static_cast<std::size_t>(n));
} else {
ESP_LOGI(kTag, "baud probe: silent at %d baud", baud);
}
}
uart_set_baudrate(static_cast<uart_port_t>(uartPort), kBaudRate);
flushUartRx(uartPort);
}
// The BT1035 prints an unsolicited boot banner (+VER=..., +DEVSTAT=1, ...)
// once its full Bluetooth stack finishes initialising — this blocks for up
// to kBootBannerWaitMs waiting for it, since that's the real boot-complete
// signal (the module is otherwise silent and won't answer AT commands
// until this appears).
void logRawUartBoot(int uartPort) noexcept
{
std::array<std::uint8_t, 128> buf{};
const int n = uart_read_bytes(static_cast<uart_port_t>(uartPort), buf.data(),
buf.size(), pdMS_TO_TICKS(3500));
buf.size(), pdMS_TO_TICKS(kBootBannerWaitMs));
if (n <= 0) {
ESP_LOGW("Bt1035", "no spontaneous UART bytes after hardware reset");
return;
@@ -743,6 +798,15 @@ std::expected<void, Bt1035Error> Bt1035Driver::setAutoReconnect(
return transmitAndExpectOk(core::buildBt1035SetAutoConnLine(times));
}
std::expected<void, Bt1035Error> Bt1035Driver::setA2dpCodecConfig(
std::uint8_t bitmask)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
return transmitAndExpectOk(core::buildBt1035A2dpCodecConfigLine(bitmask));
}
std::expected<std::uint8_t, Bt1035Error> Bt1035Driver::queryAutoReconnect()
{
if (auto ready = ensureBooted(); !ready) {
@@ -869,6 +933,32 @@ bool Bt1035Driver::waitForA2dpStreaming(int timeoutMs)
ESP_LOGI(kTag, "stream wait: A2DPSTAT=%u",
static_cast<unsigned>(static_cast<std::uint8_t>(*state)));
if (*state == core::Bt1035A2dpState::Streaming) {
// Diagnostic, 2026-08-24: confirm which codec actually got
// negotiated now that AT+A2DPCFG=1 (AAC) is sent at boot --
// previously this was never queried, so every link was
// silently SBC-only with no way to tell from the logs.
if (auto codec = queryA2dpEncoder(); codec) {
// Feasycom guide §5.3.5's AT+A2DPENC response table for
// BT1035 only lists SBC/aptX/aptX-HD/aptX-LL/aptX-
// Adaptive -- no AAC code, even though §5.3.4's
// AT+A2DPCFG can enable it. Not inventing a mapping for
// that gap: an AAC link (or anything else undocumented)
// logs as "unknown" rather than a guessed label.
const char* name = "unknown";
switch (*codec) {
case core::Bt1035A2dpCodec::Sbc: name = "SBC"; break;
case core::Bt1035A2dpCodec::Aptx: name = "aptX"; break;
case core::Bt1035A2dpCodec::AptxHd: name = "aptX-HD"; break;
case core::Bt1035A2dpCodec::AptxLl: name = "aptX-LL"; break;
case core::Bt1035A2dpCodec::AptxAdaptive:
name = "aptX-Adaptive";
break;
}
ESP_LOGI(kTag, "A2DP streaming with codec: %s", name);
} else {
ESP_LOGW(kTag, "A2DPENC query failed (%d)",
static_cast<int>(codec.error()));
}
return true;
}
const TickType_t now = xTaskGetTickCount();
@@ -919,24 +1009,44 @@ std::expected<void, Bt1035Error> Bt1035Driver::runInitSequence()
std::expected<void, Bt1035Error> Bt1035Driver::resetAndInitOnce()
{
// Feasycom BT1035 programming user guide §2.2, pin 34 SYS_CTRL:
// "Delay 100ms, pull high" — the datasheet's own OFF-state timing spec
// (§4.7) says SYS_CTRL must be asserted >20ms before the internal
// regulators start powering up at all, so pulling it high with no
// lead-in delay (the previous sequence here) races the chip's own
// power-on requirement. Held low with RESET already asserted, then a
// 100ms lead-in exactly matching the guide, then SYS_CTRL high, then
// extra settle time before releasing RESET into a chip that's had a
// chance to actually power up first.
gpio_set_level(static_cast<gpio_num_t>(pins_.sysCtlGpio), 0);
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
vTaskDelay(pdMS_TO_TICKS(kSysCtlLeadInMs));
gpio_set_level(static_cast<gpio_num_t>(pins_.sysCtlGpio), 1);
vTaskDelay(pdMS_TO_TICKS(kSysCtlSettleMs));
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
vTaskDelay(pdMS_TO_TICKS(kPostResetMs));
// 2026-08-23 fix: force a genuine power-down/restart on every retry by
// driving RESET# together with SYS_CTRL, per datasheet §4.8: "Assertion
// of RESET# beyond the Reset Protection timeout (typically >~1.8 s)
// causes the device to power down if VCHG is not present and SYS_CTRL
// is low. FSC-BT1035 then requires a SYS_CTRL assertion ... to
// restart." The prior design (2026-08-22) only pulsed SYS_CTRL and left
// RESET# floating; per §4.7, "when booted, software takes control of
// the internal regulators and the state of SYS_CTRL is ignored" — so
// once the module had booted once, that pulse alone could never force
// a real power-cycle. Asserting RESET# is the documented way to do it.
const auto sysCtlPin = static_cast<gpio_num_t>(pins_.sysCtlGpio);
const auto resetPin = static_cast<gpio_num_t>(pins_.resetGpio);
const auto ctsPin = static_cast<gpio_num_t>(pins_.ctsGpio);
const auto rtsPin = static_cast<gpio_num_t>(pins_.rtsGpio);
// Assert RESET# (active-low) and deassert SYS_CTRL together, held past
// the ~1.8s Reset Protection timeout so the module actually powers
// down rather than staying "protected" on (§4.8).
gpio_set_level(resetPin, 0);
gpio_set_level(sysCtlPin, 0);
vTaskDelay(pdMS_TO_TICKS(kSysCtlDeassertMs));
// Release RESET# before requesting power-up, then assert SYS_CTRL for
// the datasheet's own >=20ms minimum to start the boot (§4.7).
gpio_set_level(resetPin, 1);
gpio_set_level(sysCtlPin, 1);
vTaskDelay(pdMS_TO_TICKS(kSysCtlAssertMs));
ESP_LOGI(kTag, "power-up: SYS_CTRL=%d (want 1, driven) RESET#=%d "
"(want 1, driven)",
gpio_get_level(sysCtlPin), gpio_get_level(resetPin));
ESP_LOGI(kTag, "before banner wait: CTS=%d (module's flow-control "
"input, host side floating) RTS/PIO2=%d (module's "
"PA_MUTE by factory default)",
gpio_get_level(ctsPin), gpio_get_level(rtsPin));
logRawUartBoot(uartPort_);
ESP_LOGI(kTag, "after banner wait: CTS=%d RTS/PIO2=%d",
gpio_get_level(ctsPin), gpio_get_level(rtsPin));
uart_flush_input(static_cast<uart_port_t>(uartPort_));
vTaskDelay(pdMS_TO_TICKS(kPostUartMs));
@@ -949,20 +1059,65 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
return {};
}
// RESET# (pin 8) is actively driven (2026-08-23 fix): datasheet §4.8
// states the Reset Protection timeout that forces a genuine power-down
// is triggered by *asserting RESET#* (while SYS_CTRL is low), not by
// toggling SYS_CTRL alone. Leaving RESET# floating (the 2026-08-22
// diagnostic change) meant that mechanism was never actually invoked —
// every retry only pulsed SYS_CTRL, which the module ignores once
// already booted (§4.7: "when booted, software takes control of the
// internal regulators and the state of SYS_CTRL is ignored"). Driven
// HIGH immediately below (deasserted) before anything else runs, so
// configuring the pin never glitches it low.
gpio_config_t resetCfg = {};
resetCfg.pin_bit_mask = 1ULL << pins_.resetGpio;
resetCfg.mode = GPIO_MODE_OUTPUT;
resetCfg.mode = GPIO_MODE_INPUT_OUTPUT;
resetCfg.pull_up_en = GPIO_PULLUP_DISABLE;
resetCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
if (gpio_config(&resetCfg) != ESP_OK) {
return std::unexpected(Bt1035Error::ResetFailed);
}
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
// SYS_CTRL (pin 34) stays actively driven (GPIO_MODE_INPUT_OUTPUT:
// the INPUT bit is what makes gpio_get_level() read the real driven
// level instead of a stale register, for the diagnostic log).
gpio_config_t sysCfg = {};
sysCfg.pin_bit_mask = 1ULL << pins_.sysCtlGpio;
sysCfg.mode = GPIO_MODE_OUTPUT;
sysCfg.mode = GPIO_MODE_INPUT_OUTPUT;
if (gpio_config(&sysCfg) != ESP_OK) {
return std::unexpected(Bt1035Error::ResetFailed);
}
// CTS (module pin 15, host->module) and RTS (module pin 16/PIO2,
// factory default function PA_MUTE per the Feasycom programming
// guide) — diagnostic only (2026-08-22): this driver does not
// implement UART hardware flow control (UART_HW_FLOWCTRL_DISABLE
// below), so these are wired but otherwise unused. Configured as
// floating inputs, pull-up/pull-down both disabled, purely to read
// back their level for the diagnostic log — never driven. Exploring
// whether the module's CTS input floating could be gating its own
// UART TX (a common hardware-flow-control behavior), and whether RTS
// toggles at all (would indicate the module's internal firmware is
// alive even when UART TX is silent).
gpio_config_t ctsCfg = {};
ctsCfg.pin_bit_mask = 1ULL << pins_.ctsGpio;
ctsCfg.mode = GPIO_MODE_INPUT;
ctsCfg.pull_up_en = GPIO_PULLUP_DISABLE;
ctsCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
if (gpio_config(&ctsCfg) != ESP_OK) {
return std::unexpected(Bt1035Error::ResetFailed);
}
gpio_config_t rtsCfg = {};
rtsCfg.pin_bit_mask = 1ULL << pins_.rtsGpio;
rtsCfg.mode = GPIO_MODE_INPUT;
rtsCfg.pull_up_en = GPIO_PULLUP_DISABLE;
rtsCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
if (gpio_config(&rtsCfg) != ESP_OK) {
return std::unexpected(Bt1035Error::ResetFailed);
}
if (!uartInstalled_) {
const uart_config_t uartCfg = {
.baud_rate = kBaudRate,
@@ -994,19 +1149,9 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
uartInstalled_ = true;
}
std::expected<void, Bt1035Error> init = std::unexpected(Bt1035Error::UnexpectedResponse);
for (int attempt = 1; attempt <= kBootAttempts; ++attempt) {
init = resetAndInitOnce();
if (init) {
break;
}
ESP_LOGW(kTag, "boot attempt %d/%d failed", attempt, kBootAttempts);
if (attempt < kBootAttempts) {
vTaskDelay(pdMS_TO_TICKS(kBootRetryDelayMs));
}
}
if (!init) {
ESP_LOGE(kTag, "AT init failed after %d attempts", kBootAttempts);
if (auto init = resetAndInitOnce(); !init) {
ESP_LOGE(kTag, "AT init failed");
probeBaudRates(uartPort_);
return init;
}
@@ -1014,7 +1159,7 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
ESP_LOGI(kTag, "auto-link disabled (AT+LINKCFG=0,0)");
booted_ = true;
ESP_LOGI(kTag, "I2S slave mode enabled (AT+AUXCFG=3, AT+I2SCFG=35)");
ESP_LOGI(kTag, "I2S slave mode enabled (AT+AUXCFG=3, AT+I2SCFG=67)");
return {};
}
@@ -33,7 +33,8 @@ namespace eeprom24aa {
* @pubstate Borrows an existing I2C master bus (shared with ADAU1701). The
* EUI-48 lives at word address 0xFA..0xFF (read-only, factory
* programmed) per the 24AA025E48 datasheet (DS20001191); the FM
* ANTCAP calibration byte lives at word address 0x00 in the
* ANTCAP calibration byte lives at word address 0x00, and the
* DAB ANTCAP calibration byte at word address 0x01, both in the
* remaining user-writable 250 bytes.
*
* @author Michele Bigi
@@ -41,11 +42,14 @@ namespace eeprom24aa {
*/
class Eeprom24aa : public core::IDeviceIdentitySource {
public:
/** Valid FM ANTCAP calibration values are 0-128 (AN649/AN851); any
* stored byte above this, including the EEPROM's blank/erased 0xFF,
* reads back as "never calibrated" — no separate sentinel write
/** Valid ANTCAP calibration values (FM or DAB) are 0-128 (AN649/AN851);
* any stored byte above this, including the EEPROM's blank/erased
* 0xFF, reads back as "never calibrated" — no separate sentinel write
* needed for a fresh chip. */
static constexpr std::uint8_t kFmAntCapMax = 128U;
/** Same range as kFmAntCapMax; kept as a separate name for the DAB
* calibration byte's own doc comments below. */
static constexpr std::uint8_t kDabAntCapMax = 128U;
/**
* @brief Eeprom24aa — bind to a running I2C master bus and 7-bit addr.
@@ -105,6 +109,37 @@ public:
[[nodiscard]] std::expected<void, core::IdentityError>
writeFmAntCap(std::uint8_t value);
/**
* @brief readDabAntCap — read the stored DAB antenna calibration byte.
*
* @dname readDabAntCap
* @return Calibrated ANTCAP (0-kDabAntCapMax) if one was ever saved via
* writeDabAntCap(), nullopt if the byte is blank/out of range,
* or IdentityError on an I2C failure.
* @pubstate performs one I2C read of one byte at word address 0x01.
*
* @author Michele Bigi
* @date 2026-08-20
*/
[[nodiscard]] std::expected<std::optional<std::uint8_t>, core::IdentityError>
readDabAntCap();
/**
* @brief writeDabAntCap — persist a DAB antenna calibration value.
*
* @dname writeDabAntCap
* @param value ANTCAP to store, 0-kDabAntCapMax (AN649 Command
* 0xB0 ARG4; found via a sweep, not computed).
* @return Ok on success, or IdentityError::I2cFailed.
* @pubstate performs one I2C byte write at word address 0x01, then
* blocks for the chip's write-cycle time before returning.
*
* @author Michele Bigi
* @date 2026-08-20
*/
[[nodiscard]] std::expected<void, core::IdentityError>
writeDabAntCap(std::uint8_t value);
private:
i2c_master_bus_handle_t bus_;
std::uint8_t addr7_;
@@ -30,6 +30,8 @@ constexpr std::uint8_t kEui48WordAddress = 0xFAU;
/** FM ANTCAP calibration byte, in the chip's user-writable region (anywhere
* below the factory-locked 0xFA-0xFF EUI-48 block). */
constexpr std::uint8_t kFmAntCapWordAddress = 0x00U;
/** DAB ANTCAP calibration byte, next word address after the FM byte. */
constexpr std::uint8_t kDabAntCapWordAddress = 0x01U;
constexpr int kI2cTimeoutMs = 100;
/** DS20001191 §"Page Write"/"Byte Write": max write cycle time after STOP
* before the chip acknowledges further I2C traffic. */
@@ -148,4 +150,75 @@ Eeprom24aa::writeFmAntCap(std::uint8_t value)
return {};
}
std::expected<std::optional<std::uint8_t>, core::IdentityError>
Eeprom24aa::readDabAntCap()
{
if (bus_ == nullptr) {
return std::unexpected(core::IdentityError::I2cFailed);
}
i2c_device_config_t devCfg = {};
devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7;
devCfg.device_address = addr7_;
devCfg.scl_speed_hz = 100000;
i2c_master_dev_handle_t dev = nullptr;
if (i2c_master_bus_add_device(bus_, &devCfg, &dev) != ESP_OK) {
ESP_LOGW(kTag, "i2c_master_bus_add_device failed");
return std::unexpected(core::IdentityError::I2cFailed);
}
const std::uint8_t wordAddress = kDabAntCapWordAddress;
std::uint8_t value = 0xFFU;
const esp_err_t err = i2c_master_transmit_receive(
dev, &wordAddress, 1U, &value, 1U, kI2cTimeoutMs);
i2c_master_bus_rm_device(dev);
if (err != ESP_OK) {
ESP_LOGW(kTag, "DAB ANTCAP read failed (err=0x%x)",
static_cast<unsigned>(err));
return std::unexpected(core::IdentityError::ReadFailed);
}
if (value > kDabAntCapMax) {
return std::optional<std::uint8_t>{};
}
return std::optional<std::uint8_t>{value};
}
std::expected<void, core::IdentityError>
Eeprom24aa::writeDabAntCap(std::uint8_t value)
{
if (bus_ == nullptr) {
return std::unexpected(core::IdentityError::I2cFailed);
}
i2c_device_config_t devCfg = {};
devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7;
devCfg.device_address = addr7_;
devCfg.scl_speed_hz = 100000;
i2c_master_dev_handle_t dev = nullptr;
if (i2c_master_bus_add_device(bus_, &devCfg, &dev) != ESP_OK) {
ESP_LOGW(kTag, "i2c_master_bus_add_device failed");
return std::unexpected(core::IdentityError::I2cFailed);
}
const std::array<std::uint8_t, 2> payload = {kDabAntCapWordAddress, value};
const esp_err_t err =
i2c_master_transmit(dev, payload.data(), payload.size(), kI2cTimeoutMs);
i2c_master_bus_rm_device(dev);
if (err != ESP_OK) {
ESP_LOGW(kTag, "DAB ANTCAP write failed (err=0x%x)",
static_cast<unsigned>(err));
return std::unexpected(core::IdentityError::I2cFailed);
}
vTaskDelay(pdMS_TO_TICKS(kI2cWriteCycleMs));
return {};
}
} // namespace eeprom24aa
@@ -112,6 +112,16 @@ public:
* verified live (72 = 720 uA startup bias).
* @param xtalCtun POWER_UP ARG8 CTUN, 0-63 (AN649 §Command 0x01);
* default matches the values already verified live.
* @param xtalFreqHz POWER_UP ARG4-7 XTAL_FREQ in Hz (AN649 §Command
* 0x01); default 19,200,000 (nominal crystal
* frequency). Deliberately overriding this away
* from the crystal's true nominal value is a valid
* software calibration trick: it tells the chip's
* internal PLL math "the crystal actually runs at
* this rate," compensating any real physical
* offset (from load-cap mismatch etc) without
* touching CTUN. See FM_RSQ_STATUS FREQOFF for the
* measurement this is meant to null out.
* @return Ok on success, or Si4684Error.
* @pubstate writes booted_ and loadedBand_ on success.
*
@@ -120,7 +130,28 @@ public:
*/
[[nodiscard]] std::expected<void, Si4684Error> boot(
Si4684Band band, std::uint8_t xtalIbias = 72U,
std::uint8_t xtalCtun = 31U);
std::uint8_t xtalCtun = 31U, std::uint32_t xtalFreqHz = 19200000U);
/**
* @brief recalibrateXtal — re-run boot() with new crystal parameters.
*
* @dname recalibrateXtal
* @param xtalIbias New POWER_UP ARG3 IBIAS.
* @param xtalCtun New POWER_UP ARG8 CTUN.
* @param xtalFreqHz New POWER_UP ARG4-7 XTAL_FREQ in Hz.
* @return Ok on success, or Si4684Error::NotBooted if never booted.
* @pubstate forces booted_=false then re-runs boot() for the currently
* loaded band -- full RSTB# pulse + patch/image reload, same
* as a cold boot, just without an ESP32 restart. Diagnostic:
* lets a calibration script iterate crystal parameters live
* over HTTP instead of a firmware rebuild+reflash per value.
*
* @author Michele Bigi
* @date 2026-08-23
*/
[[nodiscard]] std::expected<void, Si4684Error> recalibrateXtal(
std::uint8_t xtalIbias, std::uint8_t xtalCtun,
std::uint32_t xtalFreqHz);
/**
* @brief isBooted — query whether boot completed successfully.
@@ -288,13 +319,18 @@ public:
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @param antCap ANTCAP[7:0] override (0-128, AN649 Command 0xB0
* ARG4). 0 = automatic front-end tuning; other
* values force a specific varactor setting, for
* antenna calibration sweeps (mirrors tuneFm).
* @return Ok on success, or Si4684Error.
* @pubstate sends DAB_TUNE_FREQ and waits for STC.
*
* @author Michele Bigi
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, Si4684Error> tuneDab(std::uint8_t freqIndex);
[[nodiscard]] std::expected<void, Si4684Error> tuneDab(
std::uint8_t freqIndex, std::uint8_t antCap = 0U);
/**
* @brief readDabDigRadStatus — read ensemble lock metrics.
@@ -96,6 +96,7 @@ public:
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @param antCap Forwarded to Si4684Driver::tuneDab (0 = auto).
* @return Ok on success, or a mapped TunerError.
* @pubstate writes dabIndex_ on success.
*
@@ -103,7 +104,7 @@ public:
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
std::uint8_t freqIndex) override;
std::uint8_t freqIndex, std::uint8_t antCap = 0U) override;
/**
* @brief tuneFm — tune to an FM centre frequency in kHz.
@@ -177,6 +178,24 @@ public:
[[nodiscard]] std::expected<void, core::TunerError> setVolume(
std::uint8_t level) override;
/**
* @brief recalibrateXtal — re-run driver boot() with new crystal
* parameters, without an ESP32 restart.
*
* @dname recalibrateXtal
* @param ibias New POWER_UP ARG3 IBIAS.
* @param ctun New POWER_UP ARG8 CTUN.
* @param xtalFreqHz New POWER_UP ARG4-7 XTAL_FREQ in Hz.
* @return Ok on success, or a mapped TunerError.
* @pubstate delegates to driver_.recalibrateXtal(); caller must re-tune
* afterwards, this only reboots the chip.
*
* @author Michele Bigi
* @date 2026-08-23
*/
[[nodiscard]] std::expected<void, core::TunerError> recalibrateXtal(
std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz);
private:
/**
* @brief mapError — translate Si4684Error to core::TunerError.
@@ -98,6 +98,12 @@ struct Si4684FmRsq {
std::int8_t snrDb; ///< SNR in dB.
bool valid; ///< RSQ valid flag from the chip.
bool stereo; ///< Stereo pilot detected.
/** AN649 FM_RSQ_STATUS RESP8 FREQOFF: signed offset in units of 2 PPM
* (range -128..127, i.e. -256..+254 PPM). Crystal calibration signal:
* every locked station's carrier reads the same PPM error when the
* XTAL_FREQ/CTUN reference is off, since real broadcast transmitters
* are themselves GPS/rubidium-locked. */
std::int8_t freqOffBppm;
};
/**
@@ -40,6 +40,8 @@ constexpr std::size_t kSpiReplyLeadIn = 1U;
/** FM_RSQ_STATUS field indices with kSpiReplyLeadIn (AN649 RESP510). */
constexpr std::size_t kFmRsqOffValid = 6U;
constexpr std::size_t kFmRsqOffReadFreq = 7U;
/** AN649 FM_RSQ_STATUS RESP8 FREQOFF: signed offset in 2 PPM units. */
constexpr std::size_t kFmRsqOffFreqOff = 9U;
constexpr std::size_t kFmRsqOffRssi = 10U;
constexpr std::size_t kFmRsqOffSnr = 11U;
@@ -64,6 +66,12 @@ 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 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
* default -- that under-de-emphasizes treble on every station. */
constexpr std::uint16_t kPropFmAudioDeEmphasis = 0x3900U;
constexpr std::uint16_t kFmAudioDeEmphasisEurope = 0x0001U;
/** AN649 FM valid tune properties (defaults RSSI 17 dBµV, SNR 10 dB). */
constexpr std::uint16_t kPropFmValidRssiThreshold = 0x3202U;
constexpr std::uint16_t kPropFmValidSnrThreshold = 0x3204U;
@@ -486,6 +494,16 @@ std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
{0xB302U, 0x0000U},
{0xB303U, 0x0000U},
{0xB401U, 0x0002U},
// AN649 Property 0xB500 DAB_ACF_ENABLE: bit0 SOFTMUTE_ENABLE,
// bit1 COMF_NOISE_ENABLE, datasheet default 0x0003 (both on).
// Tried 0x0003 on 2026-08-23 hoping it would mask the periodic
// hiss/unintelligible-voice symptom on real DAB reception --
// live A/B test made it worse (COMF_NOISE injects synthetic
// noise on every brief signal-quality dip, and frequent
// softmute engagement chopped up speech). PE5PVB's independent
// SI4684-DAB-Receiver project also explicitly disables this
// (Set_Property(0xB500, 0x0000)), matching what real testing
// shows here -- disabled deliberately, not an oversight.
{0xB500U, 0x0000U},
};
for (const auto& prop : kDabProps) {
@@ -542,6 +560,11 @@ std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
if (auto rds = setProperty(kPropFmRdsConfig, 0x0001U); !rds) {
return rds;
}
if (auto deEmph = setProperty(kPropFmAudioDeEmphasis,
kFmAudioDeEmphasisEurope);
!deEmph) {
return deEmph;
}
// AN649 §0x3202/0x3204: lower seek/tune validity for weak lab antennas.
if (auto rssi = setProperty(kPropFmValidRssiThreshold,
kFmValidRssiThresholdDbuV);
@@ -597,7 +620,8 @@ std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
}
std::expected<void, Si4684Error> Si4684Driver::boot(
Si4684Band band, std::uint8_t xtalIbias, std::uint8_t xtalCtun)
Si4684Band band, std::uint8_t xtalIbias, std::uint8_t xtalCtun,
std::uint32_t xtalFreqHz)
{
if (booted_ && loadedBand_ == band) {
return {};
@@ -672,10 +696,19 @@ std::expected<void, Si4684Error> Si4684Driver::boot(
}
// ARG2=0x17(CLK_MODE=crystal,TR_SIZE), ARG3=IBIAS, ARG4-7=XTAL_FREQ
// 19.2 MHz (0x0124F800), ARG8=CTUN, ARG9=0x10 (fixed bit4=1 per AN649
// (little-endian, nominal 19.2 MHz = 0x0124F800; may be intentionally
// offset from nominal as a software crystal calibration -- see the
// xtalFreqHz doc comment), ARG8=CTUN, ARG9=0x10 (fixed bit4=1 per AN649
// §Command 0x01), ARG10-15=0 (AN649 POWER_UP argument table).
std::uint8_t powerUp[] = {
0x17, xtalIbias, 0x00, 0xf8, 0x24, 0x01, xtalCtun, 0x10,
0x17,
xtalIbias,
static_cast<std::uint8_t>(xtalFreqHz & 0xFFU),
static_cast<std::uint8_t>((xtalFreqHz >> 8) & 0xFFU),
static_cast<std::uint8_t>((xtalFreqHz >> 16) & 0xFFU),
static_cast<std::uint8_t>((xtalFreqHz >> 24) & 0xFFU),
xtalCtun,
0x10,
0x00, 0x00, 0x00, 0x18, 0x00, 0x00,
};
if (auto pu = writeCommand(Command::PowerUp, powerUp, sizeof(powerUp));
@@ -745,6 +778,17 @@ std::expected<void, Si4684Error> Si4684Driver::boot(
return {};
}
std::expected<void, Si4684Error> Si4684Driver::recalibrateXtal(
std::uint8_t xtalIbias, std::uint8_t xtalCtun, std::uint32_t xtalFreqHz)
{
if (auto ready = ensureBooted(); !ready) {
return ready;
}
const Si4684Band band = loadedBand_;
booted_ = false;
return boot(band, xtalIbias, xtalCtun, xtalFreqHz);
}
bool Si4684Driver::isBooted() const noexcept
{
return booted_;
@@ -920,6 +964,7 @@ std::expected<Si4684FmRsq, Si4684Error> Si4684Driver::readFmRsq()
static_cast<std::int8_t>(raw[kFmRsqOffSnr]),
freqInBand && chipValid,
false,
static_cast<std::int8_t>(raw[kFmRsqOffFreqOff]),
};
return rsq;
}
@@ -1037,7 +1082,8 @@ std::expected<void, Si4684Error> Si4684Driver::installDefaultDabFrequencyPlan()
return sendCommand(cmd);
}
std::expected<void, Si4684Error> Si4684Driver::tuneDab(std::uint8_t freqIndex)
std::expected<void, Si4684Error> Si4684Driver::tuneDab(
std::uint8_t freqIndex, std::uint8_t antCap)
{
if (auto band = ensureBand(Si4684Band::Dab); !band) {
return band;
@@ -1047,8 +1093,9 @@ std::expected<void, Si4684Error> Si4684Driver::tuneDab(std::uint8_t freqIndex)
}
// writeCommand() always prepends a fixed ARG1=0x00 (INJECTION=0), so
// this array starts at ARG2 (AN649 Command 0xB0 table: ARG2=FREQ_INDEX,
// ARG3=0x00 fixed, ARG4=ANTCAP[7:0], ARG5=ANTCAP[15:8]).
const std::uint8_t args[] = {freqIndex, 0x00U, 0x00U, 0x00U};
// ARG3=0x00 fixed, ARG4=ANTCAP[7:0], ARG5=ANTCAP[15:8] -- high byte
// always 0, range is 0-128, same as tuneFm's ANTCAP).
const std::uint8_t args[] = {freqIndex, 0x00U, antCap, 0x00U};
if (auto cmd = writeCommand(Command::DabTuneFreq, args, sizeof(args));
!cmd) {
return std::unexpected(Si4684Error::TuneFailed);
@@ -169,6 +169,7 @@ std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
status.locked = rsq->valid;
status.fmRssiDbuV = rsq->rssiDbuV;
status.fmSnrDb = rsq->snrDb;
status.fmFreqOffBppm = rsq->freqOffBppm;
status.fmStereo = rsq->stereo;
status.fmChipReadFrequency = rsq->frequency;
// Keep commanded frequency when chip READFREQ is stale (stuck at band
@@ -209,12 +210,12 @@ std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
}
std::expected<void, core::TunerError> Si4684Tuner::tuneDab(
std::uint8_t freqIndex)
std::uint8_t freqIndex, std::uint8_t antCap)
{
if (auto ready = ensureBandLoaded(core::TunerBand::Dab); !ready) {
return ready;
}
if (auto result = driver_.tuneDab(freqIndex); !result) {
if (auto result = driver_.tuneDab(freqIndex, antCap); !result) {
return std::unexpected(mapError(result.error()));
}
dabIndex_ = freqIndex;
@@ -342,4 +343,14 @@ std::expected<void, core::TunerError> Si4684Tuner::setVolume(std::uint8_t level)
return {};
}
std::expected<void, core::TunerError> Si4684Tuner::recalibrateXtal(
std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz)
{
if (auto result = driver_.recalibrateXtal(ibias, ctun, xtalFreqHz);
!result) {
return std::unexpected(mapError(result.error()));
}
return {};
}
} // namespace si4684
@@ -86,14 +86,14 @@ struct PhoneStreamSink {
};
/**
* @brief AntennaCalibration — plain function pointer over EEPROM-backed
* FM ANTCAP storage, so net/ never includes eeprom24aa headers
* directly; main/ supplies it (HardwareBootstrap owns the I2C
* bus and EEPROM handle).
* @brief AntennaCalibration — plain function pointers over EEPROM-backed
* FM and DAB ANTCAP storage, so net/ never includes eeprom24aa
* headers directly; main/ supplies it (HardwareBootstrap owns the
* I2C bus and EEPROM handle).
*
* @dname AntennaCalibration
* @return n/a (type)
* @pubstate Free function with process lifetime; no per-instance state.
* @pubstate Free functions with process lifetime; no per-instance state.
*
* @author Michele Bigi
* @date 2026-08-19
@@ -102,6 +102,16 @@ struct AntennaCalibration {
/** Persist a new FM ANTCAP calibration value to EEPROM.
* @return false on an I2C failure. */
bool (*save)(std::uint8_t antCap);
/** Persist a new DAB ANTCAP calibration value to EEPROM.
* @return false on an I2C failure. */
bool (*saveDab)(std::uint8_t antCap);
/** Diagnostic-only, 2026-08-23: re-run Si4684Driver::boot() with new
* crystal parameters (no ESP32 restart, no persistence). Bridged here
* rather than through a new context field to avoid a second plumbing
* chain for what is a temporary calibration tool.
* @return false if the chip was never booted or the reboot failed. */
bool (*recalibrateXtal)(std::uint8_t ibias, std::uint8_t ctun,
std::uint32_t xtalFreqHz);
};
/**
+148 -8
View File
@@ -430,12 +430,12 @@ esp_err_t tunerTunePostHandler(httpd_req_t* req)
std::expected<void, core::TunerError> result = std::unexpected(
core::TunerError::InvalidInput);
// Omitting antcap uses the board's saved calibration for that band (or
// hardware auto-tune if never calibrated) — only an explicit value in
// the request overrides it, e.g. for a calibration sweep.
if (parsed->band == core::TunerBand::Dab) {
result = ctx->tuner->tuneDab(parsed->dabFreqIndex);
result = ctx->tuner->tuneDab(parsed->dabFreqIndex, parsed->antCap);
} else if (parsed->fmFrequency) {
// Omitting antcap uses the board's saved calibration (or hardware
// auto-tune if never calibrated) — only an explicit value in the
// request overrides it, e.g. for a calibration sweep.
result = ctx->tuner->tuneFm(*parsed->fmFrequency, parsed->antCap);
}
@@ -676,17 +676,76 @@ esp_err_t tunerCalibrateAntennaPostHandler(httpd_req_t* req)
return httpd_resp_send(req, json.c_str(), json.size());
}
if (!ctx->antennaCalibration->save(*parsed)) {
const bool isDab = parsed->band == core::TunerBand::Dab;
const bool saved = isDab ? ctx->antennaCalibration->saveDab(parsed->antCap)
: ctx->antennaCalibration->save(parsed->antCap);
if (!saved) {
const std::string json = core::serializeTunerErrorJson("store_failed");
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());
}
ctx->tuner->setDefaultFmAntCap(*parsed);
if (isDab) {
ctx->tuner->setDefaultDabAntCap(parsed->antCap);
} else {
ctx->tuner->setDefaultFmAntCap(parsed->antCap);
}
const std::string json =
std::string("{\"status\":\"saved\",\"antcap\":")
+ std::to_string(*parsed) + "}";
std::string("{\"status\":\"saved\",\"band\":\"")
+ (isDab ? "dab" : "fm") + "\",\"antcap\":"
+ std::to_string(parsed->antCap) + "}";
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
/**
* @brief tunerXtalCalibratePostHandler — POST /api/tuner/xtal-calibrate.
*
* Diagnostic-only, 2026-08-23: reboots the Si4684 with new IBIAS/CTUN/
* XTAL_FREQ (AN649 §Command 0x01 POWER_UP) without an ESP32 restart or
* NVS persistence, so a calibration script can iterate crystal parameters
* live. Caller must re-tune afterwards -- this only reboots the chip.
* See FM_RSQ_STATUS FREQOFF (GET /api/tuner/status, "freqoff_ppm") for the
* measurement this is meant to null out.
*/
esp_err_t tunerXtalCalibratePostHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
if (ctx == nullptr || ctx->antennaCalibration == nullptr
|| ctx->antennaCalibration->recalibrateXtal == nullptr) {
httpd_resp_set_status(req, "503 Service Unavailable");
return httpd_resp_send(req, nullptr, 0);
}
std::array<char, 128> body{};
if (!readRequestBody(req, body)) {
httpd_resp_set_status(req, "400 Bad Request");
return httpd_resp_send(req, nullptr, 0);
}
const auto parsed =
core::parseXtalCalibrationJson(std::string_view(body.data()));
if (!parsed) {
const std::string json = core::serializeTunerErrorJson("invalid_json");
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 (!ctx->antennaCalibration->recalibrateXtal(
parsed->ibias, parsed->ctun, parsed->xtalFreqHz)) {
const std::string json = core::serializeTunerErrorJson("boot_failed");
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 =
std::string("{\"status\":\"recalibrated\",\"ibias\":")
+ std::to_string(parsed->ibias) + ",\"ctun\":"
+ std::to_string(parsed->ctun) + ",\"xtal_freq_hz\":"
+ std::to_string(parsed->xtalFreqHz) + "}";
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
@@ -1700,6 +1759,63 @@ esp_err_t bluetoothAutoReconnectPostHandler(httpd_req_t* req)
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
}
esp_err_t bluetoothA2dpCodecConfigPostHandler(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{};
(void)readRequestBody(req, body);
const auto mask = core::parseBluetoothA2dpCodecConfigJson(
std::string_view(body.data()));
if (!mask) {
const std::string json =
core::serializeBluetoothErrorJson(parseErrorToken(mask.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->setA2dpCodecConfig(*mask); !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());
}
ESP_LOGI(kTag, "A2DP codec config set to bitmask %u — reconnect the "
"peer for it to take effect",
static_cast<unsigned>(*mask));
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
}
esp_err_t bluetoothA2dpCodecGetHandler(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);
}
const auto codec = ctx->bluetooth->queryA2dpCodec();
if (!codec) {
const std::string json =
core::serializeBluetoothErrorJson(bt1035ErrorToken(codec.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::serializeBluetoothA2dpCodecJson(*codec);
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.c_str(), json.size());
}
esp_err_t stationsGetHandler(httpd_req_t* req)
{
auto* ctx = routeContextFrom(req);
@@ -2067,6 +2183,14 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &tunerCalibrateAntennaUri);
const httpd_uri_t tunerXtalCalibrateUri = {
.uri = "/api/tuner/xtal-calibrate",
.method = HTTP_POST,
.handler = tunerXtalCalibratePostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &tunerXtalCalibrateUri);
const httpd_uri_t audioProfileGetUri = {
.uri = "/api/audio/profile",
.method = HTTP_GET,
@@ -2267,6 +2391,22 @@ std::expected<void, NetError> SetupWebServer::start(
};
httpd_register_uri_handler(server_, &bluetoothAutoReconnectUri);
const httpd_uri_t bluetoothA2dpCodecConfigUri = {
.uri = "/api/bluetooth/a2dp-codec",
.method = HTTP_POST,
.handler = bluetoothA2dpCodecConfigPostHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothA2dpCodecConfigUri);
const httpd_uri_t bluetoothA2dpCodecGetUri = {
.uri = "/api/bluetooth/a2dp-codec",
.method = HTTP_GET,
.handler = bluetoothA2dpCodecGetHandler,
.user_ctx = routeCtx,
};
httpd_register_uri_handler(server_, &bluetoothA2dpCodecGetUri);
const httpd_uri_t stationsGetUri = {
.uri = "/api/stations",
.method = HTTP_GET,
@@ -26,7 +26,12 @@ namespace secure_store {
namespace {
constexpr char kTag[] = "NvsAudioProfileStore";
constexpr char kNamespace[] = "digiradio";
constexpr char kProfileKey[] = "audio_profile_json";
// NVS key names are capped at 15 chars (NVS_KEY_NAME_MAX_SIZE=16 incl. NUL);
// the previous "audio_profile_json" (18 chars) made every nvs_set_str call
// fail with ESP_ERR_NVS_KEY_TOO_LONG (0x1109), silently -- applyProfile()
// always updated live audio correctly but persistProfile() never actually
// wrote anything, so nothing survived a reboot.
constexpr char kProfileKey[] = "audio_profile";
} // namespace
bool NvsAudioProfileStore::hasProfile() const
@@ -107,6 +107,30 @@ public:
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setAutoReconnect(
std::uint8_t times);
/**
* @brief setA2dpCodecConfig enable optional A2DP codecs at runtime.
*
* @dname setA2dpCodecConfig
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
* BIT4=aptX-Adaptive, BIT5=LDAC; 0 forces SBC-only.
* @return Ok on success, or a Bt1035Error.
* @pubstate Only affects the next negotiation call disconnectA2dp()
* (or have the peer reconnect) for an already-streaming link
* to pick up the new codec set.
*/
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setA2dpCodecConfig(
std::uint8_t bitmask);
/**
* @brief queryA2dpCodec read the currently negotiated A2DP codec.
*
* @dname queryA2dpCodec
* @return Negotiated codec, or a Bt1035Error (e.g. no active link).
* @pubstate none
*/
[[nodiscard]] std::expected<core::Bt1035A2dpCodec, bt1035::Bt1035Error>
queryA2dpCodec();
/**
* @brief scanNearby classic BT/EDR discovery, cancelling pairing.
*
@@ -148,6 +148,18 @@ std::expected<void, bt1035::Bt1035Error> BluetoothService::setAutoReconnect(
return driver_.setAutoReconnect(times);
}
std::expected<void, bt1035::Bt1035Error> BluetoothService::setA2dpCodecConfig(
std::uint8_t bitmask)
{
return driver_.setA2dpCodecConfig(bitmask);
}
std::expected<core::Bt1035A2dpCodec, bt1035::Bt1035Error>
BluetoothService::queryA2dpCodec()
{
return driver_.queryA2dpEncoder();
}
std::expected<std::vector<core::Bt1035ScannedDevice>, bt1035::Bt1035Error>
BluetoothService::scanNearby(std::uint8_t scanSeconds)
{
@@ -73,6 +73,10 @@ public:
*
* @dname tuneDab
* @param freqIndex Ensemble index 037.
* @param antCap Front-end antenna varactor override for this one
* tune (e.g. for a calibration sweep). Omit to use
* defaultDabAntCap_ (the board's saved calibration,
* or hardware auto-tune if never calibrated).
* @return Ok on success, or a TunerError from ITuner.
* @pubstate writes lastDabIndex_ on success; clears last-played DAB ids.
*
@@ -80,7 +84,22 @@ public:
* @date 2026-07-06
*/
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
std::uint8_t freqIndex);
std::uint8_t freqIndex,
std::optional<std::uint8_t> antCap = std::nullopt);
/**
* @brief setDefaultDabAntCap set the board's calibrated DAB ANTCAP.
*
* @dname setDefaultDabAntCap
* @param antCap Value applied to every DAB tune that doesn't pass an
* explicit override (0 = chip auto-tune, the factory
* default before any calibration is saved).
* @pubstate writes defaultDabAntCap_. Does not itself re-tune.
*
* @author Michele Bigi
* @date 2026-08-20
*/
void setDefaultDabAntCap(std::uint8_t antCap) noexcept;
/**
* @brief tuneFm tune to an FM centre frequency.
@@ -220,6 +239,7 @@ private:
core::FrequencyKHz lastFmFrequency_;
std::uint8_t volume_;
std::uint8_t defaultFmAntCap_;
std::uint8_t defaultDabAntCap_;
std::optional<std::uint32_t> lastPlayedServiceId_;
std::optional<std::uint32_t> lastPlayedComponentId_;
};
@@ -139,6 +139,7 @@ TunerService::TunerService(core::ITuner& tuner)
, lastFmFrequency_(defaultFmFrequency())
, volume_(40U)
, defaultFmAntCap_(0U)
, defaultDabAntCap_(0U)
{
}
@@ -147,6 +148,11 @@ void TunerService::setDefaultFmAntCap(std::uint8_t antCap) noexcept
defaultFmAntCap_ = antCap;
}
void TunerService::setDefaultDabAntCap(std::uint8_t antCap) noexcept
{
defaultDabAntCap_ = antCap;
}
std::expected<core::TunerStatus, core::TunerError> TunerService::refreshStatus()
{
auto status = tuner_.readStatus();
@@ -161,9 +167,11 @@ std::expected<core::TunerStatus, core::TunerError> TunerService::refreshStatus()
}
std::expected<void, core::TunerError> TunerService::tuneDab(
std::uint8_t freqIndex)
std::uint8_t freqIndex, std::optional<std::uint8_t> antCap)
{
if (auto result = tuner_.tuneDab(freqIndex); !result) {
if (auto result =
tuner_.tuneDab(freqIndex, antCap.value_or(defaultDabAntCap_));
!result) {
return result;
}
lastDabIndex_ = freqIndex;
+132 -7
View File
@@ -95,13 +95,99 @@ Short version:
- FM front-end auto-tune measurably suboptimal on this board's actual
matching network — ANTCAP calibration swept and persisted to EEPROM,
`POST /api/tuner/calibrate-antenna`.
- BT1035 intermittent boot failure — root cause still unknown, but a
reset+init retry loop (up to 3 attempts) was added since the failure
looked like power-up timing jitter, not a permanent fault.
- **Still open**: BT1035 root cause; intermittent multi-second HTTP
unresponsiveness under load; DAB signal quality still antenna-limited;
24 KB `nvs` partition may be undersized (`saveProfile()` `store_failed`
seen intermittently, error code never captured).
- BT1035 total boot silence — root cause found and fixed (2026-08-20):
the module's spontaneous boot banner (`+VER=...`, `+DEVSTAT=1`) doesn't
appear until ~18-24s after RESET# releases (full BT stack init, not
just the internal regulator), but the boot code only waited 3.5s before
cutting power and restarting — so every attempt, in every prior session,
cut power before the module could ever finish booting even once. Power
rails (VBAT_IN/SYS_CTRL/VDD_IO/1.8V_OUT) and TX/RX wiring were all
independently verified correct with a multimeter first — the module and
PCB were never at fault. Fixed by waiting up to 25s for the banner
(`kBootBannerWaitMs`); boot now succeeds on the first attempt.
- **BT1035 — a second, harder failure mode confirmed intermittent, not
hardware (2026-08-21).** Distinct from the banner-timing bug above: even
with the 25s wait already in place, boot sometimes still gets zero UART
bytes at all — no banner, no AT response, silent across all 8 probed
baud rates (9600-921600). Root-cause evidence this session: VBAT_IN
(3.3V), 1.8V_OUT (1.8V), SYS_CTRL/RESET (~3.27V, matching the firmware's
own GPIO readback), and BT1035 TX (idle-HIGH ~3.29V, no short/float) all
measured normal with a multimeter. The BT1035's 32 MHz crystal is
integrated inside the sealed Feasycom module (confirmed via the module's
own datasheet block diagram — no external crystal on our schematic), so
it can't be inspected or reworked from our side; a marginal
oscillator-startup margin inside the module is the leading suspect.
**Decisive evidence it's intermittent, not a dead unit**: the exact same
physical module booted cleanly (banner + all AT commands `OK`) on one
attempt and went totally silent on the very next attempt, no physical
changes in between. A replacement module is therefore not a guaranteed
fix — the same defect class could recur on a different unit. Mitigated
(not fixed) by an indefinite background retry task
(`hardware::bt1035RetryTask` in `main/hardware_bootstrap.cpp`): if the
initial `Bt1035Driver::boot()` fails, a background FreeRTOS task keeps
calling `boot()` again with no artificial delay between attempts (each
attempt already takes ~25-60s on its own) until it succeeds, while the
rest of the system (tuner, Wi-Fi, web UI) stays fully usable in the
meantime. Turns a permanent-until-manual-power-cycle failure into a
bounded, self-recovering delay. See
`docs/si4684-rf-investigation-report.md` (2026-08-21 entry) for the full
session narrative, including a UART TX/RX loopback test attempt that was
inconclusive (bridging the ESP32's own TX/RX pins from cold boot caused
an unrelated, reproducible, harmless early-boot hang, not yet explained).
- **BT1035 — git archaeology + minimal patch, follow-up (2026-08-21).**
Traced the full commit history of `Bt1035Driver.cpp` from the last
documented-good boot (`6ca40f1`) through the regression (`6f7b6dd`, a
redundant `AT+RESET`) and its fix (`fd9d4ae`, 5/5 clean boots — removed
the `AT+RESET` and introduced the boot-banner listen at 3500ms in the
same commit). Comparing `fd9d4ae` to this session's working tree found
one real structural difference beyond the justified 25s banner window:
today's earlier commit (`3a58d33`) had added an intra-`boot()` retry
loop (2 attempts, only 300ms between hardware reset pulses) that never
existed in the validated baseline — shorter than the BT1035 datasheet's
own "Reset Protection timeout (typically >1.8s)", so the second pulse
may not have reached a clean power-off state. **Fixed**: removed the
intra-`boot()` retry loop entirely (`kBootAttempts`/`kBootRetryDelayMs`
deleted); `boot()` now makes exactly one attempt per call, matching
`fd9d4ae`. Retries remain exclusively at the `bt1035RetryTask` level
(whole clean `boot()` calls, never re-pulsing pins faster than one full
cycle apart — confirmed live, ~31.8s between attempts). Host tests
(20/20) and firmware build green; flashed and observed live. **Result
inconclusive on hit rate**: a 20-minute post-flash window captured 31
consecutive silent retry attempts, zero successes — worse than earlier
the same day. The patch is kept because it's structurally correct (only
known deviation from the historically validated design removed), not
because this sample proved a better success rate. Root cause of the
underlying intermittent silence is still open (see entry above).
- **BT1035 — RESET#/SYS_CTRL redesign, CTS/RTS diagnostics, Feasycom
escalation (2026-08-22).** A sibling project's PinScope netlist report
(RESET# pulled to GND, SYS_CTRL pulled HIGH by stray resistors) was
checked against our own schematic and does **not** apply to us — our
RESET#/SYS_CTRL wiring is correct, verified via netlist. Its RF_OUT/pin
51 floating finding **does** also apply to us, but is a separate,
RF-range-only concern (datasheet documents both internal- and
external-antenna variants; can't tell which we have), not the cause of
the digital/UART boot silence. As a diagnostic test, RESET# is now
never driven at all (floating input, relying on the module's own
internal pull-up per §4.8) and SYS_CTRL now does a genuine LOW(2.5s)→
HIGH power-cycle on every retry attempt (previously asserted once ever
and left alone, meaning retries never actually power-cycled the
module). Also added read-only diagnostics on the previously-unused
CTS/RTS pins (physically wired, named in `board_pins.hpp`, never
configured by any driver code, host flow control disabled) — live
readings were perfectly stable (CTS=HIGH, RTS=LOW) across ~12 samples
over 30+ minutes, arguing against pure floating-noise. **Across all of
today's changes combined, zero successful boots were observed in
cumulative 45+ minutes of live testing** — inconclusive-to-negative,
not proof any change helped or hurt. Escalated to Feasycom support with
a detailed email (drafted, kept outside the repo) covering the
symptom, everything ruled out, the CTS/RTS open question, and the
antenna-variant question; paused further live experimentation pending
their reply rather than keep permuting timing parameters blind.
- **Still open**: intermittent multi-second HTTP unresponsiveness under
load; DAB signal quality still antenna-limited; 24 KB `nvs` partition
may be undersized (`saveProfile()` `store_failed` seen intermittently,
error code never captured); BT1035 intermittent total-silence boot
failures (mitigated via background retry, not root-caused — see above).
---
@@ -118,6 +204,45 @@ Done in fw 0.8.5 unless noted:
---
## TODO — calibration functions need to become permanent, in-firmware, on-demand tools (2026-08-23)
Both ANTCAP calibration (`tools/si4684_antenna_calibration.py`) and Si4684
crystal calibration (`tools/si4684_xtal_calibration.py`,
`POST /api/tuner/xtal-calibrate`) currently exist as **host-side Python
scripts driving live-but-unpersisted HTTP endpoints** — they compute a
result but the operator has to hand-edit firmware source (constants in
`Si4684Driver.cpp` / the `gSi4684.boot(...)` call in
`hardware_bootstrap.cpp`) and reflash to make a result permanent.
**Wanted instead**: both calibration procedures should be triggerable
on-demand *from the device itself* (an HTTP endpoint is enough — no UI
required yet) and, once a result converges, **write the result to the
24AA025E48 EEPROM** (same chip/pattern already used for ANTCAP
persistence, see `Eeprom24aa::writeFmAntCap`/`writeDabAntCap`) so it
survives a reboot without a firmware reflash. `recalibrateXtal()`
(`Si4684Driver.cpp`) already does the live re-boot-with-new-params part;
what's missing is EEPROM persistence for **all three** crystal
calibration parameters -- `ibias`, `ctun`, AND `xtalFreqHz` (not just
XTAL_FREQ; confirmed explicitly 2026-08-24 that all three need to
persist, not only the one this session happened to tune) -- plus loading
them at boot the same way `main.cpp` already loads the saved FM/DAB
ANTCAP into `TunerService` before the first tune. ANTCAP already
persists this way (2 bytes/band, word addresses 0x00/0x01) — the xtal
calibration needs its own new EEPROM word address(es) alongside those
(ibias fits in 1 byte, ctun in 1 byte, xtalFreqHz needs 4 bytes -- 6
bytes total, or pack more compactly if EEPROM space is tight). Also
still needed: deciding whether the FREQOFF-averaging/damping/
convergence-loop logic (currently in `tools/si4684_xtal_calibration.py`)
moves into firmware, or stays host-side with just an EEPROM-persist step
added at the end of the existing HTTP flow.
Not started — explicitly deferred to a future session, noted here only so
it isn't lost. See `docs/si4684-rf-investigation-report.md`'s 2026-08-23
entry for full context on why this calibration was needed and how it
currently works.
---
## Quality gates (run from `Software/` before merge)
```bash
+507 -4
View File
@@ -718,13 +718,516 @@ small once anything blocks even briefly.
observed, on the low side). ~~Redo the ANTCAP sweep~~ — **done this
session for FM** (see the ANTCAP antenna calibration feature commit);
antcap=102 saved as the board's default, +6 to +11 dB RSSI/SNR across the
band. DAB doesn't have an equivalent calibrated-default mechanism yet
worth adding the same idea (DAB_TUNE_FREQ also takes an ANTCAP argument
per AN649) if DAB signal quality remains the limiting factor after this.
band. ~~DAB doesn't have an equivalent calibrated-default mechanism yet~~
— **added and swept 2026-08-20, see below; no default saved (auto-tune
already best on the ensembles tested).**
- Try a proper FM/DAB antenna to see how much of the crackle/noise clears
up versus how much is inherent to the current antenna's gain/placement.
- Investigate the intermittent multi-second HTTP unresponsiveness noted
above — reproducible, not yet root-caused, not obviously related to any
single change this session.
- BT1035 boot-failure root cause still open (see section above) — non-fatal
now, so it's no longer blocking, but still unexplained.
now, so it's no longer blocking, but still unexplained. **Recurred
2026-08-20, see below — still open, confirmed not caused by physical
handling.**
## 2026-08-20 update: DAB ANTCAP override added and swept live; BT1035 "total UART silence" recurred
**DAB ANTCAP — implemented, built, flashed, swept live via the HTTP API.**
Extended the ANTCAP override (AN649 Command 0x30 ARG4/5 for FM, Command
0xB0 ARG4/5 for DAB) from FM-only to DAB, mirroring the existing FM
mechanism end to end: `ITuner::tuneDab`/`Si4684Driver::tuneDab` gained an
`antCap` parameter (was hardcoded `0x00`/auto); `TunerService` gained
`defaultDabAntCap_`/`setDefaultDabAntCap()`; `Eeprom24aa` gained
`readDabAntCap()`/`writeDabAntCap()` at word address 0x01 (FM stays at
0x00); `HardwareBootstrap` gained `dabAntCapCalibration()`/
`saveDabAntCapCalibration()`, loaded at boot alongside the FM one; the
`net::AntennaCalibration` bridge gained `saveDab`; both `POST
/api/tuner/tune` (one-shot override, `{"band":"dab","freq_index":N,
"antcap":V}`) and `POST /api/tuner/calibrate-antenna` (persists to EEPROM,
`{"band":"dab","antcap":V}`, `band` defaults to `"fm"` so old clients are
unaffected) now accept DAB. Host build + 20/20 ctest + doxygen +
check-manual-sync all green before flashing.
Swept live via the API (`freq_index` 0-128 step 8) against three real
ensembles:
- **freq_index 5** (weakest known ensemble, 7 dB CNR baseline from the
2026-08-16 sweep): did not lock at all this session, at any ANTCAP
including auto — signal currently below threshold, not a code issue
(indices 22/23 locked normally in the same session).
- **freq_index 23** (strongest, 21-26 dB): CNR jittered ±3 dB across the
whole ANTCAP range with no discernible trend — already saturated, sweep
can't discriminate on a signal this strong.
- **freq_index 22** (medium, 16-20 dB): auto (0) and antcap=32 tied for
best (20 dB CNR); antcap=72 and 80 caused total loss of lock (a dead
zone to avoid); the rest of the range gave no systematic gain over auto,
unlike FM's clean +6 to +11 dB improvement.
**Decision: left DAB on auto-tune, nothing saved to EEPROM.** Unlike FM,
no ANTCAP value tested beat the chip's own auto-tune by a margin worth
trusting. If DAB audio quality is still the limiting factor later, retest
specifically on a weak ensemble (index 5 or similar) once it's receivable
again — ANTCAP calibration matters most on weak signals, which is exactly
the case that wasn't testable this session.
**BT1035 "total UART silence" recurred — same still-open issue as before,
confirmed (again) not physical.** During the DAB sweep, the board was
reset several times via opening a `pyserial` connection for log capture —
each open triggers a hardware EN/reset pulse on this ESP32-S3 (confirmed:
happens even with `dsrdtr=False, rtscts=False` and explicit
`setDTR(False)`/`setRTS(False)` — this is the USB-native auto-reset
circuit firing on port open, not a pyserial default that can be disabled
from the Mac side). One of these resets left BT1035 silent: `no
spontaneous UART bytes after hardware reset` on both boot attempts (2/2),
then silent across all 8 probed baud rates (9600-921600). This is *not*
the "banner arrives late" issue fixed 2026-08-20 earlier this same session
(`kBootBannerWaitMs = 25000` was already in effect and made no
difference) — it's the harder, total-silence failure mode already logged
above (the "Unrelated finding from the same session" note before the
2026-08-19 entry), recurring. Confirmed again this time that it is not
caused by physical handling: a full physical power-off for 60 s did not
recover it (Si4684/ADAU1701 both came back up fine on the same power
cycle, ruling out a board-wide power issue). Root cause still not
identified. `/api/bluetooth/status` and `/api/bluetooth/paired` correctly
report `{"status":"error","reason":"at_timeout"}` while in this state; the
rest of the device (tuner, web UI) stays usable per the existing
non-fatal-BT1035-boot design.
## 2026-08-21 update: BT1035 total silence confirmed intermittent (not
## hardware); background retry mitigation added
Follow-up session dedicated entirely to the "total UART silence" BT1035
failure mode above. Summary: **confirmed intermittent on genuinely
identical hardware, root cause narrowed to the module's internal crystal
(not our PCB, not fixable by us), and mitigated (not fixed) with an
indefinite background boot retry.**
**Diagnostic instrumentation (temporary, added then reverted this
session)**: added `BT1035 AT TX: <line>` / `BT1035 UART RX RAW: <hex or
<empty>>` / `BT1035 AT RESULT: OK|ERROR|TIMEOUT` logging around
`Bt1035Driver::transmitAndCollect()`, and temporarily dropped
`kBootAttempts` to 1 for single-attempt clarity. This confirmed the
failure signature precisely: `AT` is transmitted, zero bytes ever come
back (`<empty>`), timeout. Reverted via `git checkout` once the manual
diagnosis was done — not kept in the codebase.
**Multimeter checks, all normal** (scope-level checks — crystal
oscillation, power-on transient — remain out of reach without an
oscilloscope):
- VBAT_IN: 3.3V (datasheet range 3.0-4.2V) ✓
- 1.8V_OUT (module's internal regulator): 1.8V ✓ — proves the module's
own power management *is* running, it isn't simply unpowered
- SYS_CTRL / RESET (post-boot): ~3.27V, matching the firmware's own GPIO
readback log (`post-reset: SYS_CTRL=1 RESET=1`) ✓
- BT1035 TX pin (module side) to GND: 3.29V, idle-HIGH, no short/float/
reversed polarity ✓ (though idle-HIGH alone doesn't prove the module's
firmware is executing — some pads default HIGH from reset state alone)
- A 10kΩ pull-down the user had added on SYS_CTRL (matching the
datasheet's own recommendation for an undriven pin) was checked and is
not the cause — the ESP32 GPIO drives push-pull and its own readback
confirms it reaches a valid HIGH regardless.
**Crystal location determined**: the BT1035 datasheet's own block diagram
shows "32MHz Crystal" as an internal block of the QCC3056 die, and the
DigiRadio schematic netlist (`Netlist_Schematic1_2026-08-07.asc`) has no
XTAL_IN/XTAL_OUT pins wired to any external crystal for U11 — confirming
the oscillator is sealed inside the Feasycom module, not on our PCB. This
is why nothing on our side (layout, load caps, our firmware) can affect
it; if the failure really is a marginal oscillator-startup margin, it's a
property of that specific physical module unit (or the part's design
tolerance in general).
**Decisive evidence of intermittency, not a dead unit**: across repeated
reboots in the same session (physical power-cycles and serial-port-open
resets, which also hard-reset this ESP32-S3's native USB-CDC), the
identical physical module was observed to boot **completely successfully**
at least once — spontaneous banner `+VER=FSC-BT1035,V6.1.1,20240521` +
`+DEVSTAT=1`, then `AT` and `AT+AUXCFG=3` both answered `OK` — and to fail
completely silently on other attempts, with no physical change in between.
This rules out "defective/dead module" as an explanation; ordering a
replacement module is therefore *not* a guaranteed fix, since the same
physical unit demonstrably works when it works.
**UART loopback test attempt — inconclusive, logged for future
reference.** Tried to isolate ESP32 vs. module by bridging the ESP32-S3's
own GPIO40 (BT1035 UART TX)/GPIO41 (BT1035 UART RX) pins with a jumper
held by hand on the ESP32 module's castellated pads (no series
resistor/test point exists on this net per the schematic netlist — U8.33
↔ U11.P$14 and U8.34 ↔ U11.P$13 directly, nothing else). Twice
reproducibly, bridging those pins from cold boot caused the ESP32 itself
to hang very early in boot (right after the bootloader's "Disabling RNG
early entropy source" line, before `app_main()` even starts) — harmless
(board recovers fully once the jumper is removed) but unexplained, and it
sidesteps the actual test rather than answering it. Not pursued further
this session given the practical difficulty of hand-holding a wire onto
castellated pads without a proper SMD test hook. If retried: attach the
jumper *after* the ESP32 has already booted past that early stage (there's
a ~25s window before the BT1035 AT command is actually sent) rather than
from a cold boot.
**Mitigation implemented: indefinite background boot retry.** Since the
module's own internal fault (if that's what it is) isn't something we can
fix, and since it demonstrably self-clears on a later attempt rather than
needing repair, `main/hardware_bootstrap.cpp` now spawns a
`bt1035RetryTask` FreeRTOS task whenever the initial `HardwareBootstrap::
boot()`'s call to `Bt1035Driver::boot()` fails. The task loops calling
`boot()` again with **no artificial delay** between attempts — each
attempt already blocks for ~25-60s on its own (the banner wait times
`kBootAttempts`, plus an 8-step baud-rate sweep on final failure), so no
extra backoff is needed on top — until it succeeds, at which point it runs
the same post-boot setup (device name, auto-reconnect) the normal success
path does, then exits. The rest of the system (Wi-Fi, tuner, web UI) never
blocks on this and stays fully usable throughout. Verified live: after a
forced failure (2 attempts + baud sweep, ~62s), the retry task started
immediately, the HTTP server and heartbeat came up normally in parallel,
and the retry task began a fresh attempt right away without any pause.
**Open going forward**: root cause of the intermittent total-silence mode
is still not identified — this session's diagnosis exhausted what's
possible with a multimeter alone. Real progress would need either an
oscilloscope on SYS_CTRL/RESET/crystal across several boots to correlate
success/failure with power-on timing jitter, or a large-N automated
reboot-cycle statistic (attempted this session via a `pyserial` script,
but the ESP32-S3's native USB-CDC re-enumerating on every hardware reset
made a fully unattended multi-cycle script unreliable — a naive read loop
silently produced a false "0/5 success" result once across a reconnect
window). A future attempt at that statistic needs to detect the USB path
disappearing/reappearing and reopen the port, or use a separate
hardware UART-to-USB adapter that doesn't disconnect when the target
resets.
**Also discussed this session (not implemented, for a future hardware
revision)**: whether a different/newer SoC could eliminate the need for
the external BT1035 module entirely. Confirmed via web search that
Espressif's new **ESP32-S31** (RISC-V, announced April 2026) has
integrated **Bluetooth 5.4 with both LE and Classic (BR/EDR)** support —
unlike the ESP32-S3 used today, which is BLE-only at the silicon level
(confirmed: no Classic BT/A2DP hardware exists on S3, this is not a
firmware limitation). An `ESP32-S31-WROOM-3` module also exists. This
would be a significant main-MCU redesign, not a drop-in swap, and its
ESP-IDF support maturity/availability wasn't independently verified this
session — worth a dedicated evaluation before committing to it for a
future hardware revision.
## 2026-08-21 follow-up: git archaeology on the boot-retry structure;
## minimal patch to restore the validated single-attempt design
Separate follow-up session, requested specifically to re-derive the
BT1035 boot regression analysis directly from git history rather than
from further live hardware probing, per the project's own house rule
(2026-08-14 postmortem): exhaust the code-path diff against a known-good
commit before floating new hardware theories.
**Full commit archaeology** (`git log --follow` on
`Bt1035Driver.cpp`):
```
6ca40f1 "all companion chips ready" — baseline, 0 known bugs
6f7b6dd added a redundant AT+RESET right after the hardware reset pulse
fd9d4ae (2026-08-15) fixed 6f7b6dd in one commit: removed the redundant
AT+RESET AND introduced logRawUartBoot() for the first time,
already at its final 3500ms window (the "1500ms too short"
text in the report/commit message describes an intermediate
value tried live during that debugging session, never itself
committed) — 5/5 clean boots documented after this fix.
3a58d33 (2026-08-20, this project's own earlier commit today) widened
the banner wait 3500ms → 25000ms (real banner measured arriving
up to ~18.5-42s post-reset) AND, in the same commit, introduced
a NEW intra-boot() retry loop (kBootAttempts=2, only
kBootRetryDelayMs=300ms between the two hardware reset pulses)
that did not exist in fd9d4ae's validated design.
```
**Finding**: comparing `fd9d4ae` (the last commit with a documented,
validated 5/5 clean-boot run) against the working tree confirmed exactly
three differences, only one of them structural:
1. Banner wait 3500ms → 25000ms — justified by this session's own real
measurements, kept.
2. `GPIO_MODE_OUTPUT``GPIO_MODE_INPUT_OUTPUT` on RESET/SYS_CTRL —
purely additive (enables `gpio_get_level()` readback for the
pre-power/post-syscl/post-reset diagnostic logs), electrically
neutral, kept.
3. **A new intra-`boot()` retry loop with only 300ms between the two
hardware reset pulses — this did not exist in the validated baseline.**
The BT1035 datasheet's own "Reset Protection timeout (typically
>1.8s)" (already gathered earlier this session) means a second
SYS_CTRL/RESET pulse fired only 300ms after a failed attempt would not
reliably reach a clean power-off state — risking re-interrupting the
module mid bring-up, the same class of bug 6f7b6dd/fd9d4ae already
dealt with once (redundant AT+RESET). This is the only difference
flagged as a plausible contributor, not asserted as certain.
Also confirmed via repo-wide search: `AT+RESET` (`Bt1035AtCommand::Reset`)
is referenced only in the unit test, never in production code; no other
task/thread touches the BT1035 UART during its boot window
(`savedSpeakerReconnectTask` only starts after `HardwareBootstrap::boot()`
returns; the new `bt1035RetryTask` calls `boot()` sequentially, never
concurrently). `logRawUartBoot()`'s single `uart_read_bytes()` call and
the following `uart_flush_input()` were confirmed, both by code reading
and by this session's own successful-boot log capture (banner appeared,
then `AT``OK` immediately after, no stall), to not swallow or discard
data that `runInitSequence()` would otherwise need — `runInitSequence()`
does its own fresh TX/RX cycle regardless of what the banner-capture step
saw.
**Minimal patch applied** (user-directed, exact scope agreed before
touching code): removed the intra-`boot()` retry loop entirely —
`boot()` now makes exactly one `resetAndInitOnce()` call per invocation,
structurally identical to `fd9d4ae`. Removed `kBootAttempts` and
`kBootRetryDelayMs` (dead after the loop's removal); `probeBaudRates()`'s
log line adjusted accordingly (no longer references the removed
attempt count). `kBootBannerWaitMs=25000` and the `GPIO_MODE_INPUT_OUTPUT`
readback were explicitly left untouched. Retries now live exclusively one
layer up, in `hardware::bt1035RetryTask` (`main/hardware_bootstrap.cpp`,
added earlier this session), which only re-invokes a full, clean `boot()`
call — never re-pulses the pins faster than one whole boot cycle apart.
Host tests (20/20) and firmware build both green before flashing.
**Live result after flashing**: structurally the retry cadence is now
clean — confirmed via serial log, each `bt1035RetryTask` iteration is
spaced ~31.8s apart (25s banner wait + ~2s AT timeout + ~4s baud sweep,
no extra gap), matching the intended single-attempt-per-call design
exactly, versus the old back-to-back double-pulse. **However, a 20-minute
monitoring window immediately after flashing captured 31 consecutive
retry attempts, all silent — zero successes**, a worse hit rate in this
specific sample than earlier in the day (which had at least one clean
success among fewer attempts). This neither confirms nor refutes the
Reset-Protection-timing hypothesis on its own — the patch is kept because
it's structurally correct (matches the one historically validated design,
removes the only unexplained difference from it), not because this
sample proves it improved the success rate. The underlying intermittent
root cause (most likely the module's internal, sealed 32MHz crystal
startup margin — see the 2026-08-21 entry above) remains unresolved and
would need an oscilloscope to pin down further.
## 2026-08-22 update: RESET#/SYS_CTRL redesign, CTS/RTS diagnostics,
## PinScope report from a sibling project, Feasycom support escalation
**PinScope findings from a sibling "DigiRadio evolution" project with the
same BT1035 wiring pattern** were reviewed for transferability. Checked
each finding against our own schematic netlist (exact BT1035 pin numbers
cross-referenced against the datasheet's own pin table) rather than
assuming they apply:
- **U16-001 (RESET# pulled to GND by a stray R67) and U16-002 (SYS_CTRL
pulled HIGH by a stray R69): do NOT apply to our board.** Verified via
netlist: our RESET# net (`GPIO17`) has only the ESP32 and the BT1035,
no resistor; our SYS_CTRL pull-down (R12) genuinely goes to GND (pins
1/22, confirmed GND in the datasheet), not a stray pull-up.
- **U16-003 (VCHG/VCHG_SENSE unconnected): same on our board, presumably
intentional** (no USB charging via the BT1035).
- **U16-004 (RF_OUT/pin 51 floating): also true on our board** — but this
is a genuinely open question, not a confirmed defect: the BT1035
datasheet documents both an "Internal Antenna" (§9.2, on-board antenna,
no RF_OUT routing needed, PCB keep-out area required instead) and
"External Antenna" (§9.3, RF_OUT routed out) layout option, and we
cannot tell from the datasheet alone which variant this specific module
part/order uses. This affects actual Bluetooth RF range once the module
boots — separate from, and does not explain, the intermittent
total-silence boot symptom (RF_OUT is downstream of the digital
baseband processor that generates the boot banner and answers AT
commands).
- The sibling report's "RESET#/SYS_CTRL/VCHG compound badly" warning
does not transfer to us, since our RESET#/SYS_CTRL wiring is correct.
**RESET#/SYS_CTRL hardware-management redesign (diagnostic test,
requested explicitly to see if external RESET# control was itself
contributing to the intermittent failures):**
- RESET# (pin 8) is no longer driven by this driver at all: reconfigured
as a floating input (`GPIO_MODE_INPUT`, pull-up/pull-down both
explicitly disabled), relying entirely on the BT1035's own datasheet-
documented "fixed strong pull-up to VDD_IO" (§4.8). GPIO17 confirmed
reading HIGH via this internal pull-up, live.
- SYS_CTRL (pin 34) redesigned to perform a genuine LOW→HIGH power-cycle
on *every* `resetAndInitOnce()` call (held LOW 2.5s — comfortably
longer than the datasheet's "~1.8s typical" Reset Protection timeout —
then HIGH for >=20ms per §4.7), rather than being asserted once ever at
first boot and left alone: previously, every later retry from
`bt1035RetryTask` was silently reusing an already-HIGH SYS_CTRL line
and never actually power-cycling the module at all.
- Both changes build/test clean and were confirmed live to behave exactly
as designed (RESET# reads HIGH via internal pull-up; SYS_CTRL cadence
matches the 2.5s+20ms design on every retry).
**Result: inconclusive-to-negative on hit rate.** Across a cumulative
~45+ minutes of live monitoring after these changes (two separate
sessions, dozens of retry attempts), **zero successful boots were
observed** — no banner, ever, in this window. This is not better than,
and arguably worse than, the small sample seen the same week under the
*previous* (RESET#-driven) design, which did show at least one clean
success among fewer attempts. This should not be read as proof the new
design is wrong — the previous design also produced a 31-attempt/0-success
streak in one session this same week — but it is also not evidence the
redesign helped. Kept anyway because it is independently correct per the
datasheet (RESET# genuinely can be left unconnected; SYS_CTRL retries
should be genuine power-cycles), not because it demonstrably fixed
anything.
**New CTS/RTS diagnostic instrumentation.** Discovered, previously
unexamined this entire investigation: the board physically wires
`board::pins::Bt1035Cts` (GPIO21 -> BT1035 pin 15, UART_CTS) and
`board::pins::Bt1035Rts` (BT1035 pin 16, UART_RTS -> GPIO14) — both
**named in `board_pins.hpp` since the pin's original definition, but
never configured or used by any driver code**, and the UART is
initialized with `UART_HW_FLOWCTRL_DISABLE`. Added both as read-only
floating-input diagnostics (`Bt1035Pins::ctsGpio`/`rtsGpio`, logged
before and after the banner-wait window in `resetAndInitOnce()`), purely
to observe — never driven.
Datasheet research (not just speculation) on what this could mean:
- §4.1 Table 4-1 lists flow control as one of several **configurable**
UART settings ("Supports Automatic Flow Control (CTS and RTS lines)"),
not stated as active by default.
- No AT command to explicitly enable/disable flow control was found in
the programming guide.
- Pin 16 (UART_RTS/PIO2)'s documented **factory-default alternate
function is "PA mute pin"** (`AT+MUTEPIO`'s own default parameter is
PIO2) — i.e., out of the box this pin most likely isn't acting as RTS
at all.
- Live readings, across ~12 samples over 30+ minutes and multiple
power-cycles: **CTS=HIGH, RTS=LOW, perfectly stable, zero variation.**
This argues against a genuinely floating/noisy input (which would be
expected to show at least some jitter across dozens of samples) —
something is holding both at a fixed level, whether that's incidental
ESP32 GPIO leakage, an internal pull inside the module, or the module
actively driving its own RTS/PA_MUTE output. We do not yet have a
reading from a *successful* boot to compare against, since none
occurred in this session's remaining test window.
**Escalated to Feasycom support** (email drafted, not yet sent by the
user) with: the full symptom description, everything ruled out this
session (power rails, RESET#/SYS_CTRL sequencing variants), the CTS/RTS
finding reframed as an open question rather than a confirmed cause (per
the datasheet nuance above), and the RF_OUT/antenna-variant question.
Decided to pause further live hardware experimentation until a reply is
received, rather than keep varying RESET#/SYS_CTRL/timing parameters
without new information — see the email draft (kept outside the repo, in
the session's scratch directory) for the exact wording sent.
**How to apply, for a future session**: don't re-propose "try removing
RESET# drive" or "try a genuine SYS_CTRL power-cycle on retry" as fresh
ideas — both were tried this session, both are justified independently,
neither showed a measurable improvement in a non-trivial sample. Don't
assume CTS/RTS floating is confirmed as the cause either — the CTS=1/
RTS=0 stability argues against pure floating-noise, and flow control may
not even be engaged by default per the datasheet. The single most
valuable next input is Feasycom's own answer, not another round of
timing-parameter permutation.
## 2026-08-23 update: FM/DAB pitch-distortion root-caused and fixed (uncalibrated Si4684 crystal reference); a separate downstream audio-quality issue found and left open
**Symptom**: user reported FM+DAB audio hiss/distortion, later sharpened to
"voce stonata" (mistuned/off-pitch voice), present on both bands.
**Elimination chain, each step verified on real hardware, not theory**:
RF signal strength (new antenna, RSSI/SNR excellent — see the ANTCAP
section above) → boot-time ADAU1701 DSP program load (added read-back
verification to `SIGMA_WRITE_REGISTER_BLOCK`/`sigma_safeload_block`,
confirmed clean on every boot and every runtime safeload) → ADAU1701 EQ
band 0 ("fixed high-pass," never touched at runtime) — initially
miscalculated as an unstable filter from a wrong fixed-point bit-width
assumption (8.23 vs the chip's actual 5.23/28-bit format), corrected and
confirmed stable via SigmaStudio's own live register capture connected to
the device → ADAU1701 mixer (all input knobs centered, confirmed) →
`DAB_ACF_ENABLE` (0xB500, found disabled with no citation; datasheet
default is 3; tried enabling it — made things audibly *worse*, likely
because COMF_NOISE_ENABLE literally injects synthetic noise on signal
dips; reverted to 0x0000, matching what PE5PVB's independent
SI4684-DAB-Receiver project also does deliberately — not the cause, but
no longer an unexplained magic number) → **decisive test**: a 440 Hz tone
generated by the ESP32 and written directly over the shared I2S bus
(`main/esp32_i2s_test_tone.{hpp,cpp}`, `CONFIG_ESP32_I2S_TEST_TONE`) came
through clean and perfectly in-tune, verified with a real tuner — isolated
the pitch problem to the Si4684 itself, ruling out ADAU1701/mixer/I2S
receiving/BT1035/speaker → TR_SIZE (0x7) and IBIAS (72 = 720µA) checked
against AN649 Figure 13 ("Safe Range of Operation for a 19.2 MHz
Crystal"), both comfortably inside the safe range for this crystal's ESR.
**Root cause**: `Si4684Driver::boot()`'s `xtalCtun=31`/`xtalIbias=72`
defaults had only a vague "already verified live" justification — no
actual measurement for *this* board's crystal (Abracon
ABM8-19.200MHZ-10-1-U-T, CL=10pF decoded from the part number's own
ordering-code table, two external 15pF load caps per the schematic) ever
existed.
**Fix, two stages**:
1. CTUN empirical trim by ear (AN649 §9.3 says trim by measurement; no
oscilloscope available, and a multimeter's frequency counter reads the
strong I2S LRCLK signal fine but returns 0 on the crystal pins
themselves — too weak/high-impedance for a general-purpose meter to
trigger on). Swept 31→5→0 (0 = the floor of the 0-63 range),
monotonic improvement each step, still "less bad, not fixed" at the
floor.
2. **XTAL_FREQ precision trim using the chip's own measurement, no lab
equipment needed**: real FM/DAB transmitters are GPS/rubidium-locked,
so FM_RSQ_STATUS's FREQOFF field (AN649 Command 0x32 RESP8, signed,
units of 2 PPM) directly reports the *receiver's* crystal error on any
locked station. Added `Si4684Driver::recalibrateXtal()` (forces a full
re-boot with new IBIAS/CTUN/XTAL_FREQ, no ESP32 restart), a new
endpoint `POST /api/tuner/xtal-calibrate`, exposed FREQOFF as
`"freqoff_ppm"` in `GET /api/tuner/status`, and
`tools/si4684_xtal_calibration.py` to automate the trim loop
(tune → average several FREQOFF samples → correct → repeat).
**Non-obvious gotcha, found only by watching the first attempt
diverge, not documented anywhere**: the correction sign is
`xtal_freq *= (1 - ppm/1e6)`, not `(1 + ppm/1e6)` — the "tell it the
truth" sign convention makes the error grow, not shrink (confirmed
live: ppm went 28→60→122→254/no-lock across 3 iterations before the
sign was flipped). Averaging + damping (0.6) were both needed for
smooth convergence; a single raw FREQOFF sample has enough
reception-noise jitter (~±20-35 ppm swings observed) to make an
undamped loop oscillate instead of settling.
**Final calibrated values** (now the firmware default,
`main/hardware_bootstrap.cpp`): `CTUN=0`, `XTAL_FREQ=19,199,750 Hz`
(≈-13 ppm off the 19.2 MHz nominal). Converged residual: **-3.8 ppm at
87.6 MHz, -3.0 ppm at 105.1 MHz** — consistent across two stations at
opposite ends of the FM band (the cross-check AN649 itself recommends),
confirming this really is the crystal reference and not something
frequency-dependent. Down from **+70 ppm** uncorrected at nominal
XTAL_FREQ. No physical hardware change (different load-cap values) ended
up being necessary — contrary to what seemed likely after CTUN alone.
**User-confirmed result**: "migliorato moltissimo" (improved a lot) after
this fix — the systematic pitch/tuning distortion is resolved.
### Still open: a separate downstream hiss/intelligibility issue, NOT the Si4684
After the crystal fix, the user still reported residual hiss and, more
seriously, words being unintelligible on both FM and DAB. Recordings sent
for spectral analysis showed no gross technical defects (no clipping, no
dropouts, no dominant isolated resonance) — inconclusive from the
recordings alone (phone-mic-through-air recordings are a poor tool for
this specific symptom; room acoustics and mic response confound the
signal). The user's direct listening judgement (confirmed repeatedly:
"si sente ancora fruscio e le parole sono incomprensibili") is the
ground truth here, not the recordings.
**Decisive test**: enabled `web_radio_stream` (internet radio via ESP32,
`POST /api/streaming`) with a direct HTTP MP3 stream
(`http://icecast.radiofrance.fr/franceinter-midfi.mp3`), routed through
the same shared ADAU1701 mixer/EQ/output/BT1035/Bluetooth-speaker chain
as FM/DAB but **never touching the Si4684 at all**. User confirmed:
**same symptom** (hiss + unintelligible). This is different from the
earlier synthetic-440Hz-tone test, which came through clean — the tone
test used a trivial, CPU-cheap sine generator with no decode/buffering
involved, so it never exercised whatever a real MP3-decode-under-WiFi-load
pipeline does.
**Conclusion**: the pitch/tuning problem (fixed) and this hiss/
intelligibility problem are two separate, independently-confirmed root
causes that happened to co-occur and get conflated as "one bug" for most
of this session. The Si4684/crystal is now cleared for *this* symptom —
next session should look at: (a) `web_radio_stream`'s MP3 decode/I2S
buffer-feed path for underrun/overrun under real WiFi jitter, since that
was the actual reproducer, and (b) whether the same class of issue could
independently affect the ADAU1701 mixer/EQ path under real dynamic
program content generally (the passing tone test doesn't rule this out
for FM/DAB specifically, only for a pure sine wave). Don't re-open the
Si4684/crystal-calibration question for this symptom without new
evidence — it's a different, still-unidentified mechanism.
**New permanent diagnostic tools from this session** (kept in the repo,
not removed): `GET /api/tuner/status` now reports `"freqoff_ppm"` for FM;
`POST /api/tuner/xtal-calibrate` for live Si4684 crystal re-trim without
reflashing; `CONFIG_ESP32_I2S_TEST_TONE` Kconfig option (off by default)
for isolating Si4684-specific vs. shared-downstream audio issues;
`tools/si4684_xtal_calibration.py` and `tools/si4684_antenna_calibration.py`.
+19 -11
View File
@@ -73,11 +73,19 @@ detail in `docs/si4684-rf-investigation-report.md`):
empty/garbled; confirmed live with 22 real, correctly-decoded station
labels. Also found `DAB_EVENT_INTERRUPT_SOURCE` (property 0xB300) was
never configured, so the service-list-ready event could never fire.
- **BT1035 boot failure is intermittent, not fixed at the root** — the
module sometimes sends zero UART bytes after a hardware reset. Made
non-fatal early on; a retry loop (up to 3 reset+init attempts) was added
once the timing-jitter theory held up, but the underlying cause is
still open.
- **BT1035 total boot silence, root cause found and fixed (2026-08-20).**
Not a hardware fault: VBAT_IN/SYS_CTRL/VDD_IO/1.8V_OUT and TX/RX wiring
were all independently confirmed correct with a multimeter (SYS_CTRL
and 1.8V_OUT readings pinned down by probing the nearest decoupling cap
instead of the tiny 0.5mm-pitch castellated pad directly, which had
given a false "regulator dead" reading earlier). The actual bug: the
module's spontaneous boot banner (`+VER=...`, `+DEVSTAT=1`) doesn't
appear until ~18-24s after RESET# releases — full BT stack init, not
just the internal regulator powering up. The old code only waited 3.5s
before cutting power and restarting the whole sequence, so across every
prior session the module never once got the chance to finish booting.
Fixed by waiting up to 25s (`kBootBannerWaitMs`) for the banner before
giving up; boot now succeeds on the first attempt, no retries needed.
- **FM front-end calibration.** The board's actual matching network
differs from the AN851 reference the chip's auto-tune constants assume.
Swept ANTCAP (AN851 Appendix A) and found a fixed override that beats
@@ -92,12 +100,12 @@ detail in `docs/si4684-rf-investigation-report.md`):
(`net::ble_provisioning`, ESP-IDF's own `wifi_provisioning` over the
ESP32-S3's onboard BLE, additive alongside the SoftAP), web radio
streaming stutter fix (batched I2S writes).
- **Still open**: BT1035 root cause; intermittent multi-second HTTP
unresponsiveness under load (candidate cause: a blocking Si4684 SPI wait
colliding with `max_open_sockets=3`); DAB signal quality still
antenna-limited even after calibration; NVS partition (24 KB) may be
undersized given the accumulated write traffic (`saveProfile()`
`store_failed` seen intermittently, never root-caused).
- **Still open**: intermittent multi-second HTTP unresponsiveness under
load (candidate cause: a blocking Si4684 SPI wait colliding with
`max_open_sockets=3`); DAB signal quality still antenna-limited even
after calibration; NVS partition (24 KB) may be undersized given the
accumulated write traffic (`saveProfile()` `store_failed` seen
intermittently, never root-caused).
Next work: keep chasing the open items above as the user prioritises them,
not new features unless requested.
+1
View File
@@ -8,6 +8,7 @@ idf_component_register(
"antenna_calibration.cpp"
"$<$<BOOL:${CONFIG_TEST_FIRMWARE}>:test_firmware.cpp>"
"$<$<BOOL:${CONFIG_I2S_SDATA_PROBE}>:i2s_sdata_probe.cpp>"
"$<$<BOOL:${CONFIG_ESP32_I2S_TEST_TONE}>: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
+13
View File
@@ -17,4 +17,17 @@ config I2S_SDATA_PROBE
input trace (or an equivalent test point) -- enabling this without the
rewire just reads whatever GPIO16 happens to be floating/connected to.
config ESP32_I2S_TEST_TONE
bool "ESP32-generated sine tone over the shared I2S TX channel"
default n
help
Continuously writes a sine wave to esp32_i2s_sink (the same shared
BCLK/LRCLK the Si4684 uses on its own SDATA_IN0 pin, but over
SDATA_IN1). Diagnostic: if this sounds clean while FM/DAB is
distorted, the ADAU1701's I2S input receiving is fine in general and
the problem is specific to the Si4684 side. Remember the ESP32
mixer channel is muted (-96 dB) by default in the stored audio
profile -- raise it via PUT /api/audio/profile to actually hear
this.
endmenu
+15
View File
@@ -11,13 +11,28 @@
#include "antenna_calibration.hpp"
#include "hardware_bootstrap.hpp"
#include "si4684/Si4684Tuner.hpp"
namespace antenna_calibration {
namespace {
/** Diagnostic-only, 2026-08-23: see net::AntennaCalibration::recalibrateXtal. */
bool recalibrateXtal(std::uint8_t ibias, std::uint8_t ctun,
std::uint32_t xtalFreqHz)
{
return static_cast<bool>(hardware::HardwareBootstrap::si4684Tuner()
.recalibrateXtal(ibias, ctun, xtalFreqHz));
}
} // namespace
net::AntennaCalibration& bridge() noexcept
{
static net::AntennaCalibration instance{
.save = &hardware::HardwareBootstrap::saveFmAntCapCalibration,
.saveDab = &hardware::HardwareBootstrap::saveDabAntCapCalibration,
.recalibrateXtal = &recalibrateXtal,
};
return instance;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* @file esp32_i2s_test_tone.cpp
* @brief esp32_i2s_test_tone implementation.
*
* DigiRadio firmware https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*/
#include "esp32_i2s_test_tone.hpp"
#include "esp32_i2s_sink.hpp"
#include <cmath>
#include <cstdint>
#include <numbers>
namespace esp32_i2s_test_tone {
namespace {
constexpr int kSampleRateHz = 48000;
constexpr int kFramesPerBlock = 256;
/** writeSamples() wants top-aligned 24-bit in a 32-bit slot -- 8-bit shift. */
constexpr int kTopAlignShift = 8;
/** Modest amplitude: audible but not a full-scale blast. */
constexpr float kAmplitude = 0.4F * 8388607.0F;
constexpr float kTwoPi = 2.0F * std::numbers::pi_v<float>;
} // namespace
[[noreturn]] void run(float freqHz)
{
esp32_i2s_sink::tryAcquire();
const float phaseStep = kTwoPi * freqHz / static_cast<float>(kSampleRateHz);
float phase = 0.0F;
std::int32_t buf[kFramesPerBlock * 2];
for (;;)
{
for (int i = 0; i < kFramesPerBlock; ++i)
{
phase += phaseStep;
if (phase > kTwoPi)
{
phase -= kTwoPi;
}
const auto sample =
static_cast<std::int32_t>(std::sin(phase) * kAmplitude)
<< kTopAlignShift;
buf[(i * 2) + 0] = sample;
buf[(i * 2) + 1] = sample;
}
esp32_i2s_sink::writeSamples(buf, kFramesPerBlock * 2);
}
}
} // namespace esp32_i2s_test_tone
+28
View File
@@ -0,0 +1,28 @@
/**
* @file esp32_i2s_test_tone.hpp
* @brief Continuous sine tone over the shared ESP32 -> ADAU1701 I2S TX
* channel, for isolating ADAU1701 I2S-input problems from the
* Si4684 specifically.
*
* DigiRadio firmware https://github.com/manvalan/DigiRadio
*
* Copyright 2026 Michele Bigi
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
namespace esp32_i2s_test_tone {
/**
* Generate and write a sine-wave tone over esp32_i2s_sink forever. Intended
* to run as its own FreeRTOS task (never returns). Unlike the ADAU1701's
* internal Beep cell (POST /api/audio/beep), this tone travels over the
* physical SDATA_IN1 wire and the shared BCLK/LRCLK the Si4684 also uses on
* SDATA_IN0 -- if it sounds clean while FM/DAB is distorted, the problem is
* specific to the Si4684 side, not the ADAU1701's I2S input path in general.
*
* @param freqHz Tone frequency in Hz.
*/
[[noreturn]] void run(float freqHz);
} // namespace esp32_i2s_test_tone
+111 -12
View File
@@ -32,6 +32,8 @@
#include "driver/spi_master.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
namespace hardware {
@@ -77,10 +79,13 @@ bt1035::Bt1035Driver gBt1035(
.uartRx = board::pins::Bt1035UartRx,
.resetGpio = board::pins::Bt1035Reset,
.sysCtlGpio = board::pins::Bt1035SysCtl,
.ctsGpio = board::pins::Bt1035Cts,
.rtsGpio = board::pins::Bt1035Rts,
});
core::DeviceIdentity gDeviceIdentity = core::DeviceIdentity::unknown();
std::optional<std::uint8_t> gFmAntCapCalibration;
std::optional<std::uint8_t> gDabAntCapCalibration;
bool gReady = false;
/**
@@ -95,6 +100,58 @@ bool gReady = false;
busHandle,
static_cast<std::uint8_t>(board::pins::Eeprom24aaAddr));
}
/**
* @brief applyBt1035PostBootSetup device name + auto-reconnect, run
* once after any successful BT1035 boot (first attempt or a
* later background retry).
*/
void applyBt1035PostBootSetup()
{
if (auto nameResult = gBt1035.setDeviceName(gDeviceIdentity.bluetoothName());
!nameResult) {
ESP_LOGW(kTag, "BT1035 device name set failed");
}
if (auto reconnectResult = gBt1035.setAutoReconnect(3U); !reconnectResult) {
ESP_LOGW(kTag, "BT1035 auto-reconnect set failed");
}
}
/**
* @brief bt1035RetryTask keep retrying Bt1035Driver::boot() in the
* background after the initial boot() attempt fails.
*
* @dname bt1035RetryTask
* @pubstate loops gBt1035.boot() with no artificial delay between
* attempts each call already blocks for tens of seconds
* (kBootBannerWaitMs's banner wait, times kBootAttempts), so no
* extra backoff is added on top. Exits once boot() succeeds.
*
* Why: BT1035 boot failure has been observed to be intermittent on
* identical, correctly-wired, correctly-powered hardware the same
* physical module has booted successfully and failed silently across
* different attempts in the same session, with the crystal oscillator
* inside the (sealed, non-serviceable) module the leading suspect. Since
* the fault clears on a later attempt rather than needing repair, retrying
* indefinitely in the background turns a permanent-until-manual-reboot
* failure into a bounded, self-recovering delay.
*
* @author Michele Bigi
* @date 2026-08-21
*/
void bt1035RetryTask(void* /*arg*/)
{
ESP_LOGW(kTag, "BT1035 background retry started");
while (true) {
if (auto result = gBt1035.boot(); result) {
applyBt1035PostBootSetup();
ESP_LOGI(kTag, "BT1035 background retry succeeded");
break;
}
ESP_LOGW(kTag, "BT1035 background retry attempt failed, trying again");
}
vTaskDelete(nullptr);
}
} // namespace
std::expected<void, HardwareBootError> HardwareBootstrap::boot()
@@ -103,7 +160,21 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
return {};
}
if (auto tunerResult = gSi4684.boot(si4684::Si4684Band::Dab); !tunerResult) {
// xtalCtun=0, xtalFreqHz=19199750 (2026-08-23): the compiled-in
// defaults (ctun=31, xtal=19200000 nominal) were never measured
// against this board's actual crystal (Abracon ABM8-19.200MHZ-10-1-U-T,
// CL=10pF per part number, plus two external 15pF load caps per the
// schematic). CTUN=0 was found audibly best via A/B listening (0/5/31),
// then xtalFreqHz was trimmed properly using the chip's own FM_RSQ
// FREQOFF measurement (tools/si4684_xtal_calibration.py) against two
// real, GPS-locked broadcast carriers 87.6/105.1 MHz -- converged to
// -3 to -4 ppm residual on both (cross-check confirms it's the
// crystal, not something frequency-dependent), down from +70 ppm
// uncorrected. See POST /api/tuner/xtal-calibrate to re-trim live if
// this ever needs revisiting (e.g. after a board/crystal change).
if (auto tunerResult =
gSi4684.boot(si4684::Si4684Band::Dab, 72U, 0U, 19199750U);
!tunerResult) {
ESP_LOGE(kTag, "Si4684 boot failed: error %d", static_cast<int>(tunerResult.error()));
return std::unexpected(HardwareBootError::Si4684BootFailed);
}
@@ -140,6 +211,19 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
"auto-tune");
}
if (auto antCap = eeprom.readDabAntCap(); antCap) {
gDabAntCapCalibration = *antCap;
if (gDabAntCapCalibration) {
ESP_LOGI(kTag, "DAB ANTCAP calibration loaded: %u",
static_cast<unsigned>(*gDabAntCapCalibration));
} else {
ESP_LOGI(kTag, "DAB ANTCAP not calibrated — using chip auto-tune");
}
} else {
ESP_LOGW(kTag, "DAB ANTCAP calibration read failed — using chip "
"auto-tune");
}
if (auto audioResult = gAudioService.loadAndApply(); !audioResult) {
ESP_LOGW(kTag, "ADAU1701 profile apply failed");
}
@@ -150,18 +234,15 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
}
if (auto btResult = gBt1035.boot(); !btResult) {
ESP_LOGE(kTag, "BT1035 boot failed — continuing without Bluetooth");
ESP_LOGE(kTag, "BT1035 boot failed — continuing without Bluetooth, "
"retrying in background");
if (xTaskCreate(bt1035RetryTask, "bt1035_retry", 4096, nullptr, 3,
nullptr)
!= pdPASS) {
ESP_LOGW(kTag, "BT1035 background retry task create failed");
}
} else {
if (auto nameResult =
gBt1035.setDeviceName(gDeviceIdentity.bluetoothName());
!nameResult) {
ESP_LOGW(kTag, "BT1035 device name set failed");
}
if (auto reconnectResult = gBt1035.setAutoReconnect(3U);
!reconnectResult) {
ESP_LOGW(kTag, "BT1035 auto-reconnect set failed");
}
applyBt1035PostBootSetup();
}
gReady = true;
@@ -216,4 +297,22 @@ bool HardwareBootstrap::saveFmAntCapCalibration(std::uint8_t antCap)
return true;
}
std::optional<std::uint8_t> HardwareBootstrap::dabAntCapCalibration() noexcept
{
return gDabAntCapCalibration;
}
bool HardwareBootstrap::saveDabAntCapCalibration(std::uint8_t antCap)
{
eeprom24aa::Eeprom24aa eeprom = makeEeprom();
if (auto written = eeprom.writeDabAntCap(antCap); !written) {
ESP_LOGW(kTag, "DAB ANTCAP calibration write failed");
return false;
}
gDabAntCapCalibration = antCap;
ESP_LOGI(kTag, "DAB ANTCAP calibration saved: %u",
static_cast<unsigned>(antCap));
return true;
}
} // namespace hardware
+29
View File
@@ -168,6 +168,35 @@ public:
* @date 2026-08-19
*/
[[nodiscard]] static bool saveFmAntCapCalibration(std::uint8_t antCap);
/**
* @brief dabAntCapCalibration saved DAB antenna calibration, if any.
*
* @dname dabAntCapCalibration
* @return Calibrated ANTCAP (0-128) read from the 24AA025E48 during
* boot(), or nullopt if never calibrated / the read failed.
* @pubstate reads gDabAntCapCalibration; set once during boot().
*
* @author Michele Bigi
* @date 2026-08-20
*/
[[nodiscard]] static std::optional<std::uint8_t>
dabAntCapCalibration() noexcept;
/**
* @brief saveDabAntCapCalibration persist a new DAB ANTCAP to EEPROM.
*
* @dname saveDabAntCapCalibration
* @param antCap Value found via a calibration sweep (0-128).
* @return true on success, false on an I2C failure.
* @pubstate writes the 24AA025E48 user region and gDabAntCapCalibration.
* Does not itself change any live tuner state callers must
* also call TunerService::setDefaultDabAntCap() to apply it.
*
* @author Michele Bigi
* @date 2026-08-20
*/
[[nodiscard]] static bool saveDabAntCapCalibration(std::uint8_t antCap);
};
} // namespace hardware
+23
View File
@@ -45,6 +45,10 @@
#include "i2s_sdata_probe.hpp"
#endif
#if CONFIG_ESP32_I2S_TEST_TONE
#include "esp32_i2s_test_tone.hpp"
#endif
namespace {
constexpr char kTag[] = "digiradio";
@@ -133,6 +137,14 @@ void heartbeatTask(void* arg)
}
}
#if CONFIG_ESP32_I2S_TEST_TONE
[[noreturn]] void i2sTestToneTask(void* arg)
{
(void)arg;
esp32_i2s_test_tone::run(440.0F);
}
#endif
} // namespace
/**
@@ -184,6 +196,10 @@ extern "C" void app_main()
antCap) {
tunerService.setDefaultFmAntCap(*antCap);
}
if (auto antCap = hardware::HardwareBootstrap::dabAntCapCalibration();
antCap) {
tunerService.setDefaultDabAntCap(*antCap);
}
static station::StationService stationService(store, tunerService);
@@ -209,6 +225,13 @@ extern "C" void app_main()
"streaming will be unavailable");
}
#if CONFIG_ESP32_I2S_TEST_TONE
if (xTaskCreate(i2sTestToneTask, "i2s_test_tone", 4096, nullptr, 4,
nullptr) != pdPASS) {
ESP_LOGW(kTag, "ESP32 I2S test tone task create failed");
}
#endif
auto netResult = net::NetBootstrap::start(
store,
tunerService,
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""si4684_antenna_calibration.py — AN851 Appendix A varactor-tuning sweep.
DigiRadio firmware https://github.com/manvalan/DigiRadio
Copyright 2026 Michele Bigi
SPDX-License-Identifier: Apache-2.0
Runs the Si4684 antenna varactor calibration procedure described in AN851
("Si468x AM/AMHD-FM/FMHD-DAB/DAB+ antenna/matching network design
guidelines"), Appendix A, driving the device's existing HTTP tuner API
instead of a bench Test_Get_RSSI command:
1. At each of several test frequencies, sweep ANTCAP from --antcap-min
to --antcap-max (AN851: 1-128) via POST /api/tuner/tune, averaging
--samples (AN851: 5) reads per step via GET /api/tuner/status.
2. Pick the ANTCAP with the best average reading at each frequency.
3. Linear-fit best_antcap(frequency_MHz) = (m/1000)*frequency_MHz + b,
AN851's own formula, giving VARM (m, property 0x1710) and VARB
(b, property 0x1711) as signed 16-bit integers.
Scope and limits (read before running):
* This firmware's HTTP API has no endpoint to write VARM/VARB directly
(see components/net/src/SetupWebServer.cpp) -- only a single fixed
ANTCAP override can be persisted, via POST /api/tuner/calibrate-antenna.
This script only COMPUTES the fit and prints it; it does not write
anything to the device. Applying the result means hand-editing the
kFmTuneFeVarm/kFmTuneFeVarb (or the DAB kDabProps table) constants in
components/drivers/si4684/src/Si4684Driver.cpp and reflashing.
* DAB caveat: DAB_TUNE_FREQ (AN649 Command 0xB0) addresses frequencies by
an index into a chip-side table (default "European frequency list")
loaded via DAB_SET_FREQ_LIST. AN649's copy in this repo does not publish
that table's contents, and neither the firmware nor its HTTP API expose
the real MHz for a given freq_index. This script does NOT assume any
index<->MHz mapping -- for --band dab you must supply the real
frequency for each index yourself (e.g. read off a known ensemble's
published transmitter frequency). Do not guess; a wrong MHz value here
silently produces a wrong VARM/VARB fit.
* DAB metric caveat: AN851 Appendix A calls for RSSI at each step, but
this firmware's DAB status JSON does not expose RSSI (only
dab.fic_quality and dab.cnr_db -- see core/TunerStatus.hpp). This
script uses dab.cnr_db as the optimization metric for --band dab
instead. That is a deliberate substitution, not a datasheet value --
treat DAB fit results with more skepticism than FM ones.
* Known dead zone (docs/si4684-rf-investigation-report.md, 2026-08-20):
DAB freq_index 22 lost lock entirely at antcap 72 and 80 on this board.
This script does not skip any antcap value in range; a step with no
lock is logged as "NO LOCK" and simply excluded from that point's
average, not treated as a fatal error.
Usage:
python3 tools/si4684_antenna_calibration.py --host digiradio-XXXXXX.local \\
--band fm --point 88500 --point 98000 --point 107900
python3 tools/si4684_antenna_calibration.py --host 192.168.1.56 \\
--band dab --point 5:174928 --point 12:198160 --point 23:225648 \\
--raw-csv dab_sweep.csv
"""
from __future__ import annotations
import argparse
import csv
import json
import socket
import statistics
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Optional
def parse_point(band: str, raw: str) -> dict:
"""Parse one --point argument into a tune target for the given band."""
if band == "dab":
if ":" not in raw:
raise ValueError(
"DAB points must be 'freq_index:frequency_khz', e.g. 12:198160"
)
idx_s, khz_s = raw.split(":", 1)
idx = int(idx_s)
khz = int(khz_s)
if not 0 <= idx <= 37:
raise ValueError("freq_index must be 0-37 (core/TunerJson.cpp limit)")
if khz <= 0:
raise ValueError("frequency_khz must be positive")
return {
"index": idx,
"khz": khz,
"mhz": khz / 1000.0,
"label": f"idx{idx}@{khz / 1000:.3f}MHz",
}
khz = int(raw)
if not 87500 <= khz <= 108000:
raise ValueError("FM frequency_khz should be within 87500-108000")
return {"khz": khz, "mhz": khz / 1000.0, "label": f"{khz / 1000:.3f}MHz"}
def tune_payload(band: str, point: dict, antcap: int) -> dict:
if band == "dab":
return {"band": "dab", "freq_index": point["index"], "antcap": antcap}
return {"band": "fm", "frequency_khz": point["khz"], "antcap": antcap}
def extract_metric(status: dict, band: str) -> Optional[float]:
"""AN851's optimization metric: RSSI for FM, CNR for DAB (see docstring)."""
if not status.get("locked", False):
return None
if band == "fm":
fm = status.get("fm") or {}
rssi = fm.get("rssi_dbuv")
return float(rssi) if rssi is not None else None
dab = status.get("dab") or {}
cnr = dab.get("cnr_db")
return float(cnr) if cnr is not None else None
def http_request(
host: str,
method: str,
path: str,
payload: Optional[dict],
timeout: float,
retries: int,
) -> dict:
url = f"http://{host}{path}"
# The firmware's JSON parser (components/core/src/TunerJson.cpp) does
# plain substring search for e.g. `"key":` and is not whitespace-
# tolerant -- json.dumps()'s default ": "/", " separators break every
# request with "invalid_json". Compact separators match what curl's
# -d with no spaces sends, which the firmware does accept.
data = json.dumps(payload, separators=(",", ":")).encode("utf-8") if payload is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
last_err: Optional[BaseException] = None
for attempt in range(retries + 1):
try:
req = urllib.request.Request(url, data=data, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
return json.loads(body) if body else {}
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
last_err = exc
if attempt < retries:
time.sleep(1.0 + attempt) # device is known to stall briefly under HTTP load
raise RuntimeError(f"{method} {path} failed after {retries + 1} attempt(s): {last_err}")
def sweep_point(
host: str,
band: str,
point: dict,
antcap_values: list,
samples: int,
settle_s: float,
sample_gap_s: float,
timeout: float,
retries: int,
raw_rows: Optional[list],
) -> list:
results = []
for antcap in antcap_values:
try:
status = http_request(
host, "POST", "/api/tuner/tune", tune_payload(band, point, antcap), timeout, retries
)
except RuntimeError as exc:
print(f" antcap={antcap:3d} tune failed: {exc}", file=sys.stderr)
results.append((antcap, None))
continue
time.sleep(settle_s)
readings = []
first = extract_metric(status, band)
if first is not None:
readings.append(first)
for _ in range(max(0, samples - 1)):
time.sleep(sample_gap_s)
try:
status = http_request(host, "GET", "/api/tuner/status", None, timeout, retries)
except RuntimeError as exc:
print(f" antcap={antcap:3d} status read failed: {exc}", file=sys.stderr)
continue
reading = extract_metric(status, band)
if reading is not None:
readings.append(reading)
if raw_rows is not None:
for i, reading in enumerate(readings):
raw_rows.append(
{"point": point["label"], "antcap": antcap, "sample": i, "metric": reading}
)
avg = statistics.mean(readings) if readings else None
state = "locked" if readings else "NO LOCK"
avg_str = f"{avg:6.2f}" if avg is not None else " - "
print(f" antcap={antcap:3d} samples={len(readings)}/{samples} avg={avg_str} [{state}]")
results.append((antcap, avg))
return results
def linear_fit(xs: list, ys: list) -> Optional[tuple]:
n = len(xs)
if n < 2:
return None
mean_x = sum(xs) / n
mean_y = sum(ys) / n
num = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
den = sum((x - mean_x) ** 2 for x in xs)
if den == 0:
return None
slope = num / den
intercept = mean_y - slope * mean_x
return slope, intercept
def main() -> int:
parser = argparse.ArgumentParser(
description="AN851 Appendix A ANTCAP sweep + VARM/VARB linear fit over the tuner HTTP API.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--host", required=True, help="Device IP or mDNS host, e.g. digiradio-XXXXXX.local")
parser.add_argument("--band", required=True, choices=["fm", "dab"])
parser.add_argument(
"--point",
action="append",
required=True,
metavar="FREQ_KHZ|INDEX:FREQ_KHZ",
help="FM: frequency in kHz (e.g. 98000). DAB: freq_index:frequency_khz "
"(e.g. 12:198160) -- see script docstring for why the MHz value must "
"come from you, not this script.",
)
parser.add_argument("--antcap-min", type=int, default=1)
parser.add_argument("--antcap-max", type=int, default=128)
parser.add_argument("--antcap-step", type=int, default=1)
parser.add_argument(
"--samples", type=int, default=5, help="Reads averaged per ANTCAP step (AN851 Appendix A: 5)"
)
parser.add_argument(
"--settle-ms",
type=int,
default=150,
help="Delay after tune before the first reading (engineering margin, not from AN851)",
)
parser.add_argument("--sample-gap-ms", type=int, default=80, help="Delay between repeated status reads")
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout per request, seconds")
parser.add_argument(
"--retries", type=int, default=2, help="Retries per request (device is known to stall briefly)"
)
parser.add_argument("--raw-csv", type=Path, default=None, help="Optional path to dump every raw sample")
args = parser.parse_args()
if not 1 <= args.antcap_min <= args.antcap_max <= 128:
print("error: antcap range must be within 1-128 (0 = auto, not part of the sweep)", file=sys.stderr)
return 1
points = []
for raw in args.point:
try:
points.append(parse_point(args.band, raw))
except ValueError as exc:
print(f"error: invalid --point {raw!r}: {exc}", file=sys.stderr)
return 1
antcap_values = list(range(args.antcap_min, args.antcap_max + 1, args.antcap_step))
raw_rows: Optional[list] = [] if args.raw_csv else None
fit_points = []
for point in points:
print(f"\n=== sweeping {args.band} @ {point['label']} ===")
results = sweep_point(
args.host,
args.band,
point,
antcap_values,
args.samples,
args.settle_ms / 1000.0,
args.sample_gap_ms / 1000.0,
args.timeout,
args.retries,
raw_rows,
)
valid = [(a, v) for a, v in results if v is not None]
if not valid:
print(f" WARNING: no locked reading anywhere -- excluding {point['label']} from the fit")
continue
best_antcap, best_val = max(valid, key=lambda t: t[1])
print(f" best: antcap={best_antcap} ({best_val:.2f})")
fit_points.append((point["mhz"], best_antcap, point["label"]))
if args.raw_csv:
with args.raw_csv.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["point", "antcap", "sample", "metric"])
writer.writeheader()
writer.writerows(raw_rows)
print(f"\nraw samples written to {args.raw_csv}")
print(f"\n=== AN851 Appendix A fit (band={args.band}) ===")
if len(fit_points) < 2:
print(
"Not enough locked points to fit VARM/VARB (need >= 2 usable frequencies). "
"Try different --point values or check the antenna/signal."
)
return 1
xs = [p[0] for p in fit_points]
ys = [p[1] for p in fit_points]
fit = linear_fit(xs, ys)
if fit is None:
print("Fit failed (degenerate frequency set -- all points at the same MHz?).")
return 1
slope, intercept = fit
m = round(slope * 1000)
b = round(intercept)
m_ok = -32768 <= m <= 32767
b_ok = -32768 <= b <= 32767
print(" points used: " + ", ".join(f"{label} -> antcap {a}" for _, a, label in fit_points))
print(" varactor_value = (m/1000)*frequency_MHz + b [AN851 Appendix A]")
print(f" m (slope x1000, property 0x1710) = {m}" + ("" if m_ok else " ** OUT OF int16 RANGE **"))
print(f" b (intercept, property 0x1711) = {b}" + ("" if b_ok else " ** OUT OF int16 RANGE **"))
if m_ok and b_ok:
print(f" -> 0x1710 = 0x{m & 0xFFFF:04X} 0x1711 = 0x{b & 0xFFFF:04X}")
print()
print(" Not written to the device. To apply: edit the k{Fm,Dab}TuneFeVarm/")
print(" k{Fm,Dab}TuneFeVarb constants in components/drivers/si4684/src/Si4684Driver.cpp")
print(" (current values and AN851 Appendix B citation are next to them), reflash, then")
print(" re-run this sweep with antcap=0 (auto) at the same points to confirm auto-tune")
print(" now tracks the measured optimum.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""si4684_xtal_calibration.py — live Si4684 crystal trim via FM_RSQ FREQOFF.
DigiRadio firmware https://github.com/manvalan/DigiRadio
Copyright 2026 Michele Bigi
SPDX-License-Identifier: Apache-2.0
Uses the Si4684's own FM_RSQ_STATUS FREQOFF field (AN649 Command 0x32,
RESP8, signed offset in units of 2 PPM) as a precision frequency reference
-- real FM broadcast transmitters are GPS/rubidium-locked, so a locked
station's carrier reads the same PPM error as the receiver's own crystal
reference error, no lab equipment required.
Procedure (matches AN649 §9.3's own recommendation to trim by measurement,
just automated over HTTP instead of by ear):
1. Tune to a strong, stable FM station.
2. Read GET /api/tuner/status -> fm.freqoff_ppm.
3. new_xtal_freq_hz = 19200000 * (1 + ppm/1e6)
4. POST /api/tuner/xtal-calibrate {"xtal_freq_hz": new_xtal_freq_hz}
(this reboots the Si4684, no ESP32 restart, no persistence yet).
5. Re-tune, re-read freqoff_ppm. Repeat until it converges near 0.
6. Optionally cross-check on a second station at the other end of the
band -- if the residual offset differs systematically between the two,
the error isn't (only) the crystal; don't chase it further with this
tool.
Nothing here is persisted to flash. Once you have a converged xtal_freq_hz,
hand it to a human/Claude to bake into the Si4684Driver::boot() default
call in main/hardware_bootstrap.cpp -- this script only calibrates the
currently-running session.
Usage:
python3 tools/si4684_xtal_calibration.py --host 192.168.1.62 \\
--frequency-khz 87600
python3 tools/si4684_xtal_calibration.py --host 192.168.1.62 \\
--frequency-khz 87600 --once # single read, no correction loop
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.error
import urllib.request
from typing import Optional
def http_request(host: str, method: str, path: str, payload: Optional[dict],
timeout: float) -> dict:
url = f"http://{host}{path}"
# Compact separators: the firmware's hand-rolled JSON parser is not
# whitespace-tolerant (confirmed bug in components/core/src/TunerJson.cpp).
data = json.dumps(payload, separators=(",", ":")).encode() if payload is not None else None
headers = {"Content-Type": "application/json"} if data is not None else {}
req = urllib.request.Request(url, data=data, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
return json.loads(body) if body else {}
def tune_fm(host: str, frequency_khz: int, timeout: float) -> dict:
return http_request(host, "POST", "/api/tuner/tune",
{"band": "fm", "frequency_khz": frequency_khz}, timeout)
def read_status(host: str, timeout: float) -> dict:
return http_request(host, "GET", "/api/tuner/status", None, timeout)
def recalibrate(host: str, xtal_freq_hz: int, ibias: int, ctun: int,
timeout: float) -> dict:
return http_request(
host, "POST", "/api/tuner/xtal-calibrate",
{"xtal_freq_hz": xtal_freq_hz, "ibias": ibias, "ctun": ctun}, timeout)
def main() -> int:
parser = argparse.ArgumentParser(
description="Trim Si4684 XTAL_FREQ live using FM_RSQ_STATUS FREQOFF "
"-- no lab equipment, uses locked broadcast carriers as reference.")
parser.add_argument("--host", required=True, help="Device IP or mDNS host")
parser.add_argument("--frequency-khz", type=int, required=True,
help="A strong, stable FM station to lock onto, e.g. 87600")
parser.add_argument("--ibias", type=int, default=72, help="POWER_UP IBIAS (0-127)")
parser.add_argument("--ctun", type=int, default=0,
help="POWER_UP CTUN (0-63) -- default 0, the best value "
"found by ear on 2026-08-23; leave alone unless you have "
"a reason to also move it")
parser.add_argument("--start-xtal-freq-hz", type=int, default=19200000,
help="Starting XTAL_FREQ before the first correction")
parser.add_argument("--max-iterations", type=int, default=6)
parser.add_argument("--converge-ppm", type=float, default=1.0,
help="Stop once |freqoff_ppm| is under this many ppm")
parser.add_argument("--settle-s", type=float, default=1.0,
help="Delay after tune/recalibrate before reading status")
parser.add_argument("--samples", type=int, default=5,
help="FREQOFF reads averaged per iteration (reduces "
"reception-noise jitter in the ppm estimate)")
parser.add_argument("--sample-gap-s", type=float, default=0.3)
parser.add_argument("--damping", type=float, default=0.6,
help="Fraction of the measured ppm correction applied "
"per iteration (<1.0 avoids overshoot/oscillation "
"around the true value)")
parser.add_argument("--timeout", type=float, default=10.0)
parser.add_argument("--once", action="store_true",
help="Single read only, no correction loop")
args = parser.parse_args()
xtal_freq_hz = args.start_xtal_freq_hz
for iteration in range(1 if args.once else args.max_iterations):
try:
recalibrate(args.host, xtal_freq_hz, args.ibias, args.ctun, args.timeout)
except (urllib.error.URLError, OSError) as exc:
print(f"error: xtal-calibrate failed: {exc}", file=sys.stderr)
return 1
time.sleep(args.settle_s)
try:
status = tune_fm(args.host, args.frequency_khz, args.timeout)
except (urllib.error.URLError, OSError) as exc:
print(f"error: tune failed: {exc}", file=sys.stderr)
return 1
time.sleep(args.settle_s)
samples = []
for s in range(args.samples):
if s > 0:
time.sleep(args.sample_gap_s)
try:
status = read_status(args.host, args.timeout)
except (urllib.error.URLError, OSError) as exc:
print(f"error: status read failed: {exc}", file=sys.stderr)
return 1
fm = status.get("fm") or {}
if status.get("locked", False) and fm.get("freqoff_ppm") is not None:
samples.append((fm["freqoff_ppm"], fm.get("rssi_dbuv"), fm.get("snr_db")))
if not samples:
print(" no lock / no FREQOFF reading across all samples -- pick "
"a stronger station and retry", file=sys.stderr)
return 1
ppm_avg = sum(s[0] for s in samples) / len(samples)
rssi = samples[-1][1]
snr = samples[-1][2]
print(f"iter {iteration}: xtal_freq_hz={xtal_freq_hz} "
f"samples={len(samples)}/{args.samples} rssi={rssi} snr={snr} "
f"freqoff_ppm_avg={ppm_avg:.1f} "
f"raw={[s[0] for s in samples]}")
if args.once:
return 0
if abs(ppm_avg) <= args.converge_ppm:
print(f"\nConverged: xtal_freq_hz={xtal_freq_hz} "
f"(residual {ppm_avg:.1f} ppm avg, ibias={args.ibias}, "
f"ctun={args.ctun})")
print("Not persisted -- to make this the boot default, edit the "
"gSi4684.boot(...) call in main/hardware_bootstrap.cpp.")
return 0
# Empirically determined 2026-08-23: correcting in the "+ppm"
# direction diverges (each iteration made freqoff_ppm larger, not
# smaller). The correct sign is "-ppm" -- confirmed by a live A/B
# test, not derived from AN649 (which doesn't specify the relation
# between XTAL_FREQ and FREQOFF's sign convention). Damping avoids
# overshoot from single-reading reception noise.
xtal_freq_hz = round(xtal_freq_hz * (1.0 - args.damping * ppm_avg / 1e6))
print(f"\nDid not converge within {args.max_iterations} iterations "
f"(last xtal_freq_hz={xtal_freq_hz}). Try again, or check the "
f"second-station cross-check described in this script's docstring "
f"-- a residual that varies with frequency isn't the crystal.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB