diff --git a/README.md b/README.md index 27f3249..d0953cd 100644 --- a/README.md +++ b/README.md @@ -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) + --- diff --git a/Software/APP/igiRadio/Documentation/APP_ARCHITECTURE.md b/Software/APP/igiRadio/Documentation/APP_ARCHITECTURE.md new file mode 100644 index 0000000..2cec32b --- /dev/null +++ b/Software/APP/igiRadio/Documentation/APP_ARCHITECTURE.md @@ -0,0 +1,35 @@ +# APP_ARCHITECTURE — igiRadio + +## Stack + +``` +SwiftUI Views + ↓ @Observable ViewModels +DigiRadioService (protocol) + ↓ +RealDigiRadioService ──→ HTTPDigiRadioClient (URLSession) +MockDigiRadioService ──→ dati in-memory + +BLEProvisioningService (solo setup Wi‑Fi) + ↓ 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. diff --git a/Software/APP/igiRadio/Documentation/BLE_PROTOCOL.md b/Software/APP/igiRadio/Documentation/BLE_PROTOCOL.md new file mode 100644 index 0000000..49eeb6e --- /dev/null +++ b/Software/APP/igiRadio/Documentation/BLE_PROTOCOL.md @@ -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 Wi‑Fi (setup iniziale). + +--- + +## BLE Wi‑Fi 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-` (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 Wi‑Fi salvate) +2. Advertise BLE con nome = SoftAP SSID +3. App iOS invia credenziali Wi‑Fi 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. diff --git a/Software/APP/igiRadio/Documentation/DEVELOPMENT.md b/Software/APP/igiRadio/Documentation/DEVELOPMENT.md new file mode 100644 index 0000000..84b3e79 --- /dev/null +++ b/Software/APP/igiRadio/Documentation/DEVELOPMENT.md @@ -0,0 +1,58 @@ +# DEVELOPMENT — igiRadio + +## Requisiti + +- macOS con **Xcode 15+** (testato Xcode 26.6) +- iOS 17+ deployment target +- Dispositivo DigiRadio su stessa rete Wi‑Fi (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 Wi‑Fi (BLE discovery in-app, o app ESP BLE Provisioning; oppure SoftAP `http://192.168.4.1`) +2. In app: Impostazioni → Connessione → host `http://digiradio-.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`. diff --git a/Software/APP/igiRadio/Documentation/DEVICE_STATE.md b/Software/APP/igiRadio/Documentation/DEVICE_STATE.md new file mode 100644 index 0000000..9977b93 --- /dev/null +++ b/Software/APP/igiRadio/Documentation/DEVICE_STATE.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. diff --git a/Software/APP/igiRadio/Documentation/PROJECT_ANALYSIS.md b/Software/APP/igiRadio/Documentation/PROJECT_ANALYSIS.md new file mode 100644 index 0000000..98ae23d --- /dev/null +++ b/Software/APP/igiRadio/Documentation/PROJECT_ANALYSIS.md @@ -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** (Wi‑Fi). + +- Setup mode: SoftAP `DigiRadio-` → `http://192.168.4.1/` +- STA mode: stessa API su IP LAN o **mDNS** `digiradio-.local` +- **NON esiste** un protocollo GATT proprietario documentato per tuner/audio/preset. + +### BLE (ESP32-S3 onboard) + +**Solo provisioning Wi‑Fi** 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-` (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 (Wi‑Fi/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 0–100 | +| POST | `/api/audio/bass-enhance` | Enhancement bass 0–100 | +| 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 0–15 | +| 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 Wi‑Fi | ✅ | 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 **Wi‑Fi 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 | diff --git a/Software/APP/igiRadio/Documentation/UI_ARCHITECTURE.md b/Software/APP/igiRadio/Documentation/UI_ARCHITECTURE.md new file mode 100644 index 0000000..0ab521f --- /dev/null +++ b/Software/APP/igiRadio/Documentation/UI_ARCHITECTURE.md @@ -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. diff --git a/Software/APP/igiRadio/Scripts/generate_xcodeproj.py b/Software/APP/igiRadio/Scripts/generate_xcodeproj.py new file mode 100644 index 0000000..875b0a3 --- /dev/null +++ b/Software/APP/igiRadio/Scripts/generate_xcodeproj.py @@ -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 = ""; +\t\t}}; +\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */ = {{ +\t\t\tisa = PBXFileSystemSynchronizedRootGroup; +\t\t\tpath = Tests/{PROJECT}Tests; +\t\t\tsourceTree = ""; +\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 = ""; +\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 = ""; +\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''' + + + + + + + + + + + + + + + + + + + + + +''' +(scheme_dir / f"{PROJECT}.xcscheme").write_text(scheme) +print(f"Wrote {out}") + +if __name__ == "__main__": + pass diff --git a/Software/APP/igiRadio/Tests/igiRadioTests/igiRadioTests.swift b/Software/APP/igiRadio/Tests/igiRadioTests/igiRadioTests.swift new file mode 100644 index 0000000..9b86fe1 --- /dev/null +++ b/Software/APP/igiRadio/Tests/igiRadioTests/igiRadioTests.swift @@ -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) + } +} diff --git a/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj b/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c944f63 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj @@ -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 = ""; }; + F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = igiRadio; sourceTree = ""; }; +/* 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 = ""; + }; + C5D6659C8B3150288BA78279 = { + isa = PBXGroup; + children = ( + F7E3CB0B027CB68EA63A4AB8 /* igiRadio */, + 693436FA1780106DA05D06E3 /* Tests/igiRadioTests */, + 2761999F42D5E1910DD2B679 /* Products */, + ); + sourceTree = ""; + }; +/* 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 */; +} diff --git a/Software/APP/igiRadio/igiRadio.xcodeproj/xcshareddata/xcschemes/igiRadio.xcscheme b/Software/APP/igiRadio/igiRadio.xcodeproj/xcshareddata/xcschemes/igiRadio.xcscheme new file mode 100644 index 0000000..e5d884e --- /dev/null +++ b/Software/APP/igiRadio/igiRadio.xcodeproj/xcshareddata/xcschemes/igiRadio.xcscheme @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Software/APP/igiRadio/igiRadio/App/AppEnvironment.swift b/Software/APP/igiRadio/igiRadio/App/AppEnvironment.swift new file mode 100644 index 0000000..412089d --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/App/AppEnvironment.swift @@ -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() + } +} diff --git a/Software/APP/igiRadio/igiRadio/App/igiRadioApp.swift b/Software/APP/igiRadio/igiRadio/App/igiRadioApp.swift new file mode 100644 index 0000000..8a34c86 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/App/igiRadioApp.swift @@ -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 + } + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Components/AudioComponents.swift b/Software/APP/igiRadio/igiRadio/Components/AudioComponents.swift new file mode 100644 index 0000000..1f3641d --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Components/AudioComponents.swift @@ -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 = -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 = -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 + 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 { + 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) -> 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" + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Components/DesignSystem.swift b/Software/APP/igiRadio/igiRadio/Components/DesignSystem.swift new file mode 100644 index 0000000..872b132 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Components/DesignSystem.swift @@ -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: 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") + } +} diff --git a/Software/APP/igiRadio/igiRadio/Components/RadioComponents.swift b/Software/APP/igiRadio/igiRadio/Components/RadioComponents.swift new file mode 100644 index 0000000..f01f705 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Components/RadioComponents.swift @@ -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) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift b/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift new file mode 100644 index 0000000..c172e3a --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift @@ -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" + } +} diff --git a/Software/APP/igiRadio/igiRadio/Models/AudioProfileTemplate.swift b/Software/APP/igiRadio/igiRadio/Models/AudioProfileTemplate.swift new file mode 100644 index 0000000..2725f74 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Models/AudioProfileTemplate.swift @@ -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 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Models/DigiRadioState.swift b/Software/APP/igiRadio/igiRadio/Models/DigiRadioState.swift new file mode 100644 index 0000000..dbe2fe0 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Models/DigiRadioState.swift @@ -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 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..6da0343 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/Contents.json b/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Resources/Info.plist b/Software/APP/igiRadio/igiRadio/Resources/Info.plist new file mode 100644 index 0000000..af75307 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Resources/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + igiRadio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSBluetoothAlwaysUsageDescription + igiRadio usa Bluetooth per trovare DigiRadio in setup mode e configurare il Wi‑Fi. + NSLocalNetworkUsageDescription + igiRadio si connette a DigiRadio sulla rete locale per controllare radio e audio. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Software/APP/igiRadio/igiRadio/Services/Audio/AudioProfileStore.swift b/Software/APP/igiRadio/igiRadio/Services/Audio/AudioProfileStore.swift new file mode 100644 index 0000000..0d341d5 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Audio/AudioProfileStore.swift @@ -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() + } +} diff --git a/Software/APP/igiRadio/igiRadio/Services/Audio/LocalAudioDecoder.swift b/Software/APP/igiRadio/igiRadio/Services/Audio/LocalAudioDecoder.swift new file mode 100644 index 0000000..0da750c --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Audio/LocalAudioDecoder.swift @@ -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 { + 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 + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Services/Audio/PhonePCMStreamService.swift b/Software/APP/igiRadio/igiRadio/Services/Audio/PhonePCMStreamService.swift new file mode 100644 index 0000000..3e83bcb --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Audio/PhonePCMStreamService.swift @@ -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? + 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) 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) 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) 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[..] = [] + + 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) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioService.swift b/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioService.swift new file mode 100644 index 0000000..2311b56 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioService.swift @@ -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 +} diff --git a/Software/APP/igiRadio/igiRadio/Services/Device/RealDigiRadioService.swift b/Software/APP/igiRadio/igiRadio/Services/Device/RealDigiRadioService.swift new file mode 100644 index 0000000..1c038cd --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Device/RealDigiRadioService.swift @@ -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 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Services/DigiRadioProtocol/HTTPDigiRadioClient.swift b/Software/APP/igiRadio/igiRadio/Services/DigiRadioProtocol/HTTPDigiRadioClient.swift new file mode 100644 index 0000000..53f0408 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/DigiRadioProtocol/HTTPDigiRadioClient.swift @@ -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(_ 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(_ 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(_ 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(_ 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 + }() +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/AudioProfileEditorViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/AudioProfileEditorViewModel.swift new file mode 100644 index 0000000..b6ef50b --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/AudioProfileEditorViewModel.swift @@ -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? + + 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) + } +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/AudioProfilesViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/AudioProfilesViewModel.swift new file mode 100644 index 0000000..44b2af8 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/AudioProfilesViewModel.swift @@ -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 + ) + } +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/AudioViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/AudioViewModel.swift new file mode 100644 index 0000000..fb7f875 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/AudioViewModel.swift @@ -0,0 +1,75 @@ +import Foundation +import Observation + +@Observable +final class AudioViewModel { + private let service: any DigiRadioService + private var applyTask: Task? + + 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() + } +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/ConnectionViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/ConnectionViewModel.swift new file mode 100644 index 0000000..5c76c74 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/ConnectionViewModel.swift @@ -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) + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/HomeViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..73f7d22 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/HomeViewModel.swift @@ -0,0 +1,52 @@ +import Foundation +import Observation + +@Observable +final class HomeViewModel { + private let service: any DigiRadioService + private var refreshTask: Task? + + 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) + } +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/StreamingViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/StreamingViewModel.swift new file mode 100644 index 0000000..65d3b2a --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/StreamingViewModel.swift @@ -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? + + 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)) + } + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/AudioProfileEditorView.swift b/Software/APP/igiRadio/igiRadio/Views/AudioProfileEditorView.swift new file mode 100644 index 0000000..e99ca60 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/AudioProfileEditorView.swift @@ -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 } + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/AudioProfilesView.swift b/Software/APP/igiRadio/igiRadio/Views/AudioProfilesView.swift new file mode 100644 index 0000000..40a7430 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/AudioProfilesView.swift @@ -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") + } + } + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/AudioView.swift b/Software/APP/igiRadio/igiRadio/Views/AudioView.swift new file mode 100644 index 0000000..43019ac --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/AudioView.swift @@ -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) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/BLEProvisioningView.swift b/Software/APP/igiRadio/igiRadio/Views/BLEProvisioningView.swift new file mode 100644 index 0000000..e4261ae --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/BLEProvisioningView.swift @@ -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-).") + 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 Wi‑Fi, 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() } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/BluetoothView.swift b/Software/APP/igiRadio/igiRadio/Views/BluetoothView.swift new file mode 100644 index 0000000..74420f2 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/BluetoothView.swift @@ -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 Wi‑Fi.") + .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) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/ConnectionView.swift b/Software/APP/igiRadio/igiRadio/Views/ConnectionView.swift new file mode 100644 index 0000000..ffe6674 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/ConnectionView.swift @@ -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 Wi‑Fi (BLE)") { + NavigationLink("Configura Wi‑Fi via BLE") { + BLEProvisioningView() + } + Text("DigiRadio espone BLE solo per il provisioning Wi‑Fi. 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-.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() + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/DABRadioView.swift b/Software/APP/igiRadio/igiRadio/Views/DABRadioView.swift new file mode 100644 index 0000000..fc248f2 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/DABRadioView.swift @@ -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 + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift b/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift new file mode 100644 index 0000000..82d8ee7 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift @@ -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 ? "Sì" : "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 + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/HomeView.swift b/Software/APP/igiRadio/igiRadio/Views/HomeView.swift new file mode 100644 index 0000000..d4cb1cd --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/HomeView.swift @@ -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" + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/PresetsView.swift b/Software/APP/igiRadio/igiRadio/Views/PresetsView.swift new file mode 100644 index 0000000..3712e81 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/PresetsView.swift @@ -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 = "" + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/RootView.swift b/Software/APP/igiRadio/igiRadio/Views/RootView.swift new file mode 100644 index 0000000..84e43a9 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/RootView.swift @@ -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" + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift b/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift new file mode 100644 index 0000000..be7bdde --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift @@ -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 ? "Sì" : "No") + LabeledContent("Banda", value: environment.state.tuner.band.rawValue.uppercased()) + LabeledContent("Locked", value: environment.state.tuner.locked ? "Sì" : "No") + } + Section("Bluetooth") { + LabeledContent("A2DP", value: environment.state.bluetooth.a2dpState.rawValue) + LabeledContent("Pairing", value: environment.state.bluetooth.pairing ? "Sì" : "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() } } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/StreamingView.swift b/Software/APP/igiRadio/igiRadio/Views/StreamingView.swift new file mode 100644 index 0000000..3721b59 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/StreamingView.swift @@ -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() + } +} diff --git a/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h b/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h index b961913..3462031 100644 --- a/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h +++ b/Software/Firmware/ADAU1701-Firmware/SigmaStudioFW.h @@ -49,15 +49,19 @@ 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, - unsigned int address, - unsigned int length, - ADI_REG_TYPE* pData); +int SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress, + unsigned int address, + unsigned int length, + ADI_REG_TYPE* pData); /** * @brief Read a contiguous register/data block from the ADAU1701. @@ -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). */ diff --git a/Software/components/core/include/core/BluetoothJson.hpp b/Software/components/core/include/core/BluetoothJson.hpp index 80c2842..84d088c 100644 --- a/Software/components/core/include/core/BluetoothJson.hpp +++ b/Software/components/core/include/core/BluetoothJson.hpp @@ -112,6 +112,34 @@ struct BluetoothConnectRequest { [[nodiscard]] std::expected parseBluetoothAutoReconnectJson(std::string_view json); +/** + * @brief parseBluetoothA2dpCodecConfigJson — validate POST codec_mask field. + * + * @dname parseBluetoothA2dpCodecConfigJson + * @param json Request body with \c codec_mask 0–63 (see AT+A2DPCFG bits). + * @return Bitmask on success, or ParseError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-08-24 + */ +[[nodiscard]] std::expected +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. * diff --git a/Software/components/core/include/core/Bt1035At.hpp b/Software/components/core/include/core/Bt1035At.hpp index 1e63175..a52dc54 100644 --- a/Software/components/core/include/core/Bt1035At.hpp +++ b/Software/components/core/include/core/Bt1035At.hpp @@ -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. * diff --git a/Software/components/core/include/core/ITuner.hpp b/Software/components/core/include/core/ITuner.hpp index d14948b..829b467 100644 --- a/Software/components/core/include/core/ITuner.hpp +++ b/Software/components/core/include/core/ITuner.hpp @@ -86,6 +86,10 @@ public: * * @dname tuneDab * @param freqIndex Ensemble index 0–37. + * @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 tuneDab( - std::uint8_t freqIndex) = 0; + std::uint8_t freqIndex, std::uint8_t antCap = 0U) = 0; /** * @brief tuneFm — tune to an FM centre frequency. diff --git a/Software/components/core/include/core/TunerJson.hpp b/Software/components/core/include/core/TunerJson.hpp index 8e2d662..a63d35f 100644 --- a/Software/components/core/include/core/TunerJson.hpp +++ b/Software/components/core/include/core/TunerJson.hpp @@ -41,8 +41,9 @@ struct TunerTuneRequest { TunerBand band; ///< Target band (Dab or Fm). std::uint8_t dabFreqIndex; ///< Band III ensemble index (0–37) when band is Dab. std::optional fmFrequency; ///< FM centre frequency when band is Fm. - std::optional antCap; ///< FM antenna varactor override (0–128), - ///< for calibration sweeps; ignored for Dab. + std::optional antCap; ///< Antenna varactor override (0–128), + ///< for calibration sweeps; applies to + ///< whichever band is being tuned. }; /** @@ -241,19 +242,75 @@ struct TunerFmScannedStation { [[nodiscard]] std::string serializeTunerFmBandScanJson( const std::vector& 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 +[[nodiscard]] std::expected 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 +parseXtalCalibrationJson(std::string_view json); + } // namespace core diff --git a/Software/components/core/include/core/TunerStatus.hpp b/Software/components/core/include/core/TunerStatus.hpp index f93963d..c8e4828 100644 --- a/Software/components/core/include/core/TunerStatus.hpp +++ b/Software/components/core/include/core/TunerStatus.hpp @@ -63,6 +63,11 @@ struct TunerStatus { std::optional fmChipReadFrequency; ///< FM_RSQ READFREQ (may lag commanded). std::optional fmRssiDbuV; ///< FM RSSI in dBµV. std::optional 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 fmFreqOffBppm; std::optional fmStereo; ///< FM stereo pilot detected. std::optional fmStationName; ///< FM RDS program service name. std::optional fmRadiotext; ///< FM RDS radiotext (RT). diff --git a/Software/components/core/src/AudioProfileJson.cpp b/Software/components/core/src/AudioProfileJson.cpp index 3edcb72..89e3511 100644 --- a/Software/components/core/src/AudioProfileJson.cpp +++ b/Software/components/core/src/AudioProfileJson.cpp @@ -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; } diff --git a/Software/components/core/src/BluetoothJson.cpp b/Software/components/core/src/BluetoothJson.cpp index 1722f94..4f53950 100644 --- a/Software/components/core/src/BluetoothJson.cpp +++ b/Software/components/core/src/BluetoothJson.cpp @@ -96,6 +96,33 @@ parseBluetoothAutoReconnectJson(std::string_view json) return static_cast(raw); } +std::expected +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(raw); +} + +std::string serializeBluetoothA2dpCodecJson(Bt1035A2dpCodec codec) +{ + std::ostringstream out; + out << "{\"codec\":\"" << a2dpCodecToken(codec) << "\"}"; + return out.str(); +} + std::string serializeBluetoothScanJson( const std::vector& 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::size_t valueEnd = json.find('"', valueStart); - if (valueEnd != std::string_view::npos) { - request.name.assign(json.substr(valueStart, valueEnd - valueStart)); + 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.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::size_t valueEnd = json.find('"', valueStart); - if (valueEnd != std::string_view::npos) { - target.name.assign(json.substr(valueStart, valueEnd - valueStart)); + 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)); + } } } return target; diff --git a/Software/components/core/src/Bt1035At.cpp b/Software/components/core/src/Bt1035At.cpp index 2121052..11de00d 100644 --- a/Software/components/core/src/Bt1035At.cpp +++ b/Software/components/core/src/Bt1035At.cpp @@ -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 bootInitSequence() noex return std::array{ Bt1035AtCommand::Ping, Bt1035AtCommand::I2sMode, - Bt1035AtCommand::I2sSlave48k24, + Bt1035AtCommand::I2sSlave48k32, + Bt1035AtCommand::A2dpCodecConfig, }; } diff --git a/Software/components/core/src/DspParamJson.cpp b/Software/components/core/src/DspParamJson.cpp index 4868f6c..00be98e 100644 --- a/Software/components/core/src/DspParamJson.cpp +++ b/Software/components/core/src/DspParamJson.cpp @@ -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 {}; diff --git a/Software/components/core/src/StationListJson.cpp b/Software/components/core/src/StationListJson.cpp index a46287c..faba0fc 100644 --- a/Software/components/core/src/StationListJson.cpp +++ b/Software/components/core/src/StationListJson.cpp @@ -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 {}; diff --git a/Software/components/core/src/TunerJson.cpp b/Software/components/core/src/TunerJson.cpp index ed1c5c8..b69e1fc 100644 --- a/Software/components/core/src/TunerJson.cpp +++ b/Software/components/core/src/TunerJson.cpp @@ -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(*status.fmSnrDb); } + if (status.fmFreqOffBppm) { + out << ",\"freqoff_ppm\":" + << (static_cast(*status.fmFreqOffBppm) * 2); + } if (status.fmStereo) { out << ",\"stereo\":" << (*status.fmStereo ? "true" : "false"); } @@ -200,13 +213,13 @@ std::expected parseTunerTuneJson( return std::unexpected(freq.error()); } req.fmFrequency = *freq; - unsigned long antCap = 0U; - if (extractJsonUint(json, "antcap", antCap) && antCap <= 128U) { - req.antCap = static_cast(antCap); - } } else { return std::unexpected(ParseError::InvalidJson); } + unsigned long antCap = 0U; + if (extractJsonUint(json, "antcap", antCap) && antCap <= 128U) { + req.antCap = static_cast(antCap); + } return req; } @@ -340,8 +353,8 @@ std::string serializeTunerFmBandScanJson( return out.str(); } -std::expected parseAntennaCalibrationJson( - std::string_view json) +std::expected +parseAntennaCalibrationJson(std::string_view json) { if (json.find('{') == std::string_view::npos) { return std::unexpected(ParseError::InvalidJson); @@ -350,7 +363,55 @@ std::expected parseAntennaCalibrationJson( if (!extractJsonUint(json, "antcap", antCap) || antCap > 128U) { return std::unexpected(ParseError::MissingField); } - return static_cast(antCap); + + AntennaCalibrationRequest req = {}; + req.antCap = static_cast(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 +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(ibias); + } + + unsigned long ctun = 0U; + if (extractJsonUint(json, "ctun", ctun)) { + if (ctun > 63U) { + return std::unexpected(ParseError::InvalidJson); + } + req.ctun = static_cast(ctun); + } + + unsigned long xtalFreqHz = 0U; + if (extractJsonUint(json, "xtal_freq_hz", xtalFreqHz)) { + req.xtalFreqHz = static_cast(xtalFreqHz); + } + + return req; } } // namespace core diff --git a/Software/components/core/src/WebRadioJson.cpp b/Software/components/core/src/WebRadioJson.cpp index fee4b2f..5f4c870 100644 --- a/Software/components/core/src/WebRadioJson.cpp +++ b/Software/components/core/src/WebRadioJson.cpp @@ -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; } diff --git a/Software/components/core/src/WifiProvisionJson.cpp b/Software/components/core/src/WifiProvisionJson.cpp index f3590e0..1739dc9 100644 --- a/Software/components/core/src/WifiProvisionJson.cpp +++ b/Software/components/core/src/WifiProvisionJson.cpp @@ -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 {}; diff --git a/Software/components/core/test/bt1035_at_test.cpp b/Software/components/core/test/bt1035_at_test.cpp index f529165..8c7ed96 100644 --- a/Software/components/core/test/bt1035_at_test.cpp +++ b/Software/components/core/test/bt1035_at_test.cpp @@ -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 = diff --git a/Software/components/core/test/integration_service_test.cpp b/Software/components/core/test/integration_service_test.cpp index e76ab39..41451d1 100644 --- a/Software/components/core/test/integration_service_test.cpp +++ b/Software/components/core/test/integration_service_test.cpp @@ -111,7 +111,7 @@ public: } [[nodiscard]] std::expected tuneDab( - std::uint8_t) override + std::uint8_t, std::uint8_t) override { return {}; } diff --git a/Software/components/core/test/station_service_test.cpp b/Software/components/core/test/station_service_test.cpp index 89b8d05..494e0f7 100644 --- a/Software/components/core/test/station_service_test.cpp +++ b/Software/components/core/test/station_service_test.cpp @@ -53,7 +53,7 @@ public: } [[nodiscard]] std::expected tuneDab( - std::uint8_t) override + std::uint8_t, std::uint8_t) override { return {}; } diff --git a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp index e4d2b6f..d0924db 100644 --- a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp +++ b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp @@ -68,6 +68,7 @@ namespace adau1701 { const unsigned char deviceAddr = static_cast(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(data.size()), - const_cast( - reinterpret_cast(data.data()))); + auto *bytes = const_cast( + reinterpret_cast(data.data())); + const auto length = static_cast(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(index), + static_cast(write.address()), + static_cast(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(index), + static_cast(write.address()), + static_cast(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(pins_.i2cAddr7 << 1); - ADI_REG_TYPE serialInFix = 0x08U; - SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U, - &serialInFix); + const unsigned baseAddr = paramAddrEqBandBase( + static_cast(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( + (static_cast(raw[0]) << 24) | + (static_cast(raw[1]) << 16) | + (static_cast(raw[2]) << 8) | + static_cast(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( + (static_cast(raw[0]) << 24) + | (static_cast(raw[1]) << 16) + | (static_cast(raw[2]) << 8) + | static_cast(raw[3])), + static_cast(fixpoint) / + static_cast(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(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(ADDR_LIMITER1_THRESHOLD), + thresholdFixpoint); + !lim1) + { + ESP_LOGW(kTag, "Limiter1 threshold override failed"); + } + if (auto lim2 = safeloadFixpoint( + static_cast(ADDR_LIMITER2_THRESHOLD), + thresholdFixpoint); + !lim2) + { + ESP_LOGW(kTag, "Limiter2 threshold override failed"); + } + ESP_LOGI(kTag, "Limiter1/2 threshold set to %.1f dBFS", + static_cast(kLimiterThresholdDb)); } booted_ = true; diff --git a/Software/components/drivers/adau1701/src/SigmaStudioFW.c b/Software/components/drivers/adau1701/src/SigmaStudioFW.c index cefd7c6..38c37d7 100644 --- a/Software/components/drivers/adau1701/src/SigmaStudioFW.c +++ b/Software/components/drivers/adau1701/src/SigmaStudioFW.c @@ -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 +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, - unsigned int address, - unsigned int length, - ADI_REG_TYPE* pData) +/* + * 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, diff --git a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp index 3d65d06..ebd7f0e 100644 --- a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp +++ b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp @@ -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 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 setA2dpCodecConfig( + std::uint8_t bitmask); + /** * @brief queryPairedList — enumerate paired remotes (AT+PLIST). * diff --git a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp index acc40c0..c935bcb 100644 --- a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp +++ b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp @@ -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 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(uartPort), baud) + != ESP_OK) { + continue; + } + flushUartRx(uartPort); + uart_write_bytes(static_cast(uartPort), "AT\r\n", 4); + + std::array buf{}; + const int n = uart_read_bytes(static_cast(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(n)); + } else { + ESP_LOGI(kTag, "baud probe: silent at %d baud", baud); + } + } + uart_set_baudrate(static_cast(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 buf{}; const int n = uart_read_bytes(static_cast(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 Bt1035Driver::setAutoReconnect( return transmitAndExpectOk(core::buildBt1035SetAutoConnLine(times)); } +std::expected Bt1035Driver::setA2dpCodecConfig( + std::uint8_t bitmask) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + return transmitAndExpectOk(core::buildBt1035A2dpCodecConfigLine(bitmask)); +} + std::expected 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(static_cast(*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(codec.error())); + } return true; } const TickType_t now = xTaskGetTickCount(); @@ -919,24 +1009,44 @@ std::expected Bt1035Driver::runInitSequence() std::expected 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(pins_.sysCtlGpio), 0); - gpio_set_level(static_cast(pins_.resetGpio), 0); - vTaskDelay(pdMS_TO_TICKS(kSysCtlLeadInMs)); - gpio_set_level(static_cast(pins_.sysCtlGpio), 1); - vTaskDelay(pdMS_TO_TICKS(kSysCtlSettleMs)); - gpio_set_level(static_cast(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(pins_.sysCtlGpio); + const auto resetPin = static_cast(pins_.resetGpio); + const auto ctsPin = static_cast(pins_.ctsGpio); + const auto rtsPin = static_cast(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(uartPort_)); vTaskDelay(pdMS_TO_TICKS(kPostUartMs)); @@ -949,20 +1059,65 @@ std::expected 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(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 Bt1035Driver::boot() uartInstalled_ = true; } - std::expected 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 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 {}; } diff --git a/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp index 8b97bb2..5db36d9 100644 --- a/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp +++ b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp @@ -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 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, 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 + writeDabAntCap(std::uint8_t value); + private: i2c_master_bus_handle_t bus_; std::uint8_t addr7_; diff --git a/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp b/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp index c7c1172..600e011 100644 --- a/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp +++ b/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp @@ -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, 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(err)); + return std::unexpected(core::IdentityError::ReadFailed); + } + + if (value > kDabAntCapMax) { + return std::optional{}; + } + return std::optional{value}; +} + +std::expected +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 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(err)); + return std::unexpected(core::IdentityError::I2cFailed); + } + + vTaskDelay(pdMS_TO_TICKS(kI2cWriteCycleMs)); + return {}; +} + } // namespace eeprom24aa diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp index 7966702..46d96a6 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Driver.hpp @@ -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 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 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 0–37. + * @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 tuneDab(std::uint8_t freqIndex); + [[nodiscard]] std::expected tuneDab( + std::uint8_t freqIndex, std::uint8_t antCap = 0U); /** * @brief readDabDigRadStatus — read ensemble lock metrics. diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp index 31cf006..5bc1c24 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Tuner.hpp @@ -96,6 +96,7 @@ public: * * @dname tuneDab * @param freqIndex Ensemble index 0–37. + * @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 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 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 recalibrateXtal( + std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz); + private: /** * @brief mapError — translate Si4684Error to core::TunerError. diff --git a/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp b/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp index 2656181..cbe1f67 100644 --- a/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp +++ b/Software/components/drivers/si4684/include/si4684/Si4684Types.hpp @@ -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; }; /** diff --git a/Software/components/drivers/si4684/src/Si4684Driver.cpp b/Software/components/drivers/si4684/src/Si4684Driver.cpp index 80ea1a8..5934402 100644 --- a/Software/components/drivers/si4684/src/Si4684Driver.cpp +++ b/Software/components/drivers/si4684/src/Si4684Driver.cpp @@ -40,6 +40,8 @@ constexpr std::size_t kSpiReplyLeadIn = 1U; /** FM_RSQ_STATUS field indices with kSpiReplyLeadIn (AN649 RESP5–10). */ 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 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 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 Si4684Driver::configureAfterBoot( } std::expected 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 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(xtalFreqHz & 0xFFU), + static_cast((xtalFreqHz >> 8) & 0xFFU), + static_cast((xtalFreqHz >> 16) & 0xFFU), + static_cast((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 Si4684Driver::boot( return {}; } +std::expected 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 Si4684Driver::readFmRsq() static_cast(raw[kFmRsqOffSnr]), freqInBand && chipValid, false, + static_cast(raw[kFmRsqOffFreqOff]), }; return rsq; } @@ -1037,7 +1082,8 @@ std::expected Si4684Driver::installDefaultDabFrequencyPlan() return sendCommand(cmd); } -std::expected Si4684Driver::tuneDab(std::uint8_t freqIndex) +std::expected 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 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); diff --git a/Software/components/drivers/si4684/src/Si4684Tuner.cpp b/Software/components/drivers/si4684/src/Si4684Tuner.cpp index d8b31f0..d13af7e 100644 --- a/Software/components/drivers/si4684/src/Si4684Tuner.cpp +++ b/Software/components/drivers/si4684/src/Si4684Tuner.cpp @@ -169,6 +169,7 @@ std::expected 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 Si4684Tuner::readStatus() } std::expected 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 Si4684Tuner::setVolume(std::uint8_t level) return {}; } +std::expected 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 diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index 57e39a7..b8af885 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -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); }; /** diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index 5cdf1c7..43fbd2c 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -430,12 +430,12 @@ esp_err_t tunerTunePostHandler(httpd_req_t* req) std::expected 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 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 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(*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 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 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, diff --git a/Software/components/secure_store/src/NvsAudioProfileStore.cpp b/Software/components/secure_store/src/NvsAudioProfileStore.cpp index 01c2b5a..9cacbea 100644 --- a/Software/components/secure_store/src/NvsAudioProfileStore.cpp +++ b/Software/components/secure_store/src/NvsAudioProfileStore.cpp @@ -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 diff --git a/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp b/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp index 5941331..44f9cc7 100644 --- a/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp +++ b/Software/components/services/bluetooth/include/bluetooth/BluetoothService.hpp @@ -107,6 +107,30 @@ public: [[nodiscard]] std::expected 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 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 + queryA2dpCodec(); + /** * @brief scanNearby — classic BT/EDR discovery, cancelling pairing. * diff --git a/Software/components/services/bluetooth/src/BluetoothService.cpp b/Software/components/services/bluetooth/src/BluetoothService.cpp index 59a9c97..4019e39 100644 --- a/Software/components/services/bluetooth/src/BluetoothService.cpp +++ b/Software/components/services/bluetooth/src/BluetoothService.cpp @@ -148,6 +148,18 @@ std::expected BluetoothService::setAutoReconnect( return driver_.setAutoReconnect(times); } +std::expected BluetoothService::setA2dpCodecConfig( + std::uint8_t bitmask) +{ + return driver_.setA2dpCodecConfig(bitmask); +} + +std::expected +BluetoothService::queryA2dpCodec() +{ + return driver_.queryA2dpEncoder(); +} + std::expected, bt1035::Bt1035Error> BluetoothService::scanNearby(std::uint8_t scanSeconds) { diff --git a/Software/components/services/tuner/include/tuner/TunerService.hpp b/Software/components/services/tuner/include/tuner/TunerService.hpp index ec6019e..d716f85 100644 --- a/Software/components/services/tuner/include/tuner/TunerService.hpp +++ b/Software/components/services/tuner/include/tuner/TunerService.hpp @@ -73,6 +73,10 @@ public: * * @dname tuneDab * @param freqIndex Ensemble index 0–37. + * @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 tuneDab( - std::uint8_t freqIndex); + std::uint8_t freqIndex, + std::optional 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 lastPlayedServiceId_; std::optional lastPlayedComponentId_; }; diff --git a/Software/components/services/tuner/src/TunerService.cpp b/Software/components/services/tuner/src/TunerService.cpp index 95d1a87..f674207 100644 --- a/Software/components/services/tuner/src/TunerService.cpp +++ b/Software/components/services/tuner/src/TunerService.cpp @@ -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 TunerService::refreshStatus() { auto status = tuner_.readStatus(); @@ -161,9 +167,11 @@ std::expected TunerService::refreshStatus() } std::expected TunerService::tuneDab( - std::uint8_t freqIndex) + std::uint8_t freqIndex, std::optional antCap) { - if (auto result = tuner_.tuneDab(freqIndex); !result) { + if (auto result = + tuner_.tuneDab(freqIndex, antCap.value_or(defaultDabAntCap_)); + !result) { return result; } lastDabIndex_ = freqIndex; diff --git a/Software/docs/TODO.md b/Software/docs/TODO.md index 128dc06..45d17e0 100644 --- a/Software/docs/TODO.md +++ b/Software/docs/TODO.md @@ -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 diff --git a/Software/docs/si4684-rf-investigation-report.md b/Software/docs/si4684-rf-investigation-report.md index 2150763..9125700 100644 --- a/Software/docs/si4684-rf-investigation-report.md +++ b/Software/docs/si4684-rf-investigation-report.md @@ -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: ` / `BT1035 UART RX RAW: >` / `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 (``), 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`. diff --git a/Software/instructions.md b/Software/instructions.md index 8d1a8c3..ba0a953 100644 --- a/Software/instructions.md +++ b/Software/instructions.md @@ -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. diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index 8893b97..a46d76e 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -8,6 +8,7 @@ idf_component_register( "antenna_calibration.cpp" "$<$:test_firmware.cpp>" "$<$:i2s_sdata_probe.cpp>" + "$<$: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 diff --git a/Software/main/Kconfig.projbuild b/Software/main/Kconfig.projbuild index 6f4467a..2789a1b 100644 --- a/Software/main/Kconfig.projbuild +++ b/Software/main/Kconfig.projbuild @@ -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 diff --git a/Software/main/antenna_calibration.cpp b/Software/main/antenna_calibration.cpp index 9d9dc41..aab8b0d 100644 --- a/Software/main/antenna_calibration.cpp +++ b/Software/main/antenna_calibration.cpp @@ -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(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; } diff --git a/Software/main/esp32_i2s_test_tone.cpp b/Software/main/esp32_i2s_test_tone.cpp new file mode 100644 index 0000000..6ed220b --- /dev/null +++ b/Software/main/esp32_i2s_test_tone.cpp @@ -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 +#include +#include + +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; +} // namespace + +[[noreturn]] void run(float freqHz) +{ + esp32_i2s_sink::tryAcquire(); + + const float phaseStep = kTwoPi * freqHz / static_cast(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::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 diff --git a/Software/main/esp32_i2s_test_tone.hpp b/Software/main/esp32_i2s_test_tone.hpp new file mode 100644 index 0000000..40478ae --- /dev/null +++ b/Software/main/esp32_i2s_test_tone.hpp @@ -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 diff --git a/Software/main/hardware_bootstrap.cpp b/Software/main/hardware_bootstrap.cpp index 518cda0..169e210 100644 --- a/Software/main/hardware_bootstrap.cpp +++ b/Software/main/hardware_bootstrap.cpp @@ -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 gFmAntCapCalibration; +std::optional gDabAntCapCalibration; bool gReady = false; /** @@ -95,6 +100,58 @@ bool gReady = false; busHandle, static_cast(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 HardwareBootstrap::boot() @@ -103,7 +160,21 @@ std::expected 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(tunerResult.error())); return std::unexpected(HardwareBootError::Si4684BootFailed); } @@ -140,6 +211,19 @@ std::expected HardwareBootstrap::boot() "auto-tune"); } + if (auto antCap = eeprom.readDabAntCap(); antCap) { + gDabAntCapCalibration = *antCap; + if (gDabAntCapCalibration) { + ESP_LOGI(kTag, "DAB ANTCAP calibration loaded: %u", + static_cast(*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 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 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(antCap)); + return true; +} + } // namespace hardware diff --git a/Software/main/hardware_bootstrap.hpp b/Software/main/hardware_bootstrap.hpp index 169f411..2405a69 100644 --- a/Software/main/hardware_bootstrap.hpp +++ b/Software/main/hardware_bootstrap.hpp @@ -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 + 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 diff --git a/Software/main/main.cpp b/Software/main/main.cpp index d8dbda1..0c5473d 100644 --- a/Software/main/main.cpp +++ b/Software/main/main.cpp @@ -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, diff --git a/Software/tools/si4684_antenna_calibration.py b/Software/tools/si4684_antenna_calibration.py new file mode 100644 index 0000000..f8e9972 --- /dev/null +++ b/Software/tools/si4684_antenna_calibration.py @@ -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()) diff --git a/Software/tools/si4684_xtal_calibration.py b/Software/tools/si4684_xtal_calibration.py new file mode 100644 index 0000000..97ce6e3 --- /dev/null +++ b/Software/tools/si4684_xtal_calibration.py @@ -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()) diff --git a/docs/images/digiradio-pcb.jpg b/docs/images/digiradio-pcb.jpg new file mode 100644 index 0000000..3524b07 Binary files /dev/null and b/docs/images/digiradio-pcb.jpg differ