Merge cursor/igiRadio-ios-app into main
# Conflicts: # Software/components/core/include/core/TunerJson.hpp # Software/components/core/src/TunerJson.cpp # Software/components/drivers/bt1035/src/Bt1035Driver.cpp # Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp # Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp # Software/components/net/include/net/SetupWebServer.hpp # Software/components/net/src/SetupWebServer.cpp # Software/components/services/tuner/include/tuner/TunerService.hpp # Software/components/services/tuner/src/TunerService.cpp # Software/docs/TODO.md # Software/docs/si4684-rf-investigation-report.md # Software/instructions.md # Software/main/antenna_calibration.cpp # Software/main/hardware_bootstrap.cpp # Software/main/hardware_bootstrap.hpp # Software/main/main.cpp
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||

|
||||
[](Software/LICENSE)
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# APP_ARCHITECTURE — igiRadio
|
||||
|
||||
## Stack
|
||||
|
||||
```
|
||||
SwiftUI Views
|
||||
↓ @Observable ViewModels
|
||||
DigiRadioService (protocol)
|
||||
↓
|
||||
RealDigiRadioService ──→ HTTPDigiRadioClient (URLSession)
|
||||
MockDigiRadioService ──→ dati in-memory
|
||||
|
||||
BLEProvisioningService (solo setup 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.
|
||||
@@ -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-<suffix>` (es. `DigiRadio-CC4DB4`) |
|
||||
| Riferimento firmware | `components/net/src/BleProvisioning.cpp` |
|
||||
| Riferimento manuale | `ch-api.tex` §BLE provisioning |
|
||||
|
||||
### Flusso
|
||||
|
||||
1. Dispositivo in setup mode (no credenziali 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.
|
||||
@@ -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-<suffix>.local`
|
||||
3. Verificare `GET /api/health`
|
||||
|
||||
## Documentazione firmware
|
||||
|
||||
`/Users/michelebigi/Documents/Develop/DigiRadio/Software/docs/manual/ch-api.tex`
|
||||
|
||||
## Architettura runtime
|
||||
|
||||
```
|
||||
SwiftUI → ViewModel → DigiRadioService
|
||||
├── RealDigiRadioService → HTTPDigiRadioClient (REST JSON)
|
||||
└── MockDigiRadioService
|
||||
BLEProvisioningService (CoreBluetooth, solo discovery setup)
|
||||
```
|
||||
|
||||
Il controllo tuner/audio **non** usa GATT proprietario — vedi `BLE_PROTOCOL.md`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# DEVICE_STATE — DigiRadioState
|
||||
|
||||
Modello centrale in `Models/DigiRadioState.swift`.
|
||||
|
||||
## Sezioni
|
||||
|
||||
| Sezione | Campi documentati |
|
||||
|---------|-------------------|
|
||||
| `connection` | host, isConnected, lastError |
|
||||
| `health` | status, firmware, serialNumber, chips |
|
||||
| `tuner` | band, locked, volume, fm?, dab? |
|
||||
| `audio` | mixer, master, eq, enhancements |
|
||||
| `bluetooth` | booted, pairing, a2dpState, deviceName, speaker |
|
||||
| `stations` | [Station] |
|
||||
| `streaming` | enabled, url |
|
||||
|
||||
Campi non documentati nell'API **non** sono presenti nel modello.
|
||||
@@ -0,0 +1,151 @@
|
||||
# PROJECT_ANALYSIS — DigiRadio (fonte: documentazione firmware)
|
||||
|
||||
**Data analisi:** 2026-08-18
|
||||
**Fonte primaria:** `Software/docs/manual/` (non modificata)
|
||||
**Firmware di riferimento:** 0.8.5+
|
||||
|
||||
---
|
||||
|
||||
## 1. Documenti analizzati
|
||||
|
||||
| File | Contenuto |
|
||||
|------|-----------|
|
||||
| `ch-intro.tex` | Introduzione prodotto |
|
||||
| `ch-hardware.tex` | PCB, pinout, catena audio |
|
||||
| `ch-firmware.tex` | Architettura firmware ESP32-S3 |
|
||||
| `ch-si4684.tex` | Tuner FM/DAB, tune, seek, RDS, DLS |
|
||||
| `ch-adau1701.tex` | DSP SigmaStudio, mixer, EQ, safeload |
|
||||
| `ch-bt1035.tex` | Modulo BT classic A2DP (UART AT), I2S da ADAU |
|
||||
| `ch-sigmastudio.tex` | Design SigmaStudio |
|
||||
| `ch-api.tex` | **HTTP REST API** (protocollo app) |
|
||||
| `ch-classes.tex` | Classi firmware |
|
||||
| `ch-build.tex` | Build/flash |
|
||||
| `ch-licensing.tex` | Licenze |
|
||||
|
||||
Documentazione aggiuntiva letta: `Software/CLAUDE.md`, `Software/docs/security-flash-nvs.md`, `BleProvisioning.cpp`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Distinzione critica: BLE vs controllo
|
||||
|
||||
### Controllo dispositivo (tuner, audio, preset, BT config)
|
||||
|
||||
**Trasporto documentato:** HTTP JSON REST su **TCP porta 80** (Wi‑Fi).
|
||||
|
||||
- Setup mode: SoftAP `DigiRadio-<suffix>` → `http://192.168.4.1/`
|
||||
- STA mode: stessa API su IP LAN o **mDNS** `digiradio-<suffix>.local`
|
||||
- **NON esiste** un protocollo GATT proprietario documentato per tuner/audio/preset.
|
||||
|
||||
### BLE (ESP32-S3 onboard)
|
||||
|
||||
**Solo provisioning 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-<suffix>` (es. `DigiRadio-CC4DB4`)
|
||||
- Su successo: credenziali salvate in NVS, reboot in STA
|
||||
- **Non è un endpoint HTTP** — nessuna REST call
|
||||
|
||||
### Bluetooth Audio (streaming verso speaker esterno)
|
||||
|
||||
**Modulo separato FSC-BT1035** (classic BR/EDR A2DP), controllato dal firmware via **UART AT**, esposto all'app solo tramite **HTTP** `/api/bluetooth/*`.
|
||||
|
||||
L'iPhone **non** si collega in A2DP al DigiRadio per ascoltare: il DigiRadio invia audio al Bose/Speaker via BT1035.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architettura hardware (sintesi)
|
||||
|
||||
```
|
||||
Si4684 (SPI) ──I2S──► ADAU1701 (I2S master 48 kHz) ──I2S──► BT1035 ──A2DP──► Speaker
|
||||
ESP32-S3 (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 |
|
||||
@@ -0,0 +1,31 @@
|
||||
# UI_ARCHITECTURE — igiRadio
|
||||
|
||||
## iPhone
|
||||
|
||||
`TabView` con tab: Home, Radio, Presets, Audio, Settings.
|
||||
|
||||
## iPad
|
||||
|
||||
`NavigationSplitView`:
|
||||
- Sidebar: Home, FM, DAB, Presets, Bluetooth, Audio, Settings
|
||||
- Detail: contenuto selezionato
|
||||
|
||||
## Schermate
|
||||
|
||||
| Schermata | ViewModel |
|
||||
|-----------|-----------|
|
||||
| Connection | `ConnectionViewModel` |
|
||||
| Home | `HomeViewModel` |
|
||||
| FM | `FMViewModel` |
|
||||
| DAB | `DABViewModel` |
|
||||
| Presets | `PresetsViewModel` |
|
||||
| Audio | `AudioViewModel` |
|
||||
| Bluetooth | `BluetoothViewModel` |
|
||||
| Settings | `SettingsViewModel` |
|
||||
| Device Info | `DeviceViewModel` |
|
||||
| Diagnostics | `DiagnosticsViewModel` |
|
||||
| Firmware | `FirmwareViewModel` |
|
||||
|
||||
## Design System
|
||||
|
||||
`Components/DesignSystem/` — colori semantici, tipografia, card, slider, status dot.
|
||||
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate igiRadio.xcodeproj using PBXFileSystemSynchronizedRootGroup."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PROJECT = "igiRadio"
|
||||
BUNDLE = "com.digiradio.igiRadio"
|
||||
|
||||
def uid(n):
|
||||
# 24-char hex IDs (stable)
|
||||
import hashlib
|
||||
return hashlib.md5(f"igiRadio-{n}".encode()).hexdigest()[:24].upper()
|
||||
|
||||
IDS = {k: uid(k) for k in [
|
||||
"project", "main", "products", "app", "test", "app_product", "test_product",
|
||||
"sources", "resources", "fw_app", "fw_test", "test_sources", "test_fw",
|
||||
"sync_app", "sync_tests", "sync_exception", "proj_cfg", "app_cfg", "test_cfg",
|
||||
"dbg_proj", "rel_proj", "dbg_app", "rel_app", "dbg_test", "rel_test",
|
||||
"proxy", "dep",
|
||||
]}
|
||||
|
||||
pbx = f'''// !$*UTF8*$!
|
||||
{{
|
||||
\tarchiveVersion = 1;
|
||||
\tclasses = {{
|
||||
\t}};
|
||||
\tobjectVersion = 77;
|
||||
\tobjects = {{
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
\t\t{IDS["proxy"]} /* PBXContainerItemProxy */ = {{
|
||||
\t\t\tisa = PBXContainerItemProxy;
|
||||
\t\t\tcontainerPortal = {IDS["project"]} /* Project object */;
|
||||
\t\t\tproxyType = 1;
|
||||
\t\t\tremoteGlobalIDString = {IDS["app"]};
|
||||
\t\t\tremoteInfo = {PROJECT};
|
||||
\t\t}};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
\t\t{IDS["app_product"]} /* {PROJECT}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT}.app; sourceTree = BUILT_PRODUCTS_DIR; }};
|
||||
\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = {PROJECT}Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }};
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */ = {{
|
||||
\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
\t\t\tmembershipExceptions = (
|
||||
\t\t\t\tResources/Info.plist,
|
||||
\t\t\t);
|
||||
\t\t\ttarget = {IDS["app"]} /* {PROJECT} */;
|
||||
\t\t}};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
\t\t{IDS["sync_app"]} /* {PROJECT} */ = {{
|
||||
\t\t\tisa = PBXFileSystemSynchronizedRootGroup;
|
||||
\t\t\texceptions = (
|
||||
\t\t\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */,
|
||||
\t\t\t);
|
||||
\t\t\tpath = {PROJECT};
|
||||
\t\t\tsourceTree = "<group>";
|
||||
\t\t}};
|
||||
\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */ = {{
|
||||
\t\t\tisa = PBXFileSystemSynchronizedRootGroup;
|
||||
\t\t\tpath = Tests/{PROJECT}Tests;
|
||||
\t\t\tsourceTree = "<group>";
|
||||
\t\t}};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
\t\t{IDS["fw_app"]} /* Frameworks */ = {{
|
||||
\t\t\tisa = PBXFrameworksBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
\t\t{IDS["test_fw"]} /* Frameworks */ = {{
|
||||
\t\t\tisa = PBXFrameworksBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
\t\t{IDS["products"]} /* Products */ = {{
|
||||
\t\t\tisa = PBXGroup;
|
||||
\t\t\tchildren = (
|
||||
\t\t\t\t{IDS["app_product"]} /* {PROJECT}.app */,
|
||||
\t\t\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */,
|
||||
\t\t\t);
|
||||
\t\t\tname = Products;
|
||||
\t\t\tsourceTree = "<group>";
|
||||
\t\t}};
|
||||
\t\t{IDS["main"]} = {{
|
||||
\t\t\tisa = PBXGroup;
|
||||
\t\t\tchildren = (
|
||||
\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */,
|
||||
\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */,
|
||||
\t\t\t\t{IDS["products"]} /* Products */,
|
||||
\t\t\t);
|
||||
\t\t\tsourceTree = "<group>";
|
||||
\t\t}};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
\t\t{IDS["app"]} /* {PROJECT} */ = {{
|
||||
\t\t\tisa = PBXNativeTarget;
|
||||
\t\t\tbuildConfigurationList = {IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */;
|
||||
\t\t\tbuildPhases = (
|
||||
\t\t\t\t{IDS["sources"]} /* Sources */,
|
||||
\t\t\t\t{IDS["fw_app"]} /* Frameworks */,
|
||||
\t\t\t\t{IDS["resources"]} /* Resources */,
|
||||
\t\t\t);
|
||||
\t\t\tbuildRules = (
|
||||
\t\t\t);
|
||||
\t\t\tdependencies = (
|
||||
\t\t\t);
|
||||
\t\t\tfileSystemSynchronizedGroups = (
|
||||
\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */,
|
||||
\t\t\t);
|
||||
\t\t\tname = {PROJECT};
|
||||
\t\t\tproductName = {PROJECT};
|
||||
\t\t\tproductReference = {IDS["app_product"]} /* {PROJECT}.app */;
|
||||
\t\t\tproductType = "com.apple.product-type.application";
|
||||
\t\t}};
|
||||
\t\t{IDS["test"]} /* {PROJECT}Tests */ = {{
|
||||
\t\t\tisa = PBXNativeTarget;
|
||||
\t\t\tbuildConfigurationList = {IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */;
|
||||
\t\t\tbuildPhases = (
|
||||
\t\t\t\t{IDS["test_sources"]} /* Sources */,
|
||||
\t\t\t\t{IDS["test_fw"]} /* Frameworks */,
|
||||
\t\t\t);
|
||||
\t\t\tbuildRules = (
|
||||
\t\t\t);
|
||||
\t\t\tdependencies = (
|
||||
\t\t\t\t{IDS["dep"]} /* PBXTargetDependency */,
|
||||
\t\t\t);
|
||||
\t\t\tfileSystemSynchronizedGroups = (
|
||||
\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */,
|
||||
\t\t\t);
|
||||
\t\t\tname = {PROJECT}Tests;
|
||||
\t\t\tproductName = {PROJECT}Tests;
|
||||
\t\t\tproductReference = {IDS["test_product"]} /* {PROJECT}Tests.xctest */;
|
||||
\t\t\tproductType = "com.apple.product-type.bundle.unit-test";
|
||||
\t\t}};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
\t\t{IDS["project"]} /* Project object */ = {{
|
||||
\t\t\tisa = PBXProject;
|
||||
\t\t\tattributes = {{
|
||||
\t\t\t\tBuildIndependentTargetsInParallel = 1;
|
||||
\t\t\t\tLastSwiftUpdateCheck = 2600;
|
||||
\t\t\t\tLastUpgradeCheck = 2600;
|
||||
\t\t\t\tTargetAttributes = {{
|
||||
\t\t\t\t\t{IDS["app"]} = {{
|
||||
\t\t\t\t\t\tCreatedOnToolsVersion = 26.0;
|
||||
\t\t\t\t\t}};
|
||||
\t\t\t\t\t{IDS["test"]} = {{
|
||||
\t\t\t\t\t\tCreatedOnToolsVersion = 26.0;
|
||||
\t\t\t\t\t\tTestTargetID = {IDS["app"]};
|
||||
\t\t\t\t\t}};
|
||||
\t\t\t\t}};
|
||||
\t\t\t}};
|
||||
\t\t\tbuildConfigurationList = {IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */;
|
||||
\t\t\tcompatibilityVersion = "Xcode 16.0";
|
||||
\t\t\tdevelopmentRegion = en;
|
||||
\t\t\thasScannedForEncodings = 0;
|
||||
\t\t\tknownRegions = (
|
||||
\t\t\t\ten,
|
||||
\t\t\t\tBase,
|
||||
\t\t\t);
|
||||
\t\t\tmainGroup = {IDS["main"]};
|
||||
\t\t\tproductRefGroup = {IDS["products"]} /* Products */;
|
||||
\t\t\tprojectDirPath = "";
|
||||
\t\t\tprojectRoot = "";
|
||||
\t\t\ttargets = (
|
||||
\t\t\t\t{IDS["app"]} /* {PROJECT} */,
|
||||
\t\t\t\t{IDS["test"]} /* {PROJECT}Tests */,
|
||||
\t\t\t);
|
||||
\t\t}};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
\t\t{IDS["resources"]} /* Resources */ = {{
|
||||
\t\t\tisa = PBXResourcesBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
\t\t{IDS["sources"]} /* Sources */ = {{
|
||||
\t\t\tisa = PBXSourcesBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
\t\t{IDS["test_sources"]} /* Sources */ = {{
|
||||
\t\t\tisa = PBXSourcesBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
\t\t{IDS["dep"]} /* PBXTargetDependency */ = {{
|
||||
\t\t\tisa = PBXTargetDependency;
|
||||
\t\t\ttarget = {IDS["app"]} /* {PROJECT} */;
|
||||
\t\t\ttargetProxy = {IDS["proxy"]} /* PBXContainerItemProxy */;
|
||||
\t\t}};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
\t\t{IDS["dbg_proj"]} /* Debug */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;
|
||||
\t\t\t\tCLANG_ENABLE_MODULES = YES;
|
||||
\t\t\t\tCOPY_PHASE_STRIP = NO;
|
||||
\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;
|
||||
\t\t\t\tENABLE_TESTABILITY = YES;
|
||||
\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
\t\t\t\tONLY_ACTIVE_ARCH = YES;
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Debug;
|
||||
\t\t}};
|
||||
\t\t{IDS["rel_proj"]} /* Release */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;
|
||||
\t\t\t\tCLANG_ENABLE_MODULES = YES;
|
||||
\t\t\t\tCOPY_PHASE_STRIP = NO;
|
||||
\t\t\t\tDEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
\t\t\t\tENABLE_NS_ASSERTIONS = NO;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;
|
||||
\t\t\t\tVALIDATE_PRODUCT = YES;
|
||||
\t\t\t}};
|
||||
\t\t\tname = Release;
|
||||
\t\t}};
|
||||
\t\t{IDS["dbg_app"]} /* Debug */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
\t\t\t\tCODE_SIGN_STYLE = Automatic;
|
||||
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
||||
\t\t\t\tENABLE_PREVIEWS = YES;
|
||||
\t\t\t\tGENERATE_INFOPLIST_FILE = NO;
|
||||
\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (
|
||||
\t\t\t\t\t"$(inherited)",
|
||||
\t\t\t\t\t"@executable_path/Frameworks",
|
||||
\t\t\t\t);
|
||||
\t\t\t\tMARKETING_VERSION = 1.0;
|
||||
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE};
|
||||
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;
|
||||
\t\t\t\tSWIFT_VERSION = 5.0;
|
||||
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Debug;
|
||||
\t\t}};
|
||||
\t\t{IDS["rel_app"]} /* Release */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
\t\t\t\tCODE_SIGN_STYLE = Automatic;
|
||||
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
||||
\t\t\t\tENABLE_PREVIEWS = YES;
|
||||
\t\t\t\tGENERATE_INFOPLIST_FILE = NO;
|
||||
\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (
|
||||
\t\t\t\t\t"$(inherited)",
|
||||
\t\t\t\t\t"@executable_path/Frameworks",
|
||||
\t\t\t\t);
|
||||
\t\t\t\tMARKETING_VERSION = 1.0;
|
||||
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE};
|
||||
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;
|
||||
\t\t\t\tSWIFT_VERSION = 5.0;
|
||||
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Release;
|
||||
\t\t}};
|
||||
\t\t{IDS["dbg_test"]} /* Debug */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)";
|
||||
\t\t\t\tCODE_SIGN_STYLE = Automatic;
|
||||
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
||||
\t\t\t\tGENERATE_INFOPLIST_FILE = YES;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tMARKETING_VERSION = 1.0;
|
||||
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests;
|
||||
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;
|
||||
\t\t\t\tSWIFT_VERSION = 5.0;
|
||||
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
|
||||
\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Debug;
|
||||
\t\t}};
|
||||
\t\t{IDS["rel_test"]} /* Release */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)";
|
||||
\t\t\t\tCODE_SIGN_STYLE = Automatic;
|
||||
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
||||
\t\t\t\tGENERATE_INFOPLIST_FILE = YES;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tMARKETING_VERSION = 1.0;
|
||||
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests;
|
||||
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;
|
||||
\t\t\t\tSWIFT_VERSION = 5.0;
|
||||
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
|
||||
\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Release;
|
||||
\t\t}};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
\t\t{IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */ = {{
|
||||
\t\t\tisa = XCConfigurationList;
|
||||
\t\t\tbuildConfigurations = (
|
||||
\t\t\t\t{IDS["dbg_proj"]} /* Debug */,
|
||||
\t\t\t\t{IDS["rel_proj"]} /* Release */,
|
||||
\t\t\t);
|
||||
\t\t\tdefaultConfigurationIsVisible = 0;
|
||||
\t\t\tdefaultConfigurationName = Release;
|
||||
\t\t}};
|
||||
\t\t{IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */ = {{
|
||||
\t\t\tisa = XCConfigurationList;
|
||||
\t\t\tbuildConfigurations = (
|
||||
\t\t\t\t{IDS["dbg_app"]} /* Debug */,
|
||||
\t\t\t\t{IDS["rel_app"]} /* Release */,
|
||||
\t\t\t);
|
||||
\t\t\tdefaultConfigurationIsVisible = 0;
|
||||
\t\t\tdefaultConfigurationName = Release;
|
||||
\t\t}};
|
||||
\t\t{IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */ = {{
|
||||
\t\t\tisa = XCConfigurationList;
|
||||
\t\t\tbuildConfigurations = (
|
||||
\t\t\t\t{IDS["dbg_test"]} /* Debug */,
|
||||
\t\t\t\t{IDS["rel_test"]} /* Release */,
|
||||
\t\t\t);
|
||||
\t\t\tdefaultConfigurationIsVisible = 0;
|
||||
\t\t\tdefaultConfigurationName = Release;
|
||||
\t\t}};
|
||||
/* End XCConfigurationList section */
|
||||
\t}};
|
||||
\trootObject = {IDS["project"]} /* Project object */;
|
||||
}}
|
||||
'''
|
||||
|
||||
out = ROOT / f"{PROJECT}.xcodeproj" / "project.pbxproj"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(pbx)
|
||||
|
||||
scheme_dir = ROOT / f"{PROJECT}.xcodeproj" / "xcshareddata" / "xcschemes"
|
||||
scheme_dir.mkdir(parents=True, exist_ok=True)
|
||||
scheme = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="2600" version="1.7">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['app']}" BuildableName="{PROJECT}.app" BlueprintName="{PROJECT}" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['test']}" BuildableName="{PROJECT}Tests.xctest" BlueprintName="{PROJECT}Tests" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['app']}" BuildableName="{PROJECT}.app" BlueprintName="{PROJECT}" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
</Scheme>
|
||||
'''
|
||||
(scheme_dir / f"{PROJECT}.xcscheme").write_text(scheme)
|
||||
print(f"Wrote {out}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
@@ -0,0 +1,65 @@
|
||||
import XCTest
|
||||
@testable import igiRadio
|
||||
|
||||
final class HTTPDecoderTests: XCTestCase {
|
||||
func testDecodeHealth() throws {
|
||||
let json = """
|
||||
{"status":"ok","fw":"0.8.5","serialNumber":"CC4DB4MOCK01",
|
||||
"chips":{"si4684":true,"adau1701":true,"bt1035":true}}
|
||||
""".data(using: .utf8)!
|
||||
let health = try JSONDecoder.api.decode(HealthResponse.self, from: json)
|
||||
XCTAssertEqual(health.status, "ok")
|
||||
XCTAssertEqual(health.fw, "0.8.5")
|
||||
XCTAssertEqual(health.serialNumber, "CC4DB4MOCK01")
|
||||
XCTAssertTrue(health.chips.si4684)
|
||||
}
|
||||
|
||||
func testDecodeTunerFM() throws {
|
||||
let json = """
|
||||
{"booted":true,"band":"fm","locked":true,"volume":50,
|
||||
"fm":{"frequency_khz":102300,"rssi_dbuv":48,"snr_db":20,"stereo":true,
|
||||
"station_name":"Radio Test","radiotext":"Hello"},
|
||||
"dab":null}
|
||||
""".data(using: .utf8)!
|
||||
let tuner = try JSONDecoder.api.decode(TunerStatusResponse.self, from: json)
|
||||
XCTAssertEqual(tuner.band, "fm")
|
||||
XCTAssertEqual(tuner.fm?.frequencyKhz, 102300)
|
||||
XCTAssertEqual(tuner.fm?.stationName, "Radio Test")
|
||||
}
|
||||
|
||||
func testDecodeAudioProfile() throws {
|
||||
let json = """
|
||||
{"mixer":{"si4684_left_db":0,"si4684_right_db":0,"esp32_left_db":0,"esp32_right_db":0,
|
||||
"mix_left_db":0,"mix_right_db":0},
|
||||
"master":{"left_db":10,"right_db":10},
|
||||
"eq":[{"gain_db":0,"center_hz":40,"q":1.414}],
|
||||
"enhancements":{"stereo_level":5,"bass_level":7}}
|
||||
""".data(using: .utf8)!
|
||||
let profile = try JSONDecoder.api.decode(AudioProfileDTO.self, from: json)
|
||||
XCTAssertEqual(profile.master.leftDb, 10)
|
||||
XCTAssertEqual(profile.enhancements.bassLevel, 7)
|
||||
}
|
||||
|
||||
func testA2DPStateMapping() {
|
||||
XCTAssertEqual(A2DPState(apiValue: "streaming"), .streaming)
|
||||
XCTAssertEqual(A2DPState(apiValue: "unknown-value"), .unknown)
|
||||
}
|
||||
|
||||
func testBLENameToHost() {
|
||||
XCTAssertEqual(
|
||||
DigiRadioDiscoveryService.httpHost(fromBLEName: "DigiRadio-CC4DB4"),
|
||||
"http://digiradio-cc4db4.local"
|
||||
)
|
||||
XCTAssertNil(DigiRadioDiscoveryService.httpHost(fromBLEName: "OtherDevice"))
|
||||
}
|
||||
}
|
||||
|
||||
final class MockDigiRadioServiceTests: XCTestCase {
|
||||
func testMockConnectAndTune() async throws {
|
||||
let mock = MockDigiRadioService()
|
||||
try await mock.connect(host: "mock.local")
|
||||
XCTAssertTrue(mock.state.connection.isConnected)
|
||||
try await mock.tuneFM(frequencyKhz: 101_500)
|
||||
XCTAssertEqual(mock.state.tuner.fm?.frequencyKhz, 101_500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 71;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = D7ACBDA7D90DDE7BBA02A41B /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0938FB30D2E421C09D31973A;
|
||||
remoteInfo = igiRadio;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
366935DDA7048CE6717FCB93 /* igiRadio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = igiRadio.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = igiRadioTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Resources/Info.plist,
|
||||
);
|
||||
target = 0938FB30D2E421C09D31973A /* igiRadio */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
693436FA1780106DA05D06E3 /* Tests/igiRadioTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests/igiRadioTests; sourceTree = "<group>"; };
|
||||
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (7D085337BF0EA6EB04337F45 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = igiRadio; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
0A8032441559F0A3B8F103F8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
71F357EA3EEC95DAEFCF7345 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2761999F42D5E1910DD2B679 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
366935DDA7048CE6717FCB93 /* igiRadio.app */,
|
||||
97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C5D6659C8B3150288BA78279 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */,
|
||||
693436FA1780106DA05D06E3 /* Tests/igiRadioTests */,
|
||||
2761999F42D5E1910DD2B679 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
0938FB30D2E421C09D31973A /* igiRadio */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = ECA92BA517002122145704D1 /* Build configuration list for PBXNativeTarget "igiRadio" */;
|
||||
buildPhases = (
|
||||
DF1C5CA20CABFD7B8DA40E94 /* Sources */,
|
||||
71F357EA3EEC95DAEFCF7345 /* Frameworks */,
|
||||
3C8EE57839841131D4276B21 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */,
|
||||
);
|
||||
name = igiRadio;
|
||||
productName = igiRadio;
|
||||
productReference = 366935DDA7048CE6717FCB93 /* igiRadio.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
48A9EB97D44FF615ADCA031A /* igiRadioTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */;
|
||||
buildPhases = (
|
||||
F994E2BFA3D135FCD316A95C /* Sources */,
|
||||
0A8032441559F0A3B8F103F8 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
693436FA1780106DA05D06E3 /* Tests/igiRadioTests */,
|
||||
);
|
||||
name = igiRadioTests;
|
||||
productName = igiRadioTests;
|
||||
productReference = 97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
D7ACBDA7D90DDE7BBA02A41B /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
0938FB30D2E421C09D31973A = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
48A9EB97D44FF615ADCA031A = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
TestTargetID = 0938FB30D2E421C09D31973A;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = C8A2C2BD01CBC6B9DFC6F199 /* Build configuration list for PBXProject "igiRadio" */;
|
||||
compatibilityVersion = "Xcode 16.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = C5D6659C8B3150288BA78279;
|
||||
productRefGroup = 2761999F42D5E1910DD2B679 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
0938FB30D2E421C09D31973A /* igiRadio */,
|
||||
48A9EB97D44FF615ADCA031A /* igiRadioTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
3C8EE57839841131D4276B21 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
DF1C5CA20CABFD7B8DA40E94 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F994E2BFA3D135FCD316A95C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0938FB30D2E421C09D31973A /* igiRadio */;
|
||||
targetProxy = C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
54274B7C1572A50D5BE0DEA8 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
73C8BF7223EFFA7108969F77 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = igiRadio/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadio;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
90735310944459417946066C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadioTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/igiRadio.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/igiRadio";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E6AD029DFF2D9078F77BDCBA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadioTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/igiRadio.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/igiRadio";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
FA1BD134133059F9163AC34F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = igiRadio/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadio;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
FDBD275B398C4976FD6BC80D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
90735310944459417946066C /* Debug */,
|
||||
E6AD029DFF2D9078F77BDCBA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C8A2C2BD01CBC6B9DFC6F199 /* Build configuration list for PBXProject "igiRadio" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
FDBD275B398C4976FD6BC80D /* Debug */,
|
||||
54274B7C1572A50D5BE0DEA8 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
ECA92BA517002122145704D1 /* Build configuration list for PBXNativeTarget "igiRadio" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
73C8BF7223EFFA7108969F77 /* Debug */,
|
||||
FA1BD134133059F9163AC34F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = D7ACBDA7D90DDE7BBA02A41B /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="2600" version="1.7">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="0938FB30D2E421C09D31973A" BuildableName="igiRadio.app" BlueprintName="igiRadio" ReferencedContainer="container:igiRadio.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="48A9EB97D44FF615ADCA031A" BuildableName="igiRadioTests.xctest" BlueprintName="igiRadioTests" ReferencedContainer="container:igiRadio.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="0938FB30D2E421C09D31973A" BuildableName="igiRadio.app" BlueprintName="igiRadio" ReferencedContainer="container:igiRadio.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Dependency container for igiRadio.
|
||||
@Observable
|
||||
final class AppEnvironment {
|
||||
var useMockDevice: Bool
|
||||
private(set) var digiRadio: any DigiRadioService
|
||||
|
||||
var state: DigiRadioState {
|
||||
digiRadio.state
|
||||
}
|
||||
|
||||
init(useMockDevice: Bool = {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}()) {
|
||||
self.useMockDevice = useMockDevice
|
||||
self.digiRadio = useMockDevice ? MockDigiRadioService() : RealDigiRadioService()
|
||||
}
|
||||
|
||||
func setUseMock(_ enabled: Bool) {
|
||||
guard useMockDevice != enabled else { return }
|
||||
useMockDevice = enabled
|
||||
digiRadio = enabled ? MockDigiRadioService() : RealDigiRadioService()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct igiRadioApp: App {
|
||||
@State private var environment = AppEnvironment()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(environment)
|
||||
.task {
|
||||
#if DEBUG
|
||||
if environment.useMockDevice, !environment.state.connection.isConnected {
|
||||
try? await environment.digiRadio.connect(host: "mock.local")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Vertical fader (mixer channels)
|
||||
|
||||
struct IGIVerticalFader: View {
|
||||
var label: String
|
||||
var icon: String
|
||||
@Binding var valueDb: Double
|
||||
var range: ClosedRange<Double> = -40 ... 12
|
||||
var onCommit: () -> Void
|
||||
|
||||
private let trackHeight: CGFloat = 160
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: IGITheme.spacingS) {
|
||||
Text(formattedDb)
|
||||
.font(.caption.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(valueDb > 0 ? IGITheme.accent : .secondary)
|
||||
.contentTransition(.numericText())
|
||||
.animation(.snappy, value: valueDb)
|
||||
|
||||
GeometryReader { geo in
|
||||
let h = geo.size.height
|
||||
ZStack(alignment: .bottom) {
|
||||
Capsule()
|
||||
.fill(Color.primary.opacity(0.08))
|
||||
Capsule()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [IGITheme.accent.opacity(0.35), IGITheme.accent],
|
||||
startPoint: .bottom,
|
||||
endPoint: .top
|
||||
)
|
||||
)
|
||||
.frame(height: fillHeight(total: h))
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Circle()
|
||||
.fill(.background)
|
||||
.shadow(color: .black.opacity(0.18), radius: 4, y: 2)
|
||||
.overlay(Circle().stroke(IGITheme.accent.opacity(0.5), lineWidth: 2))
|
||||
.frame(width: 28, height: 28)
|
||||
.offset(y: thumbOffset(total: h))
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { drag in
|
||||
valueDb = db(from: drag.location.y, height: h)
|
||||
}
|
||||
.onEnded { _ in onCommit() }
|
||||
)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(label), \(formattedDb)")
|
||||
.accessibilityAdjustableAction { direction in
|
||||
let step = 0.5
|
||||
switch direction {
|
||||
case .increment: valueDb = min(range.upperBound, valueDb + step)
|
||||
case .decrement: valueDb = max(range.lowerBound, valueDb - step)
|
||||
@unknown default: break
|
||||
}
|
||||
onCommit()
|
||||
}
|
||||
}
|
||||
.frame(height: trackHeight)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.frame(width: 52)
|
||||
}
|
||||
.frame(width: 64)
|
||||
}
|
||||
|
||||
private var formattedDb: String {
|
||||
valueDb >= 0 ? String(format: "+%.1f", valueDb) : String(format: "%.1f", valueDb)
|
||||
}
|
||||
|
||||
private func normalized(_ db: Double) -> Double {
|
||||
(db - range.lowerBound) / (range.upperBound - range.lowerBound)
|
||||
}
|
||||
|
||||
private func fillHeight(total: CGFloat) -> CGFloat {
|
||||
max(4, total * normalized(valueDb))
|
||||
}
|
||||
|
||||
private func thumbOffset(total: CGFloat) -> CGFloat {
|
||||
let travel = total - 28
|
||||
return travel * (1 - normalized(valueDb))
|
||||
}
|
||||
|
||||
private func db(from y: CGFloat, height: CGFloat) -> Double {
|
||||
let clamped = max(0, min(height, y))
|
||||
let norm = 1 - (clamped / height)
|
||||
let raw = range.lowerBound + norm * (range.upperBound - range.lowerBound)
|
||||
return (raw * 2).rounded() / 2
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mixer channel group
|
||||
|
||||
struct IGIMixerChannelGroup: View {
|
||||
var title: String
|
||||
var subtitle: String
|
||||
var systemImage: String
|
||||
@Binding var leftDb: Double
|
||||
@Binding var rightDb: Double
|
||||
var onCommit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
HStack(spacing: IGITheme.spacingS) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(IGITheme.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 10))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.headline)
|
||||
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: IGITheme.spacingL) {
|
||||
Spacer()
|
||||
IGIVerticalFader(label: "Sinistra", icon: "l.circle", valueDb: $leftDb, onCommit: onCommit)
|
||||
IGIVerticalFader(label: "Destra", icon: "r.circle", valueDb: $rightDb, onCommit: onCommit)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.igiAudioCard()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Graphic equalizer
|
||||
|
||||
struct IGIGraphicEqualizer: View {
|
||||
@Binding var bands: [EQBandState]
|
||||
var onCommit: () -> Void
|
||||
|
||||
private let gainRange: ClosedRange<Double> = -12 ... 12
|
||||
private let barMaxHeight: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
// Curve preview
|
||||
GeometryReader { geo in
|
||||
let w = geo.size.width
|
||||
let h = geo.size.height
|
||||
let sorted = bands.sorted { $0.centerHz < $1.centerHz }
|
||||
if sorted.count >= 2 {
|
||||
Path { path in
|
||||
for (i, band) in sorted.enumerated() {
|
||||
let x = w * CGFloat(i) / CGFloat(max(1, sorted.count - 1))
|
||||
let y = h - normalizedGain(band.gainDb) * h
|
||||
if i == 0 { path.move(to: CGPoint(x: x, y: y)) }
|
||||
else { path.addLine(to: CGPoint(x: x, y: y)) }
|
||||
}
|
||||
}
|
||||
.stroke(
|
||||
LinearGradient(colors: [IGITheme.accent, .purple], startPoint: .leading, endPoint: .trailing),
|
||||
style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: 48)
|
||||
.padding(.horizontal, IGITheme.spacingS)
|
||||
|
||||
HStack(alignment: .bottom, spacing: IGITheme.spacingS) {
|
||||
ForEach($bands) { $band in
|
||||
EQBar(
|
||||
centerHz: band.centerHz,
|
||||
gainDb: $band.gainDb,
|
||||
maxHeight: barMaxHeight,
|
||||
range: gainRange,
|
||||
onCommit: onCommit
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
HStack {
|
||||
Text("-12 dB").font(.caption2).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("0").font(.caption2.weight(.medium))
|
||||
Spacer()
|
||||
Text("+12 dB").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.igiAudioCard()
|
||||
}
|
||||
|
||||
private func normalizedGain(_ db: Double) -> CGFloat {
|
||||
CGFloat((db - gainRange.lowerBound) / (gainRange.upperBound - gainRange.lowerBound))
|
||||
}
|
||||
}
|
||||
|
||||
private struct EQBar: View {
|
||||
var centerHz: Double
|
||||
@Binding var gainDb: Double
|
||||
var maxHeight: CGFloat
|
||||
var range: ClosedRange<Double>
|
||||
var onCommit: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Text(gainLabel)
|
||||
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(gainDb == 0 ? .secondary : IGITheme.accent)
|
||||
.frame(height: 14)
|
||||
|
||||
GeometryReader { geo in
|
||||
let h = geo.size.height
|
||||
let mid = h * 0.5
|
||||
ZStack(alignment: .bottom) {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.06))
|
||||
|
||||
if gainDb >= 0 {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(barGradient)
|
||||
.frame(height: max(4, normalized(gainDb) * mid))
|
||||
.offset(y: -mid)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(barGradient)
|
||||
.frame(height: max(4, normalized(abs(gainDb)) * mid))
|
||||
.offset(y: -(mid - max(4, normalized(abs(gainDb)) * mid)))
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
Rectangle()
|
||||
.fill(Color.primary.opacity(0.2))
|
||||
.frame(height: 1)
|
||||
.position(x: geo.size.width / 2, y: mid)
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { drag in
|
||||
gainDb = db(from: drag.location.y, height: h)
|
||||
}
|
||||
.onEnded { _ in onCommit() }
|
||||
)
|
||||
}
|
||||
.frame(height: maxHeight)
|
||||
|
||||
Text(freqLabel)
|
||||
.font(.system(size: 9, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Banda \(freqLabel)")
|
||||
.accessibilityValue(gainLabel)
|
||||
}
|
||||
|
||||
private var gainLabel: String {
|
||||
gainDb >= 0 ? String(format: "+%.1f", gainDb) : String(format: "%.1f", gainDb)
|
||||
}
|
||||
|
||||
private var freqLabel: String {
|
||||
if centerHz >= 1000 {
|
||||
return String(format: "%.0fk", centerHz / 1000)
|
||||
}
|
||||
return String(format: "%.0f", centerHz)
|
||||
}
|
||||
|
||||
private var barGradient: LinearGradient {
|
||||
if gainDb >= 0 {
|
||||
return LinearGradient(colors: [IGITheme.accent.opacity(0.5), IGITheme.accent], startPoint: .bottom, endPoint: .top)
|
||||
}
|
||||
return LinearGradient(colors: [.orange.opacity(0.4), .orange], startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
|
||||
private func normalized(_ db: Double) -> CGFloat {
|
||||
CGFloat(db / range.upperBound)
|
||||
}
|
||||
|
||||
private func db(from y: CGFloat, height: CGFloat) -> Double {
|
||||
let clamped = max(0, min(height, y))
|
||||
let norm = 1 - (clamped / height)
|
||||
let raw = range.lowerBound + norm * (range.upperBound - range.lowerBound)
|
||||
return (raw * 2).rounded() / 2
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Enhancement arc dial
|
||||
|
||||
struct IGIEnhancementDial: View {
|
||||
var title: String
|
||||
var systemImage: String
|
||||
@Binding var level: Double
|
||||
var onCommit: () -> Void
|
||||
|
||||
@State private var dragOrigin: Double?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: IGITheme.spacingS) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.08), lineWidth: 10)
|
||||
Circle()
|
||||
.trim(from: 0, to: level / 100)
|
||||
.stroke(
|
||||
AngularGradient(colors: [IGITheme.accent.opacity(0.4), IGITheme.accent], center: .center),
|
||||
style: StrokeStyle(lineWidth: 10, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
Text("\(Int(level))")
|
||||
.font(.title2.weight(.bold).monospacedDigit())
|
||||
}
|
||||
}
|
||||
.frame(width: 88, height: 88)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { drag in
|
||||
if dragOrigin == nil { dragOrigin = level }
|
||||
let base = dragOrigin ?? level
|
||||
level = max(0, min(100, base - drag.translation.height / 2))
|
||||
}
|
||||
.onEnded { _ in
|
||||
dragOrigin = nil
|
||||
onCommit()
|
||||
}
|
||||
)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue("\(Int(level)) su cento")
|
||||
|
||||
Text(title)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Master volume hero
|
||||
|
||||
struct IGIMasterVolumeHero: View {
|
||||
@Binding var leftDb: Double
|
||||
@Binding var rightDb: Double
|
||||
var onCommit: () -> Void
|
||||
|
||||
private var linkedDb: Binding<Double> {
|
||||
Binding(
|
||||
get: { (leftDb + rightDb) / 2 },
|
||||
set: { newValue in
|
||||
leftDb = newValue
|
||||
rightDb = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Volume master")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(Int(linkedDb.wrappedValue))")
|
||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(IGITheme.accent.gradient)
|
||||
.symbolEffect(.variableColor.iterative, options: .repeating, value: linkedDb.wrappedValue)
|
||||
}
|
||||
|
||||
Slider(value: linkedDb, in: 0 ... 100, step: 1) { editing in
|
||||
if !editing { onCommit() }
|
||||
}
|
||||
.tint(IGITheme.accent)
|
||||
|
||||
HStack(spacing: IGITheme.spacingL) {
|
||||
stereoBalance(label: "L", value: $leftDb)
|
||||
stereoBalance(label: "R", value: $rightDb)
|
||||
}
|
||||
}
|
||||
.igiAudioCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func stereoBalance(label: String, value: Binding<Double>) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Canale \(label)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Text(label).font(.caption.weight(.bold))
|
||||
Slider(value: value, in: 0 ... 100, step: 1) { editing in
|
||||
if !editing { onCommit() }
|
||||
}
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card style
|
||||
|
||||
extension View {
|
||||
func igiAudioCard() -> some View {
|
||||
igiPremiumCard()
|
||||
}
|
||||
}
|
||||
|
||||
enum AudioPanel: String, CaseIterable, Identifiable {
|
||||
case mixer = "Mixer"
|
||||
case enhance = "FX"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .mixer: "slider.vertical.3"
|
||||
case .enhance: "sparkles"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
|
||||
enum IGITheme {
|
||||
static let cornerRadius: CGFloat = 16
|
||||
static let cardRadius: CGFloat = 20
|
||||
static let spacingXS: CGFloat = 4
|
||||
static let spacingS: CGFloat = 8
|
||||
static let spacingM: CGFloat = 16
|
||||
static let spacingL: CGFloat = 24
|
||||
static let spacingXL: CGFloat = 32
|
||||
|
||||
static let accent = Color.accentColor
|
||||
static let cardBackground = Color(.secondarySystemGroupedBackground)
|
||||
static let screenBackground = Color(.systemGroupedBackground)
|
||||
}
|
||||
|
||||
struct IGICard<Content: View>: View {
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding(IGITheme.spacingM)
|
||||
.background(IGITheme.cardBackground, in: RoundedRectangle(cornerRadius: IGITheme.cardRadius, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIStatusDot: View {
|
||||
var isConnected: Bool
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
.accessibilityLabel(isConnected ? "Connesso" : "Non connesso")
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIPrimaryButton: View {
|
||||
var title: String
|
||||
var systemImage: String?
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label {
|
||||
Text(title)
|
||||
.fontWeight(.semibold)
|
||||
} icon: {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
struct IGISignalBar: View {
|
||||
var level: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.secondary.opacity(0.2))
|
||||
Capsule()
|
||||
.fill(Color.accentColor.gradient)
|
||||
.frame(width: proxy.size.width * max(0, min(1, level)))
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
.accessibilityLabel("Segnale")
|
||||
.accessibilityValue("\(Int(level * 100)) percento")
|
||||
}
|
||||
}
|
||||
|
||||
struct IGISectionHeader: View {
|
||||
var title: String
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIEmptyState: View {
|
||||
var title: String
|
||||
var message: String
|
||||
var systemImage: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(title).font(.title3.weight(.semibold))
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(IGITheme.spacingXL)
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIVolumeSlider: View {
|
||||
@Binding var value: Double
|
||||
var onEditingEnded: ((Double) -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
HStack {
|
||||
Image(systemName: "speaker.fill")
|
||||
Slider(value: $value, in: 0 ... 100, step: 1) { editing in
|
||||
if !editing { onEditingEnded?(value) }
|
||||
}
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
}
|
||||
Text("Volume \(Int(value))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIScanningIndicator: View {
|
||||
@State private var rotation: Double = 0
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.accentColor.opacity(0.2), lineWidth: 4)
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.28)
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||
.rotationEffect(.degrees(rotation))
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
.onAppear {
|
||||
guard !reduceMotion else { return }
|
||||
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
|
||||
rotation = 360
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Ricerca in corso")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Shared premium surface
|
||||
|
||||
extension View {
|
||||
func igiPremiumCard() -> some View {
|
||||
self
|
||||
.padding(IGITheme.spacingM)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: IGITheme.cardRadius, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.shadow(color: .black.opacity(0.06), radius: 12, y: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIHeroBackground: View {
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
IGITheme.screenBackground,
|
||||
IGITheme.accent.opacity(0.08),
|
||||
IGITheme.screenBackground
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport
|
||||
|
||||
struct IGITransportCluster: View {
|
||||
var onPrevious: () -> Void
|
||||
var onPlay: () -> Void
|
||||
var onNext: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: IGITheme.spacingL) {
|
||||
transportButton(icon: "backward.fill", size: 56, prominent: false, action: onPrevious)
|
||||
transportButton(icon: "play.fill", size: 76, prominent: true, action: onPlay)
|
||||
transportButton(icon: "forward.fill", size: 56, prominent: false, action: onNext)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func transportButton(icon: String, size: CGFloat, prominent: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(prominent ? .largeTitle : .title2)
|
||||
.foregroundStyle(prominent ? .white : .primary)
|
||||
.frame(width: size, height: size)
|
||||
.background {
|
||||
if prominent {
|
||||
Circle().fill(IGITheme.accent.gradient)
|
||||
.shadow(color: IGITheme.accent.opacity(0.35), radius: 12, y: 6)
|
||||
} else {
|
||||
Circle().fill(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Band badge
|
||||
|
||||
struct IGIBandBadge: View {
|
||||
var band: TunerBand
|
||||
var locked: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: band == .fm ? "dot.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right")
|
||||
Text(band.rawValue.uppercased())
|
||||
.fontWeight(.bold)
|
||||
if locked {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(IGITheme.accent.opacity(0.15), in: Capsule())
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preset chip
|
||||
|
||||
struct IGIPresetChip: View {
|
||||
var name: String
|
||||
var subtitle: String
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(minWidth: 110, alignment: .leading)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.06))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Metric bar
|
||||
|
||||
struct IGIMetricBar: View {
|
||||
var label: String
|
||||
var value: String
|
||||
var level: Double
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(label).font(.caption).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(value).font(.caption.monospacedDigit().weight(.medium))
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.primary.opacity(0.08))
|
||||
Capsule()
|
||||
.fill(IGITheme.accent.gradient)
|
||||
.frame(width: geo.size.width * max(0, min(1, level)))
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FM frequency hero
|
||||
|
||||
struct IGIFrequencyHero: View {
|
||||
@Binding var frequencyMHz: Double
|
||||
var stationName: String?
|
||||
var isTuning: Bool
|
||||
var onTune: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: IGITheme.spacingL) {
|
||||
if let stationName, !stationName.isEmpty {
|
||||
Text(stationName)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.06), lineWidth: 20)
|
||||
Circle()
|
||||
.trim(from: 0, to: normalizedFrequency)
|
||||
.stroke(
|
||||
AngularGradient(colors: [IGITheme.accent.opacity(0.3), IGITheme.accent], center: .center),
|
||||
style: StrokeStyle(lineWidth: 20, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.snappy, value: frequencyMHz)
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(String(format: "%.2f", frequencyMHz))
|
||||
.font(.system(size: 44, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
Text("MHz")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 220, height: 220)
|
||||
|
||||
Slider(value: $frequencyMHz, in: 64 ... 108, step: 0.05)
|
||||
.tint(IGITheme.accent)
|
||||
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
stepButton("−0.1") { frequencyMHz = max(64, frequencyMHz - 0.1) }
|
||||
Button(action: onTune) {
|
||||
Group {
|
||||
if isTuning {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Sintonizza")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isTuning)
|
||||
stepButton("+0.1") { frequencyMHz = min(108, frequencyMHz + 0.1) }
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
private var normalizedFrequency: Double {
|
||||
(frequencyMHz - 64) / (108 - 64)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func stepButton(_ title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(title, action: action)
|
||||
.font(.subheadline.weight(.semibold).monospacedDigit())
|
||||
.frame(width: 56, height: 48)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scan result row
|
||||
|
||||
struct IGIScanResultRow: View {
|
||||
var title: String
|
||||
var frequencyMHz: Double
|
||||
var rssi: Int?
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(IGITheme.accent.opacity(0.12))
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: "radio.fill")
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.headline).lineLimit(1)
|
||||
Text(String(format: "%.2f MHz", frequencyMHz))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if let rssi {
|
||||
Text("\(rssi)")
|
||||
.font(.caption.monospacedDigit().weight(.semibold))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.primary.opacity(0.06), in: Capsule())
|
||||
}
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
}
|
||||
.padding(IGITheme.spacingS)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class MockDigiRadioService: DigiRadioService {
|
||||
private(set) var state = DigiRadioState()
|
||||
|
||||
init() {
|
||||
seed()
|
||||
}
|
||||
|
||||
func connect(host: String) async throws {
|
||||
try await delay()
|
||||
state.connection.host = host
|
||||
state.connection.isConnected = true
|
||||
state.connection.lastError = nil
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
state.connection = ConnectionState()
|
||||
seed()
|
||||
}
|
||||
|
||||
func refreshHealth() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshTunerStatus() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshAudioProfile() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshBluetoothStatus() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshStations() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshStreaming() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func setStreaming(enabled: Bool, url: String) async throws {
|
||||
try await delay()
|
||||
state.streaming = StreamingState(enabled: enabled, url: url)
|
||||
}
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws {
|
||||
try await delay()
|
||||
state.tuner.band = .fm
|
||||
state.tuner.locked = true
|
||||
state.tuner.fm = FMTunerState(
|
||||
frequencyKhz: frequencyKhz,
|
||||
rssiDbuv: 48,
|
||||
snrDb: 22,
|
||||
stereo: true,
|
||||
stationName: "Radio Mock",
|
||||
radiotext: "igiRadio demo mode"
|
||||
)
|
||||
}
|
||||
|
||||
func tuneDAB(freqIndex: Int) async throws {
|
||||
try await delay()
|
||||
state.tuner.band = .dab
|
||||
state.tuner.locked = true
|
||||
state.tuner.dab = DABTunerState(
|
||||
freqIndex: freqIndex,
|
||||
ficQuality: 78,
|
||||
cnrDb: 24,
|
||||
playingServiceId: 101,
|
||||
playingComponentId: 1,
|
||||
dynamicLabel: "DAB Mock Ensemble"
|
||||
)
|
||||
}
|
||||
|
||||
func playDAB(serviceId: Int, componentId: Int) async throws {
|
||||
try await delay()
|
||||
state.tuner.dab?.playingServiceId = serviceId
|
||||
state.tuner.dab?.playingComponentId = componentId
|
||||
state.tuner.dab?.dynamicLabel = "Now playing mock service"
|
||||
}
|
||||
|
||||
func seekFM(direction: String) async throws {
|
||||
try await delay()
|
||||
guard var fm = state.tuner.fm else { return }
|
||||
let step = direction == "down" ? -100 : 100
|
||||
fm.frequencyKhz = max(64_000, min(108_000, fm.frequencyKhz + step))
|
||||
state.tuner.fm = fm
|
||||
}
|
||||
|
||||
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse {
|
||||
try await delay(1.2)
|
||||
return TunerScanResponse(
|
||||
status: "found",
|
||||
band: "fm",
|
||||
steps: 8,
|
||||
frequencyKhz: 101_500,
|
||||
stationName: name ?? "Mock FM",
|
||||
freqIndex: nil,
|
||||
serviceId: nil,
|
||||
componentId: nil
|
||||
)
|
||||
}
|
||||
|
||||
func scanFullFM() async throws -> [FMScanHit] {
|
||||
try await delay(1.5)
|
||||
return [
|
||||
FMScanHit(frequencyKhz: 97_500, rssiDbuv: 55, snrDb: 20, stationName: "Mock R1"),
|
||||
FMScanHit(frequencyKhz: 101_500, rssiDbuv: 48, snrDb: 18, stationName: "Mock R2"),
|
||||
FMScanHit(frequencyKhz: 102_300, rssiDbuv: 51, snrDb: 19, stationName: "Radio Italia")
|
||||
]
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Int) async throws {
|
||||
try await delay()
|
||||
let clamped = max(0, min(100, volume))
|
||||
state.tuner.volume = clamped
|
||||
state.audio.master.leftDb = Double(clamped)
|
||||
state.audio.master.rightDb = Double(clamped)
|
||||
}
|
||||
|
||||
func listDABServices() async throws -> [DABService] {
|
||||
try await delay()
|
||||
return [
|
||||
DABService(serviceId: 101, componentId: 1, label: "Mock Radio 1"),
|
||||
DABService(serviceId: 102, componentId: 1, label: "Mock Radio 2"),
|
||||
DABService(serviceId: 103, componentId: 1, label: "Mock Jazz")
|
||||
]
|
||||
}
|
||||
|
||||
func saveStation(_ station: Station) async throws {
|
||||
try await delay()
|
||||
state.stations.append(station)
|
||||
}
|
||||
|
||||
func removeStation(at index: Int) async throws {
|
||||
try await delay()
|
||||
guard state.stations.indices.contains(index) else { return }
|
||||
state.stations.remove(at: index)
|
||||
}
|
||||
|
||||
func reorderStation(from: Int, to: Int) async throws {
|
||||
try await delay()
|
||||
guard state.stations.indices.contains(from), state.stations.indices.contains(to) else { return }
|
||||
let item = state.stations.remove(at: from)
|
||||
state.stations.insert(item, at: to)
|
||||
}
|
||||
|
||||
func tuneStation(at index: Int) async throws {
|
||||
try await delay()
|
||||
guard state.stations.indices.contains(index) else { return }
|
||||
let station = state.stations[index]
|
||||
switch station.band {
|
||||
case .fm:
|
||||
if let freq = station.fmFrequencyKhz { try await tuneFM(frequencyKhz: freq) }
|
||||
case .dab:
|
||||
if let idx = station.dabFreqIndex { try await tuneDAB(freqIndex: idx) }
|
||||
if let sid = station.dabServiceId, let cid = station.dabComponentId {
|
||||
try await playDAB(serviceId: sid, componentId: cid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyAudioProfile(_ profile: AudioProfileDTO) async throws {
|
||||
try await delay()
|
||||
state.audio.mixer = profile.mixer
|
||||
state.audio.master = profile.master
|
||||
state.audio.eq = profile.eq
|
||||
state.audio.enhancements = profile.enhancements
|
||||
}
|
||||
|
||||
func setStereoEnhance(level: Int) async throws {
|
||||
try await delay()
|
||||
state.audio.enhancements.stereoLevel = level
|
||||
}
|
||||
|
||||
func setBassEnhance(level: Int) async throws {
|
||||
try await delay()
|
||||
state.audio.enhancements.bassLevel = level
|
||||
}
|
||||
|
||||
func resetAudio() async throws {
|
||||
try await delay()
|
||||
state.audio = AudioState()
|
||||
}
|
||||
|
||||
func scanBluetooth(seconds: Int) async throws {
|
||||
try await delay(0.8)
|
||||
state.bluetooth.nearbyDevices = [
|
||||
BluetoothDevice(index: 0, mac: "AA:BB:CC:DD:EE:FF", name: "Bose Solo II", rssiDbm: -52),
|
||||
BluetoothDevice(index: 1, mac: "11:22:33:44:55:66", name: "Living Room", rssiDbm: -68)
|
||||
]
|
||||
}
|
||||
|
||||
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
|
||||
try await delay()
|
||||
state.bluetooth.a2dpState = .streaming
|
||||
state.bluetooth.deviceName = name ?? "Speaker"
|
||||
if save {
|
||||
state.bluetooth.savedSpeaker = SavedSpeaker(mac: mac, name: name ?? "Speaker")
|
||||
}
|
||||
}
|
||||
|
||||
func reconnectBluetooth() async throws {
|
||||
try await delay()
|
||||
state.bluetooth.a2dpState = .streaming
|
||||
}
|
||||
|
||||
private func delay(_ seconds: Double = 0.35) async throws {
|
||||
try await Task.sleep(for: .seconds(seconds))
|
||||
}
|
||||
|
||||
private func seed() {
|
||||
state.health = HealthState(
|
||||
status: "ok",
|
||||
firmware: "0.8.5-mock",
|
||||
serialNumber: "CC4DB4MOCK01",
|
||||
chips: ChipHealth(si4684: true, adau1701: true, bt1035: true)
|
||||
)
|
||||
state.tuner = TunerState(
|
||||
booted: true,
|
||||
band: .fm,
|
||||
locked: true,
|
||||
volume: 42,
|
||||
fm: FMTunerState(
|
||||
frequencyKhz: 102_300,
|
||||
rssiDbuv: 51,
|
||||
snrDb: 19,
|
||||
stereo: true,
|
||||
stationName: "Radio Italia",
|
||||
radiotext: "Mock mode — nessun hardware collegato"
|
||||
),
|
||||
dab: nil
|
||||
)
|
||||
state.audio = AudioState(
|
||||
mixer: MixerState(),
|
||||
master: MasterVolumeState(leftDb: 42, rightDb: 42),
|
||||
eq: (0 ..< 6).map { i in
|
||||
EQBandState(index: i, gainDb: 0, centerHz: [40, 120, 400, 1000, 3000, 10000][i], q: 1.414)
|
||||
},
|
||||
enhancements: EnhancementsState(stereoLevel: 20, bassLevel: 15)
|
||||
)
|
||||
state.bluetooth = BluetoothState(
|
||||
booted: true,
|
||||
pairing: false,
|
||||
a2dpState: .streaming,
|
||||
deviceName: "Bose Solo II",
|
||||
autoReconnect: 5,
|
||||
savedSpeaker: SavedSpeaker(mac: "BC:87:FA:E6:9D:6E", name: "Bose Solo II")
|
||||
)
|
||||
state.stations = [
|
||||
Station(name: "Radio Italia", band: .fm, fmFrequencyKhz: 102_300),
|
||||
Station(name: "RDS", band: .fm, fmFrequencyKhz: 97_500)
|
||||
]
|
||||
state.streaming = StreamingState(enabled: false, url: "")
|
||||
state.connection.isConnected = true
|
||||
state.connection.host = "digiradio-mock.local"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
/// Audio profile: EQ + enhancements (stored on device via PUT /api/audio/profile).
|
||||
struct AudioProfileTemplate: Identifiable, Codable, Equatable, Hashable {
|
||||
var id: String
|
||||
var name: String
|
||||
var subtitle: String
|
||||
var systemImage: String
|
||||
var isBuiltIn: Bool
|
||||
var eq: [EQBandState]
|
||||
var enhancements: EnhancementsState
|
||||
|
||||
static let bandCenters: [Double] = [40, 120, 400, 1000, 3000, 10_000]
|
||||
|
||||
static func eqBands(gains: [Double]) -> [EQBandState] {
|
||||
zip(bandCenters, gains).enumerated().map { index, pair in
|
||||
EQBandState(index: index, gainDb: pair.1, centerHz: pair.0, q: 1.414)
|
||||
}
|
||||
}
|
||||
|
||||
func profileDTO(mixer: MixerState, master: MasterVolumeState) -> AudioProfileDTO {
|
||||
AudioProfileDTO(mixer: mixer, master: master, eq: eq, enhancements: enhancements)
|
||||
}
|
||||
|
||||
func duplicatedAsUser(named name: String) -> AudioProfileTemplate {
|
||||
AudioProfileTemplate(
|
||||
id: UUID().uuidString,
|
||||
name: name,
|
||||
subtitle: "Profilo personalizzato",
|
||||
systemImage: "slider.horizontal.3",
|
||||
isBuiltIn: false,
|
||||
eq: eq,
|
||||
enhancements: enhancements
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum AudioProfileLibrary {
|
||||
static let builtIn: [AudioProfileTemplate] = [
|
||||
template(id: "builtin.flat", name: "Piatto", subtitle: "Risposta neutra", icon: "minus", gains: [0, 0, 0, 0, 0, 0], stereo: 0, bass: 0),
|
||||
template(id: "builtin.vocal", name: "Voci", subtitle: "Parlato e podcast", icon: "person.wave.2", gains: [-2, -1, 3, 4, 2, 0], stereo: 15, bass: 5),
|
||||
template(id: "builtin.bass", name: "Bassi", subtitle: "Più corpo e profondità", icon: "waveform.path", gains: [6, 4, 1, 0, -1, -2], stereo: 10, bass: 35),
|
||||
template(id: "builtin.treble", name: "Alti", subtitle: "Dettaglio e aria", icon: "sparkles", gains: [-2, -1, 0, 2, 4, 5], stereo: 25, bass: 0),
|
||||
template(id: "builtin.rock", name: "Rock", subtitle: "Energia V-shape", icon: "guitars", gains: [5, 3, -1, 1, 4, 5], stereo: 30, bass: 25),
|
||||
template(id: "builtin.jazz", name: "Jazz", subtitle: "Caldo e naturale", icon: "music.quarternote.3", gains: [3, 2, 1, 2, 1, 0], stereo: 20, bass: 15),
|
||||
template(id: "builtin.classical", name: "Classica", subtitle: "Equilibrio dinamico", icon: "hifispeaker.2", gains: [2, 1, 0, 0, 2, 3], stereo: 15, bass: 5),
|
||||
template(id: "builtin.lounge", name: "Lounge", subtitle: "Ascolto rilassato", icon: "moon.stars", gains: [2, 1, 0, -1, -2, -3], stereo: 10, bass: 20),
|
||||
template(id: "builtin.fm", name: "Radio FM", subtitle: "Voce in evidenza", icon: "radio", gains: [-1, 0, 2, 3, 2, 1], stereo: 20, bass: 10)
|
||||
]
|
||||
|
||||
private static func template(
|
||||
id: String, name: String, subtitle: String, icon: String,
|
||||
gains: [Double], stereo: Int, bass: Int
|
||||
) -> AudioProfileTemplate {
|
||||
AudioProfileTemplate(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: subtitle,
|
||||
systemImage: icon,
|
||||
isBuiltIn: true,
|
||||
eq: AudioProfileTemplate.eqBands(gains: gains),
|
||||
enhancements: EnhancementsState(stereoLevel: stereo, bassLevel: bass)
|
||||
)
|
||||
}
|
||||
|
||||
static func matchActive(_ profile: AudioProfileTemplate, eq: [EQBandState], enhancements: EnhancementsState) -> Bool {
|
||||
guard eq.count == profile.eq.count else { return false }
|
||||
let sortedEq = eq.sorted { $0.centerHz < $1.centerHz }
|
||||
let sortedProfile = profile.eq.sorted { $0.centerHz < $1.centerHz }
|
||||
let eqMatch = zip(sortedEq, sortedProfile).allSatisfy { abs($0.gainDb - $1.gainDb) < 0.6 }
|
||||
return eqMatch
|
||||
&& profile.enhancements.stereoLevel == enhancements.stereoLevel
|
||||
&& profile.enhancements.bassLevel == enhancements.bassLevel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
|
||||
/// Central device snapshot — only documented API fields.
|
||||
struct DigiRadioState: Equatable, Sendable {
|
||||
var connection = ConnectionState()
|
||||
var health = HealthState()
|
||||
var tuner = TunerState()
|
||||
var audio = AudioState()
|
||||
var bluetooth = BluetoothState()
|
||||
var stations: [Station] = []
|
||||
var streaming = StreamingState()
|
||||
}
|
||||
|
||||
struct ConnectionState: Equatable, Sendable {
|
||||
var host: String = ""
|
||||
var isConnected = false
|
||||
var isConnecting = false
|
||||
var lastError: String?
|
||||
}
|
||||
|
||||
struct HealthState: Equatable, Sendable {
|
||||
var status: String = ""
|
||||
var firmware: String = ""
|
||||
var serialNumber: String = ""
|
||||
var chips = ChipHealth()
|
||||
}
|
||||
|
||||
struct ChipHealth: Equatable, Sendable {
|
||||
var si4684 = false
|
||||
var adau1701 = false
|
||||
var bt1035 = false
|
||||
}
|
||||
|
||||
struct TunerState: Equatable, Sendable {
|
||||
var booted = false
|
||||
var band: TunerBand = .fm
|
||||
var locked = false
|
||||
var volume: Int = 0
|
||||
var fm: FMTunerState?
|
||||
var dab: DABTunerState?
|
||||
}
|
||||
|
||||
enum TunerBand: String, Codable, Sendable, CaseIterable {
|
||||
case fm
|
||||
case dab
|
||||
}
|
||||
|
||||
struct FMTunerState: Equatable, Sendable, Codable {
|
||||
var frequencyKhz: Int
|
||||
var rssiDbuv: Int?
|
||||
var snrDb: Int?
|
||||
var stereo: Bool?
|
||||
var stationName: String?
|
||||
var radiotext: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case frequencyKhz = "frequency_khz"
|
||||
case rssiDbuv = "rssi_dbuv"
|
||||
case snrDb = "snr_db"
|
||||
case stereo
|
||||
case stationName = "station_name"
|
||||
case radiotext
|
||||
}
|
||||
}
|
||||
|
||||
struct DABTunerState: Equatable, Sendable, Codable {
|
||||
var freqIndex: Int
|
||||
var ficQuality: Int?
|
||||
var cnrDb: Int?
|
||||
var playingServiceId: Int?
|
||||
var playingComponentId: Int?
|
||||
var dynamicLabel: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case freqIndex = "freq_index"
|
||||
case ficQuality = "fic_quality"
|
||||
case cnrDb = "cnr_db"
|
||||
case playingServiceId = "playing_service_id"
|
||||
case playingComponentId = "playing_component_id"
|
||||
case dynamicLabel = "dynamic_label"
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioState: Equatable, Sendable {
|
||||
var mixer = MixerState()
|
||||
var master = MasterVolumeState()
|
||||
var eq: [EQBandState] = []
|
||||
var enhancements = EnhancementsState()
|
||||
}
|
||||
|
||||
struct MixerState: Equatable, Sendable, Codable {
|
||||
var si4684LeftDb: Double = 0
|
||||
var si4684RightDb: Double = 0
|
||||
var esp32LeftDb: Double = 0
|
||||
var esp32RightDb: Double = 0
|
||||
var mixLeftDb: Double = 0
|
||||
var mixRightDb: Double = 0
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case si4684LeftDb = "si4684_left_db"
|
||||
case si4684RightDb = "si4684_right_db"
|
||||
case esp32LeftDb = "esp32_left_db"
|
||||
case esp32RightDb = "esp32_right_db"
|
||||
case mixLeftDb = "mix_left_db"
|
||||
case mixRightDb = "mix_right_db"
|
||||
}
|
||||
}
|
||||
|
||||
struct MasterVolumeState: Equatable, Sendable, Codable {
|
||||
var leftDb: Double = 0
|
||||
var rightDb: Double = 0
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case leftDb = "left_db"
|
||||
case rightDb = "right_db"
|
||||
}
|
||||
}
|
||||
|
||||
struct EQBandState: Equatable, Hashable, Sendable, Codable, Identifiable {
|
||||
var id: Int { index }
|
||||
var index: Int
|
||||
var gainDb: Double
|
||||
var centerHz: Double
|
||||
var q: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case gainDb = "gain_db"
|
||||
case centerHz = "center_hz"
|
||||
case q
|
||||
}
|
||||
|
||||
init(index: Int, gainDb: Double, centerHz: Double, q: Double) {
|
||||
self.index = index
|
||||
self.gainDb = gainDb
|
||||
self.centerHz = centerHz
|
||||
self.q = q
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
index = 0
|
||||
gainDb = try container.decode(Double.self, forKey: .gainDb)
|
||||
centerHz = try container.decode(Double.self, forKey: .centerHz)
|
||||
q = try container.decode(Double.self, forKey: .q)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(gainDb, forKey: .gainDb)
|
||||
try container.encode(centerHz, forKey: .centerHz)
|
||||
try container.encode(q, forKey: .q)
|
||||
}
|
||||
}
|
||||
|
||||
struct EnhancementsState: Equatable, Hashable, Sendable, Codable {
|
||||
var stereoLevel: Int = 0
|
||||
var bassLevel: Int = 0
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case stereoLevel = "stereo_level"
|
||||
case bassLevel = "bass_level"
|
||||
}
|
||||
}
|
||||
|
||||
struct BluetoothState: Equatable, Sendable {
|
||||
var booted = false
|
||||
var pairing = false
|
||||
var a2dpState: A2DPState = .unknown
|
||||
var deviceName: String = ""
|
||||
var autoReconnect: Int = 0
|
||||
var savedSpeaker: SavedSpeaker?
|
||||
var nearbyDevices: [BluetoothDevice] = []
|
||||
var pairedDevices: [BluetoothPairedDevice] = []
|
||||
}
|
||||
|
||||
enum A2DPState: String, Sendable {
|
||||
case standby, connecting, connected, streaming, paused, unknown
|
||||
|
||||
init(apiValue: String) {
|
||||
switch apiValue.lowercased() {
|
||||
case "standby": self = .standby
|
||||
case "connecting": self = .connecting
|
||||
case "connected": self = .connected
|
||||
case "streaming": self = .streaming
|
||||
case "paused": self = .paused
|
||||
default: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedSpeaker: Equatable, Sendable {
|
||||
var mac: String
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct BluetoothDevice: Equatable, Sendable, Identifiable {
|
||||
var id: String { mac }
|
||||
var index: Int
|
||||
var mac: String
|
||||
var name: String
|
||||
var rssiDbm: Int
|
||||
}
|
||||
|
||||
struct BluetoothPairedDevice: Equatable, Sendable, Identifiable {
|
||||
var id: String { mac }
|
||||
var index: Int
|
||||
var mac: String
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct Station: Equatable, Sendable, Codable, Identifiable {
|
||||
var id: String { "\(band.rawValue)-\(name)-\(fmFrequencyKhz ?? dabFreqIndex ?? 0)" }
|
||||
var name: String
|
||||
var band: TunerBand
|
||||
var dabFreqIndex: Int?
|
||||
var dabServiceId: Int?
|
||||
var dabComponentId: Int?
|
||||
var fmFrequencyKhz: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, band
|
||||
case dabFreqIndex = "dab_freq_index"
|
||||
case dabServiceId = "dab_service_id"
|
||||
case dabComponentId = "dab_component_id"
|
||||
case fmFrequencyKhz = "fm_frequency_khz"
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingState: Equatable, Sendable, Codable {
|
||||
var enabled = false
|
||||
var url: String = ""
|
||||
}
|
||||
|
||||
struct DABService: Equatable, Sendable, Identifiable, Codable {
|
||||
var id: String { "\(serviceId)-\(componentId)" }
|
||||
var serviceId: Int
|
||||
var componentId: Int
|
||||
var label: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case serviceId = "service_id"
|
||||
case componentId = "component_id"
|
||||
case label
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.984",
|
||||
"green" : "0.447",
|
||||
"red" : "0.227"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "0.584",
|
||||
"red" : "0.376"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>igiRadio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>igiRadio usa Bluetooth per trovare DigiRadio in setup mode e configurare il Wi‑Fi.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>igiRadio si connette a DigiRadio sulla rete locale per controllare radio e audio.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
/// Persists user-created audio profiles on the iPhone (firmware stores one active profile only).
|
||||
enum AudioProfileStore {
|
||||
private static let key = "igiRadio.userAudioProfiles"
|
||||
|
||||
static func loadUserProfiles() -> [AudioProfileTemplate] {
|
||||
guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
|
||||
return (try? JSONDecoder().decode([AudioProfileTemplate].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
static func saveUserProfiles(_ profiles: [AudioProfileTemplate]) {
|
||||
guard let data = try? JSONEncoder().encode(profiles) else { return }
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
|
||||
static func allProfiles() -> [AudioProfileTemplate] {
|
||||
AudioProfileLibrary.builtIn + loadUserProfiles()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
/// Decodes local audio files to interleaved PCM s16le stereo @ 48 kHz for PUT /api/stream/phone.
|
||||
enum LocalAudioDecoder {
|
||||
static let outputSampleRate: Double = 48_000
|
||||
static let chunkFrames: AVAudioFrameCount = 2048
|
||||
|
||||
static func pcmChunks(from fileURL: URL) -> AsyncThrowingStream<Data, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
do {
|
||||
let accessed = fileURL.startAccessingSecurityScopedResource()
|
||||
defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } }
|
||||
|
||||
let inputFile = try AVAudioFile(forReading: fileURL)
|
||||
guard let outputFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatInt16,
|
||||
sampleRate: outputSampleRate,
|
||||
channels: 2,
|
||||
interleaved: true
|
||||
) else {
|
||||
throw PhoneStreamError.formatUnsupported
|
||||
}
|
||||
|
||||
guard let converter = AVAudioConverter(from: inputFile.processingFormat, to: outputFormat) else {
|
||||
throw PhoneStreamError.formatUnsupported
|
||||
}
|
||||
|
||||
let inputBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: inputFile.processingFormat,
|
||||
frameCapacity: chunkFrames
|
||||
)!
|
||||
let outputBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: outputFormat,
|
||||
frameCapacity: chunkFrames
|
||||
)!
|
||||
|
||||
while inputFile.framePosition < inputFile.length {
|
||||
try Task.checkCancellation()
|
||||
try inputFile.read(into: inputBuffer, frameCount: chunkFrames)
|
||||
if inputBuffer.frameLength == 0 { break }
|
||||
|
||||
var inputProvided = false
|
||||
var convertError: NSError?
|
||||
let status = converter.convert(to: outputBuffer, error: &convertError) { _, outStatus in
|
||||
if inputProvided {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
inputProvided = true
|
||||
outStatus.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
|
||||
if status == .error {
|
||||
throw convertError ?? PhoneStreamError.decodeFailed
|
||||
}
|
||||
if outputBuffer.frameLength == 0 { continue }
|
||||
|
||||
let byteCount = Int(outputBuffer.frameLength) * Int(outputFormat.streamDescription.pointee.mBytesPerFrame)
|
||||
guard let channelData = outputBuffer.int16ChannelData else { continue }
|
||||
continuation.yield(Data(bytes: channelData[0], count: byteCount))
|
||||
}
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PhoneStreamError: LocalizedError {
|
||||
case notConnected
|
||||
case formatUnsupported
|
||||
case decodeFailed
|
||||
case deviceRejected(Int, String?)
|
||||
case transport(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "DigiRadio non connesso."
|
||||
case .formatUnsupported: return "Formato audio non supportato."
|
||||
case .decodeFailed: return "Impossibile decodificare il file."
|
||||
case let .deviceRejected(code, body): return "Device HTTP \(code): \(body ?? "")"
|
||||
case let .transport(error): return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
/// Streams PCM to DigiRadio via documented PUT /api/stream/phone (chunked HTTP/1.1).
|
||||
actor PhonePCMStreamService {
|
||||
static let shared = PhonePCMStreamService()
|
||||
|
||||
private(set) var isStreaming = false
|
||||
private(set) var statusMessage = ""
|
||||
private var streamTask: Task<Void, Never>?
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "PhoneStream")
|
||||
|
||||
func start(fileURL: URL, connectionHost: String) {
|
||||
stop()
|
||||
let host = Self.parseHost(connectionHost)
|
||||
guard !host.isEmpty else {
|
||||
statusMessage = "Host non valido"
|
||||
return
|
||||
}
|
||||
|
||||
isStreaming = true
|
||||
statusMessage = "Avvio stream…"
|
||||
streamTask = Task {
|
||||
do {
|
||||
try await stream(fileURL: fileURL, host: host)
|
||||
statusMessage = "Stream completato"
|
||||
isStreaming = false
|
||||
} catch is CancellationError {
|
||||
statusMessage = "Stream interrotto"
|
||||
isStreaming = false
|
||||
} catch {
|
||||
statusMessage = error.localizedDescription
|
||||
isStreaming = false
|
||||
logger.error("Phone stream failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
streamTask?.cancel()
|
||||
streamTask = nil
|
||||
isStreaming = false
|
||||
}
|
||||
|
||||
private func stream(fileURL: URL, host: String) async throws {
|
||||
let chunks = LocalAudioDecoder.pcmChunks(from: fileURL)
|
||||
try await sendChunkedPUT(host: host, chunks: chunks)
|
||||
}
|
||||
|
||||
private func sendChunkedPUT(host: String, chunks: AsyncThrowingStream<Data, Error>) async throws {
|
||||
let connection = NWConnection(host: NWEndpoint.Host(host), port: 80, using: .tcp)
|
||||
connection.start(queue: .global(qos: .userInitiated))
|
||||
try await waitUntilReady(connection)
|
||||
|
||||
let header = """
|
||||
PUT /api/stream/phone HTTP/1.1\r
|
||||
Host: \(host)\r
|
||||
Transfer-Encoding: chunked\r
|
||||
Connection: close\r
|
||||
Content-Type: application/octet-stream\r
|
||||
\r
|
||||
"""
|
||||
try await send(connection, Data(header.utf8))
|
||||
|
||||
for try await chunk in chunks {
|
||||
try Task.checkCancellation()
|
||||
try await sendChunk(connection, chunk)
|
||||
statusMessage = "Streaming…"
|
||||
}
|
||||
|
||||
try await send(connection, Data("0\r\n\r\n".utf8))
|
||||
_ = try await readResponse(connection)
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
private func waitUntilReady(_ connection: NWConnection) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
var finished = false
|
||||
connection.stateUpdateHandler = { state in
|
||||
guard !finished else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
finished = true
|
||||
continuation.resume()
|
||||
case let .failed(error):
|
||||
finished = true
|
||||
continuation.resume(throwing: PhoneStreamError.transport(error))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ connection: NWConnection, _ data: Data) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: PhoneStreamError.transport(error))
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func sendChunk(_ connection: NWConnection, _ chunk: Data) async throws {
|
||||
var payload = Data()
|
||||
payload.append(contentsOf: "\(String(chunk.count, radix: 16, uppercase: false))\r\n".utf8)
|
||||
payload.append(chunk)
|
||||
payload.append(contentsOf: "\r\n".utf8)
|
||||
try await send(connection, payload)
|
||||
}
|
||||
|
||||
private func readResponse(_ connection: NWConnection) async throws -> Int {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, _, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: PhoneStreamError.transport(error))
|
||||
return
|
||||
}
|
||||
guard let data, let text = String(data: data, encoding: .utf8) else {
|
||||
continuation.resume(returning: 200)
|
||||
return
|
||||
}
|
||||
let statusLine = text.split(separator: "\r\n").first.map(String.init) ?? ""
|
||||
if statusLine.contains("409") {
|
||||
continuation.resume(throwing: PhoneStreamError.deviceRejected(409, "Stream occupato (web radio attiva?)"))
|
||||
} else if statusLine.contains("503") {
|
||||
continuation.resume(throwing: PhoneStreamError.deviceRejected(503, "Sink I2S non disponibile"))
|
||||
} else if statusLine.contains("200") {
|
||||
continuation.resume(returning: 200)
|
||||
} else if let code = Int(statusLine.split(separator: " ").dropFirst().first ?? "") {
|
||||
continuation.resume(throwing: PhoneStreamError.deviceRejected(code, statusLine))
|
||||
} else {
|
||||
continuation.resume(returning: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseHost(_ raw: String) -> String {
|
||||
var h = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
h = h.replacingOccurrences(of: "http://", with: "")
|
||||
h = h.replacingOccurrences(of: "https://", with: "")
|
||||
if let slash = h.firstIndex(of: "/") {
|
||||
h = String(h[..<slash])
|
||||
}
|
||||
return h
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Discovers DigiRadio BLE provisioning endpoints (ESP-IDF wifi_provisioning / scheme_ble).
|
||||
/// Full credential exchange uses Espressif protocomm Security1 (PoP = device serial).
|
||||
/// This service performs discovery only; credential provisioning UI is documented in BLE_PROTOCOL.md.
|
||||
@Observable
|
||||
final class BLEProvisioningService: NSObject {
|
||||
enum Phase: Equatable {
|
||||
case idle
|
||||
case scanning
|
||||
case found(name: String, identifier: UUID)
|
||||
case unsupported
|
||||
case error(String)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .idle
|
||||
private(set) var discoveredDevices: [(name: String, id: UUID, rssi: Int)] = []
|
||||
|
||||
private var central: CBCentralManager?
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "BLE")
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
central = CBCentralManager(delegate: self, queue: .main)
|
||||
}
|
||||
|
||||
func startScan() {
|
||||
guard let central else { return }
|
||||
discoveredDevices = []
|
||||
phase = .scanning
|
||||
guard central.state == .poweredOn else {
|
||||
phase = .error("Bluetooth non disponibile")
|
||||
return
|
||||
}
|
||||
central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
||||
logger.info("BLE scan started")
|
||||
}
|
||||
|
||||
func stopScan() {
|
||||
central?.stopScan()
|
||||
if case .scanning = phase {
|
||||
phase = discoveredDevices.isEmpty ? .idle : .found(name: discoveredDevices[0].name, identifier: discoveredDevices[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BLEProvisioningService: CBCentralManagerDelegate {
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
if central.state != .poweredOn, case .scanning = phase {
|
||||
phase = .error("Bluetooth spento o non autorizzato")
|
||||
central.stopScan()
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(
|
||||
_ central: CBCentralManager,
|
||||
didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber
|
||||
) {
|
||||
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
|
||||
guard name.hasPrefix("DigiRadio") else { return }
|
||||
let entry = (name: name, id: peripheral.identifier, rssi: RSSI.intValue)
|
||||
if !discoveredDevices.contains(where: { $0.id == entry.id }) {
|
||||
discoveredDevices.append(entry)
|
||||
logger.debug("Found \(name, privacy: .public) RSSI \(RSSI.intValue)")
|
||||
}
|
||||
phase = .found(name: name, identifier: peripheral.identifier)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Maps BLE setup names (DigiRadio-XXXX) to HTTP hosts and verifies reachability.
|
||||
@Observable
|
||||
final class DigiRadioDiscoveryService {
|
||||
struct DiscoveredDevice: Identifiable, Equatable {
|
||||
var id: String { host }
|
||||
var name: String
|
||||
var host: String
|
||||
var isReachable: Bool
|
||||
var firmware: String?
|
||||
}
|
||||
|
||||
private(set) var devices: [DiscoveredDevice] = []
|
||||
private(set) var isSearching = false
|
||||
|
||||
private let ble = BLEProvisioningService()
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "Discovery")
|
||||
private var probeTasks: [Task<Void, Never>] = []
|
||||
|
||||
func start() {
|
||||
guard !isSearching else { return }
|
||||
isSearching = true
|
||||
devices = []
|
||||
ble.startScan()
|
||||
logger.info("Discovery started")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isSearching = false
|
||||
ble.stopScan()
|
||||
probeTasks.forEach { $0.cancel() }
|
||||
probeTasks = []
|
||||
}
|
||||
|
||||
func refreshFromBLE() {
|
||||
let candidates = ble.discoveredDevices.compactMap { entry -> DiscoveredDevice? in
|
||||
guard let host = Self.httpHost(fromBLEName: entry.name) else { return nil }
|
||||
return DiscoveredDevice(name: entry.name, host: host, isReachable: false, firmware: nil)
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
guard !devices.contains(where: { $0.host == candidate.host }) else { continue }
|
||||
devices.append(candidate)
|
||||
probe(host: candidate.host)
|
||||
}
|
||||
}
|
||||
|
||||
func addManualHost(_ raw: String) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
var host = trimmed
|
||||
if !host.hasPrefix("http://") && !host.hasPrefix("https://") {
|
||||
host = "http://\(host)"
|
||||
}
|
||||
guard let url = URL(string: host), let schemeHost = url.host else { return }
|
||||
let normalized = "http://\(schemeHost)"
|
||||
guard !devices.contains(where: { $0.host == normalized }) else { return }
|
||||
let name = schemeHost
|
||||
devices.append(DiscoveredDevice(name: name, host: normalized, isReachable: false, firmware: nil))
|
||||
probe(host: normalized)
|
||||
}
|
||||
|
||||
static func httpHost(fromBLEName name: String) -> String? {
|
||||
// SoftAP / BLE name: DigiRadio-CC4DB4 → digiradio-cc4db4.local
|
||||
guard name.hasPrefix("DigiRadio-") else { return nil }
|
||||
let suffix = String(name.dropFirst("DigiRadio-".count)).lowercased()
|
||||
guard !suffix.isEmpty else { return nil }
|
||||
return "http://digiradio-\(suffix).local"
|
||||
}
|
||||
|
||||
private func probe(host: String) {
|
||||
let task = Task {
|
||||
let client = HTTPDigiRadioClient()
|
||||
do {
|
||||
try client.setHost(host)
|
||||
let health = try await client.fetchHealth()
|
||||
await MainActor.run {
|
||||
if let index = devices.firstIndex(where: { $0.host == host }) {
|
||||
devices[index].isReachable = health.status == "ok"
|
||||
devices[index].firmware = health.fw
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
if let index = devices.firstIndex(where: { $0.host == host }) {
|
||||
devices[index].isReachable = false
|
||||
}
|
||||
}
|
||||
logger.debug("Probe failed for \(host, privacy: .public): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
probeTasks.append(task)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
enum DigiRadioError: LocalizedError, Equatable {
|
||||
case notConnected
|
||||
case invalidHost
|
||||
case httpStatus(Int, String?)
|
||||
case decodingFailed
|
||||
case network(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "Non connesso a DigiRadio."
|
||||
case .invalidHost: return "Host non valido."
|
||||
case let .httpStatus(code, reason): return "HTTP \(code): \(reason ?? "errore")"
|
||||
case .decodingFailed: return "Risposta non valida dal dispositivo."
|
||||
case let .network(error): return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: DigiRadioError, rhs: DigiRadioError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.notConnected, .notConnected), (.invalidHost, .invalidHost), (.decodingFailed, .decodingFailed):
|
||||
return true
|
||||
case let (.httpStatus(a, b), .httpStatus(c, d)):
|
||||
return a == c && b == d
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level API for controlling DigiRadio (HTTP REST per firmware docs).
|
||||
protocol DigiRadioService: AnyObject {
|
||||
var state: DigiRadioState { get }
|
||||
|
||||
func connect(host: String) async throws
|
||||
func disconnect()
|
||||
func refreshHealth() async throws
|
||||
func refreshTunerStatus() async throws
|
||||
func refreshAudioProfile() async throws
|
||||
func refreshBluetoothStatus() async throws
|
||||
func refreshStations() async throws
|
||||
func refreshStreaming() async throws
|
||||
func setStreaming(enabled: Bool, url: String) async throws
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws
|
||||
func tuneDAB(freqIndex: Int) async throws
|
||||
func playDAB(serviceId: Int, componentId: Int) async throws
|
||||
func seekFM(direction: String) async throws
|
||||
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse
|
||||
func scanFullFM() async throws -> [FMScanHit]
|
||||
func setVolume(_ volume: Int) async throws
|
||||
func listDABServices() async throws -> [DABService]
|
||||
|
||||
func saveStation(_ station: Station) async throws
|
||||
func removeStation(at index: Int) async throws
|
||||
func reorderStation(from: Int, to: Int) async throws
|
||||
func tuneStation(at index: Int) async throws
|
||||
|
||||
func applyAudioProfile(_ profile: AudioProfileDTO) async throws
|
||||
func setStereoEnhance(level: Int) async throws
|
||||
func setBassEnhance(level: Int) async throws
|
||||
func resetAudio() async throws
|
||||
|
||||
func scanBluetooth(seconds: Int) async throws
|
||||
func connectBluetooth(mac: String, name: String?, save: Bool) async throws
|
||||
func reconnectBluetooth() async throws
|
||||
}
|
||||
|
||||
/// Wire DTO for PUT /api/audio/profile
|
||||
struct AudioProfileDTO: Codable, Equatable, Sendable {
|
||||
var mixer: MixerState
|
||||
var master: MasterVolumeState
|
||||
var eq: [EQBandState]
|
||||
var enhancements: EnhancementsState
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@Observable
|
||||
final class RealDigiRadioService: DigiRadioService {
|
||||
private(set) var state = DigiRadioState()
|
||||
private let client = HTTPDigiRadioClient()
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "Device")
|
||||
|
||||
func connect(host: String) async throws {
|
||||
state.connection.isConnecting = true
|
||||
state.connection.lastError = nil
|
||||
defer { state.connection.isConnecting = false }
|
||||
|
||||
try client.setHost(host)
|
||||
state.connection.host = host
|
||||
try await refreshHealth()
|
||||
state.connection.isConnected = true
|
||||
logger.info("Connected to \(host, privacy: .public)")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
client.clearHost()
|
||||
state.connection = ConnectionState()
|
||||
}
|
||||
|
||||
func refreshHealth() async throws {
|
||||
let health = try await client.fetchHealth()
|
||||
state.health.status = health.status
|
||||
state.health.firmware = health.fw
|
||||
state.health.serialNumber = health.serialNumber
|
||||
state.health.chips = ChipHealth(
|
||||
si4684: health.chips.si4684,
|
||||
adau1701: health.chips.adau1701,
|
||||
bt1035: health.chips.bt1035
|
||||
)
|
||||
}
|
||||
|
||||
func refreshTunerStatus() async throws {
|
||||
let tuner = try await client.fetchTunerStatus()
|
||||
applyTuner(tuner)
|
||||
}
|
||||
|
||||
func refreshAudioProfile() async throws {
|
||||
let profile = try await client.fetchAudioProfile()
|
||||
state.audio.mixer = profile.mixer
|
||||
state.audio.master = profile.master
|
||||
state.audio.eq = profile.eq.enumerated().map { index, band in
|
||||
EQBandState(index: index, gainDb: band.gainDb, centerHz: band.centerHz, q: band.q)
|
||||
}
|
||||
state.audio.enhancements = profile.enhancements
|
||||
}
|
||||
|
||||
func refreshBluetoothStatus() async throws {
|
||||
let bt = try await client.fetchBluetoothStatus()
|
||||
state.bluetooth.booted = bt.booted
|
||||
state.bluetooth.pairing = bt.pairing
|
||||
state.bluetooth.a2dpState = A2DPState(apiValue: bt.a2dp)
|
||||
state.bluetooth.deviceName = bt.deviceName
|
||||
state.bluetooth.autoReconnect = bt.autoReconnect
|
||||
|
||||
let speaker = try await client.fetchSavedSpeaker()
|
||||
if speaker.configured, let mac = speaker.mac, let name = speaker.name {
|
||||
state.bluetooth.savedSpeaker = SavedSpeaker(mac: mac, name: name)
|
||||
} else {
|
||||
state.bluetooth.savedSpeaker = nil
|
||||
}
|
||||
}
|
||||
|
||||
func refreshStations() async throws {
|
||||
let list = try await client.fetchStations()
|
||||
state.stations = list.stations
|
||||
}
|
||||
|
||||
func refreshStreaming() async throws {
|
||||
state.streaming = try await client.fetchStreaming()
|
||||
}
|
||||
|
||||
func setStreaming(enabled: Bool, url: String) async throws {
|
||||
let config = StreamingState(enabled: enabled, url: url)
|
||||
state.streaming = try await client.setStreaming(config)
|
||||
}
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws {
|
||||
let tuner = try await client.tune(body: TuneRequest(band: "fm", freqIndex: nil, frequencyKhz: frequencyKhz))
|
||||
applyTuner(tuner)
|
||||
}
|
||||
|
||||
func tuneDAB(freqIndex: Int) async throws {
|
||||
let tuner = try await client.tune(body: TuneRequest(band: "dab", freqIndex: freqIndex, frequencyKhz: nil))
|
||||
applyTuner(tuner)
|
||||
}
|
||||
|
||||
func playDAB(serviceId: Int, componentId: Int) async throws {
|
||||
try await client.playDAB(body: PlayRequest(serviceId: serviceId, componentId: componentId))
|
||||
try await refreshTunerStatus()
|
||||
}
|
||||
|
||||
func seekFM(direction: String) async throws {
|
||||
_ = try await client.seekFM(direction: direction)
|
||||
try await refreshTunerStatus()
|
||||
}
|
||||
|
||||
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse {
|
||||
try await client.scanStation(body: TunerScanRequest(band: "fm", maxSteps: maxSteps, name: name))
|
||||
}
|
||||
|
||||
func scanFullFM() async throws -> [FMScanHit] {
|
||||
let response = try await client.scanFullFM()
|
||||
return response.stations
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Int) async throws {
|
||||
var profile = try await client.fetchAudioProfile()
|
||||
let clamped = max(0, min(100, volume))
|
||||
profile.master.leftDb = Double(clamped)
|
||||
profile.master.rightDb = Double(clamped)
|
||||
try await client.putAudioProfile(profile)
|
||||
state.tuner.volume = clamped
|
||||
state.audio.master = profile.master
|
||||
}
|
||||
|
||||
func listDABServices() async throws -> [DABService] {
|
||||
let response = try await client.fetchDABServices()
|
||||
return response.services
|
||||
}
|
||||
|
||||
func saveStation(_ station: Station) async throws {
|
||||
try await client.addStation(station)
|
||||
try await refreshStations()
|
||||
}
|
||||
|
||||
func removeStation(at index: Int) async throws {
|
||||
try await client.removeStation(index: index)
|
||||
try await refreshStations()
|
||||
}
|
||||
|
||||
func reorderStation(from: Int, to: Int) async throws {
|
||||
try await client.reorderStation(from: from, to: to)
|
||||
try await refreshStations()
|
||||
}
|
||||
|
||||
func tuneStation(at index: Int) async throws {
|
||||
try await client.tuneStation(index: index)
|
||||
try await refreshTunerStatus()
|
||||
}
|
||||
|
||||
func applyAudioProfile(_ profile: AudioProfileDTO) async throws {
|
||||
try await client.putAudioProfile(profile)
|
||||
state.audio.mixer = profile.mixer
|
||||
state.audio.master = profile.master
|
||||
state.audio.eq = profile.eq
|
||||
state.audio.enhancements = profile.enhancements
|
||||
}
|
||||
|
||||
func setStereoEnhance(level: Int) async throws {
|
||||
try await client.setEnhance(path: "/api/audio/stereo-enhance", level: level)
|
||||
state.audio.enhancements.stereoLevel = level
|
||||
}
|
||||
|
||||
func setBassEnhance(level: Int) async throws {
|
||||
try await client.setEnhance(path: "/api/audio/bass-enhance", level: level)
|
||||
state.audio.enhancements.bassLevel = level
|
||||
}
|
||||
|
||||
func resetAudio() async throws {
|
||||
try await client.resetAudio()
|
||||
try await refreshAudioProfile()
|
||||
}
|
||||
|
||||
func scanBluetooth(seconds: Int) async throws {
|
||||
let response = try await client.scanBluetooth(seconds: seconds)
|
||||
state.bluetooth.nearbyDevices = response.devices.map {
|
||||
BluetoothDevice(index: $0.index, mac: $0.mac, name: $0.name, rssiDbm: $0.rssiDbm)
|
||||
}
|
||||
}
|
||||
|
||||
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
|
||||
try await client.connectBluetooth(mac: mac, name: name, save: save)
|
||||
try await refreshBluetoothStatus()
|
||||
}
|
||||
|
||||
func reconnectBluetooth() async throws {
|
||||
try await client.reconnectBluetooth()
|
||||
try await refreshBluetoothStatus()
|
||||
}
|
||||
|
||||
private func applyTuner(_ tuner: TunerStatusResponse) {
|
||||
state.tuner.booted = tuner.booted
|
||||
state.tuner.band = TunerBand(rawValue: tuner.band) ?? .fm
|
||||
state.tuner.locked = tuner.locked
|
||||
state.tuner.volume = tuner.volume
|
||||
state.tuner.fm = tuner.fm
|
||||
state.tuner.dab = tuner.dab
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// HTTP REST client for DigiRadio firmware API (ch-api.tex).
|
||||
final class HTTPDigiRadioClient {
|
||||
private let session: URLSession
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "HTTP")
|
||||
private var baseURL: URL?
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func setHost(_ host: String) throws {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw DigiRadioError.invalidHost }
|
||||
var normalized = trimmed
|
||||
if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
|
||||
normalized = "http://\(normalized)"
|
||||
}
|
||||
guard let url = URL(string: normalized) else { throw DigiRadioError.invalidHost }
|
||||
baseURL = url
|
||||
}
|
||||
|
||||
func clearHost() {
|
||||
baseURL = nil
|
||||
}
|
||||
|
||||
var isConfigured: Bool { baseURL != nil }
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
func fetchHealth() async throws -> HealthResponse {
|
||||
try await get("/api/health")
|
||||
}
|
||||
|
||||
// MARK: - Tuner
|
||||
|
||||
func fetchTunerStatus() async throws -> TunerStatusResponse {
|
||||
try await get("/api/tuner/status")
|
||||
}
|
||||
|
||||
func fetchDABServices() async throws -> DABServicesResponse {
|
||||
try await get("/api/tuner/services")
|
||||
}
|
||||
|
||||
func tune(body: TuneRequest) async throws -> TunerStatusResponse {
|
||||
try await post("/api/tuner/tune", body: body)
|
||||
}
|
||||
|
||||
func playDAB(body: PlayRequest) async throws {
|
||||
let _: StatusResponse = try await post("/api/tuner/play", body: body)
|
||||
}
|
||||
|
||||
func seekFM(direction: String) async throws -> SeekResponse {
|
||||
try await post("/api/tuner/seek", body: SeekRequest(direction: direction))
|
||||
}
|
||||
|
||||
func scanStation(body: TunerScanRequest) async throws -> TunerScanResponse {
|
||||
try await post("/api/tuner/scan", body: body)
|
||||
}
|
||||
|
||||
func scanFullFM() async throws -> FMScanFullResponse {
|
||||
try await post("/api/tuner/scan/full", body: EmptyBody())
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
func fetchAudioProfile() async throws -> AudioProfileDTO {
|
||||
try await get("/api/audio/profile")
|
||||
}
|
||||
|
||||
func putAudioProfile(_ profile: AudioProfileDTO) async throws {
|
||||
let _: StatusResponse = try await put("/api/audio/profile", body: profile)
|
||||
}
|
||||
|
||||
func resetAudio() async throws {
|
||||
let _: StatusResponse = try await post("/api/audio/reset", body: EmptyBody())
|
||||
}
|
||||
|
||||
func setEnhance(path: String, level: Int) async throws {
|
||||
let _: StatusResponse = try await post(path, body: EnhanceRequest(level: level))
|
||||
}
|
||||
|
||||
// MARK: - Bluetooth
|
||||
|
||||
func fetchBluetoothStatus() async throws -> BluetoothStatusResponse {
|
||||
try await get("/api/bluetooth/status")
|
||||
}
|
||||
|
||||
func fetchSavedSpeaker() async throws -> SavedSpeakerResponse {
|
||||
try await get("/api/bluetooth/speaker")
|
||||
}
|
||||
|
||||
func scanBluetooth(seconds: Int) async throws -> BluetoothScanResponse {
|
||||
try await post("/api/bluetooth/scan", body: ScanRequest(seconds: seconds))
|
||||
}
|
||||
|
||||
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
|
||||
let _: StatusResponse = try await post(
|
||||
"/api/bluetooth/connect",
|
||||
body: ConnectRequest(mac: mac, name: name, save: save)
|
||||
)
|
||||
}
|
||||
|
||||
func reconnectBluetooth() async throws {
|
||||
let _: StatusResponse = try await post("/api/bluetooth/reconnect", body: EmptyBody())
|
||||
}
|
||||
|
||||
// MARK: - Stations
|
||||
|
||||
func fetchStations() async throws -> StationListResponse {
|
||||
try await get("/api/stations")
|
||||
}
|
||||
|
||||
func addStation(_ station: Station) async throws {
|
||||
let _: StatusResponse = try await post("/api/stations", body: station)
|
||||
}
|
||||
|
||||
func removeStation(index: Int) async throws {
|
||||
let _: StatusResponse = try await post("/api/stations/remove", body: IndexRequest(index: index))
|
||||
}
|
||||
|
||||
func reorderStation(from: Int, to: Int) async throws {
|
||||
let _: StatusResponse = try await post(
|
||||
"/api/stations/reorder",
|
||||
body: ReorderRequest(from: from, to: to)
|
||||
)
|
||||
}
|
||||
|
||||
func tuneStation(index: Int) async throws {
|
||||
let _: StatusResponse = try await post("/api/stations/tune", body: IndexRequest(index: index))
|
||||
}
|
||||
|
||||
// MARK: - Streaming
|
||||
|
||||
func fetchStreaming() async throws -> StreamingState {
|
||||
try await get("/api/streaming")
|
||||
}
|
||||
|
||||
func setStreaming(_ config: StreamingState) async throws -> StreamingState {
|
||||
try await post("/api/streaming", body: config)
|
||||
}
|
||||
|
||||
// MARK: - HTTP helpers
|
||||
|
||||
private func url(for path: String) throws -> URL {
|
||||
guard let base = baseURL else { throw DigiRadioError.notConnected }
|
||||
guard let url = URL(string: path, relativeTo: base) else { throw DigiRadioError.invalidHost }
|
||||
return url
|
||||
}
|
||||
|
||||
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
let url = try url(for: path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
let url = try url(for: path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder.api.encode(body)
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
let url = try url(for: path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder.api.encode(body)
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
logger.debug("HTTP \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "")")
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw DigiRadioError.decodingFailed
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let reason = String(data: data, encoding: .utf8)
|
||||
throw DigiRadioError.httpStatus(http.statusCode, reason)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder.api.decode(T.self, from: data)
|
||||
} catch {
|
||||
logger.error("Decode failed: \(error.localizedDescription)")
|
||||
throw DigiRadioError.decodingFailed
|
||||
}
|
||||
} catch let error as DigiRadioError {
|
||||
throw error
|
||||
} catch {
|
||||
throw DigiRadioError.network(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API DTOs
|
||||
|
||||
struct EmptyBody: Codable {}
|
||||
|
||||
struct HealthResponse: Codable {
|
||||
var status: String
|
||||
var fw: String
|
||||
var serialNumber: String
|
||||
var chips: ChipHealthResponse
|
||||
}
|
||||
|
||||
struct ChipHealthResponse: Codable {
|
||||
var si4684: Bool
|
||||
var adau1701: Bool
|
||||
var bt1035: Bool
|
||||
}
|
||||
|
||||
struct TunerStatusResponse: Codable {
|
||||
var booted: Bool
|
||||
var band: String
|
||||
var locked: Bool
|
||||
var volume: Int
|
||||
var fm: FMTunerState?
|
||||
var dab: DABTunerState?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case booted, band, locked, volume, fm, dab
|
||||
}
|
||||
}
|
||||
|
||||
extension TunerStatusResponse {
|
||||
func dabDecoded() -> DABTunerState? {
|
||||
guard let dab else { return nil }
|
||||
return DABTunerState(
|
||||
freqIndex: dab.freqIndex,
|
||||
ficQuality: dab.ficQuality,
|
||||
cnrDb: dab.cnrDb,
|
||||
playingServiceId: dab.playingServiceId,
|
||||
playingComponentId: dab.playingComponentId,
|
||||
dynamicLabel: dab.dynamicLabel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct DABServicesResponse: Codable {
|
||||
var services: [DABService]
|
||||
}
|
||||
|
||||
struct TuneRequest: Codable {
|
||||
var band: String
|
||||
var freqIndex: Int?
|
||||
var frequencyKhz: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case band
|
||||
case freqIndex = "freq_index"
|
||||
case frequencyKhz = "frequency_khz"
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayRequest: Codable {
|
||||
var serviceId: Int
|
||||
var componentId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case serviceId = "service_id"
|
||||
case componentId = "component_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct SeekRequest: Codable {
|
||||
var direction: String
|
||||
}
|
||||
|
||||
struct SeekResponse: Codable {
|
||||
var frequencyKhz: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case frequencyKhz = "frequency_khz"
|
||||
}
|
||||
}
|
||||
|
||||
struct EnhanceRequest: Codable {
|
||||
var level: Int
|
||||
}
|
||||
|
||||
struct StatusResponse: Codable {
|
||||
var status: String
|
||||
}
|
||||
|
||||
struct BluetoothStatusResponse: Codable {
|
||||
var booted: Bool
|
||||
var pairing: Bool
|
||||
var a2dp: String
|
||||
var deviceName: String
|
||||
var autoReconnect: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case booted, pairing, a2dp
|
||||
case deviceName = "device_name"
|
||||
case autoReconnect = "auto_reconnect"
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedSpeakerResponse: Codable {
|
||||
var configured: Bool
|
||||
var mac: String?
|
||||
var name: String?
|
||||
}
|
||||
|
||||
struct ScanRequest: Codable {
|
||||
var seconds: Int
|
||||
}
|
||||
|
||||
struct BluetoothScanResponse: Codable {
|
||||
var devices: [BluetoothScanDevice]
|
||||
}
|
||||
|
||||
struct BluetoothScanDevice: Codable {
|
||||
var index: Int
|
||||
var mac: String
|
||||
var name: String
|
||||
var rssiDbm: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case index, mac, name
|
||||
case rssiDbm = "rssi_dbm"
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectRequest: Codable {
|
||||
var mac: String
|
||||
var name: String?
|
||||
var save: Bool?
|
||||
}
|
||||
|
||||
struct StationListResponse: Codable {
|
||||
var stations: [Station]
|
||||
}
|
||||
|
||||
struct IndexRequest: Codable {
|
||||
var index: Int
|
||||
}
|
||||
|
||||
struct ReorderRequest: Codable {
|
||||
var from: Int
|
||||
var to: Int
|
||||
}
|
||||
|
||||
struct TunerScanRequest: Codable {
|
||||
var band: String
|
||||
var maxSteps: Int?
|
||||
var name: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case band
|
||||
case maxSteps = "max_steps"
|
||||
case name
|
||||
}
|
||||
}
|
||||
|
||||
struct TunerScanResponse: Codable {
|
||||
var status: String
|
||||
var band: String?
|
||||
var steps: Int?
|
||||
var frequencyKhz: Int?
|
||||
var stationName: String?
|
||||
var freqIndex: Int?
|
||||
var serviceId: Int?
|
||||
var componentId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status, band, steps
|
||||
case frequencyKhz = "frequency_khz"
|
||||
case stationName = "station_name"
|
||||
case freqIndex = "freq_index"
|
||||
case serviceId = "service_id"
|
||||
case componentId = "component_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct FMScanHit: Codable, Identifiable, Equatable {
|
||||
var id: Int { frequencyKhz }
|
||||
var frequencyKhz: Int
|
||||
var rssiDbuv: Int?
|
||||
var snrDb: Int?
|
||||
var stationName: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case frequencyKhz = "frequency_khz"
|
||||
case rssiDbuv = "rssi_dbuv"
|
||||
case snrDb = "snr_db"
|
||||
case stationName = "station_name"
|
||||
}
|
||||
}
|
||||
|
||||
struct FMScanFullResponse: Codable {
|
||||
var stations: [FMScanHit]
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static let api: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
return e
|
||||
}()
|
||||
}
|
||||
|
||||
extension JSONDecoder {
|
||||
static let api: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
return d
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class AudioProfileEditorViewModel {
|
||||
var profile: AudioProfileTemplate
|
||||
private let service: any DigiRadioService
|
||||
private let onSave: (AudioProfileTemplate) -> Void
|
||||
private var applyTask: Task<Void, Never>?
|
||||
|
||||
var isSaving = false
|
||||
var isNewProfile: Bool
|
||||
|
||||
init(
|
||||
profile: AudioProfileTemplate,
|
||||
service: any DigiRadioService,
|
||||
isNewProfile: Bool,
|
||||
onSave: @escaping (AudioProfileTemplate) -> Void
|
||||
) {
|
||||
self.profile = profile
|
||||
self.service = service
|
||||
self.isNewProfile = isNewProfile
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
func schedulePreviewApply() {
|
||||
guard !profile.isBuiltIn else { return }
|
||||
applyTask?.cancel()
|
||||
applyTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(400))
|
||||
guard !Task.isCancelled else { return }
|
||||
await applyToDevice(persistUserCopy: false)
|
||||
}
|
||||
}
|
||||
|
||||
func applyToDevice(persistUserCopy: Bool) async {
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
let audio = service.state.audio
|
||||
let dto = profile.profileDTO(mixer: audio.mixer, master: audio.master)
|
||||
try? await service.applyAudioProfile(dto)
|
||||
if persistUserCopy, !profile.isBuiltIn {
|
||||
onSave(profile)
|
||||
}
|
||||
}
|
||||
|
||||
func saveUserProfile() {
|
||||
guard !profile.isBuiltIn else { return }
|
||||
onSave(profile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class AudioProfilesViewModel {
|
||||
private let service: any DigiRadioService
|
||||
|
||||
var builtIn: [AudioProfileTemplate] = AudioProfileLibrary.builtIn
|
||||
var userProfiles: [AudioProfileTemplate] = []
|
||||
var activeProfileID: String?
|
||||
var isLoading = false
|
||||
var isApplying = false
|
||||
var errorMessage: String?
|
||||
|
||||
init(service: any DigiRadioService) {
|
||||
self.service = service
|
||||
reloadUserProfiles()
|
||||
}
|
||||
|
||||
func reloadUserProfiles() {
|
||||
userProfiles = AudioProfileStore.loadUserProfiles()
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
try? await service.refreshAudioProfile()
|
||||
detectActiveProfile()
|
||||
}
|
||||
|
||||
func detectActiveProfile() {
|
||||
let audio = service.state.audio
|
||||
activeProfileID = AudioProfileStore.allProfiles().first {
|
||||
AudioProfileLibrary.matchActive($0, eq: audio.eq, enhancements: audio.enhancements)
|
||||
}?.id
|
||||
}
|
||||
|
||||
func apply(_ profile: AudioProfileTemplate) async {
|
||||
isApplying = true
|
||||
errorMessage = nil
|
||||
defer { isApplying = false }
|
||||
do {
|
||||
let audio = service.state.audio
|
||||
let dto = profile.profileDTO(mixer: audio.mixer, master: audio.master)
|
||||
try await service.applyAudioProfile(dto)
|
||||
activeProfileID = profile.id
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func deleteUserProfile(_ profile: AudioProfileTemplate) {
|
||||
guard !profile.isBuiltIn else { return }
|
||||
userProfiles.removeAll { $0.id == profile.id }
|
||||
AudioProfileStore.saveUserProfiles(userProfiles)
|
||||
if activeProfileID == profile.id { activeProfileID = nil }
|
||||
}
|
||||
|
||||
func saveUserProfile(_ profile: AudioProfileTemplate) {
|
||||
var copy = profile
|
||||
copy.isBuiltIn = false
|
||||
if let index = userProfiles.firstIndex(where: { $0.id == copy.id }) {
|
||||
userProfiles[index] = copy
|
||||
} else {
|
||||
userProfiles.append(copy)
|
||||
}
|
||||
AudioProfileStore.saveUserProfiles(userProfiles)
|
||||
}
|
||||
|
||||
func duplicateAsUser(from profile: AudioProfileTemplate, name: String) -> AudioProfileTemplate {
|
||||
AudioProfileTemplate(
|
||||
id: UUID().uuidString,
|
||||
name: name,
|
||||
subtitle: "Profilo personalizzato",
|
||||
systemImage: "slider.horizontal.3",
|
||||
isBuiltIn: false,
|
||||
eq: profile.eq,
|
||||
enhancements: profile.enhancements
|
||||
)
|
||||
}
|
||||
|
||||
func profileFromDevice(name: String) -> AudioProfileTemplate {
|
||||
let audio = service.state.audio
|
||||
return AudioProfileTemplate(
|
||||
id: UUID().uuidString,
|
||||
name: name,
|
||||
subtitle: "Creato dal dispositivo",
|
||||
systemImage: "waveform.path.ecg",
|
||||
isBuiltIn: false,
|
||||
eq: audio.eq.isEmpty ? AudioProfileLibrary.builtIn[0].eq : audio.eq,
|
||||
enhancements: audio.enhancements
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class AudioViewModel {
|
||||
private let service: any DigiRadioService
|
||||
private var applyTask: Task<Void, Never>?
|
||||
|
||||
var panel: AudioPanel = .mixer
|
||||
var mixer = MixerState()
|
||||
var master = MasterVolumeState()
|
||||
var eqBands: [EQBandState] = []
|
||||
var stereoLevel: Double = 0
|
||||
var bassLevel: Double = 0
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
|
||||
init(service: any DigiRadioService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
try? await service.refreshAudioProfile()
|
||||
syncFromDevice()
|
||||
}
|
||||
|
||||
func syncFromDevice() {
|
||||
let audio = service.state.audio
|
||||
mixer = audio.mixer
|
||||
master = audio.master
|
||||
eqBands = audio.eq
|
||||
stereoLevel = Double(audio.enhancements.stereoLevel)
|
||||
bassLevel = Double(audio.enhancements.bassLevel)
|
||||
if master.leftDb == 0 && master.rightDb == 0 {
|
||||
let vol = Double(service.state.tuner.volume)
|
||||
master = MasterVolumeState(leftDb: vol, rightDb: vol)
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleApply() {
|
||||
applyTask?.cancel()
|
||||
applyTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(350))
|
||||
guard !Task.isCancelled else { return }
|
||||
await applyNow()
|
||||
}
|
||||
}
|
||||
|
||||
func applyNow() async {
|
||||
isSaving = true
|
||||
defer { isSaving = false }
|
||||
let profile = AudioProfileDTO(
|
||||
mixer: mixer,
|
||||
master: master,
|
||||
eq: eqBands,
|
||||
enhancements: EnhancementsState(stereoLevel: Int(stereoLevel), bassLevel: Int(bassLevel))
|
||||
)
|
||||
try? await service.applyAudioProfile(profile)
|
||||
try? await service.setVolume(Int((master.leftDb + master.rightDb) / 2))
|
||||
syncFromDevice()
|
||||
}
|
||||
|
||||
func reset() async {
|
||||
try? await service.resetAudio()
|
||||
syncFromDevice()
|
||||
}
|
||||
|
||||
func applyEnhancements() async {
|
||||
try? await service.setStereoEnhance(level: Int(stereoLevel))
|
||||
try? await service.setBassEnhance(level: Int(bassLevel))
|
||||
syncFromDevice()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class ConnectionViewModel {
|
||||
private let environment: AppEnvironment
|
||||
var hostInput = "digiradio-cc4db4.local"
|
||||
var isBusy = false
|
||||
var errorMessage: String?
|
||||
let discovery = DigiRadioDiscoveryService()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
if environment.useMockDevice {
|
||||
hostInput = "mock.local"
|
||||
}
|
||||
}
|
||||
|
||||
var state: DigiRadioState { environment.state }
|
||||
|
||||
func connect() async {
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
try await environment.digiRadio.connect(host: hostInput)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func connect(to host: String) async {
|
||||
hostInput = host.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "")
|
||||
await connect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
environment.digiRadio.disconnect()
|
||||
}
|
||||
|
||||
func toggleMock(_ enabled: Bool) {
|
||||
environment.setUseMock(enabled)
|
||||
if enabled {
|
||||
hostInput = "mock.local"
|
||||
discovery.stop()
|
||||
Task { try? await environment.digiRadio.connect(host: hostInput) }
|
||||
} else {
|
||||
environment.digiRadio.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func startDiscovery() {
|
||||
discovery.start()
|
||||
discovery.addManualHost(hostInput)
|
||||
}
|
||||
|
||||
func stopDiscovery() {
|
||||
discovery.stop()
|
||||
}
|
||||
|
||||
func pollDiscovery() {
|
||||
discovery.refreshFromBLE()
|
||||
if !hostInput.isEmpty {
|
||||
discovery.addManualHost(hostInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
private let service: any DigiRadioService
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
init(service: any DigiRadioService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
var state: DigiRadioState { service.state }
|
||||
|
||||
func onAppear() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task {
|
||||
await refreshAll()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard state.connection.isConnected else { continue }
|
||||
try? await service.refreshTunerStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onDisappear() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
|
||||
func refreshAll() async {
|
||||
guard state.connection.isConnected else { return }
|
||||
try? await service.refreshHealth()
|
||||
try? await service.refreshTunerStatus()
|
||||
try? await service.refreshAudioProfile()
|
||||
try? await service.refreshBluetoothStatus()
|
||||
try? await service.refreshStations()
|
||||
}
|
||||
|
||||
func setVolume(_ value: Double) async {
|
||||
try? await service.setVolume(Int(value))
|
||||
}
|
||||
|
||||
func seekFM(_ direction: String) async {
|
||||
try? await service.seekFM(direction: direction)
|
||||
}
|
||||
|
||||
func tunePreset(at index: Int) async {
|
||||
try? await service.tuneStation(at: index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum StreamSource: String, CaseIterable, Identifiable {
|
||||
case webRadio = "Radio web"
|
||||
case urlContent = "URL contenuto"
|
||||
case localFile = "File iPhone"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .webRadio: "MP3 HTTP sul DigiRadio (decoder ESP32)"
|
||||
case .urlContent: "Podcast, playlist o stream personalizzato"
|
||||
case .localFile: "File audio decodificato e inviato dal telefono"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .webRadio: "antenna.radiowaves.left.and.right"
|
||||
case .urlContent: "link"
|
||||
case .localFile: "music.note.list"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamPreset: Identifiable {
|
||||
var id: String { name }
|
||||
var name: String
|
||||
var url: String
|
||||
}
|
||||
|
||||
enum StreamPresets {
|
||||
/// Default from firmware WebRadioConfig.hpp (documented example).
|
||||
static let catalog: [StreamPreset] = [
|
||||
StreamPreset(name: "Radio Monte Carlo", url: "http://edge.radiomontecarlo.net/RMC.mp3"),
|
||||
StreamPreset(name: "Esempio MP3", url: "http://stream.example.com/radio.mp3")
|
||||
]
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class StreamingViewModel {
|
||||
private let service: any DigiRadioService
|
||||
|
||||
var source: StreamSource = .webRadio
|
||||
var enabled = false
|
||||
var url = StreamPresets.catalog[0].url
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var errorMessage: String?
|
||||
|
||||
var selectedFileName = ""
|
||||
var selectedFileURL: URL?
|
||||
var phoneStreamActive = false
|
||||
var phoneStreamStatus = ""
|
||||
|
||||
private var statusPollTask: Task<Void, Never>?
|
||||
|
||||
init(service: any DigiRadioService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
deinit {
|
||||
statusPollTask?.cancel()
|
||||
}
|
||||
|
||||
var connectionHost: String {
|
||||
service.state.connection.host
|
||||
}
|
||||
|
||||
var isConnected: Bool {
|
||||
service.state.connection.isConnected
|
||||
}
|
||||
|
||||
func load() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
try await service.refreshStreaming()
|
||||
syncFromDevice()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
startStatusPolling()
|
||||
}
|
||||
|
||||
func selectPreset(_ preset: StreamPreset) {
|
||||
url = preset.url
|
||||
source = .webRadio
|
||||
}
|
||||
|
||||
func normalizedHTTPURL() -> String? {
|
||||
var trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if !trimmed.hasPrefix("http://") {
|
||||
if trimmed.hasPrefix("https://") {
|
||||
errorMessage = "Il firmware accetta solo URL http:// (non https)."
|
||||
return nil
|
||||
}
|
||||
trimmed = "http://\(trimmed)"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func playWebStream() async {
|
||||
guard let normalized = normalizedHTTPURL() else { return }
|
||||
url = normalized
|
||||
await apply(enabled: true)
|
||||
}
|
||||
|
||||
func stopWebStream() async {
|
||||
await apply(enabled: false)
|
||||
}
|
||||
|
||||
func apply(enabled: Bool? = nil) async {
|
||||
guard let normalized = normalizedHTTPURL() else { return }
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
defer { isSaving = false }
|
||||
let targetEnabled = enabled ?? self.enabled
|
||||
do {
|
||||
try await service.setStreaming(enabled: targetEnabled, url: normalized)
|
||||
syncFromDevice()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func pickFile(_ url: URL) {
|
||||
selectedFileURL = url
|
||||
selectedFileName = url.lastPathComponent
|
||||
source = .localFile
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func playLocalFile() async {
|
||||
guard isConnected, let fileURL = selectedFileURL else {
|
||||
errorMessage = "Seleziona un file e connetti DigiRadio."
|
||||
return
|
||||
}
|
||||
errorMessage = nil
|
||||
await stopWebStreamSilently()
|
||||
await PhonePCMStreamService.shared.start(fileURL: fileURL, connectionHost: connectionHost)
|
||||
}
|
||||
|
||||
func stopLocalFile() async {
|
||||
await PhonePCMStreamService.shared.stop()
|
||||
phoneStreamActive = false
|
||||
phoneStreamStatus = "Fermo"
|
||||
}
|
||||
|
||||
func stopPolling() {
|
||||
statusPollTask?.cancel()
|
||||
statusPollTask = nil
|
||||
}
|
||||
|
||||
private func stopWebStreamSilently() async {
|
||||
try? await service.setStreaming(enabled: false, url: url)
|
||||
syncFromDevice()
|
||||
}
|
||||
|
||||
private func syncFromDevice() {
|
||||
enabled = service.state.streaming.enabled
|
||||
if !service.state.streaming.url.isEmpty {
|
||||
url = service.state.streaming.url
|
||||
}
|
||||
}
|
||||
|
||||
private func startStatusPolling() {
|
||||
statusPollTask?.cancel()
|
||||
statusPollTask = Task { @MainActor in
|
||||
while !Task.isCancelled {
|
||||
let streamer = PhonePCMStreamService.shared
|
||||
phoneStreamActive = await streamer.isStreaming
|
||||
let status = await streamer.statusMessage
|
||||
if !status.isEmpty { phoneStreamStatus = status }
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AudioProfileEditorView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel: AudioProfileEditorViewModel?
|
||||
let profile: AudioProfileTemplate
|
||||
let isNewProfile: Bool
|
||||
let onSave: (AudioProfileTemplate) -> Void
|
||||
|
||||
var body: some View {
|
||||
let vm = viewModel ?? AudioProfileEditorViewModel(
|
||||
profile: profile,
|
||||
service: environment.digiRadio,
|
||||
isNewProfile: isNewProfile,
|
||||
onSave: onSave
|
||||
)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
if !vm.profile.isBuiltIn {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
Text("Nome profilo")
|
||||
.font(.headline)
|
||||
TextField("Nome", text: Binding(
|
||||
get: { vm.profile.name },
|
||||
set: { vm.profile.name = $0 }
|
||||
))
|
||||
.padding()
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
Text("Equalizzatore")
|
||||
.font(.title3.weight(.bold))
|
||||
IGIGraphicEqualizer(
|
||||
bands: Binding(
|
||||
get: { vm.profile.eq },
|
||||
set: { vm.profile.eq = $0 }
|
||||
),
|
||||
onCommit: { vm.schedulePreviewApply() }
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
Text("Enhancements")
|
||||
.font(.headline)
|
||||
HStack(spacing: IGITheme.spacingL) {
|
||||
IGIEnhancementDial(
|
||||
title: "Stereo",
|
||||
systemImage: "circle.lefthalf.filled",
|
||||
level: Binding(
|
||||
get: { Double(vm.profile.enhancements.stereoLevel) },
|
||||
set: { vm.profile.enhancements.stereoLevel = Int($0) }
|
||||
),
|
||||
onCommit: { vm.schedulePreviewApply() }
|
||||
)
|
||||
IGIEnhancementDial(
|
||||
title: "Bass",
|
||||
systemImage: "waveform.path",
|
||||
level: Binding(
|
||||
get: { Double(vm.profile.enhancements.bassLevel) },
|
||||
set: { vm.profile.enhancements.bassLevel = Int($0) }
|
||||
),
|
||||
onCommit: { vm.schedulePreviewApply() }
|
||||
)
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
|
||||
if !vm.profile.isBuiltIn {
|
||||
Button {
|
||||
vm.saveUserProfile()
|
||||
Task { await vm.applyToDevice(persistUserCopy: true) }
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Salva profilo", systemImage: "square.and.arrow.down")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await vm.applyToDevice(persistUserCopy: false) }
|
||||
} label: {
|
||||
Label("Applica a DigiRadio", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
if vm.profile.isBuiltIn {
|
||||
Button {
|
||||
let duplicate = vm.profile.duplicatedAsUser(named: "\(vm.profile.name) custom")
|
||||
onSave(duplicate)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Salva come profilo personale", systemImage: "plus.square.on.square")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
}
|
||||
.background(IGIHeroBackground())
|
||||
.navigationTitle(vm.profile.isBuiltIn ? vm.profile.name : "Modifica profilo")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
if viewModel == nil { viewModel = vm }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AudioProfilesView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var viewModel: AudioProfilesViewModel?
|
||||
@State private var editorProfile: AudioProfileTemplate?
|
||||
@State private var editorIsNew = false
|
||||
@State private var showNewProfileSheet = false
|
||||
@State private var newProfileName = ""
|
||||
|
||||
var body: some View {
|
||||
let vm = viewModel ?? AudioProfilesViewModel(service: environment.digiRadio)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
activeBanner(vm)
|
||||
profileSection(title: "Predefiniti", profiles: vm.builtIn, vm: vm, allowDelete: false, onDelete: nil)
|
||||
profileSection(title: "I tuoi profili", profiles: vm.userProfiles, vm: vm, allowDelete: true, onDelete: { vm.deleteUserProfile($0) })
|
||||
|
||||
if vm.userProfiles.isEmpty {
|
||||
Text("Crea un profilo personalizzato partendo da un preset o dal suono attuale del device.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
Button {
|
||||
newProfileName = "Il mio profilo"
|
||||
showNewProfileSheet = true
|
||||
} label: {
|
||||
Label("Nuovo profilo", systemImage: "plus.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
NavigationLink {
|
||||
AudioView()
|
||||
} label: {
|
||||
Label("Mixer", systemImage: "slider.vertical.3")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
}
|
||||
.background(IGIHeroBackground())
|
||||
.navigationTitle("Profilo audio")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationDestination(item: $editorProfile) { profile in
|
||||
AudioProfileEditorView(
|
||||
profile: profile,
|
||||
isNewProfile: editorIsNew,
|
||||
onSave: { saved in
|
||||
vm.saveUserProfile(saved)
|
||||
vm.reloadUserProfiles()
|
||||
vm.activeProfileID = saved.id
|
||||
}
|
||||
)
|
||||
}
|
||||
.alert("Nuovo profilo", isPresented: $showNewProfileSheet) {
|
||||
TextField("Nome", text: $newProfileName)
|
||||
Button("Annulla", role: .cancel) {}
|
||||
Button("Crea") {
|
||||
let profile = vm.profileFromDevice(name: newProfileName.isEmpty ? "Il mio profilo" : newProfileName)
|
||||
editorIsNew = true
|
||||
editorProfile = profile
|
||||
}
|
||||
} message: {
|
||||
Text("Parte dal profilo attualmente sul DigiRadio.")
|
||||
}
|
||||
.onAppear {
|
||||
if viewModel == nil { viewModel = vm }
|
||||
Task { await vm.load() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func activeBanner(_ vm: AudioProfilesViewModel) -> some View {
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Profilo attivo")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(vm.builtIn.first(where: { $0.id == vm.activeProfileID })?.name
|
||||
?? vm.userProfiles.first(where: { $0.id == vm.activeProfileID })?.name
|
||||
?? "Personalizzato / device")
|
||||
.font(.headline)
|
||||
}
|
||||
Spacer()
|
||||
if vm.isApplying { ProgressView() }
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func profileSection(
|
||||
title: String,
|
||||
profiles: [AudioProfileTemplate],
|
||||
vm: AudioProfilesViewModel,
|
||||
allowDelete: Bool,
|
||||
onDelete: ((AudioProfileTemplate) -> Void)?
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
Text(title)
|
||||
.font(.title3.weight(.bold))
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: IGITheme.spacingS)], spacing: IGITheme.spacingS) {
|
||||
ForEach(profiles) { profile in
|
||||
AudioProfileCard(
|
||||
profile: profile,
|
||||
isActive: vm.activeProfileID == profile.id,
|
||||
allowDelete: allowDelete
|
||||
) {
|
||||
Task { await vm.apply(profile) }
|
||||
} onEdit: {
|
||||
editorIsNew = false
|
||||
editorProfile = profile
|
||||
} onDelete: {
|
||||
onDelete?(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AudioProfileCard: View {
|
||||
var profile: AudioProfileTemplate
|
||||
var isActive: Bool
|
||||
var allowDelete: Bool
|
||||
var onApply: () -> Void
|
||||
var onEdit: () -> Void
|
||||
var onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
HStack {
|
||||
Image(systemName: profile.systemImage)
|
||||
.foregroundStyle(isActive ? .white : IGITheme.accent)
|
||||
Spacer()
|
||||
if isActive {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
Text(profile.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(isActive ? .white : .primary)
|
||||
Text(profile.subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(isActive ? .white.opacity(0.85) : .secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: IGITheme.spacingS) {
|
||||
Button("Applica", action: onApply)
|
||||
.font(.caption.weight(.semibold))
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(isActive ? .white : IGITheme.accent)
|
||||
|
||||
Button("Modifica", action: onEdit)
|
||||
.font(.caption.weight(.semibold))
|
||||
.buttonStyle(.bordered)
|
||||
.tint(isActive ? .white : .primary)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
.background(
|
||||
isActive
|
||||
? AnyShapeStyle(IGITheme.accent.gradient)
|
||||
: AnyShapeStyle(Color.primary.opacity(0.05)),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
.contextMenu {
|
||||
if allowDelete {
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Label("Elimina", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AudioView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var viewModel: AudioViewModel?
|
||||
|
||||
var body: some View {
|
||||
let vm = viewModel ?? AudioViewModel(service: environment.digiRadio)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
header(vm)
|
||||
|
||||
if vm.isLoading {
|
||||
ProgressView("Caricamento profilo audio…")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
} else {
|
||||
IGIMasterVolumeHero(
|
||||
leftDb: Binding(
|
||||
get: { vm.master.leftDb },
|
||||
set: { vm.master.leftDb = $0; vm.scheduleApply() }
|
||||
),
|
||||
rightDb: Binding(
|
||||
get: { vm.master.rightDb },
|
||||
set: { vm.master.rightDb = $0; vm.scheduleApply() }
|
||||
),
|
||||
onCommit: { vm.scheduleApply() }
|
||||
)
|
||||
|
||||
panelPicker(vm)
|
||||
|
||||
switch vm.panel {
|
||||
case .mixer:
|
||||
mixerPanel(vm)
|
||||
case .enhance:
|
||||
enhancePanel(vm)
|
||||
}
|
||||
|
||||
resetButton(vm)
|
||||
}
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
}
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
IGITheme.screenBackground,
|
||||
IGITheme.accent.opacity(0.06),
|
||||
IGITheme.screenBackground
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
.navigationTitle("Audio")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
if viewModel == nil { viewModel = vm }
|
||||
Task { await vm.load() }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func header(_ vm: AudioViewModel) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("ADAU1701")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
Text("Mixer · EQ · DSP")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if vm.isSaving {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Salvataggio")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func panelPicker(_ vm: AudioViewModel) -> some View {
|
||||
HStack(spacing: IGITheme.spacingS) {
|
||||
ForEach(AudioPanel.allCases) { panel in
|
||||
Button {
|
||||
withAnimation(.snappy) { vm.panel = panel }
|
||||
} label: {
|
||||
Label(panel.rawValue, systemImage: panel.icon)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
vm.panel == panel
|
||||
? AnyShapeStyle(IGITheme.accent.gradient)
|
||||
: AnyShapeStyle(Color.primary.opacity(0.06)),
|
||||
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(vm.panel == panel ? .white : .primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mixerPanel(_ vm: AudioViewModel) -> some View {
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
IGIMixerChannelGroup(
|
||||
title: "Radio Si4684",
|
||||
subtitle: "FM / DAB — ingresso tuner",
|
||||
systemImage: "antenna.radiowaves.left.and.right",
|
||||
leftDb: Binding(
|
||||
get: { vm.mixer.si4684LeftDb },
|
||||
set: { vm.mixer.si4684LeftDb = $0 }
|
||||
),
|
||||
rightDb: Binding(
|
||||
get: { vm.mixer.si4684RightDb },
|
||||
set: { vm.mixer.si4684RightDb = $0 }
|
||||
),
|
||||
onCommit: { vm.scheduleApply() }
|
||||
)
|
||||
|
||||
IGIMixerChannelGroup(
|
||||
title: "Stream ESP32",
|
||||
subtitle: "Web radio / phone push",
|
||||
systemImage: "waveform",
|
||||
leftDb: Binding(
|
||||
get: { vm.mixer.esp32LeftDb },
|
||||
set: { vm.mixer.esp32LeftDb = $0 }
|
||||
),
|
||||
rightDb: Binding(
|
||||
get: { vm.mixer.esp32RightDb },
|
||||
set: { vm.mixer.esp32RightDb = $0 }
|
||||
),
|
||||
onCommit: { vm.scheduleApply() }
|
||||
)
|
||||
|
||||
IGIMixerChannelGroup(
|
||||
title: "Mix bus",
|
||||
subtitle: "Uscita mixer ADAU",
|
||||
systemImage: "speaker.wave.2.fill",
|
||||
leftDb: Binding(
|
||||
get: { vm.mixer.mixLeftDb },
|
||||
set: { vm.mixer.mixLeftDb = $0 }
|
||||
),
|
||||
rightDb: Binding(
|
||||
get: { vm.mixer.mixRightDb },
|
||||
set: { vm.mixer.mixRightDb = $0 }
|
||||
),
|
||||
onCommit: { vm.scheduleApply() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func enhancePanel(_ vm: AudioViewModel) -> some View {
|
||||
HStack(spacing: IGITheme.spacingL) {
|
||||
IGIEnhancementDial(
|
||||
title: "Stereo",
|
||||
systemImage: "circle.lefthalf.filled",
|
||||
level: Binding(
|
||||
get: { vm.stereoLevel },
|
||||
set: { vm.stereoLevel = $0 }
|
||||
),
|
||||
onCommit: { Task { await vm.applyEnhancements() } }
|
||||
)
|
||||
IGIEnhancementDial(
|
||||
title: "Bass",
|
||||
systemImage: "waveform.path",
|
||||
level: Binding(
|
||||
get: { vm.bassLevel },
|
||||
set: { vm.bassLevel = $0 }
|
||||
),
|
||||
onCommit: { Task { await vm.applyEnhancements() } }
|
||||
)
|
||||
}
|
||||
.igiAudioCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func resetButton(_ vm: AudioViewModel) -> some View {
|
||||
Button(role: .destructive) {
|
||||
Task { await vm.reset() }
|
||||
} label: {
|
||||
Label("Ripristina profilo audio", systemImage: "arrow.counterclockwise")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, IGITheme.spacingS)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BLEProvisioningView: View {
|
||||
@State private var service = BLEProvisioningService()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Ricerca") {
|
||||
switch service.phase {
|
||||
case .idle:
|
||||
Text("Premi Avvia per cercare dispositivi DigiRadio in setup mode.")
|
||||
.foregroundStyle(.secondary)
|
||||
case .scanning:
|
||||
HStack {
|
||||
IGIScanningIndicator().frame(width: 28, height: 28)
|
||||
Text("Cerca DigiRadio...")
|
||||
}
|
||||
case let .found(name, _):
|
||||
Label("Trovato: \(name)", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .unsupported:
|
||||
Text("Provisioning BLE non ancora integrato in igiRadio.")
|
||||
case let .error(message):
|
||||
Text(message).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
if !service.discoveredDevices.isEmpty {
|
||||
Section("Dispositivi") {
|
||||
ForEach(service.discoveredDevices, id: \.id) { device in
|
||||
VStack(alignment: .leading) {
|
||||
Text(device.name).font(.headline)
|
||||
Text("RSSI \(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Avvia scansione") { service.startScan() }
|
||||
Button("Ferma scansione") { service.stopScan() }
|
||||
}
|
||||
|
||||
Section("Istruzioni") {
|
||||
Text("1. Metti DigiRadio in setup mode (SoftAP DigiRadio-<suffix>).")
|
||||
Text("2. Il dispositivo espone anche BLE provisioning ESP-IDF.")
|
||||
Text("3. Proof of Possession (PoP) = serial number del dispositivo.")
|
||||
Text("4. Dopo il join 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() }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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-<suffix>.local")
|
||||
Text("• Oppure IP LAN del dispositivo")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.navigationTitle("Connessione")
|
||||
.onAppear {
|
||||
if viewModel == nil { viewModel = vm }
|
||||
if environment.useMockDevice && !state.connection.isConnected {
|
||||
Task { await vm.connect() }
|
||||
}
|
||||
}
|
||||
.onDisappear { stopDiscovery(vm) }
|
||||
}
|
||||
|
||||
private func startDiscovery(_ vm: ConnectionViewModel) {
|
||||
vm.startDiscovery()
|
||||
discoveryTimer?.invalidate()
|
||||
discoveryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
||||
vm.pollDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDiscovery(_ vm: ConnectionViewModel) {
|
||||
discoveryTimer?.invalidate()
|
||||
discoveryTimer = nil
|
||||
vm.stopDiscovery()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DABRadioView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var freqIndex: Double = 12
|
||||
@State private var services: [DABService] = []
|
||||
@State private var loadError: String?
|
||||
|
||||
var body: some View {
|
||||
let dab = environment.state.tuner.dab
|
||||
Form {
|
||||
Section("Ensemble") {
|
||||
Stepper("Freq index: \(Int(freqIndex))", value: Binding(
|
||||
get: { Int(freqIndex) },
|
||||
set: { freqIndex = Double($0) }
|
||||
), in: 0 ... 37)
|
||||
Button("Sintonizza ensemble") {
|
||||
Task { try? await environment.digiRadio.tuneDAB(freqIndex: Int(freqIndex)) }
|
||||
}
|
||||
}
|
||||
|
||||
if let dab {
|
||||
Section("Stato DAB") {
|
||||
LabeledContent("Freq index", value: "\(dab.freqIndex)")
|
||||
if let fic = dab.ficQuality { LabeledContent("FIC quality", value: "\(fic)") }
|
||||
if let cnr = dab.cnrDb { LabeledContent("CNR", value: "\(cnr) dB") }
|
||||
if let label = dab.dynamicLabel { LabeledContent("DLS", value: label) }
|
||||
}
|
||||
}
|
||||
|
||||
Section("Servizi") {
|
||||
if let loadError {
|
||||
Text(loadError).foregroundStyle(.red)
|
||||
}
|
||||
if services.isEmpty {
|
||||
Text("Nessun servizio caricato")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(services) { service in
|
||||
Button {
|
||||
Task {
|
||||
try? await environment.digiRadio.playDAB(
|
||||
serviceId: service.serviceId,
|
||||
componentId: service.componentId
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(service.label).font(.headline)
|
||||
Text("SID \(service.serviceId) · CID \(service.componentId)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Carica servizi") { Task { await loadServices() } }
|
||||
}
|
||||
}
|
||||
.navigationTitle("DAB")
|
||||
.onAppear {
|
||||
if let idx = environment.state.tuner.dab?.freqIndex {
|
||||
freqIndex = Double(idx)
|
||||
}
|
||||
Task { try? await environment.digiRadio.refreshTunerStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadServices() async {
|
||||
loadError = nil
|
||||
do {
|
||||
services = try await environment.digiRadio.listDABServices()
|
||||
} catch {
|
||||
loadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FMRadioView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var frequencyMHz: Double = 102.3
|
||||
@State private var isTuning = false
|
||||
@State private var isScanning = false
|
||||
@State private var scanResults: [FMScanHit] = []
|
||||
@State private var scanError: String?
|
||||
|
||||
var body: some View {
|
||||
let fm = environment.state.tuner.fm
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
IGIFrequencyHero(
|
||||
frequencyMHz: $frequencyMHz,
|
||||
stationName: fm?.stationName,
|
||||
isTuning: isTuning,
|
||||
onTune: { Task { await tune() } }
|
||||
)
|
||||
|
||||
if let fm {
|
||||
signalCard(fm)
|
||||
if let ps = fm.stationName, !ps.isEmpty {
|
||||
rdsCard(fm, ps: ps)
|
||||
}
|
||||
}
|
||||
|
||||
seekSection
|
||||
scanSection
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
}
|
||||
.background(IGIHeroBackground())
|
||||
.navigationTitle("FM")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
if let khz = environment.state.tuner.fm?.frequencyKhz {
|
||||
frequencyMHz = Double(khz) / 1000.0
|
||||
}
|
||||
Task { try? await environment.digiRadio.refreshTunerStatus() }
|
||||
}
|
||||
.onChange(of: environment.state.tuner.fm?.frequencyKhz) { _, newValue in
|
||||
if let khz = newValue {
|
||||
withAnimation(.snappy) {
|
||||
frequencyMHz = Double(khz) / 1000.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func signalCard(_ fm: FMTunerState) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
Text("Segnale")
|
||||
.font(.headline)
|
||||
if let rssi = fm.rssiDbuv {
|
||||
IGIMetricBar(label: "RSSI", value: "\(rssi) dBµV", level: Double(rssi) / 80.0)
|
||||
}
|
||||
if let snr = fm.snrDb {
|
||||
IGIMetricBar(label: "SNR", value: "\(snr) dB", level: Double(snr) / 40.0)
|
||||
}
|
||||
if let stereo = fm.stereo {
|
||||
HStack {
|
||||
Text("Stereo")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(stereo ? "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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var viewModel: HomeViewModel?
|
||||
@State private var volume: Double = 42
|
||||
|
||||
var body: some View {
|
||||
let state = environment.state
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
header(state: state)
|
||||
nowPlayingCard(state: state)
|
||||
streamCard(state: state)
|
||||
quickLinks
|
||||
IGITransportCluster(
|
||||
onPrevious: { Task { await viewModel?.seekFM("down") } },
|
||||
onPlay: { Task { await viewModel?.refreshAll() } },
|
||||
onNext: { Task { await viewModel?.seekFM("up") } }
|
||||
)
|
||||
volumeSection
|
||||
presetsSection(state: state)
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
}
|
||||
.background(IGIHeroBackground())
|
||||
.navigationTitle("igiRadio")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.safeAreaInset(edge: .top) {
|
||||
if !state.connection.isConnected {
|
||||
connectionBanner
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
ConnectionView()
|
||||
} label: {
|
||||
Label(state.connection.isConnected ? "Connesso" : "Connetti", systemImage: "link")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if viewModel == nil {
|
||||
viewModel = HomeViewModel(service: environment.digiRadio)
|
||||
}
|
||||
viewModel?.onAppear()
|
||||
volume = Double(state.tuner.volume)
|
||||
Task { try? await environment.digiRadio.refreshStreaming() }
|
||||
}
|
||||
.onDisappear { viewModel?.onDisappear() }
|
||||
.onChange(of: environment.state.tuner.volume) { _, newValue in
|
||||
volume = Double(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionBanner: some View {
|
||||
NavigationLink {
|
||||
ConnectionView()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "wifi.exclamationmark")
|
||||
Text("DigiRadio non connesso — tocca per collegare")
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.padding(.horizontal, IGITheme.spacingM)
|
||||
.padding(.vertical, 10)
|
||||
.background(.orange.opacity(0.15))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func header(state: DigiRadioState) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("DigiRadio")
|
||||
.font(.title.weight(.bold))
|
||||
HStack(spacing: 8) {
|
||||
IGIStatusDot(isConnected: state.connection.isConnected)
|
||||
Text(state.connection.isConnected ? "Connesso" : "Non connesso")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
IGIBandBadge(band: state.tuner.band, locked: state.tuner.locked)
|
||||
}
|
||||
Spacer()
|
||||
if let rssi = state.tuner.fm?.rssiDbuv {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
Text("Segnale")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
IGISignalBar(level: Double(rssi) / 80.0)
|
||||
.frame(width: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func nowPlayingCard(state: DigiRadioState) -> some View {
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
IGITheme.accent.opacity(0.45),
|
||||
.purple.opacity(0.35),
|
||||
IGITheme.accent.opacity(0.25)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(height: 200)
|
||||
.overlay {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.08))
|
||||
.frame(width: 160, height: 160)
|
||||
.blur(radius: 2)
|
||||
}
|
||||
|
||||
Image(systemName: state.tuner.band == .fm ? "radio.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(.white.opacity(0.95))
|
||||
.symbolEffect(.pulse, options: .repeating, value: state.tuner.locked)
|
||||
}
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(primaryTitle(state: state))
|
||||
.font(.title2.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.contentTransition(.interpolate)
|
||||
Text(secondaryLine(state: state))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
.animation(.snappy, value: state.tuner.fm?.frequencyKhz)
|
||||
.animation(.snappy, value: state.tuner.fm?.stationName)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func streamCard(state: DigiRadioState) -> some View {
|
||||
NavigationLink {
|
||||
StreamingView()
|
||||
} label: {
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(state.streaming.enabled ? IGITheme.accent.opacity(0.2) : Color.secondary.opacity(0.12))
|
||||
.frame(width: 52, height: 52)
|
||||
Image(systemName: state.streaming.enabled ? "dot.radiowaves.forward" : "dot.radiowaves.forward.slash")
|
||||
.font(.title2)
|
||||
.foregroundStyle(state.streaming.enabled ? IGITheme.accent : .secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Web radio stream")
|
||||
.font(.headline)
|
||||
if state.streaming.enabled, !state.streaming.url.isEmpty {
|
||||
Text(state.streaming.url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Nessuno stream attivo")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var quickLinks: some View {
|
||||
HStack(spacing: IGITheme.spacingS) {
|
||||
NavigationLink {
|
||||
FMRadioView()
|
||||
} label: {
|
||||
quickLinkLabel("FM", icon: "dot.radiowaves.left.and.right")
|
||||
}
|
||||
NavigationLink {
|
||||
DABRadioView()
|
||||
} label: {
|
||||
quickLinkLabel("DAB", icon: "antenna.radiowaves.left.and.right")
|
||||
}
|
||||
NavigationLink {
|
||||
AudioProfilesView()
|
||||
} label: {
|
||||
quickLinkLabel("Profilo", icon: "waveform.path.ecg")
|
||||
}
|
||||
NavigationLink {
|
||||
StreamingView()
|
||||
} label: {
|
||||
quickLinkLabel("Stream", icon: "dot.radiowaves.forward")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func quickLinkLabel(_ title: String, icon: String) -> some View {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var volumeSection: some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
HStack {
|
||||
Text("Volume")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(Int(volume))")
|
||||
.font(.title3.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(IGITheme.accent)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
IGIVolumeSlider(value: $volume) { value in
|
||||
Task { await viewModel?.setVolume(value) }
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func presetsSection(state: DigiRadioState) -> some View {
|
||||
IGISectionHeader(title: "Preset")
|
||||
if state.stations.isEmpty {
|
||||
IGIEmptyState(
|
||||
title: "Nessun preset",
|
||||
message: "Salva le tue stazioni preferite dalla schermata Preset.",
|
||||
systemImage: "star"
|
||||
)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: IGITheme.spacingS) {
|
||||
ForEach(Array(state.stations.enumerated()), id: \.offset) { index, station in
|
||||
IGIPresetChip(
|
||||
name: station.name,
|
||||
subtitle: station.band.rawValue.uppercased()
|
||||
) {
|
||||
Task { await viewModel?.tunePreset(at: index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func primaryTitle(state: DigiRadioState) -> String {
|
||||
switch state.tuner.band {
|
||||
case .fm:
|
||||
if let name = state.tuner.fm?.stationName, !name.isEmpty { return name }
|
||||
if let khz = state.tuner.fm?.frequencyKhz {
|
||||
return String(format: "%.2f MHz", Double(khz) / 1000.0)
|
||||
}
|
||||
return "FM"
|
||||
case .dab:
|
||||
return state.tuner.dab?.dynamicLabel ?? "DAB"
|
||||
}
|
||||
}
|
||||
|
||||
private func secondaryLine(state: DigiRadioState) -> String {
|
||||
switch state.tuner.band {
|
||||
case .fm:
|
||||
if let khz = state.tuner.fm?.frequencyKhz {
|
||||
let freq = String(format: "%.2f MHz", Double(khz) / 1000.0)
|
||||
if let rt = state.tuner.fm?.radiotext, !rt.isEmpty { return "\(freq) · \(rt)" }
|
||||
return freq
|
||||
}
|
||||
return state.tuner.fm?.radiotext ?? "FM Radio"
|
||||
case .dab:
|
||||
if let cnr = state.tuner.dab?.cnrDb {
|
||||
return "CNR \(cnr) dB"
|
||||
}
|
||||
return "Digital Radio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PresetsView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var newName = ""
|
||||
@State private var newBand: TunerBand = .fm
|
||||
@State private var newFMKhz = "102300"
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(Array(environment.state.stations.enumerated()), id: \.offset) { index, station in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(station.name).font(.headline)
|
||||
Text(detail(for: station)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Play") {
|
||||
Task { try? await environment.digiRadio.tuneStation(at: index) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
for index in offsets {
|
||||
Task { try? await environment.digiRadio.removeStation(at: index) }
|
||||
}
|
||||
}
|
||||
.onMove { from, to in
|
||||
guard let source = from.first else { return }
|
||||
Task { try? await environment.digiRadio.reorderStation(from: source, to: to) }
|
||||
}
|
||||
}
|
||||
|
||||
Section("Aggiungi preset") {
|
||||
TextField("Nome", text: $newName)
|
||||
Picker("Banda", selection: $newBand) {
|
||||
ForEach(TunerBand.allCases, id: \.self) { band in
|
||||
Text(band.rawValue.uppercased()).tag(band)
|
||||
}
|
||||
}
|
||||
if newBand == .fm {
|
||||
TextField("Frequenza kHz", text: $newFMKhz)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
Button("Salva") { Task { await savePreset() } }
|
||||
.disabled(newName.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Preset")
|
||||
.toolbar { EditButton() }
|
||||
.onAppear { Task { try? await environment.digiRadio.refreshStations() } }
|
||||
}
|
||||
|
||||
private func detail(for station: Station) -> String {
|
||||
switch station.band {
|
||||
case .fm:
|
||||
if let khz = station.fmFrequencyKhz {
|
||||
return String(format: "FM %.2f MHz", Double(khz) / 1000)
|
||||
}
|
||||
return "FM"
|
||||
case .dab:
|
||||
return "DAB index \(station.dabFreqIndex ?? 0)"
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreset() async {
|
||||
let station: Station
|
||||
switch newBand {
|
||||
case .fm:
|
||||
guard let khz = Int(newFMKhz) else { return }
|
||||
station = Station(name: newName, band: .fm, fmFrequencyKhz: khz)
|
||||
case .dab:
|
||||
station = Station(
|
||||
name: newName,
|
||||
band: .dab,
|
||||
dabFreqIndex: Int(environment.state.tuner.dab?.freqIndex ?? 0),
|
||||
dabServiceId: environment.state.tuner.dab?.playingServiceId,
|
||||
dabComponentId: environment.state.tuner.dab?.playingComponentId
|
||||
)
|
||||
}
|
||||
try? await environment.digiRadio.saveStation(station)
|
||||
newName = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
IPadRootView()
|
||||
} else {
|
||||
IPhoneRootView()
|
||||
}
|
||||
}
|
||||
.tint(IGITheme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPhoneRootView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
NavigationStack { HomeView() }
|
||||
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||
|
||||
NavigationStack { FMRadioView() }
|
||||
.tabItem { Label("FM", systemImage: "dot.radiowaves.left.and.right") }
|
||||
|
||||
NavigationStack { DABRadioView() }
|
||||
.tabItem { Label("DAB", systemImage: "antenna.radiowaves.left.and.right") }
|
||||
|
||||
NavigationStack { StreamingView() }
|
||||
.tabItem { Label("Stream", systemImage: "dot.radiowaves.forward") }
|
||||
|
||||
NavigationStack { PresetsView() }
|
||||
.tabItem { Label("Preset", systemImage: "star.fill") }
|
||||
|
||||
NavigationStack { AudioProfilesView() }
|
||||
.tabItem { Label("Profilo", systemImage: "waveform.path.ecg") }
|
||||
|
||||
NavigationStack { SettingsRootView() }
|
||||
.tabItem { Label("Impostazioni", systemImage: "gearshape.fill") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadRootView: View {
|
||||
@State private var selection: SidebarItem? = .home
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(SidebarItem.allCases, selection: $selection) { item in
|
||||
NavigationLink(value: item) {
|
||||
Label(item.title, systemImage: item.systemImage)
|
||||
}
|
||||
}
|
||||
.navigationTitle("igiRadio")
|
||||
} detail: {
|
||||
switch selection ?? .home {
|
||||
case .home: HomeView()
|
||||
case .fm: FMRadioView()
|
||||
case .dab: DABRadioView()
|
||||
case .stream: StreamingView()
|
||||
case .bluetooth: BluetoothView()
|
||||
case .audioProfiles: AudioProfilesView()
|
||||
case .presets: PresetsView()
|
||||
case .settings: SettingsRootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarItem: String, CaseIterable, Identifiable {
|
||||
case home, fm, dab, stream, bluetooth, audioProfiles, presets, settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: "Home"
|
||||
case .fm: "FM"
|
||||
case .dab: "DAB"
|
||||
case .stream: "Stream"
|
||||
case .bluetooth: "Bluetooth"
|
||||
case .audioProfiles: "Profilo audio"
|
||||
case .presets: "Preset"
|
||||
case .settings: "Impostazioni"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .home: "house.fill"
|
||||
case .fm: "dot.radiowaves.left.and.right"
|
||||
case .dab: "antenna.radiowaves.left.and.right"
|
||||
case .stream: "dot.radiowaves.forward"
|
||||
case .bluetooth: "dot.radiowaves.left.and.right"
|
||||
case .audioProfiles: "waveform.path.ecg"
|
||||
case .presets: "star.fill"
|
||||
case .settings: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsRootView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section("DigiRadio") {
|
||||
NavigationLink("Connessione") { ConnectionView() }
|
||||
NavigationLink("Informazioni dispositivo") { DeviceInfoView() }
|
||||
NavigationLink("Diagnostica") { DiagnosticsView() }
|
||||
NavigationLink("Firmware") { FirmwareView() }
|
||||
}
|
||||
Section("Radio") {
|
||||
NavigationLink("FM") { FMRadioView() }
|
||||
NavigationLink("DAB") { DABRadioView() }
|
||||
NavigationLink("Web radio stream") { StreamingView() }
|
||||
NavigationLink("Preset") { PresetsView() }
|
||||
}
|
||||
Section("Audio") {
|
||||
NavigationLink {
|
||||
AudioProfilesView()
|
||||
} label: {
|
||||
Label("Profili audio", systemImage: "waveform.path.ecg")
|
||||
}
|
||||
NavigationLink {
|
||||
AudioView()
|
||||
} label: {
|
||||
Label("Mixer & volume", systemImage: "slider.vertical.3")
|
||||
}
|
||||
NavigationLink("Bluetooth speaker") { BluetoothView() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Impostazioni")
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceInfoView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
|
||||
var body: some View {
|
||||
let health = environment.state.health
|
||||
let chips = health.chips
|
||||
Form {
|
||||
LabeledContent("Nome", value: "DigiRadio")
|
||||
LabeledContent("Firmware", value: health.firmware.isEmpty ? "—" : health.firmware)
|
||||
LabeledContent("Serial number", value: health.serialNumber.isEmpty ? "—" : health.serialNumber)
|
||||
LabeledContent("Stato", value: health.status.isEmpty ? "—" : health.status)
|
||||
Section("Chip") {
|
||||
LabeledContent("Si4684", value: chips.si4684 ? "OK" : "—")
|
||||
LabeledContent("ADAU1701", value: chips.adau1701 ? "OK" : "—")
|
||||
LabeledContent("BT1035", value: chips.bt1035 ? "OK" : "—")
|
||||
}
|
||||
Section("Connessione") {
|
||||
LabeledContent("Host", value: environment.state.connection.host.isEmpty ? "—" : environment.state.connection.host)
|
||||
LabeledContent("HTTP", value: environment.state.connection.isConnected ? "Connesso" : "Non connesso")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Dispositivo")
|
||||
.onAppear { Task { try? await environment.digiRadio.refreshHealth() } }
|
||||
}
|
||||
}
|
||||
|
||||
struct DiagnosticsView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Tuner") {
|
||||
LabeledContent("Booted", value: environment.state.tuner.booted ? "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() } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct StreamingView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var viewModel: StreamingViewModel?
|
||||
@State private var showFilePicker = false
|
||||
|
||||
var body: some View {
|
||||
let vm = viewModel ?? StreamingViewModel(service: environment.digiRadio)
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
sourcePicker(vm)
|
||||
statusHero(vm)
|
||||
|
||||
switch vm.source {
|
||||
case .webRadio:
|
||||
webRadioSection(vm)
|
||||
case .urlContent:
|
||||
urlContentSection(vm)
|
||||
case .localFile:
|
||||
localFileSection(vm)
|
||||
}
|
||||
|
||||
if let error = vm.errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.igiPremiumCard()
|
||||
}
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
}
|
||||
.background(IGIHeroBackground())
|
||||
.navigationTitle("Stream")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.fileImporter(
|
||||
isPresented: $showFilePicker,
|
||||
allowedContentTypes: [.audio, .mp3, .mpeg4Audio, .wav, .aiff],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(urls) = result, let url = urls.first {
|
||||
vm.pickFile(url)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if viewModel == nil { viewModel = vm }
|
||||
Task { await vm.load() }
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel?.stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourcePicker(_ vm: StreamingViewModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
Text("Sorgente")
|
||||
.font(.headline)
|
||||
ForEach(StreamSource.allCases) { item in
|
||||
Button {
|
||||
withAnimation(.snappy) { vm.source = item }
|
||||
} label: {
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
Image(systemName: item.icon)
|
||||
.frame(width: 28)
|
||||
.foregroundStyle(vm.source == item ? .white : IGITheme.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.rawValue).font(.subheadline.weight(.semibold))
|
||||
Text(item.subtitle).font(.caption2).foregroundStyle(vm.source == item ? .white.opacity(0.85) : .secondary)
|
||||
}
|
||||
Spacer()
|
||||
if vm.source == item {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
.background(
|
||||
vm.source == item
|
||||
? AnyShapeStyle(IGITheme.accent.gradient)
|
||||
: AnyShapeStyle(Color.primary.opacity(0.05)),
|
||||
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(vm.source == item ? .white : .primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusHero(_ vm: StreamingViewModel) -> some View {
|
||||
let active = vm.source == .localFile ? vm.phoneStreamActive : vm.enabled
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
Image(systemName: active ? "dot.radiowaves.forward" : "pause.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(active ? IGITheme.accent : .secondary)
|
||||
.symbolEffect(.pulse, options: .repeating, value: active)
|
||||
|
||||
Text(active ? "In riproduzione" : "Fermo")
|
||||
.font(.title3.weight(.bold))
|
||||
Text(statusSubtitle(vm))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
private func statusSubtitle(_ vm: StreamingViewModel) -> String {
|
||||
switch vm.source {
|
||||
case .webRadio, .urlContent:
|
||||
return vm.enabled ? vm.url : "Nessuno stream web attivo"
|
||||
case .localFile:
|
||||
return vm.phoneStreamStatus.isEmpty ? "Nessun file in invio" : vm.phoneStreamStatus
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func webRadioSection(_ vm: StreamingViewModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
Text("Stazioni")
|
||||
.font(.headline)
|
||||
ForEach(StreamPresets.catalog) { preset in
|
||||
Button {
|
||||
vm.selectPreset(preset)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(preset.name).font(.headline)
|
||||
Text(preset.url).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "play.circle.fill").font(.title2)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if preset.id != StreamPresets.catalog.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
|
||||
streamURLControls(vm, playLabel: "Play radio")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func urlContentSection(_ vm: StreamingViewModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
Text("Contenuto via URL")
|
||||
.font(.headline)
|
||||
Text("Il DigiRadio scarica e decodifica MP3 HTTP sul device. L'URL deve iniziare con http:// (max 200 caratteri).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.igiPremiumCard()
|
||||
|
||||
streamURLControls(vm, playLabel: "Play contenuto")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func streamURLControls(_ vm: StreamingViewModel, playLabel: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
TextField("http://…", text: Binding(get: { vm.url }, set: { vm.url = $0 }))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.padding()
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
Button { Task { await vm.playWebStream() } } label: {
|
||||
Label(playLabel, systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(vm.isSaving || !vm.isConnected)
|
||||
|
||||
Button { Task { await vm.stopWebStream() } } label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(vm.isSaving)
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func localFileSection(_ vm: StreamingViewModel) -> some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingM) {
|
||||
Text("File sul telefono")
|
||||
.font(.headline)
|
||||
Text("L'iPhone decodifica il file e invia PCM stereo 48 kHz a PUT /api/stream/phone. Ferma prima eventuali stream web sul device.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button {
|
||||
showFilePicker = true
|
||||
} label: {
|
||||
Label(vm.selectedFileName.isEmpty ? "Scegli file audio" : vm.selectedFileName, systemImage: "folder")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding()
|
||||
.background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
HStack(spacing: IGITheme.spacingM) {
|
||||
Button { Task { await vm.playLocalFile() } } label: {
|
||||
Label("Invia a DigiRadio", systemImage: "arrow.up.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!vm.isConnected || vm.selectedFileURL == nil || vm.phoneStreamActive)
|
||||
|
||||
Button { Task { await vm.stopLocalFile() } } label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!vm.phoneStreamActive)
|
||||
}
|
||||
}
|
||||
.igiPremiumCard()
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,16 @@ void sigma_studio_unlock(void);
|
||||
/**
|
||||
* @brief Write a contiguous register/data block to the ADAU1701.
|
||||
*
|
||||
* Retries each I2C chunk up to 3 times on NACK, matching the reliability
|
||||
* already applied to the runtime safeload path (see sigma_i2c_write).
|
||||
*
|
||||
* @param devAddress SigmaStudio device address (0x68 write addr).
|
||||
* @param address 16-bit target address in DSP memory map.
|
||||
* @param length Payload length in bytes (may exceed 255).
|
||||
* @param pData Payload bytes.
|
||||
* @return 0 on success, non-zero if any chunk failed after retries.
|
||||
*/
|
||||
void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
|
||||
int SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
|
||||
unsigned int address,
|
||||
unsigned int length,
|
||||
ADI_REG_TYPE* pData);
|
||||
@@ -69,6 +73,22 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
|
||||
*/
|
||||
int sigma_i2c_read(unsigned int reg, unsigned char* data, unsigned int length);
|
||||
|
||||
/**
|
||||
* @brief Read back a previously written block and compare it byte-for-byte.
|
||||
*
|
||||
* Diagnostic aid for boot-time DSP program replay: a chunk that ACKed but
|
||||
* still landed wrong (or a register that doesn't hold the value it was
|
||||
* given) shows up here even though SIGMA_WRITE_REGISTER_BLOCK reported
|
||||
* success. Read-only — never aborts anything by itself, callers decide.
|
||||
*
|
||||
* @param address 16-bit address the block was written to.
|
||||
* @param expected Bytes that were written.
|
||||
* @param length Payload length in bytes (may exceed 255).
|
||||
* @return 0 if the read-back matches, non-zero on mismatch or read failure.
|
||||
*/
|
||||
int sigma_verify_block(unsigned int address, const ADI_REG_TYPE* expected,
|
||||
unsigned int length);
|
||||
|
||||
/** Safeload data register base (0x0810..0x0814). */
|
||||
#define ADAU1701_SAFELOAD_DATA_BASE 0x0810U
|
||||
/** Safeload address register base (0x0815..0x0819). */
|
||||
|
||||
@@ -112,6 +112,34 @@ struct BluetoothConnectRequest {
|
||||
[[nodiscard]] std::expected<std::uint8_t, ParseError>
|
||||
parseBluetoothAutoReconnectJson(std::string_view json);
|
||||
|
||||
/**
|
||||
* @brief parseBluetoothA2dpCodecConfigJson — validate POST codec_mask field.
|
||||
*
|
||||
* @dname parseBluetoothA2dpCodecConfigJson
|
||||
* @param json Request body with \c codec_mask 0–63 (see AT+A2DPCFG bits).
|
||||
* @return Bitmask on success, or ParseError.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-24
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::uint8_t, ParseError>
|
||||
parseBluetoothA2dpCodecConfigJson(std::string_view json);
|
||||
|
||||
/**
|
||||
* @brief serializeBluetoothA2dpCodecJson — negotiated codec for HTTP.
|
||||
*
|
||||
* @dname serializeBluetoothA2dpCodecJson
|
||||
* @param codec Parsed negotiated codec (AT+A2DPENC reply).
|
||||
* @return JSON object \c {"codec":"..."}.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-24
|
||||
*/
|
||||
[[nodiscard]] std::string serializeBluetoothA2dpCodecJson(
|
||||
Bt1035A2dpCodec codec);
|
||||
|
||||
/**
|
||||
* @brief serializeBluetoothScanJson — serialise scan result list.
|
||||
*
|
||||
|
||||
@@ -40,7 +40,8 @@ enum class Bt1035AtCommand {
|
||||
Reset, ///< AT+RESET — software reset (best-effort, not gated on OK).
|
||||
Ping, ///< AT — link check.
|
||||
I2sMode, ///< AT+AUXCFG=3 — I2S input from ADAU1701 (mandatory).
|
||||
I2sSlave48k24, ///< AT+I2SCFG=35 — I2S slave 48 kHz 24-bit (§5.1.4).
|
||||
I2sSlave48k32, ///< AT+I2SCFG=67 — I2S slave 48 kHz 32-bit (§5.1.4).
|
||||
A2dpCodecConfig, ///< AT+A2DPCFG=1 — enable AAC alongside mandatory SBC (§5.3.4).
|
||||
PairDiscoverable, ///< AT+PAIR=1 — enter BR/EDR/BLE discoverable mode.
|
||||
PairHidden, ///< AT+PAIR=0 — leave discoverable mode.
|
||||
A2dpStat, ///< AT+A2DPSTAT — read A2DP link state.
|
||||
@@ -105,16 +106,40 @@ enum class Bt1035AtResponseKind {
|
||||
};
|
||||
|
||||
/** Number of commands in bootInitSequence(). */
|
||||
inline constexpr std::size_t kBt1035BootInitCommandCount = 3U;
|
||||
inline constexpr std::size_t kBt1035BootInitCommandCount = 4U;
|
||||
|
||||
/**
|
||||
* Feasycom programming guide §5.1.4: I2S slave, 48 kHz, 24-bit — matches the
|
||||
* ADAU1701 serial output word length (SigmaStudio Hardware Configuration).
|
||||
* Bit field: BIT[0]=enable(1), BIT[1]=slave(1), BIT[2]=FS 48kHz(0),
|
||||
* BIT[3]=left justified(0), BIT[4]=data 1-bit delay(0), BIT[5:6]=24-bit(01)
|
||||
* -> 1 + 2 + 32 = 35.
|
||||
* Feasycom programming guide §5.3.4: AT+A2DPCFG bit field enables optional
|
||||
* codecs on top of the mandatory baseline SBC (BIT0=AAC, BIT1=aptX,
|
||||
* BIT2=aptX-LL, BIT3=aptX-HD, BIT4=aptX-Adaptive, BIT5=LDAC; BT1035 does
|
||||
* not support BT806's FastStream). Never sent before 2026-08-24, so every
|
||||
* A2DP link negotiated SBC only regardless of what the paired speaker
|
||||
* supports. Value 1 = AAC only, matching the guide's own worked example
|
||||
* -- picked over a wider bitmask because AAC is the most broadly
|
||||
* supported optional codec on consumer speakers (native on iOS) and this
|
||||
* is the datasheet-validated example, not an untested combination.
|
||||
*/
|
||||
inline constexpr std::uint8_t kBt1035I2sSlave48k24Param = 35U;
|
||||
inline constexpr std::uint8_t kBt1035A2dpCodecConfigParam = 1U;
|
||||
|
||||
/**
|
||||
* Feasycom programming guide §5.1.4: I2S slave, 48 kHz, 32-bit -> value 67.
|
||||
* This is one of only two configurations the guide validates with explicit
|
||||
* bit-clock math (the other being value 3, 16-bit); 24-bit (35, used here
|
||||
* until 2026-08-24) is not a documented example. Matches the ADAU1701's
|
||||
* actual physical output framing: the Serial Output Control Register
|
||||
* (0x081E, R15_BCLK_FREQ/R15_LRCLK_FREQ fields in the compiled SigmaStudio
|
||||
* export) fixes BCLK=3.072 MHz / LRCLK=48 kHz regardless of the OWL
|
||||
* "word length" field -- i.e. a 64-BCLK-cycle (32-bit) frame is what's
|
||||
* physically on the wire no matter what OWL says, so 67 is the value that
|
||||
* matches reality; 35 (24-bit) silently regressed in commit 6f7b6dd
|
||||
* (2026-08-08, an unrelated large feature commit) and was found to
|
||||
* contradict every other citation of this value in the repo
|
||||
* (instructions.md, README.md, CLAUDE.md, AGENTS.md, docs/TODO.md).
|
||||
* Bit field: BIT[0]=enable(1), BIT[1]=slave(1), BIT[2]=FS 48kHz(0),
|
||||
* BIT[3]=left justified(0), BIT[4]=data 1-bit delay(0), BIT[5:6]=32-bit(10)
|
||||
* -> 1 + 2 + 64 = 67.
|
||||
*/
|
||||
inline constexpr std::uint8_t kBt1035I2sSlave48k32Param = 67U;
|
||||
|
||||
/**
|
||||
* @brief buildBt1035AtLine — serialise a command with CRLF terminator.
|
||||
@@ -133,7 +158,7 @@ inline constexpr std::uint8_t kBt1035I2sSlave48k24Param = 35U;
|
||||
* @brief bootInitSequence — mandatory bring-up commands in order.
|
||||
*
|
||||
* @dname bootInitSequence
|
||||
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k24 (I2SCFG=35).
|
||||
* @return Ping, I2sMode (AUXCFG=3), I2sSlave48k32 (I2SCFG=67).
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
@@ -210,6 +235,23 @@ parseBt1035A2dpEncoderResponse(std::string_view response);
|
||||
*/
|
||||
[[nodiscard]] const char* a2dpCodecToken(Bt1035A2dpCodec codec) noexcept;
|
||||
|
||||
/**
|
||||
* @brief buildBt1035A2dpCodecConfigLine — AT+A2DPCFG with runtime bitmask.
|
||||
*
|
||||
* @dname buildBt1035A2dpCodecConfigLine
|
||||
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
|
||||
* BIT4=aptX-Adaptive, BIT5=LDAC (§5.3.4); clamped to 0-63.
|
||||
* 0 disables every optional codec (SBC-only baseline).
|
||||
* @return Full AT line including CRLF.
|
||||
* @pubstate Takes effect on the next A2DP negotiation, not an already
|
||||
* streaming link — the peer must reconnect for the change to
|
||||
* apply.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-24
|
||||
*/
|
||||
[[nodiscard]] std::string buildBt1035A2dpCodecConfigLine(std::uint8_t bitmask);
|
||||
|
||||
/**
|
||||
* @brief buildBt1035SetAutoConnLine — AT+AUTOCONN with reconnect count.
|
||||
*
|
||||
|
||||
@@ -86,6 +86,10 @@ public:
|
||||
*
|
||||
* @dname tuneDab
|
||||
* @param freqIndex Ensemble index 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<void, TunerError> tuneDab(
|
||||
std::uint8_t freqIndex) = 0;
|
||||
std::uint8_t freqIndex, std::uint8_t antCap = 0U) = 0;
|
||||
|
||||
/**
|
||||
* @brief tuneFm — tune to an FM centre frequency.
|
||||
|
||||
@@ -41,8 +41,9 @@ struct TunerTuneRequest {
|
||||
TunerBand band; ///< Target band (Dab or Fm).
|
||||
std::uint8_t dabFreqIndex; ///< Band III ensemble index (0–37) when band is Dab.
|
||||
std::optional<FrequencyKHz> fmFrequency; ///< FM centre frequency when band is Fm.
|
||||
std::optional<std::uint8_t> antCap; ///< FM antenna varactor override (0–128),
|
||||
///< for calibration sweeps; ignored for Dab.
|
||||
std::optional<std::uint8_t> 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<TunerFmScannedStation>& stations);
|
||||
|
||||
/**
|
||||
* @brief AntennaCalibrationRequest — parsed POST
|
||||
* /api/tuner/calibrate-antenna body.
|
||||
*
|
||||
* @dname AntennaCalibrationRequest
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO filled by parseAntennaCalibrationJson at the HTTP
|
||||
* boundary.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-20
|
||||
*/
|
||||
struct AntennaCalibrationRequest {
|
||||
TunerBand band; ///< Which band's ANTCAP default this saves.
|
||||
std::uint8_t antCap; ///< Value to persist (0-128).
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief parseAntennaCalibrationJson — validate POST
|
||||
* /api/tuner/calibrate-antenna body.
|
||||
*
|
||||
* @dname parseAntennaCalibrationJson
|
||||
* @param json Untrusted request body from the HTTP handler.
|
||||
* @return ANTCAP value (0-128) on success, or a ParseError.
|
||||
* @return Band + ANTCAP value (0-128) on success, or a ParseError.
|
||||
* `band` defaults to Fm when the field is omitted, preserving
|
||||
* the original FM-only request shape.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-19
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::uint8_t, ParseError>
|
||||
[[nodiscard]] std::expected<AntennaCalibrationRequest, ParseError>
|
||||
parseAntennaCalibrationJson(std::string_view json);
|
||||
|
||||
/**
|
||||
* @brief XtalCalibrationRequest — parsed POST
|
||||
* /api/tuner/xtal-calibrate body.
|
||||
*
|
||||
* @dname XtalCalibrationRequest
|
||||
* @return n/a (type)
|
||||
* @pubstate Plain DTO filled by parseXtalCalibrationJson at the HTTP
|
||||
* boundary. Diagnostic-only: live Si4684 crystal parameter
|
||||
* recalibration, no ESP32 restart required.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-23
|
||||
*/
|
||||
struct XtalCalibrationRequest {
|
||||
std::uint8_t ibias; ///< POWER_UP ARG3 IBIAS (0-127).
|
||||
std::uint8_t ctun; ///< POWER_UP ARG8 CTUN (0-63).
|
||||
std::uint32_t xtalFreqHz; ///< POWER_UP ARG4-7 XTAL_FREQ in Hz.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief parseXtalCalibrationJson — validate POST
|
||||
* /api/tuner/xtal-calibrate body.
|
||||
*
|
||||
* @dname parseXtalCalibrationJson
|
||||
* @param json Untrusted request body from the HTTP handler.
|
||||
* @return Crystal parameters on success, or a ParseError. `ibias` and
|
||||
* `ctun` default to the values already loaded at boot when
|
||||
* omitted (unusual to omit, but harmless); `xtal_freq_hz`
|
||||
* defaults to the nominal 19,200,000 Hz.
|
||||
* @pubstate none
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-23
|
||||
*/
|
||||
[[nodiscard]] std::expected<XtalCalibrationRequest, ParseError>
|
||||
parseXtalCalibrationJson(std::string_view json);
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -63,6 +63,11 @@ struct TunerStatus {
|
||||
std::optional<FrequencyKHz> fmChipReadFrequency; ///< FM_RSQ READFREQ (may lag commanded).
|
||||
std::optional<std::int8_t> fmRssiDbuV; ///< FM RSSI in dBµV.
|
||||
std::optional<std::int8_t> fmSnrDb; ///< FM SNR in dB.
|
||||
/** AN649 FM_RSQ_STATUS FREQOFF, units of 2 PPM. Crystal calibration
|
||||
* signal: real broadcast carriers are GPS/rubidium-locked, so a
|
||||
* nonzero reading here reflects the local XTAL_FREQ/CTUN reference
|
||||
* error, not the station. */
|
||||
std::optional<std::int8_t> fmFreqOffBppm;
|
||||
std::optional<bool> fmStereo; ///< FM stereo pilot detected.
|
||||
std::optional<BroadcastLabel> fmStationName; ///< FM RDS program service name.
|
||||
std::optional<BroadcastLabel> fmRadiotext; ///< FM RDS radiotext (RT).
|
||||
|
||||
@@ -58,15 +58,24 @@ namespace {
|
||||
std::string_view key,
|
||||
bool& out)
|
||||
{
|
||||
const std::string trueNeedle =
|
||||
std::string("\"") + std::string(key) + "\":true";
|
||||
const std::string falseNeedle =
|
||||
std::string("\"") + std::string(key) + "\":false";
|
||||
if (json.find(trueNeedle) != std::string_view::npos) {
|
||||
const std::string needle =
|
||||
std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t needlePos = json.find(needle);
|
||||
if (needlePos == std::string_view::npos) {
|
||||
return false;
|
||||
}
|
||||
std::size_t start = needlePos + needle.size();
|
||||
while (start < json.size()
|
||||
&& (json[start] == ' ' || json[start] == '\t'
|
||||
|| json[start] == '\r' || json[start] == '\n')) {
|
||||
++start;
|
||||
}
|
||||
const std::string_view rest = json.substr(start);
|
||||
if (rest.starts_with("true")) {
|
||||
out = true;
|
||||
return true;
|
||||
}
|
||||
if (json.find(falseNeedle) != std::string_view::npos) {
|
||||
if (rest.starts_with("false")) {
|
||||
out = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,33 @@ parseBluetoothAutoReconnectJson(std::string_view json)
|
||||
return static_cast<std::uint8_t>(raw);
|
||||
}
|
||||
|
||||
std::expected<std::uint8_t, ParseError>
|
||||
parseBluetoothA2dpCodecConfigJson(std::string_view json)
|
||||
{
|
||||
if (json.find('{') == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
const std::string needle = "\"codec_mask\":";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
char* end = nullptr;
|
||||
const unsigned long raw =
|
||||
std::strtoul(json.data() + start + needle.size(), &end, 10);
|
||||
if (end == json.data() + start + needle.size() || raw > 63U) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
return static_cast<std::uint8_t>(raw);
|
||||
}
|
||||
|
||||
std::string serializeBluetoothA2dpCodecJson(Bt1035A2dpCodec codec)
|
||||
{
|
||||
std::ostringstream out;
|
||||
out << "{\"codec\":\"" << a2dpCodecToken(codec) << "\"}";
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string serializeBluetoothScanJson(
|
||||
const std::vector<Bt1035ScannedDevice>& devices)
|
||||
{
|
||||
@@ -123,12 +150,21 @@ parseBluetoothConnectJson(std::string_view json)
|
||||
if (json.find('{') == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
const std::string needle = "\"mac\":\"";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
const std::string needle = "\"mac\":";
|
||||
const std::size_t needlePos = json.find(needle);
|
||||
if (needlePos == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
std::size_t start = needlePos + needle.size();
|
||||
while (start < json.size()
|
||||
&& (json[start] == ' ' || json[start] == '\t'
|
||||
|| json[start] == '\r' || json[start] == '\n')) {
|
||||
++start;
|
||||
}
|
||||
if (start >= json.size() || json[start] != '"') {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
const std::size_t valueStart = start + 1U;
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
@@ -156,17 +192,35 @@ BluetoothConnectRequest parseBluetoothConnectRequest(std::string_view json)
|
||||
if (auto mac = parseBluetoothConnectJson(json); mac) {
|
||||
request.mac = std::move(*mac);
|
||||
}
|
||||
const std::string nameNeedle = "\"name\":\"";
|
||||
const std::size_t nameStart = json.find(nameNeedle);
|
||||
if (nameStart != std::string_view::npos) {
|
||||
const std::size_t valueStart = nameStart + nameNeedle.size();
|
||||
const std::string nameNeedle = "\"name\":";
|
||||
const std::size_t nameNeedlePos = json.find(nameNeedle);
|
||||
if (nameNeedlePos != std::string_view::npos) {
|
||||
std::size_t nameStart = nameNeedlePos + nameNeedle.size();
|
||||
while (nameStart < json.size()
|
||||
&& (json[nameStart] == ' ' || json[nameStart] == '\t'
|
||||
|| json[nameStart] == '\r' || json[nameStart] == '\n')) {
|
||||
++nameStart;
|
||||
}
|
||||
if (nameStart < json.size() && json[nameStart] == '"') {
|
||||
const std::size_t valueStart = nameStart + 1U;
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd != std::string_view::npos) {
|
||||
request.name.assign(json.substr(valueStart, valueEnd - valueStart));
|
||||
request.name.assign(
|
||||
json.substr(valueStart, valueEnd - valueStart));
|
||||
}
|
||||
}
|
||||
request.save = json.find("\"save\":true") != std::string_view::npos
|
||||
|| json.find("\"save\": true") != std::string_view::npos;
|
||||
}
|
||||
const std::string saveNeedle = "\"save\":";
|
||||
const std::size_t saveNeedlePos = json.find(saveNeedle);
|
||||
if (saveNeedlePos != std::string_view::npos) {
|
||||
std::size_t saveStart = saveNeedlePos + saveNeedle.size();
|
||||
while (saveStart < json.size()
|
||||
&& (json[saveStart] == ' ' || json[saveStart] == '\t'
|
||||
|| json[saveStart] == '\r' || json[saveStart] == '\n')) {
|
||||
++saveStart;
|
||||
}
|
||||
request.save = json.substr(saveStart).starts_with("true");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -192,13 +246,22 @@ parseBluetoothSpeakerJson(std::string_view json)
|
||||
return std::unexpected(mac.error());
|
||||
}
|
||||
BtSpeakerTarget target{.mac = *mac, .name = {}};
|
||||
const std::string nameNeedle = "\"name\":\"";
|
||||
const std::size_t nameStart = json.find(nameNeedle);
|
||||
if (nameStart != std::string_view::npos) {
|
||||
const std::size_t valueStart = nameStart + nameNeedle.size();
|
||||
const std::string nameNeedle = "\"name\":";
|
||||
const std::size_t nameNeedlePos = json.find(nameNeedle);
|
||||
if (nameNeedlePos != std::string_view::npos) {
|
||||
std::size_t nameStart = nameNeedlePos + nameNeedle.size();
|
||||
while (nameStart < json.size()
|
||||
&& (json[nameStart] == ' ' || json[nameStart] == '\t'
|
||||
|| json[nameStart] == '\r' || json[nameStart] == '\n')) {
|
||||
++nameStart;
|
||||
}
|
||||
if (nameStart < json.size() && json[nameStart] == '"') {
|
||||
const std::size_t valueStart = nameStart + 1U;
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd != std::string_view::npos) {
|
||||
target.name.assign(json.substr(valueStart, valueEnd - valueStart));
|
||||
target.name.assign(
|
||||
json.substr(valueStart, valueEnd - valueStart));
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
|
||||
@@ -106,8 +106,10 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
|
||||
return "AT\r\n";
|
||||
case Bt1035AtCommand::I2sMode:
|
||||
return "AT+AUXCFG=3\r\n";
|
||||
case Bt1035AtCommand::I2sSlave48k24:
|
||||
return "AT+I2SCFG=35\r\n";
|
||||
case Bt1035AtCommand::I2sSlave48k32:
|
||||
return "AT+I2SCFG=67\r\n";
|
||||
case Bt1035AtCommand::A2dpCodecConfig:
|
||||
return buildBt1035A2dpCodecConfigLine(kBt1035A2dpCodecConfigParam);
|
||||
case Bt1035AtCommand::PairDiscoverable:
|
||||
return "AT+PAIR=1\r\n";
|
||||
case Bt1035AtCommand::PairHidden:
|
||||
@@ -128,6 +130,14 @@ std::string buildBt1035AtLine(Bt1035AtCommand command)
|
||||
return "AT\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035A2dpCodecConfigLine(std::uint8_t bitmask)
|
||||
{
|
||||
if (bitmask > 63U) {
|
||||
bitmask = 63U;
|
||||
}
|
||||
return "AT+A2DPCFG=" + std::to_string(bitmask) + "\r\n";
|
||||
}
|
||||
|
||||
std::string buildBt1035SetAutoConnLine(std::uint8_t times)
|
||||
{
|
||||
if (times > 15U) {
|
||||
@@ -150,7 +160,8 @@ std::array<Bt1035AtCommand, kBt1035BootInitCommandCount> bootInitSequence() noex
|
||||
return std::array<Bt1035AtCommand, kBt1035BootInitCommandCount>{
|
||||
Bt1035AtCommand::Ping,
|
||||
Bt1035AtCommand::I2sMode,
|
||||
Bt1035AtCommand::I2sSlave48k24,
|
||||
Bt1035AtCommand::I2sSlave48k32,
|
||||
Bt1035AtCommand::A2dpCodecConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,12 +24,21 @@ namespace {
|
||||
std::string_view key)
|
||||
{
|
||||
const std::string needle =
|
||||
std::string("\"") + std::string(key) + "\":\"";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t needlePos = json.find(needle);
|
||||
if (needlePos == std::string_view::npos) {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
std::size_t start = needlePos + needle.size();
|
||||
while (start < json.size()
|
||||
&& (json[start] == ' ' || json[start] == '\t'
|
||||
|| json[start] == '\r' || json[start] == '\n')) {
|
||||
++start;
|
||||
}
|
||||
if (start >= json.size() || json[start] != '"') {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + 1U;
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd == std::string_view::npos) {
|
||||
return {};
|
||||
|
||||
@@ -24,12 +24,21 @@ namespace {
|
||||
std::string_view key)
|
||||
{
|
||||
const std::string needle =
|
||||
std::string("\"") + std::string(key) + "\":\"";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t needlePos = json.find(needle);
|
||||
if (needlePos == std::string_view::npos) {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
std::size_t start = needlePos + needle.size();
|
||||
while (start < json.size()
|
||||
&& (json[start] == ' ' || json[start] == '\t'
|
||||
|| json[start] == '\r' || json[start] == '\n')) {
|
||||
++start;
|
||||
}
|
||||
if (start >= json.size() || json[start] != '"') {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + 1U;
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd == std::string_view::npos) {
|
||||
return {};
|
||||
|
||||
@@ -24,12 +24,21 @@ namespace {
|
||||
std::string_view key)
|
||||
{
|
||||
const std::string needle =
|
||||
std::string("\"") + std::string(key) + "\":\"";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t needlePos = json.find(needle);
|
||||
if (needlePos == std::string_view::npos) {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
std::size_t start = needlePos + needle.size();
|
||||
while (start < json.size()
|
||||
&& (json[start] == ' ' || json[start] == '\t'
|
||||
|| json[start] == '\r' || json[start] == '\n')) {
|
||||
++start;
|
||||
}
|
||||
if (start >= json.size() || json[start] != '"') {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + 1U;
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd == std::string_view::npos) {
|
||||
return {};
|
||||
@@ -129,6 +138,10 @@ std::string serializeTunerStatusJson(const TunerStatus& status)
|
||||
if (status.fmSnrDb) {
|
||||
out << ",\"snr_db\":" << static_cast<int>(*status.fmSnrDb);
|
||||
}
|
||||
if (status.fmFreqOffBppm) {
|
||||
out << ",\"freqoff_ppm\":"
|
||||
<< (static_cast<int>(*status.fmFreqOffBppm) * 2);
|
||||
}
|
||||
if (status.fmStereo) {
|
||||
out << ",\"stereo\":" << (*status.fmStereo ? "true" : "false");
|
||||
}
|
||||
@@ -200,13 +213,13 @@ std::expected<TunerTuneRequest, ParseError> parseTunerTuneJson(
|
||||
return std::unexpected(freq.error());
|
||||
}
|
||||
req.fmFrequency = *freq;
|
||||
} else {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
unsigned long antCap = 0U;
|
||||
if (extractJsonUint(json, "antcap", antCap) && antCap <= 128U) {
|
||||
req.antCap = static_cast<std::uint8_t>(antCap);
|
||||
}
|
||||
} else {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
@@ -340,8 +353,8 @@ std::string serializeTunerFmBandScanJson(
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::expected<std::uint8_t, ParseError> parseAntennaCalibrationJson(
|
||||
std::string_view json)
|
||||
std::expected<AntennaCalibrationRequest, ParseError>
|
||||
parseAntennaCalibrationJson(std::string_view json)
|
||||
{
|
||||
if (json.find('{') == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
@@ -350,7 +363,55 @@ std::expected<std::uint8_t, ParseError> parseAntennaCalibrationJson(
|
||||
if (!extractJsonUint(json, "antcap", antCap) || antCap > 128U) {
|
||||
return std::unexpected(ParseError::MissingField);
|
||||
}
|
||||
return static_cast<std::uint8_t>(antCap);
|
||||
|
||||
AntennaCalibrationRequest req = {};
|
||||
req.antCap = static_cast<std::uint8_t>(antCap);
|
||||
const std::string_view band = extractJsonString(json, "band");
|
||||
if (band == "dab") {
|
||||
req.band = TunerBand::Dab;
|
||||
} else if (band.empty() || band == "fm") {
|
||||
req.band = TunerBand::Fm;
|
||||
} else {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
std::expected<XtalCalibrationRequest, ParseError>
|
||||
parseXtalCalibrationJson(std::string_view json)
|
||||
{
|
||||
if (json.find('{') == std::string_view::npos) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
// Defaults match Si4684Driver::boot()'s own defaults -- a request that
|
||||
// only wants to change one parameter can omit the others.
|
||||
XtalCalibrationRequest req = {};
|
||||
req.ibias = 72U;
|
||||
req.ctun = 31U;
|
||||
req.xtalFreqHz = 19200000U;
|
||||
|
||||
unsigned long ibias = 0U;
|
||||
if (extractJsonUint(json, "ibias", ibias)) {
|
||||
if (ibias > 127U) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
req.ibias = static_cast<std::uint8_t>(ibias);
|
||||
}
|
||||
|
||||
unsigned long ctun = 0U;
|
||||
if (extractJsonUint(json, "ctun", ctun)) {
|
||||
if (ctun > 63U) {
|
||||
return std::unexpected(ParseError::InvalidJson);
|
||||
}
|
||||
req.ctun = static_cast<std::uint8_t>(ctun);
|
||||
}
|
||||
|
||||
unsigned long xtalFreqHz = 0U;
|
||||
if (extractJsonUint(json, "xtal_freq_hz", xtalFreqHz)) {
|
||||
req.xtalFreqHz = static_cast<std::uint32_t>(xtalFreqHz);
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
|
||||
@@ -24,12 +24,21 @@ constexpr std::string_view kHttpPrefix = "http://";
|
||||
std::string_view key)
|
||||
{
|
||||
const std::string needle =
|
||||
std::string("\"") + std::string(key) + "\":\"";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t needlePos = json.find(needle);
|
||||
if (needlePos == std::string_view::npos) {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
std::size_t start = needlePos + needle.size();
|
||||
while (start < json.size()
|
||||
&& (json[start] == ' ' || json[start] == '\t'
|
||||
|| json[start] == '\r' || json[start] == '\n')) {
|
||||
++start;
|
||||
}
|
||||
if (start >= json.size() || json[start] != '"') {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + 1U;
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd == std::string_view::npos) {
|
||||
return {};
|
||||
@@ -40,15 +49,24 @@ constexpr std::string_view kHttpPrefix = "http://";
|
||||
[[nodiscard]] bool extractJsonBool(std::string_view json,
|
||||
std::string_view key, bool& out)
|
||||
{
|
||||
const std::string trueNeedle =
|
||||
std::string("\"") + std::string(key) + "\":true";
|
||||
const std::string falseNeedle =
|
||||
std::string("\"") + std::string(key) + "\":false";
|
||||
if (json.find(trueNeedle) != std::string_view::npos) {
|
||||
const std::string needle =
|
||||
std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t needlePos = json.find(needle);
|
||||
if (needlePos == std::string_view::npos) {
|
||||
return false;
|
||||
}
|
||||
std::size_t start = needlePos + needle.size();
|
||||
while (start < json.size()
|
||||
&& (json[start] == ' ' || json[start] == '\t'
|
||||
|| json[start] == '\r' || json[start] == '\n')) {
|
||||
++start;
|
||||
}
|
||||
const std::string_view rest = json.substr(start);
|
||||
if (rest.starts_with("true")) {
|
||||
out = true;
|
||||
return true;
|
||||
}
|
||||
if (json.find(falseNeedle) != std::string_view::npos) {
|
||||
if (rest.starts_with("false")) {
|
||||
out = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -37,12 +37,21 @@ namespace {
|
||||
[[nodiscard]] std::string_view extractJsonString(std::string_view json,
|
||||
std::string_view key)
|
||||
{
|
||||
const std::string needle = std::string("\"") + std::string(key) + "\":\"";
|
||||
const std::size_t start = json.find(needle);
|
||||
if (start == std::string_view::npos) {
|
||||
const std::string needle = std::string("\"") + std::string(key) + "\":";
|
||||
const std::size_t needlePos = json.find(needle);
|
||||
if (needlePos == std::string_view::npos) {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + needle.size();
|
||||
std::size_t start = needlePos + needle.size();
|
||||
while (start < json.size()
|
||||
&& (json[start] == ' ' || json[start] == '\t'
|
||||
|| json[start] == '\r' || json[start] == '\n')) {
|
||||
++start;
|
||||
}
|
||||
if (start >= json.size() || json[start] != '"') {
|
||||
return {};
|
||||
}
|
||||
const std::size_t valueStart = start + 1U;
|
||||
const std::size_t valueEnd = json.find('"', valueStart);
|
||||
if (valueEnd == std::string_view::npos) {
|
||||
return {};
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace {
|
||||
std::cerr << "I2S mode must be in init sequence\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k24) {
|
||||
if (sequence[2U] != core::Bt1035AtCommand::I2sSlave48k32) {
|
||||
std::cerr << "I2SCFG must be in init sequence\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
@@ -44,9 +44,19 @@ namespace {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::string i2sCfg =
|
||||
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k24);
|
||||
if (i2sCfg != "AT+I2SCFG=35\r\n") {
|
||||
std::cerr << "I2SCFG=35 command line mismatch\n";
|
||||
core::buildBt1035AtLine(core::Bt1035AtCommand::I2sSlave48k32);
|
||||
if (i2sCfg != "AT+I2SCFG=67\r\n") {
|
||||
std::cerr << "I2SCFG=67 command line mismatch\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (sequence[3U] != core::Bt1035AtCommand::A2dpCodecConfig) {
|
||||
std::cerr << "A2DPCFG must be in init sequence\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::string a2dpCfg =
|
||||
core::buildBt1035AtLine(core::Bt1035AtCommand::A2dpCodecConfig);
|
||||
if (a2dpCfg != "AT+A2DPCFG=1\r\n") {
|
||||
std::cerr << "A2DPCFG=1 command line mismatch\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const std::string reset =
|
||||
|
||||
@@ -111,7 +111,7 @@ public:
|
||||
}
|
||||
|
||||
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
|
||||
std::uint8_t) override
|
||||
std::uint8_t, std::uint8_t) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public:
|
||||
}
|
||||
|
||||
[[nodiscard]] std::expected<void, core::TunerError> tuneDab(
|
||||
std::uint8_t) override
|
||||
std::uint8_t, std::uint8_t) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ namespace adau1701
|
||||
{
|
||||
const unsigned char deviceAddr =
|
||||
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
|
||||
std::size_t index = 0U;
|
||||
for (const core::RegisterWrite &write : program.writes())
|
||||
{
|
||||
const auto data = write.data();
|
||||
@@ -75,12 +76,35 @@ namespace adau1701
|
||||
{
|
||||
return std::unexpected(Adau1701Error::DownloadFailed);
|
||||
}
|
||||
SIGMA_WRITE_REGISTER_BLOCK(
|
||||
deviceAddr,
|
||||
write.address(),
|
||||
static_cast<unsigned int>(data.size()),
|
||||
const_cast<ADI_REG_TYPE *>(
|
||||
reinterpret_cast<const ADI_REG_TYPE *>(data.data())));
|
||||
auto *bytes = const_cast<ADI_REG_TYPE *>(
|
||||
reinterpret_cast<const ADI_REG_TYPE *>(data.data()));
|
||||
const auto length = static_cast<unsigned int>(data.size());
|
||||
if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, write.address(), length,
|
||||
bytes) != 0)
|
||||
{
|
||||
ESP_LOGE(kTag,
|
||||
"program write #%u failed (addr=0x%04X len=%u) "
|
||||
"after retries",
|
||||
static_cast<unsigned>(index),
|
||||
static_cast<unsigned>(write.address()),
|
||||
static_cast<unsigned>(length));
|
||||
return std::unexpected(Adau1701Error::DownloadFailed);
|
||||
}
|
||||
// Diagnostic only: an ACKed write that still reads back wrong
|
||||
// would otherwise be invisible (see the SIGMA_WRITE_REGISTER_BLOCK
|
||||
// comment in SigmaStudioFW.c). Logged, not fatal -- some
|
||||
// addresses in this range may be self-clearing/status bits
|
||||
// that legitimately don't read back what was written.
|
||||
if (sigma_verify_block(write.address(), bytes, length) != 0)
|
||||
{
|
||||
ESP_LOGW(kTag,
|
||||
"program write #%u read-back mismatch (addr=0x%04X "
|
||||
"len=%u) -- ACKed but did not land as written",
|
||||
static_cast<unsigned>(index),
|
||||
static_cast<unsigned>(write.address()),
|
||||
static_cast<unsigned>(length));
|
||||
}
|
||||
++index;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -158,19 +182,137 @@ namespace adau1701
|
||||
return replay;
|
||||
}
|
||||
|
||||
// SerialInputRegister (0x081F) override: bit3 IBP=1, matching the
|
||||
// BCLK edge the Si4684's I2S output actually changes data on
|
||||
// (compiled DSP program default is 0x00 = IBP=0, which produced
|
||||
// pure static on a strong locked signal). ILP=1 was also tried
|
||||
// (0x18) and made it worse (pure white noise again) — IBP alone
|
||||
// (0x08) is the correct override, confirmed live: real, recognizable
|
||||
// music instead of static/noise on a locked FM station.
|
||||
// Diagnostic (2026-08-24, extended from band-0-only): log EVERY EQ
|
||||
// band's live Param RAM contents (bands 0-5, 5 coefficients each --
|
||||
// B0,B1,B2,A0,A1 per paramAddrEqBandBase's 5-word stride). Band 0
|
||||
// is the fixed high-pass that applyEq() always skips (whatever
|
||||
// landed at program-load time plays permanently); bands 1-5 are
|
||||
// runtime-safeloaded whenever the audio profile has a nonzero
|
||||
// gain, but with the factory-default flat profile (all gains 0)
|
||||
// they should read back as the identity biquad (B0=0x00800000=1.0,
|
||||
// rest 0) written by designFlatEq(). A single steady test tone at
|
||||
// one frequency cannot reveal a bad coefficient elsewhere in a
|
||||
// band's response curve -- reading the actual RAM contents is the
|
||||
// only way to confirm what's really there, not what the software
|
||||
// believes it wrote. Decode as 5.23 (28-bit, matches the ADAU1701's
|
||||
// real Param RAM format, NOT a naive 32-bit Q8.23 -- see the
|
||||
// 2026-08-23 band-0 false alarm in
|
||||
// docs/si4684-rf-investigation-report.md for why that distinction
|
||||
// matters).
|
||||
for (unsigned band = 0U; band < 6U; ++band)
|
||||
{
|
||||
const unsigned char deviceAddr =
|
||||
static_cast<unsigned char>(pins_.i2cAddr7 << 1);
|
||||
ADI_REG_TYPE serialInFix = 0x08U;
|
||||
SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U,
|
||||
&serialInFix);
|
||||
const unsigned baseAddr = paramAddrEqBandBase(
|
||||
static_cast<std::uint8_t>(band));
|
||||
for (unsigned i = 0U; i < 5U; ++i)
|
||||
{
|
||||
unsigned char raw[4U] = {0U, 0U, 0U, 0U};
|
||||
if (sigma_i2c_read(baseAddr + i, raw, sizeof(raw)) == 0)
|
||||
{
|
||||
std::int32_t fixpoint =
|
||||
static_cast<std::int32_t>(
|
||||
(static_cast<std::uint32_t>(raw[0]) << 24) |
|
||||
(static_cast<std::uint32_t>(raw[1]) << 16) |
|
||||
(static_cast<std::uint32_t>(raw[2]) << 8) |
|
||||
static_cast<std::uint32_t>(raw[3]));
|
||||
// Sign-extend from bit 27 (28-bit/5.23 format).
|
||||
if ((fixpoint & (1 << 27)) != 0)
|
||||
{
|
||||
fixpoint -= (1 << 28);
|
||||
}
|
||||
ESP_LOGI(kTag,
|
||||
"EQ band%u param[%u] addr=0x%04X raw=0x%08X "
|
||||
"value=%f",
|
||||
band, i, baseAddr + i,
|
||||
static_cast<unsigned>(
|
||||
(static_cast<std::uint32_t>(raw[0]) << 24)
|
||||
| (static_cast<std::uint32_t>(raw[1]) << 16)
|
||||
| (static_cast<std::uint32_t>(raw[2]) << 8)
|
||||
| static_cast<std::uint32_t>(raw[3])),
|
||||
static_cast<double>(fixpoint) /
|
||||
static_cast<double>(1U << 23));
|
||||
}
|
||||
else
|
||||
{
|
||||
ESP_LOGW(kTag, "EQ band%u param[%u] read-back failed",
|
||||
band, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SerialInputRegister (0x081F) IBP override -- REMOVED 2026-08-24,
|
||||
// CONFIRMED LIVE as the root cause of the multi-day hiss/
|
||||
// unintelligible-speech investigation. History: on 2026-08-16
|
||||
// (commit 6974095) this was set to IBP=1 (0x08) alongside a
|
||||
// SEPARATE, simultaneous fix to Si4684's PIN_CONFIG_ENABLE (which
|
||||
// had been forcing the chip's analog DAC fallback instead of real
|
||||
// I2S output). Both fixes landed in the same commit and were
|
||||
// tested together: "static" became "music", credited to IBP=1.
|
||||
// But SerialInputRegister is a SINGLE register shared by every
|
||||
// SDATA_INx pin on the ADAU1701 (confirmed against the datasheet
|
||||
// 2026-08-24: one INPUT_BCLK/INPUT_LRCLK clock pair serves all
|
||||
// four SDATA_INx pins) -- so the override also applied to the
|
||||
// ESP32 leg, not just Si4684's. The PIN_CONFIG_ENABLE fix alone
|
||||
// was what turned static into music (Si4684 finally sending valid
|
||||
// I2S data at all); IBP=1 had never been validated in isolation
|
||||
// and was in fact marginal/wrong for both legs. With IBP left at
|
||||
// the compiled default (0x00, IBP=0) and PIN_CONFIG_ENABLE
|
||||
// independently correct, live listening confirmed clean audio on
|
||||
// both the Si4684 (DAB) and ESP32 (web radio) paths -- the hiss
|
||||
// is gone. Left commented out below rather than deleted, in case
|
||||
// a future hardware revision needs it revisited.
|
||||
//
|
||||
// {
|
||||
// const unsigned char deviceAddr =
|
||||
// static_cast<unsigned char>(pins_.i2cAddr7 << 1);
|
||||
// ADI_REG_TYPE serialInFix = 0x08U;
|
||||
// if (SIGMA_WRITE_REGISTER_BLOCK(deviceAddr, 0x081FU, 1U,
|
||||
// &serialInFix) != 0)
|
||||
// {
|
||||
// ESP_LOGE(kTag, "SerialInputRegister override failed after retries");
|
||||
// return std::unexpected(Adau1701Error::DownloadFailed);
|
||||
// }
|
||||
// if (sigma_verify_block(0x081FU, &serialInFix, 1U) != 0)
|
||||
// {
|
||||
// ESP_LOGW(kTag,
|
||||
// "SerialInputRegister read-back mismatch -- ACKed "
|
||||
// "but did not land as 0x08");
|
||||
// }
|
||||
// }
|
||||
|
||||
// Limiter1/Limiter2 threshold override, 2026-08-24: compiled
|
||||
// program ships both at 0x00800000 = 1.0 linear = 0 dBFS (an
|
||||
// RMS-detecting limiter, per Analog Devices' own SigmaStudio
|
||||
// Limiter cell docs), with zero headroom anywhere upstream (every
|
||||
// mixer/EQ/master-volume gain in the compiled program is unity).
|
||||
// A quiet synthetic test tone (well under 0 dBFS RMS) never
|
||||
// engaged it and sounded clean; real loudness-normalized FM/DAB/
|
||||
// streamed program content sits close to 0 dBFS RMS routinely,
|
||||
// triggering continuous gain-reduction ("pumping" per ADI's own
|
||||
// docs) heard as exactly the hiss/unintelligible-speech symptom
|
||||
// under investigation. Pulling both thresholds down to -6 dBFS
|
||||
// gives real margin without being so conservative it can't be
|
||||
// heard whether this was the mechanism.
|
||||
{
|
||||
constexpr float kLimiterThresholdDb = -6.0F;
|
||||
constexpr float kLimiterThresholdLinear = 0.50118723F; // 10^(-6/20)
|
||||
const std::int32_t thresholdFixpoint =
|
||||
core::floatToFixpoint823(kLimiterThresholdLinear);
|
||||
if (auto lim1 = safeloadFixpoint(
|
||||
static_cast<unsigned>(ADDR_LIMITER1_THRESHOLD),
|
||||
thresholdFixpoint);
|
||||
!lim1)
|
||||
{
|
||||
ESP_LOGW(kTag, "Limiter1 threshold override failed");
|
||||
}
|
||||
if (auto lim2 = safeloadFixpoint(
|
||||
static_cast<unsigned>(ADDR_LIMITER2_THRESHOLD),
|
||||
thresholdFixpoint);
|
||||
!lim2)
|
||||
{
|
||||
ESP_LOGW(kTag, "Limiter2 threshold override failed");
|
||||
}
|
||||
ESP_LOGI(kTag, "Limiter1/2 threshold set to %.1f dBFS",
|
||||
static_cast<double>(kLimiterThresholdDb));
|
||||
}
|
||||
|
||||
booted_ = true;
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
#include "SigmaStudioFW.h"
|
||||
|
||||
#include "driver/i2c_master.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static const char* kTag = "SigmaStudioFW";
|
||||
static i2c_master_dev_handle_t s_dev = NULL;
|
||||
static SemaphoreHandle_t s_lock = NULL;
|
||||
|
||||
@@ -73,14 +75,45 @@ static unsigned int sigmaWordSize(unsigned int address)
|
||||
return 4U;
|
||||
}
|
||||
|
||||
void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
|
||||
/*
|
||||
* The boot-time program/param replay was previously fire-and-forget: the
|
||||
* i2c_master_transmit result was discarded outright, so a single transient
|
||||
* NACK anywhere in the hundreds of chunked writes that make up a DSP
|
||||
* program load silently left that one chunk (a gain, a filter, a mux)
|
||||
* at its power-on-reset RAM contents instead of the SigmaStudio-designed
|
||||
* value -- indistinguishable at the time from a correct load, but capable
|
||||
* of producing exactly a persistent, localized audio artifact rather than
|
||||
* gross silence. Give it the same retry-on-NACK reliability the runtime
|
||||
* safeload path already got in sigma_i2c_write (see its comment) and make
|
||||
* failure observable instead of silent.
|
||||
*/
|
||||
static int sigmaTransmitChunk(unsigned int addr, const ADI_REG_TYPE* payload,
|
||||
unsigned int chunkBytes)
|
||||
{
|
||||
unsigned char buf[2U + 64U];
|
||||
buf[0] = (unsigned char)((addr >> 8) & 0xFFU);
|
||||
buf[1] = (unsigned char)(addr & 0xFFU);
|
||||
memcpy(buf + 2U, payload, chunkBytes);
|
||||
|
||||
static const int kMaxAttempts = 3;
|
||||
for (int attempt = 0; attempt < kMaxAttempts; ++attempt) {
|
||||
if (i2c_master_transmit(s_dev, buf, (size_t)(2U + chunkBytes), 1000) ==
|
||||
ESP_OK) {
|
||||
return 0;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(2));
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
|
||||
unsigned int address,
|
||||
unsigned int length,
|
||||
ADI_REG_TYPE* pData)
|
||||
{
|
||||
(void)devAddress;
|
||||
if (s_dev == NULL || pData == NULL || length == 0U) {
|
||||
return;
|
||||
return -1;
|
||||
}
|
||||
|
||||
enum { kChunkBytesMax = 64U };
|
||||
@@ -96,15 +129,48 @@ void SIGMA_WRITE_REGISTER_BLOCK(unsigned char devAddress,
|
||||
const unsigned int chunk =
|
||||
remaining > chunkBytes ? chunkBytes : remaining;
|
||||
const unsigned int words = chunk / wordSize;
|
||||
unsigned char buf[2U + kChunkBytesMax];
|
||||
buf[0] = (unsigned char)((addr >> 8) & 0xFFU);
|
||||
buf[1] = (unsigned char)(addr & 0xFFU);
|
||||
memcpy(buf + 2U, cursor, chunk);
|
||||
i2c_master_transmit(s_dev, buf, (size_t)(2U + chunk), 1000);
|
||||
if (sigmaTransmitChunk(addr, cursor, chunk) != 0) {
|
||||
return -1;
|
||||
}
|
||||
addr += words;
|
||||
cursor += chunk;
|
||||
remaining -= chunk;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sigma_verify_block(unsigned int address, const ADI_REG_TYPE* expected,
|
||||
unsigned int length)
|
||||
{
|
||||
if (s_dev == NULL || expected == NULL || length == 0U) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
enum { kChunkBytesMax = 64U };
|
||||
const unsigned int wordSize = sigmaWordSize(address);
|
||||
const unsigned int wordsPerChunk = kChunkBytesMax / wordSize;
|
||||
const unsigned int chunkBytes = wordsPerChunk * wordSize;
|
||||
|
||||
unsigned int addr = address;
|
||||
unsigned int remaining = length;
|
||||
const ADI_REG_TYPE* cursor = expected;
|
||||
|
||||
while (remaining > 0U) {
|
||||
const unsigned int chunk =
|
||||
remaining > chunkBytes ? chunkBytes : remaining;
|
||||
const unsigned int words = chunk / wordSize;
|
||||
unsigned char readBack[kChunkBytesMax];
|
||||
if (sigma_i2c_read(addr, readBack, chunk) != 0) {
|
||||
return -1;
|
||||
}
|
||||
if (memcmp(readBack, cursor, chunk) != 0) {
|
||||
return -1;
|
||||
}
|
||||
addr += words;
|
||||
cursor += chunk;
|
||||
remaining -= chunk;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sigma_i2c_read(unsigned int reg, unsigned char* data, unsigned int length)
|
||||
@@ -198,6 +264,37 @@ int sigma_safeload_param(unsigned int paramAddr, int fixpoint)
|
||||
return sigma_safeload_block(1U, addrs, values);
|
||||
}
|
||||
|
||||
/*
|
||||
* Diagnostic only, mirrors the boot-replay read-back in
|
||||
* SIGMA_WRITE_REGISTER_BLOCK: a safeload can ACK every transaction and
|
||||
* still not land as intended (address/data written to the wrong Param RAM
|
||||
* slot, a stale value left from a previous session's committed-but-later-
|
||||
* garbled write, etc). This is the runtime path applied on every EQ/gain
|
||||
* change and on the stored-profile replay at boot -- unlike the one-time
|
||||
* program load, it was previously unverified. Log-only: some callers pass
|
||||
* addresses this can't independently confirm are readable Param RAM (vs.
|
||||
* a write-only control register), so a mismatch here is a strong signal,
|
||||
* not a hard boot/apply-time failure.
|
||||
*/
|
||||
static void sigmaVerifyParam(unsigned int paramAddr, int fixpoint)
|
||||
{
|
||||
unsigned char readBack[4U];
|
||||
if (sigma_i2c_read(paramAddr, readBack, sizeof(readBack)) != 0) {
|
||||
ESP_LOGW(kTag, "safeload verify: read-back failed for param 0x%04X",
|
||||
paramAddr);
|
||||
return;
|
||||
}
|
||||
const int actual = (int)(((unsigned int)readBack[0] << 24) |
|
||||
((unsigned int)readBack[1] << 16) |
|
||||
((unsigned int)readBack[2] << 8) |
|
||||
(unsigned int)readBack[3]);
|
||||
if (actual != fixpoint) {
|
||||
ESP_LOGW(kTag,
|
||||
"safeload verify: param 0x%04X mismatch, wrote %d read %d",
|
||||
paramAddr, fixpoint, actual);
|
||||
}
|
||||
}
|
||||
|
||||
int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs,
|
||||
const int* fixpoints)
|
||||
{
|
||||
@@ -218,7 +315,14 @@ int sigma_safeload_block(unsigned char count, const unsigned int* paramAddrs,
|
||||
}
|
||||
}
|
||||
|
||||
return sigma_trigger_safeload();
|
||||
if (sigma_trigger_safeload() != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (unsigned char i = 0U; i < count; ++i) {
|
||||
sigmaVerifyParam(paramAddrs[i], fixpoints[i]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sigma_safeload_raw_block(unsigned char count, const unsigned int* paramAddrs,
|
||||
|
||||
@@ -37,8 +37,23 @@ namespace bt1035 {
|
||||
struct Bt1035Pins {
|
||||
int uartTx; ///< ESP32 TX -> module RX.
|
||||
int uartRx; ///< ESP32 RX <- module TX.
|
||||
int resetGpio; ///< Module RESET (active level per schematic).
|
||||
int sysCtlGpio; ///< SYS_CTL (optional module enable).
|
||||
int resetGpio; ///< Module RESET# (pin 8), active-low. Driven (not
|
||||
///< floating, since 2026-08-23): held LOW together with
|
||||
///< SYS_CTRL past the datasheet §4.8 Reset Protection
|
||||
///< timeout (~1.8 s) to force a genuine power-down on
|
||||
///< every resetAndInitOnce() attempt, then released HIGH
|
||||
///< before SYS_CTRL's power-up pulse (§4.7).
|
||||
int sysCtlGpio; ///< SYS_CTL (pin 34), active-high. Driven LOW/HIGH on
|
||||
///< every resetAndInitOnce() call, together with
|
||||
///< resetGpio, to force a real power-cycle each retry.
|
||||
int ctsGpio; ///< Host->module UART_CTS (module pin 15). Diagnostic
|
||||
///< only (2026-08-22): read-only floating input, never
|
||||
///< driven — this driver does not implement hardware
|
||||
///< flow control. See boot()'s comment.
|
||||
int rtsGpio; ///< Module UART_RTS/PIO2 (module pin 16), factory
|
||||
///< default function is PA_MUTE, not flow control
|
||||
///< (Feasycom programming guide). Diagnostic only
|
||||
///< (2026-08-22): read-only floating input.
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -238,6 +253,24 @@ public:
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::uint8_t, Bt1035Error> queryAutoReconnect();
|
||||
|
||||
/**
|
||||
* @brief setA2dpCodecConfig — enable optional A2DP codecs (AT+A2DPCFG).
|
||||
*
|
||||
* @dname setA2dpCodecConfig
|
||||
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
|
||||
* BIT4=aptX-Adaptive, BIT5=LDAC (§5.3.4); 0 forces
|
||||
* the mandatory SBC-only baseline.
|
||||
* @return Ok on success, or Bt1035Error. Only affects the *next* A2DP
|
||||
* negotiation — an already-connected peer keeps its current
|
||||
* codec until it reconnects (see disconnectA2dp()).
|
||||
* @pubstate writes UART.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-24
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Bt1035Error> setA2dpCodecConfig(
|
||||
std::uint8_t bitmask);
|
||||
|
||||
/**
|
||||
* @brief queryPairedList — enumerate paired remotes (AT+PLIST).
|
||||
*
|
||||
|
||||
@@ -36,21 +36,39 @@ constexpr int kBaudRate = 115200;
|
||||
constexpr int kUartRxBuffer = 4096;
|
||||
constexpr int kUartTxBuffer = 256;
|
||||
constexpr int kResponseTimeoutMs = 2000;
|
||||
constexpr int kPostResetMs = 500;
|
||||
constexpr int kPostUartMs = 100;
|
||||
/** Feasycom BT1035 programming user guide §2.2 (pin 34 SYS_CTRL): "Delay
|
||||
* 100ms, pull high". */
|
||||
constexpr int kSysCtlLeadInMs = 100;
|
||||
/** Margin beyond the datasheet's own >20ms SYS_CTRL-assertion-to-power-up
|
||||
* minimum (§4.7), for regulator/crystal settling before RESET releases. */
|
||||
constexpr int kSysCtlSettleMs = 50;
|
||||
/** Observed intermittently: the module sometimes needs a second RESET#
|
||||
* pulse to come up (power-up timing jitter between cold/warm boots) —
|
||||
* a single attempt with no retry was found to explain sporadic total
|
||||
* boot failures ("no spontaneous UART bytes" -> "AT init failed") on
|
||||
* otherwise-identical hardware/wiring. */
|
||||
constexpr int kBootAttempts = 3;
|
||||
constexpr int kBootRetryDelayMs = 300;
|
||||
/** Datasheet §4.7: "From the OFF state, SYS_CTRL must be asserted for
|
||||
* >20 ms to start power up." */
|
||||
constexpr int kSysCtlAssertMs = 20;
|
||||
/** Datasheet §4.8: "Reset Protection timeout (typically greater than
|
||||
* ~1.8 s) causes the device to power down if VCHG is not present and
|
||||
* SYS_CTRL is low." RESET# and SYS_CTRL are held asserted/low together for
|
||||
* comfortably longer than that before each power-up, to guarantee a
|
||||
* genuine full power-down rather than a pulse too short for the module's
|
||||
* own protection timer to act on — see resetAndInitOnce()'s comment for
|
||||
* why this now runs on every attempt, not just the first. */
|
||||
constexpr int kSysCtlDeassertMs = 2500;
|
||||
/** Measured live (2026-08-20, power/wiring confirmed sound with a
|
||||
* multimeter — VBAT_IN/SYS_CTRL/1.8V_OUT/VDD_IO all correct, TX/RX pins
|
||||
* verified via continuity): the module's spontaneous boot banner
|
||||
* (+VER=FSC-BT1035,..., +DEVSTAT=1) doesn't appear until ~18.5s after
|
||||
* RESET# releases — full BT stack init, not just the internal regulator.
|
||||
* The previous 3500ms wait here was never enough for the module to say
|
||||
* anything, so every prior boot attempt cut power and restarted before
|
||||
* the module could finish booting even once.
|
||||
*
|
||||
* boot() makes exactly one resetAndInitOnce() attempt per call (matching
|
||||
* fd9d4ae's validated 5/5-clean-boot design): the BT1035 datasheet's own
|
||||
* "Reset Protection timeout (typically >1.8s)" means a second
|
||||
* SYS_CTRL/RESET pulse fired shortly after a failed attempt would not
|
||||
* reliably reach a clean power-off state before repowering — an internal
|
||||
* retry loop here risks re-interrupting the module mid bring-up, the
|
||||
* same class of bug fixed in fd9d4ae (redundant AT+RESET). Retries now
|
||||
* live one layer up, in hardware::bt1035RetryTask
|
||||
* (main/hardware_bootstrap.cpp), which only re-invokes a full, clean
|
||||
* boot() call — never re-pulses the pins faster than a whole boot cycle
|
||||
* apart. */
|
||||
constexpr int kBootBannerWaitMs = 25000;
|
||||
|
||||
void flushUartRx(int uartPort) noexcept
|
||||
{
|
||||
@@ -62,15 +80,52 @@ void flushUartRx(int uartPort) noexcept
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic only: some BT1035 firmware prints an unsolicited boot banner on
|
||||
// UART right after the hardware RESET# pulse. Capturing it (or its absence)
|
||||
// tells us whether the UART link is electrically alive independent of the
|
||||
// AT command layer.
|
||||
// Diagnostic only, run if the single boot attempt fails at 115200 (the
|
||||
// datasheet's own default). AT+BAUD persists across RESET#/SYS_CTRL power
|
||||
// cycles (programming guide §5.1.3), so a stray manual AT+BAUD or
|
||||
// AT+RESTORE sent during earlier interactive testing could have left the
|
||||
// module listening at a different rate than our fixed assumption — this
|
||||
// sweep tells us if that's what's happening instead of guessing.
|
||||
constexpr std::array<int, 8> kBaudProbeCandidates = {
|
||||
9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600};
|
||||
|
||||
void probeBaudRates(int uartPort) noexcept
|
||||
{
|
||||
ESP_LOGW(kTag, "115200 unresponsive — sweeping baud rates");
|
||||
for (const int baud : kBaudProbeCandidates) {
|
||||
if (uart_set_baudrate(static_cast<uart_port_t>(uartPort), baud)
|
||||
!= ESP_OK) {
|
||||
continue;
|
||||
}
|
||||
flushUartRx(uartPort);
|
||||
uart_write_bytes(static_cast<uart_port_t>(uartPort), "AT\r\n", 4);
|
||||
|
||||
std::array<char, 64> buf{};
|
||||
const int n = uart_read_bytes(static_cast<uart_port_t>(uartPort),
|
||||
buf.data(), buf.size(),
|
||||
pdMS_TO_TICKS(500));
|
||||
if (n > 0) {
|
||||
ESP_LOGW(kTag, "baud probe: module responded at %d baud (%d bytes)",
|
||||
baud, n);
|
||||
ESP_LOG_BUFFER_HEX(kTag, buf.data(), static_cast<std::size_t>(n));
|
||||
} else {
|
||||
ESP_LOGI(kTag, "baud probe: silent at %d baud", baud);
|
||||
}
|
||||
}
|
||||
uart_set_baudrate(static_cast<uart_port_t>(uartPort), kBaudRate);
|
||||
flushUartRx(uartPort);
|
||||
}
|
||||
|
||||
// The BT1035 prints an unsolicited boot banner (+VER=..., +DEVSTAT=1, ...)
|
||||
// once its full Bluetooth stack finishes initialising — this blocks for up
|
||||
// to kBootBannerWaitMs waiting for it, since that's the real boot-complete
|
||||
// signal (the module is otherwise silent and won't answer AT commands
|
||||
// until this appears).
|
||||
void logRawUartBoot(int uartPort) noexcept
|
||||
{
|
||||
std::array<std::uint8_t, 128> buf{};
|
||||
const int n = uart_read_bytes(static_cast<uart_port_t>(uartPort), buf.data(),
|
||||
buf.size(), pdMS_TO_TICKS(3500));
|
||||
buf.size(), pdMS_TO_TICKS(kBootBannerWaitMs));
|
||||
if (n <= 0) {
|
||||
ESP_LOGW("Bt1035", "no spontaneous UART bytes after hardware reset");
|
||||
return;
|
||||
@@ -743,6 +798,15 @@ std::expected<void, Bt1035Error> Bt1035Driver::setAutoReconnect(
|
||||
return transmitAndExpectOk(core::buildBt1035SetAutoConnLine(times));
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::setA2dpCodecConfig(
|
||||
std::uint8_t bitmask)
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return ready;
|
||||
}
|
||||
return transmitAndExpectOk(core::buildBt1035A2dpCodecConfigLine(bitmask));
|
||||
}
|
||||
|
||||
std::expected<std::uint8_t, Bt1035Error> Bt1035Driver::queryAutoReconnect()
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
@@ -869,6 +933,32 @@ bool Bt1035Driver::waitForA2dpStreaming(int timeoutMs)
|
||||
ESP_LOGI(kTag, "stream wait: A2DPSTAT=%u",
|
||||
static_cast<unsigned>(static_cast<std::uint8_t>(*state)));
|
||||
if (*state == core::Bt1035A2dpState::Streaming) {
|
||||
// Diagnostic, 2026-08-24: confirm which codec actually got
|
||||
// negotiated now that AT+A2DPCFG=1 (AAC) is sent at boot --
|
||||
// previously this was never queried, so every link was
|
||||
// silently SBC-only with no way to tell from the logs.
|
||||
if (auto codec = queryA2dpEncoder(); codec) {
|
||||
// Feasycom guide §5.3.5's AT+A2DPENC response table for
|
||||
// BT1035 only lists SBC/aptX/aptX-HD/aptX-LL/aptX-
|
||||
// Adaptive -- no AAC code, even though §5.3.4's
|
||||
// AT+A2DPCFG can enable it. Not inventing a mapping for
|
||||
// that gap: an AAC link (or anything else undocumented)
|
||||
// logs as "unknown" rather than a guessed label.
|
||||
const char* name = "unknown";
|
||||
switch (*codec) {
|
||||
case core::Bt1035A2dpCodec::Sbc: name = "SBC"; break;
|
||||
case core::Bt1035A2dpCodec::Aptx: name = "aptX"; break;
|
||||
case core::Bt1035A2dpCodec::AptxHd: name = "aptX-HD"; break;
|
||||
case core::Bt1035A2dpCodec::AptxLl: name = "aptX-LL"; break;
|
||||
case core::Bt1035A2dpCodec::AptxAdaptive:
|
||||
name = "aptX-Adaptive";
|
||||
break;
|
||||
}
|
||||
ESP_LOGI(kTag, "A2DP streaming with codec: %s", name);
|
||||
} else {
|
||||
ESP_LOGW(kTag, "A2DPENC query failed (%d)",
|
||||
static_cast<int>(codec.error()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const TickType_t now = xTaskGetTickCount();
|
||||
@@ -919,24 +1009,44 @@ std::expected<void, Bt1035Error> Bt1035Driver::runInitSequence()
|
||||
|
||||
std::expected<void, Bt1035Error> Bt1035Driver::resetAndInitOnce()
|
||||
{
|
||||
// Feasycom BT1035 programming user guide §2.2, pin 34 SYS_CTRL:
|
||||
// "Delay 100ms, pull high" — the datasheet's own OFF-state timing spec
|
||||
// (§4.7) says SYS_CTRL must be asserted >20ms before the internal
|
||||
// regulators start powering up at all, so pulling it high with no
|
||||
// lead-in delay (the previous sequence here) races the chip's own
|
||||
// power-on requirement. Held low with RESET already asserted, then a
|
||||
// 100ms lead-in exactly matching the guide, then SYS_CTRL high, then
|
||||
// extra settle time before releasing RESET into a chip that's had a
|
||||
// chance to actually power up first.
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.sysCtlGpio), 0);
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(kSysCtlLeadInMs));
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.sysCtlGpio), 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(kSysCtlSettleMs));
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(kPostResetMs));
|
||||
// 2026-08-23 fix: force a genuine power-down/restart on every retry by
|
||||
// driving RESET# together with SYS_CTRL, per datasheet §4.8: "Assertion
|
||||
// of RESET# beyond the Reset Protection timeout (typically >~1.8 s)
|
||||
// causes the device to power down if VCHG is not present and SYS_CTRL
|
||||
// is low. FSC-BT1035 then requires a SYS_CTRL assertion ... to
|
||||
// restart." The prior design (2026-08-22) only pulsed SYS_CTRL and left
|
||||
// RESET# floating; per §4.7, "when booted, software takes control of
|
||||
// the internal regulators and the state of SYS_CTRL is ignored" — so
|
||||
// once the module had booted once, that pulse alone could never force
|
||||
// a real power-cycle. Asserting RESET# is the documented way to do it.
|
||||
const auto sysCtlPin = static_cast<gpio_num_t>(pins_.sysCtlGpio);
|
||||
const auto resetPin = static_cast<gpio_num_t>(pins_.resetGpio);
|
||||
const auto ctsPin = static_cast<gpio_num_t>(pins_.ctsGpio);
|
||||
const auto rtsPin = static_cast<gpio_num_t>(pins_.rtsGpio);
|
||||
|
||||
// Assert RESET# (active-low) and deassert SYS_CTRL together, held past
|
||||
// the ~1.8s Reset Protection timeout so the module actually powers
|
||||
// down rather than staying "protected" on (§4.8).
|
||||
gpio_set_level(resetPin, 0);
|
||||
gpio_set_level(sysCtlPin, 0);
|
||||
vTaskDelay(pdMS_TO_TICKS(kSysCtlDeassertMs));
|
||||
|
||||
// Release RESET# before requesting power-up, then assert SYS_CTRL for
|
||||
// the datasheet's own >=20ms minimum to start the boot (§4.7).
|
||||
gpio_set_level(resetPin, 1);
|
||||
gpio_set_level(sysCtlPin, 1);
|
||||
vTaskDelay(pdMS_TO_TICKS(kSysCtlAssertMs));
|
||||
ESP_LOGI(kTag, "power-up: SYS_CTRL=%d (want 1, driven) RESET#=%d "
|
||||
"(want 1, driven)",
|
||||
gpio_get_level(sysCtlPin), gpio_get_level(resetPin));
|
||||
ESP_LOGI(kTag, "before banner wait: CTS=%d (module's flow-control "
|
||||
"input, host side floating) RTS/PIO2=%d (module's "
|
||||
"PA_MUTE by factory default)",
|
||||
gpio_get_level(ctsPin), gpio_get_level(rtsPin));
|
||||
|
||||
logRawUartBoot(uartPort_);
|
||||
ESP_LOGI(kTag, "after banner wait: CTS=%d RTS/PIO2=%d",
|
||||
gpio_get_level(ctsPin), gpio_get_level(rtsPin));
|
||||
uart_flush_input(static_cast<uart_port_t>(uartPort_));
|
||||
vTaskDelay(pdMS_TO_TICKS(kPostUartMs));
|
||||
|
||||
@@ -949,20 +1059,65 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
||||
return {};
|
||||
}
|
||||
|
||||
// RESET# (pin 8) is actively driven (2026-08-23 fix): datasheet §4.8
|
||||
// states the Reset Protection timeout that forces a genuine power-down
|
||||
// is triggered by *asserting RESET#* (while SYS_CTRL is low), not by
|
||||
// toggling SYS_CTRL alone. Leaving RESET# floating (the 2026-08-22
|
||||
// diagnostic change) meant that mechanism was never actually invoked —
|
||||
// every retry only pulsed SYS_CTRL, which the module ignores once
|
||||
// already booted (§4.7: "when booted, software takes control of the
|
||||
// internal regulators and the state of SYS_CTRL is ignored"). Driven
|
||||
// HIGH immediately below (deasserted) before anything else runs, so
|
||||
// configuring the pin never glitches it low.
|
||||
gpio_config_t resetCfg = {};
|
||||
resetCfg.pin_bit_mask = 1ULL << pins_.resetGpio;
|
||||
resetCfg.mode = GPIO_MODE_OUTPUT;
|
||||
resetCfg.mode = GPIO_MODE_INPUT_OUTPUT;
|
||||
resetCfg.pull_up_en = GPIO_PULLUP_DISABLE;
|
||||
resetCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
|
||||
if (gpio_config(&resetCfg) != ESP_OK) {
|
||||
return std::unexpected(Bt1035Error::ResetFailed);
|
||||
}
|
||||
gpio_set_level(static_cast<gpio_num_t>(pins_.resetGpio), 1);
|
||||
|
||||
// SYS_CTRL (pin 34) stays actively driven (GPIO_MODE_INPUT_OUTPUT:
|
||||
// the INPUT bit is what makes gpio_get_level() read the real driven
|
||||
// level instead of a stale register, for the diagnostic log).
|
||||
gpio_config_t sysCfg = {};
|
||||
sysCfg.pin_bit_mask = 1ULL << pins_.sysCtlGpio;
|
||||
sysCfg.mode = GPIO_MODE_OUTPUT;
|
||||
sysCfg.mode = GPIO_MODE_INPUT_OUTPUT;
|
||||
if (gpio_config(&sysCfg) != ESP_OK) {
|
||||
return std::unexpected(Bt1035Error::ResetFailed);
|
||||
}
|
||||
|
||||
// CTS (module pin 15, host->module) and RTS (module pin 16/PIO2,
|
||||
// factory default function PA_MUTE per the Feasycom programming
|
||||
// guide) — diagnostic only (2026-08-22): this driver does not
|
||||
// implement UART hardware flow control (UART_HW_FLOWCTRL_DISABLE
|
||||
// below), so these are wired but otherwise unused. Configured as
|
||||
// floating inputs, pull-up/pull-down both disabled, purely to read
|
||||
// back their level for the diagnostic log — never driven. Exploring
|
||||
// whether the module's CTS input floating could be gating its own
|
||||
// UART TX (a common hardware-flow-control behavior), and whether RTS
|
||||
// toggles at all (would indicate the module's internal firmware is
|
||||
// alive even when UART TX is silent).
|
||||
gpio_config_t ctsCfg = {};
|
||||
ctsCfg.pin_bit_mask = 1ULL << pins_.ctsGpio;
|
||||
ctsCfg.mode = GPIO_MODE_INPUT;
|
||||
ctsCfg.pull_up_en = GPIO_PULLUP_DISABLE;
|
||||
ctsCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
|
||||
if (gpio_config(&ctsCfg) != ESP_OK) {
|
||||
return std::unexpected(Bt1035Error::ResetFailed);
|
||||
}
|
||||
|
||||
gpio_config_t rtsCfg = {};
|
||||
rtsCfg.pin_bit_mask = 1ULL << pins_.rtsGpio;
|
||||
rtsCfg.mode = GPIO_MODE_INPUT;
|
||||
rtsCfg.pull_up_en = GPIO_PULLUP_DISABLE;
|
||||
rtsCfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
|
||||
if (gpio_config(&rtsCfg) != ESP_OK) {
|
||||
return std::unexpected(Bt1035Error::ResetFailed);
|
||||
}
|
||||
|
||||
if (!uartInstalled_) {
|
||||
const uart_config_t uartCfg = {
|
||||
.baud_rate = kBaudRate,
|
||||
@@ -994,19 +1149,9 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
||||
uartInstalled_ = true;
|
||||
}
|
||||
|
||||
std::expected<void, Bt1035Error> init = std::unexpected(Bt1035Error::UnexpectedResponse);
|
||||
for (int attempt = 1; attempt <= kBootAttempts; ++attempt) {
|
||||
init = resetAndInitOnce();
|
||||
if (init) {
|
||||
break;
|
||||
}
|
||||
ESP_LOGW(kTag, "boot attempt %d/%d failed", attempt, kBootAttempts);
|
||||
if (attempt < kBootAttempts) {
|
||||
vTaskDelay(pdMS_TO_TICKS(kBootRetryDelayMs));
|
||||
}
|
||||
}
|
||||
if (!init) {
|
||||
ESP_LOGE(kTag, "AT init failed after %d attempts", kBootAttempts);
|
||||
if (auto init = resetAndInitOnce(); !init) {
|
||||
ESP_LOGE(kTag, "AT init failed");
|
||||
probeBaudRates(uartPort_);
|
||||
return init;
|
||||
}
|
||||
|
||||
@@ -1014,7 +1159,7 @@ std::expected<void, Bt1035Error> Bt1035Driver::boot()
|
||||
ESP_LOGI(kTag, "auto-link disabled (AT+LINKCFG=0,0)");
|
||||
|
||||
booted_ = true;
|
||||
ESP_LOGI(kTag, "I2S slave mode enabled (AT+AUXCFG=3, AT+I2SCFG=35)");
|
||||
ESP_LOGI(kTag, "I2S slave mode enabled (AT+AUXCFG=3, AT+I2SCFG=67)");
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ namespace eeprom24aa {
|
||||
* @pubstate Borrows an existing I2C master bus (shared with ADAU1701). The
|
||||
* EUI-48 lives at word address 0xFA..0xFF (read-only, factory
|
||||
* programmed) per the 24AA025E48 datasheet (DS20001191); the FM
|
||||
* ANTCAP calibration byte lives at word address 0x00 in the
|
||||
* ANTCAP calibration byte lives at word address 0x00, and the
|
||||
* DAB ANTCAP calibration byte at word address 0x01, both in the
|
||||
* remaining user-writable 250 bytes.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
@@ -41,11 +42,14 @@ namespace eeprom24aa {
|
||||
*/
|
||||
class Eeprom24aa : public core::IDeviceIdentitySource {
|
||||
public:
|
||||
/** Valid FM ANTCAP calibration values are 0-128 (AN649/AN851); any
|
||||
* stored byte above this, including the EEPROM's blank/erased 0xFF,
|
||||
* reads back as "never calibrated" — no separate sentinel write
|
||||
/** Valid ANTCAP calibration values (FM or DAB) are 0-128 (AN649/AN851);
|
||||
* any stored byte above this, including the EEPROM's blank/erased
|
||||
* 0xFF, reads back as "never calibrated" — no separate sentinel write
|
||||
* needed for a fresh chip. */
|
||||
static constexpr std::uint8_t kFmAntCapMax = 128U;
|
||||
/** Same range as kFmAntCapMax; kept as a separate name for the DAB
|
||||
* calibration byte's own doc comments below. */
|
||||
static constexpr std::uint8_t kDabAntCapMax = 128U;
|
||||
|
||||
/**
|
||||
* @brief Eeprom24aa — bind to a running I2C master bus and 7-bit addr.
|
||||
@@ -105,6 +109,37 @@ public:
|
||||
[[nodiscard]] std::expected<void, core::IdentityError>
|
||||
writeFmAntCap(std::uint8_t value);
|
||||
|
||||
/**
|
||||
* @brief readDabAntCap — read the stored DAB antenna calibration byte.
|
||||
*
|
||||
* @dname readDabAntCap
|
||||
* @return Calibrated ANTCAP (0-kDabAntCapMax) if one was ever saved via
|
||||
* writeDabAntCap(), nullopt if the byte is blank/out of range,
|
||||
* or IdentityError on an I2C failure.
|
||||
* @pubstate performs one I2C read of one byte at word address 0x01.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-20
|
||||
*/
|
||||
[[nodiscard]] std::expected<std::optional<std::uint8_t>, core::IdentityError>
|
||||
readDabAntCap();
|
||||
|
||||
/**
|
||||
* @brief writeDabAntCap — persist a DAB antenna calibration value.
|
||||
*
|
||||
* @dname writeDabAntCap
|
||||
* @param value ANTCAP to store, 0-kDabAntCapMax (AN649 Command
|
||||
* 0xB0 ARG4; found via a sweep, not computed).
|
||||
* @return Ok on success, or IdentityError::I2cFailed.
|
||||
* @pubstate performs one I2C byte write at word address 0x01, then
|
||||
* blocks for the chip's write-cycle time before returning.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-20
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::IdentityError>
|
||||
writeDabAntCap(std::uint8_t value);
|
||||
|
||||
private:
|
||||
i2c_master_bus_handle_t bus_;
|
||||
std::uint8_t addr7_;
|
||||
|
||||
@@ -30,6 +30,8 @@ constexpr std::uint8_t kEui48WordAddress = 0xFAU;
|
||||
/** FM ANTCAP calibration byte, in the chip's user-writable region (anywhere
|
||||
* below the factory-locked 0xFA-0xFF EUI-48 block). */
|
||||
constexpr std::uint8_t kFmAntCapWordAddress = 0x00U;
|
||||
/** DAB ANTCAP calibration byte, next word address after the FM byte. */
|
||||
constexpr std::uint8_t kDabAntCapWordAddress = 0x01U;
|
||||
constexpr int kI2cTimeoutMs = 100;
|
||||
/** DS20001191 §"Page Write"/"Byte Write": max write cycle time after STOP
|
||||
* before the chip acknowledges further I2C traffic. */
|
||||
@@ -148,4 +150,75 @@ Eeprom24aa::writeFmAntCap(std::uint8_t value)
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<std::optional<std::uint8_t>, core::IdentityError>
|
||||
Eeprom24aa::readDabAntCap()
|
||||
{
|
||||
if (bus_ == nullptr) {
|
||||
return std::unexpected(core::IdentityError::I2cFailed);
|
||||
}
|
||||
|
||||
i2c_device_config_t devCfg = {};
|
||||
devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7;
|
||||
devCfg.device_address = addr7_;
|
||||
devCfg.scl_speed_hz = 100000;
|
||||
|
||||
i2c_master_dev_handle_t dev = nullptr;
|
||||
if (i2c_master_bus_add_device(bus_, &devCfg, &dev) != ESP_OK) {
|
||||
ESP_LOGW(kTag, "i2c_master_bus_add_device failed");
|
||||
return std::unexpected(core::IdentityError::I2cFailed);
|
||||
}
|
||||
|
||||
const std::uint8_t wordAddress = kDabAntCapWordAddress;
|
||||
std::uint8_t value = 0xFFU;
|
||||
const esp_err_t err = i2c_master_transmit_receive(
|
||||
dev, &wordAddress, 1U, &value, 1U, kI2cTimeoutMs);
|
||||
|
||||
i2c_master_bus_rm_device(dev);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(kTag, "DAB ANTCAP read failed (err=0x%x)",
|
||||
static_cast<unsigned>(err));
|
||||
return std::unexpected(core::IdentityError::ReadFailed);
|
||||
}
|
||||
|
||||
if (value > kDabAntCapMax) {
|
||||
return std::optional<std::uint8_t>{};
|
||||
}
|
||||
return std::optional<std::uint8_t>{value};
|
||||
}
|
||||
|
||||
std::expected<void, core::IdentityError>
|
||||
Eeprom24aa::writeDabAntCap(std::uint8_t value)
|
||||
{
|
||||
if (bus_ == nullptr) {
|
||||
return std::unexpected(core::IdentityError::I2cFailed);
|
||||
}
|
||||
|
||||
i2c_device_config_t devCfg = {};
|
||||
devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7;
|
||||
devCfg.device_address = addr7_;
|
||||
devCfg.scl_speed_hz = 100000;
|
||||
|
||||
i2c_master_dev_handle_t dev = nullptr;
|
||||
if (i2c_master_bus_add_device(bus_, &devCfg, &dev) != ESP_OK) {
|
||||
ESP_LOGW(kTag, "i2c_master_bus_add_device failed");
|
||||
return std::unexpected(core::IdentityError::I2cFailed);
|
||||
}
|
||||
|
||||
const std::array<std::uint8_t, 2> payload = {kDabAntCapWordAddress, value};
|
||||
const esp_err_t err =
|
||||
i2c_master_transmit(dev, payload.data(), payload.size(), kI2cTimeoutMs);
|
||||
|
||||
i2c_master_bus_rm_device(dev);
|
||||
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(kTag, "DAB ANTCAP write failed (err=0x%x)",
|
||||
static_cast<unsigned>(err));
|
||||
return std::unexpected(core::IdentityError::I2cFailed);
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(kI2cWriteCycleMs));
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace eeprom24aa
|
||||
|
||||
@@ -112,6 +112,16 @@ public:
|
||||
* verified live (72 = 720 uA startup bias).
|
||||
* @param xtalCtun POWER_UP ARG8 CTUN, 0-63 (AN649 §Command 0x01);
|
||||
* default matches the values already verified live.
|
||||
* @param xtalFreqHz POWER_UP ARG4-7 XTAL_FREQ in Hz (AN649 §Command
|
||||
* 0x01); default 19,200,000 (nominal crystal
|
||||
* frequency). Deliberately overriding this away
|
||||
* from the crystal's true nominal value is a valid
|
||||
* software calibration trick: it tells the chip's
|
||||
* internal PLL math "the crystal actually runs at
|
||||
* this rate," compensating any real physical
|
||||
* offset (from load-cap mismatch etc) without
|
||||
* touching CTUN. See FM_RSQ_STATUS FREQOFF for the
|
||||
* measurement this is meant to null out.
|
||||
* @return Ok on success, or Si4684Error.
|
||||
* @pubstate writes booted_ and loadedBand_ on success.
|
||||
*
|
||||
@@ -120,7 +130,28 @@ public:
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> boot(
|
||||
Si4684Band band, std::uint8_t xtalIbias = 72U,
|
||||
std::uint8_t xtalCtun = 31U);
|
||||
std::uint8_t xtalCtun = 31U, std::uint32_t xtalFreqHz = 19200000U);
|
||||
|
||||
/**
|
||||
* @brief recalibrateXtal — re-run boot() with new crystal parameters.
|
||||
*
|
||||
* @dname recalibrateXtal
|
||||
* @param xtalIbias New POWER_UP ARG3 IBIAS.
|
||||
* @param xtalCtun New POWER_UP ARG8 CTUN.
|
||||
* @param xtalFreqHz New POWER_UP ARG4-7 XTAL_FREQ in Hz.
|
||||
* @return Ok on success, or Si4684Error::NotBooted if never booted.
|
||||
* @pubstate forces booted_=false then re-runs boot() for the currently
|
||||
* loaded band -- full RSTB# pulse + patch/image reload, same
|
||||
* as a cold boot, just without an ESP32 restart. Diagnostic:
|
||||
* lets a calibration script iterate crystal parameters live
|
||||
* over HTTP instead of a firmware rebuild+reflash per value.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-23
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, Si4684Error> recalibrateXtal(
|
||||
std::uint8_t xtalIbias, std::uint8_t xtalCtun,
|
||||
std::uint32_t xtalFreqHz);
|
||||
|
||||
/**
|
||||
* @brief isBooted — query whether boot completed successfully.
|
||||
@@ -288,13 +319,18 @@ public:
|
||||
*
|
||||
* @dname tuneDab
|
||||
* @param freqIndex Ensemble index 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<void, Si4684Error> tuneDab(std::uint8_t freqIndex);
|
||||
[[nodiscard]] std::expected<void, Si4684Error> tuneDab(
|
||||
std::uint8_t freqIndex, std::uint8_t antCap = 0U);
|
||||
|
||||
/**
|
||||
* @brief readDabDigRadStatus — read ensemble lock metrics.
|
||||
|
||||
@@ -96,6 +96,7 @@ public:
|
||||
*
|
||||
* @dname tuneDab
|
||||
* @param freqIndex Ensemble index 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<void, core::TunerError> tuneDab(
|
||||
std::uint8_t freqIndex) override;
|
||||
std::uint8_t freqIndex, std::uint8_t antCap = 0U) override;
|
||||
|
||||
/**
|
||||
* @brief tuneFm — tune to an FM centre frequency in kHz.
|
||||
@@ -177,6 +178,24 @@ public:
|
||||
[[nodiscard]] std::expected<void, core::TunerError> setVolume(
|
||||
std::uint8_t level) override;
|
||||
|
||||
/**
|
||||
* @brief recalibrateXtal — re-run driver boot() with new crystal
|
||||
* parameters, without an ESP32 restart.
|
||||
*
|
||||
* @dname recalibrateXtal
|
||||
* @param ibias New POWER_UP ARG3 IBIAS.
|
||||
* @param ctun New POWER_UP ARG8 CTUN.
|
||||
* @param xtalFreqHz New POWER_UP ARG4-7 XTAL_FREQ in Hz.
|
||||
* @return Ok on success, or a mapped TunerError.
|
||||
* @pubstate delegates to driver_.recalibrateXtal(); caller must re-tune
|
||||
* afterwards, this only reboots the chip.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-23
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, core::TunerError> recalibrateXtal(
|
||||
std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz);
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief mapError — translate Si4684Error to core::TunerError.
|
||||
|
||||
@@ -98,6 +98,12 @@ struct Si4684FmRsq {
|
||||
std::int8_t snrDb; ///< SNR in dB.
|
||||
bool valid; ///< RSQ valid flag from the chip.
|
||||
bool stereo; ///< Stereo pilot detected.
|
||||
/** AN649 FM_RSQ_STATUS RESP8 FREQOFF: signed offset in units of 2 PPM
|
||||
* (range -128..127, i.e. -256..+254 PPM). Crystal calibration signal:
|
||||
* every locked station's carrier reads the same PPM error when the
|
||||
* XTAL_FREQ/CTUN reference is off, since real broadcast transmitters
|
||||
* are themselves GPS/rubidium-locked. */
|
||||
std::int8_t freqOffBppm;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,8 @@ constexpr std::size_t kSpiReplyLeadIn = 1U;
|
||||
/** FM_RSQ_STATUS field indices with kSpiReplyLeadIn (AN649 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<void, Si4684Error> Si4684Driver::configureAfterBoot(
|
||||
{0xB302U, 0x0000U},
|
||||
{0xB303U, 0x0000U},
|
||||
{0xB401U, 0x0002U},
|
||||
// AN649 Property 0xB500 DAB_ACF_ENABLE: bit0 SOFTMUTE_ENABLE,
|
||||
// bit1 COMF_NOISE_ENABLE, datasheet default 0x0003 (both on).
|
||||
// Tried 0x0003 on 2026-08-23 hoping it would mask the periodic
|
||||
// hiss/unintelligible-voice symptom on real DAB reception --
|
||||
// live A/B test made it worse (COMF_NOISE injects synthetic
|
||||
// noise on every brief signal-quality dip, and frequent
|
||||
// softmute engagement chopped up speech). PE5PVB's independent
|
||||
// SI4684-DAB-Receiver project also explicitly disables this
|
||||
// (Set_Property(0xB500, 0x0000)), matching what real testing
|
||||
// shows here -- disabled deliberately, not an oversight.
|
||||
{0xB500U, 0x0000U},
|
||||
};
|
||||
for (const auto& prop : kDabProps) {
|
||||
@@ -542,6 +560,11 @@ std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
|
||||
if (auto rds = setProperty(kPropFmRdsConfig, 0x0001U); !rds) {
|
||||
return rds;
|
||||
}
|
||||
if (auto deEmph = setProperty(kPropFmAudioDeEmphasis,
|
||||
kFmAudioDeEmphasisEurope);
|
||||
!deEmph) {
|
||||
return deEmph;
|
||||
}
|
||||
// AN649 §0x3202/0x3204: lower seek/tune validity for weak lab antennas.
|
||||
if (auto rssi = setProperty(kPropFmValidRssiThreshold,
|
||||
kFmValidRssiThresholdDbuV);
|
||||
@@ -597,7 +620,8 @@ std::expected<void, Si4684Error> Si4684Driver::configureAfterBoot(
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::boot(
|
||||
Si4684Band band, std::uint8_t xtalIbias, std::uint8_t xtalCtun)
|
||||
Si4684Band band, std::uint8_t xtalIbias, std::uint8_t xtalCtun,
|
||||
std::uint32_t xtalFreqHz)
|
||||
{
|
||||
if (booted_ && loadedBand_ == band) {
|
||||
return {};
|
||||
@@ -672,10 +696,19 @@ std::expected<void, Si4684Error> Si4684Driver::boot(
|
||||
}
|
||||
|
||||
// ARG2=0x17(CLK_MODE=crystal,TR_SIZE), ARG3=IBIAS, ARG4-7=XTAL_FREQ
|
||||
// 19.2 MHz (0x0124F800), ARG8=CTUN, ARG9=0x10 (fixed bit4=1 per AN649
|
||||
// (little-endian, nominal 19.2 MHz = 0x0124F800; may be intentionally
|
||||
// offset from nominal as a software crystal calibration -- see the
|
||||
// xtalFreqHz doc comment), ARG8=CTUN, ARG9=0x10 (fixed bit4=1 per AN649
|
||||
// §Command 0x01), ARG10-15=0 (AN649 POWER_UP argument table).
|
||||
std::uint8_t powerUp[] = {
|
||||
0x17, xtalIbias, 0x00, 0xf8, 0x24, 0x01, xtalCtun, 0x10,
|
||||
0x17,
|
||||
xtalIbias,
|
||||
static_cast<std::uint8_t>(xtalFreqHz & 0xFFU),
|
||||
static_cast<std::uint8_t>((xtalFreqHz >> 8) & 0xFFU),
|
||||
static_cast<std::uint8_t>((xtalFreqHz >> 16) & 0xFFU),
|
||||
static_cast<std::uint8_t>((xtalFreqHz >> 24) & 0xFFU),
|
||||
xtalCtun,
|
||||
0x10,
|
||||
0x00, 0x00, 0x00, 0x18, 0x00, 0x00,
|
||||
};
|
||||
if (auto pu = writeCommand(Command::PowerUp, powerUp, sizeof(powerUp));
|
||||
@@ -745,6 +778,17 @@ std::expected<void, Si4684Error> Si4684Driver::boot(
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::recalibrateXtal(
|
||||
std::uint8_t xtalIbias, std::uint8_t xtalCtun, std::uint32_t xtalFreqHz)
|
||||
{
|
||||
if (auto ready = ensureBooted(); !ready) {
|
||||
return ready;
|
||||
}
|
||||
const Si4684Band band = loadedBand_;
|
||||
booted_ = false;
|
||||
return boot(band, xtalIbias, xtalCtun, xtalFreqHz);
|
||||
}
|
||||
|
||||
bool Si4684Driver::isBooted() const noexcept
|
||||
{
|
||||
return booted_;
|
||||
@@ -920,6 +964,7 @@ std::expected<Si4684FmRsq, Si4684Error> Si4684Driver::readFmRsq()
|
||||
static_cast<std::int8_t>(raw[kFmRsqOffSnr]),
|
||||
freqInBand && chipValid,
|
||||
false,
|
||||
static_cast<std::int8_t>(raw[kFmRsqOffFreqOff]),
|
||||
};
|
||||
return rsq;
|
||||
}
|
||||
@@ -1037,7 +1082,8 @@ std::expected<void, Si4684Error> Si4684Driver::installDefaultDabFrequencyPlan()
|
||||
return sendCommand(cmd);
|
||||
}
|
||||
|
||||
std::expected<void, Si4684Error> Si4684Driver::tuneDab(std::uint8_t freqIndex)
|
||||
std::expected<void, Si4684Error> Si4684Driver::tuneDab(
|
||||
std::uint8_t freqIndex, std::uint8_t antCap)
|
||||
{
|
||||
if (auto band = ensureBand(Si4684Band::Dab); !band) {
|
||||
return band;
|
||||
@@ -1047,8 +1093,9 @@ std::expected<void, Si4684Error> Si4684Driver::tuneDab(std::uint8_t freqIndex)
|
||||
}
|
||||
// writeCommand() always prepends a fixed ARG1=0x00 (INJECTION=0), so
|
||||
// this array starts at ARG2 (AN649 Command 0xB0 table: ARG2=FREQ_INDEX,
|
||||
// ARG3=0x00 fixed, ARG4=ANTCAP[7:0], ARG5=ANTCAP[15:8]).
|
||||
const std::uint8_t args[] = {freqIndex, 0x00U, 0x00U, 0x00U};
|
||||
// ARG3=0x00 fixed, ARG4=ANTCAP[7:0], ARG5=ANTCAP[15:8] -- high byte
|
||||
// always 0, range is 0-128, same as tuneFm's ANTCAP).
|
||||
const std::uint8_t args[] = {freqIndex, 0x00U, antCap, 0x00U};
|
||||
if (auto cmd = writeCommand(Command::DabTuneFreq, args, sizeof(args));
|
||||
!cmd) {
|
||||
return std::unexpected(Si4684Error::TuneFailed);
|
||||
|
||||
@@ -169,6 +169,7 @@ std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
|
||||
status.locked = rsq->valid;
|
||||
status.fmRssiDbuV = rsq->rssiDbuV;
|
||||
status.fmSnrDb = rsq->snrDb;
|
||||
status.fmFreqOffBppm = rsq->freqOffBppm;
|
||||
status.fmStereo = rsq->stereo;
|
||||
status.fmChipReadFrequency = rsq->frequency;
|
||||
// Keep commanded frequency when chip READFREQ is stale (stuck at band
|
||||
@@ -209,12 +210,12 @@ std::expected<core::TunerStatus, core::TunerError> Si4684Tuner::readStatus()
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> Si4684Tuner::tuneDab(
|
||||
std::uint8_t freqIndex)
|
||||
std::uint8_t freqIndex, std::uint8_t antCap)
|
||||
{
|
||||
if (auto ready = ensureBandLoaded(core::TunerBand::Dab); !ready) {
|
||||
return ready;
|
||||
}
|
||||
if (auto result = driver_.tuneDab(freqIndex); !result) {
|
||||
if (auto result = driver_.tuneDab(freqIndex, antCap); !result) {
|
||||
return std::unexpected(mapError(result.error()));
|
||||
}
|
||||
dabIndex_ = freqIndex;
|
||||
@@ -342,4 +343,14 @@ std::expected<void, core::TunerError> Si4684Tuner::setVolume(std::uint8_t level)
|
||||
return {};
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> Si4684Tuner::recalibrateXtal(
|
||||
std::uint8_t ibias, std::uint8_t ctun, std::uint32_t xtalFreqHz)
|
||||
{
|
||||
if (auto result = driver_.recalibrateXtal(ibias, ctun, xtalFreqHz);
|
||||
!result) {
|
||||
return std::unexpected(mapError(result.error()));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace si4684
|
||||
|
||||
@@ -86,14 +86,14 @@ struct PhoneStreamSink {
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief AntennaCalibration — plain function pointer over EEPROM-backed
|
||||
* FM ANTCAP storage, so net/ never includes eeprom24aa headers
|
||||
* directly; main/ supplies it (HardwareBootstrap owns the I2C
|
||||
* bus and EEPROM handle).
|
||||
* @brief AntennaCalibration — plain function pointers over EEPROM-backed
|
||||
* FM and DAB ANTCAP storage, so net/ never includes eeprom24aa
|
||||
* headers directly; main/ supplies it (HardwareBootstrap owns the
|
||||
* I2C bus and EEPROM handle).
|
||||
*
|
||||
* @dname AntennaCalibration
|
||||
* @return n/a (type)
|
||||
* @pubstate Free function with process lifetime; no per-instance state.
|
||||
* @pubstate Free functions with process lifetime; no per-instance state.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-19
|
||||
@@ -102,6 +102,16 @@ struct AntennaCalibration {
|
||||
/** Persist a new FM ANTCAP calibration value to EEPROM.
|
||||
* @return false on an I2C failure. */
|
||||
bool (*save)(std::uint8_t antCap);
|
||||
/** Persist a new DAB ANTCAP calibration value to EEPROM.
|
||||
* @return false on an I2C failure. */
|
||||
bool (*saveDab)(std::uint8_t antCap);
|
||||
/** Diagnostic-only, 2026-08-23: re-run Si4684Driver::boot() with new
|
||||
* crystal parameters (no ESP32 restart, no persistence). Bridged here
|
||||
* rather than through a new context field to avoid a second plumbing
|
||||
* chain for what is a temporary calibration tool.
|
||||
* @return false if the chip was never booted or the reboot failed. */
|
||||
bool (*recalibrateXtal)(std::uint8_t ibias, std::uint8_t ctun,
|
||||
std::uint32_t xtalFreqHz);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -430,12 +430,12 @@ esp_err_t tunerTunePostHandler(httpd_req_t* req)
|
||||
|
||||
std::expected<void, core::TunerError> result = std::unexpected(
|
||||
core::TunerError::InvalidInput);
|
||||
// Omitting antcap uses the board's saved calibration for that band (or
|
||||
// hardware auto-tune if never calibrated) — only an explicit value in
|
||||
// the request overrides it, e.g. for a calibration sweep.
|
||||
if (parsed->band == core::TunerBand::Dab) {
|
||||
result = ctx->tuner->tuneDab(parsed->dabFreqIndex);
|
||||
result = ctx->tuner->tuneDab(parsed->dabFreqIndex, parsed->antCap);
|
||||
} else if (parsed->fmFrequency) {
|
||||
// Omitting antcap uses the board's saved calibration (or hardware
|
||||
// auto-tune if never calibrated) — only an explicit value in the
|
||||
// request overrides it, e.g. for a calibration sweep.
|
||||
result = ctx->tuner->tuneFm(*parsed->fmFrequency, parsed->antCap);
|
||||
}
|
||||
|
||||
@@ -676,17 +676,76 @@ esp_err_t tunerCalibrateAntennaPostHandler(httpd_req_t* req)
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
if (!ctx->antennaCalibration->save(*parsed)) {
|
||||
const bool isDab = parsed->band == core::TunerBand::Dab;
|
||||
const bool saved = isDab ? ctx->antennaCalibration->saveDab(parsed->antCap)
|
||||
: ctx->antennaCalibration->save(parsed->antCap);
|
||||
if (!saved) {
|
||||
const std::string json = core::serializeTunerErrorJson("store_failed");
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
ctx->tuner->setDefaultFmAntCap(*parsed);
|
||||
if (isDab) {
|
||||
ctx->tuner->setDefaultDabAntCap(parsed->antCap);
|
||||
} else {
|
||||
ctx->tuner->setDefaultFmAntCap(parsed->antCap);
|
||||
}
|
||||
|
||||
const std::string json =
|
||||
std::string("{\"status\":\"saved\",\"antcap\":")
|
||||
+ std::to_string(*parsed) + "}";
|
||||
std::string("{\"status\":\"saved\",\"band\":\"")
|
||||
+ (isDab ? "dab" : "fm") + "\",\"antcap\":"
|
||||
+ std::to_string(parsed->antCap) + "}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief tunerXtalCalibratePostHandler — POST /api/tuner/xtal-calibrate.
|
||||
*
|
||||
* Diagnostic-only, 2026-08-23: reboots the Si4684 with new IBIAS/CTUN/
|
||||
* XTAL_FREQ (AN649 §Command 0x01 POWER_UP) without an ESP32 restart or
|
||||
* NVS persistence, so a calibration script can iterate crystal parameters
|
||||
* live. Caller must re-tune afterwards -- this only reboots the chip.
|
||||
* See FM_RSQ_STATUS FREQOFF (GET /api/tuner/status, "freqoff_ppm") for the
|
||||
* measurement this is meant to null out.
|
||||
*/
|
||||
esp_err_t tunerXtalCalibratePostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->antennaCalibration == nullptr
|
||||
|| ctx->antennaCalibration->recalibrateXtal == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
std::array<char, 128> body{};
|
||||
if (!readRequestBody(req, body)) {
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
const auto parsed =
|
||||
core::parseXtalCalibrationJson(std::string_view(body.data()));
|
||||
if (!parsed) {
|
||||
const std::string json = core::serializeTunerErrorJson("invalid_json");
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
if (!ctx->antennaCalibration->recalibrateXtal(
|
||||
parsed->ibias, parsed->ctun, parsed->xtalFreqHz)) {
|
||||
const std::string json = core::serializeTunerErrorJson("boot_failed");
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
const std::string json =
|
||||
std::string("{\"status\":\"recalibrated\",\"ibias\":")
|
||||
+ std::to_string(parsed->ibias) + ",\"ctun\":"
|
||||
+ std::to_string(parsed->ctun) + ",\"xtal_freq_hz\":"
|
||||
+ std::to_string(parsed->xtalFreqHz) + "}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
@@ -1700,6 +1759,63 @@ esp_err_t bluetoothAutoReconnectPostHandler(httpd_req_t* req)
|
||||
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
|
||||
}
|
||||
|
||||
esp_err_t bluetoothA2dpCodecConfigPostHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
std::array<char, 128> body{};
|
||||
(void)readRequestBody(req, body);
|
||||
const auto mask = core::parseBluetoothA2dpCodecConfigJson(
|
||||
std::string_view(body.data()));
|
||||
if (!mask) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(parseErrorToken(mask.error()));
|
||||
httpd_resp_set_status(req, "400 Bad Request");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
if (auto result = ctx->bluetooth->setA2dpCodecConfig(*mask); !result) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(result.error()));
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
ESP_LOGI(kTag, "A2DP codec config set to bitmask %u — reconnect the "
|
||||
"peer for it to take effect",
|
||||
static_cast<unsigned>(*mask));
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, "{\"status\":\"saved\"}", 18);
|
||||
}
|
||||
|
||||
esp_err_t bluetoothA2dpCodecGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
if (ctx == nullptr || ctx->bluetooth == nullptr) {
|
||||
httpd_resp_set_status(req, "503 Service Unavailable");
|
||||
return httpd_resp_send(req, nullptr, 0);
|
||||
}
|
||||
|
||||
const auto codec = ctx->bluetooth->queryA2dpCodec();
|
||||
if (!codec) {
|
||||
const std::string json =
|
||||
core::serializeBluetoothErrorJson(bt1035ErrorToken(codec.error()));
|
||||
httpd_resp_set_status(req, "500 Internal Server Error");
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
const std::string json = core::serializeBluetoothA2dpCodecJson(*codec);
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
return httpd_resp_send(req, json.c_str(), json.size());
|
||||
}
|
||||
|
||||
esp_err_t stationsGetHandler(httpd_req_t* req)
|
||||
{
|
||||
auto* ctx = routeContextFrom(req);
|
||||
@@ -2067,6 +2183,14 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
};
|
||||
httpd_register_uri_handler(server_, &tunerCalibrateAntennaUri);
|
||||
|
||||
const httpd_uri_t tunerXtalCalibrateUri = {
|
||||
.uri = "/api/tuner/xtal-calibrate",
|
||||
.method = HTTP_POST,
|
||||
.handler = tunerXtalCalibratePostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &tunerXtalCalibrateUri);
|
||||
|
||||
const httpd_uri_t audioProfileGetUri = {
|
||||
.uri = "/api/audio/profile",
|
||||
.method = HTTP_GET,
|
||||
@@ -2267,6 +2391,22 @@ std::expected<void, NetError> SetupWebServer::start(
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothAutoReconnectUri);
|
||||
|
||||
const httpd_uri_t bluetoothA2dpCodecConfigUri = {
|
||||
.uri = "/api/bluetooth/a2dp-codec",
|
||||
.method = HTTP_POST,
|
||||
.handler = bluetoothA2dpCodecConfigPostHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothA2dpCodecConfigUri);
|
||||
|
||||
const httpd_uri_t bluetoothA2dpCodecGetUri = {
|
||||
.uri = "/api/bluetooth/a2dp-codec",
|
||||
.method = HTTP_GET,
|
||||
.handler = bluetoothA2dpCodecGetHandler,
|
||||
.user_ctx = routeCtx,
|
||||
};
|
||||
httpd_register_uri_handler(server_, &bluetoothA2dpCodecGetUri);
|
||||
|
||||
const httpd_uri_t stationsGetUri = {
|
||||
.uri = "/api/stations",
|
||||
.method = HTTP_GET,
|
||||
|
||||
@@ -26,7 +26,12 @@ namespace secure_store {
|
||||
namespace {
|
||||
constexpr char kTag[] = "NvsAudioProfileStore";
|
||||
constexpr char kNamespace[] = "digiradio";
|
||||
constexpr char kProfileKey[] = "audio_profile_json";
|
||||
// NVS key names are capped at 15 chars (NVS_KEY_NAME_MAX_SIZE=16 incl. NUL);
|
||||
// the previous "audio_profile_json" (18 chars) made every nvs_set_str call
|
||||
// fail with ESP_ERR_NVS_KEY_TOO_LONG (0x1109), silently -- applyProfile()
|
||||
// always updated live audio correctly but persistProfile() never actually
|
||||
// wrote anything, so nothing survived a reboot.
|
||||
constexpr char kProfileKey[] = "audio_profile";
|
||||
} // namespace
|
||||
|
||||
bool NvsAudioProfileStore::hasProfile() const
|
||||
|
||||
@@ -107,6 +107,30 @@ public:
|
||||
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setAutoReconnect(
|
||||
std::uint8_t times);
|
||||
|
||||
/**
|
||||
* @brief setA2dpCodecConfig — enable optional A2DP codecs at runtime.
|
||||
*
|
||||
* @dname setA2dpCodecConfig
|
||||
* @param bitmask BIT0=AAC, BIT1=aptX, BIT2=aptX-LL, BIT3=aptX-HD,
|
||||
* BIT4=aptX-Adaptive, BIT5=LDAC; 0 forces SBC-only.
|
||||
* @return Ok on success, or a Bt1035Error.
|
||||
* @pubstate Only affects the next negotiation — call disconnectA2dp()
|
||||
* (or have the peer reconnect) for an already-streaming link
|
||||
* to pick up the new codec set.
|
||||
*/
|
||||
[[nodiscard]] std::expected<void, bt1035::Bt1035Error> setA2dpCodecConfig(
|
||||
std::uint8_t bitmask);
|
||||
|
||||
/**
|
||||
* @brief queryA2dpCodec — read the currently negotiated A2DP codec.
|
||||
*
|
||||
* @dname queryA2dpCodec
|
||||
* @return Negotiated codec, or a Bt1035Error (e.g. no active link).
|
||||
* @pubstate none
|
||||
*/
|
||||
[[nodiscard]] std::expected<core::Bt1035A2dpCodec, bt1035::Bt1035Error>
|
||||
queryA2dpCodec();
|
||||
|
||||
/**
|
||||
* @brief scanNearby — classic BT/EDR discovery, cancelling pairing.
|
||||
*
|
||||
|
||||
@@ -148,6 +148,18 @@ std::expected<void, bt1035::Bt1035Error> BluetoothService::setAutoReconnect(
|
||||
return driver_.setAutoReconnect(times);
|
||||
}
|
||||
|
||||
std::expected<void, bt1035::Bt1035Error> BluetoothService::setA2dpCodecConfig(
|
||||
std::uint8_t bitmask)
|
||||
{
|
||||
return driver_.setA2dpCodecConfig(bitmask);
|
||||
}
|
||||
|
||||
std::expected<core::Bt1035A2dpCodec, bt1035::Bt1035Error>
|
||||
BluetoothService::queryA2dpCodec()
|
||||
{
|
||||
return driver_.queryA2dpEncoder();
|
||||
}
|
||||
|
||||
std::expected<std::vector<core::Bt1035ScannedDevice>, bt1035::Bt1035Error>
|
||||
BluetoothService::scanNearby(std::uint8_t scanSeconds)
|
||||
{
|
||||
|
||||
@@ -73,6 +73,10 @@ public:
|
||||
*
|
||||
* @dname tuneDab
|
||||
* @param freqIndex Ensemble index 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<void, core::TunerError> tuneDab(
|
||||
std::uint8_t freqIndex);
|
||||
std::uint8_t freqIndex,
|
||||
std::optional<std::uint8_t> antCap = std::nullopt);
|
||||
|
||||
/**
|
||||
* @brief setDefaultDabAntCap — set the board's calibrated DAB ANTCAP.
|
||||
*
|
||||
* @dname setDefaultDabAntCap
|
||||
* @param antCap Value applied to every DAB tune that doesn't pass an
|
||||
* explicit override (0 = chip auto-tune, the factory
|
||||
* default before any calibration is saved).
|
||||
* @pubstate writes defaultDabAntCap_. Does not itself re-tune.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-20
|
||||
*/
|
||||
void setDefaultDabAntCap(std::uint8_t antCap) noexcept;
|
||||
|
||||
/**
|
||||
* @brief tuneFm — tune to an FM centre frequency.
|
||||
@@ -220,6 +239,7 @@ private:
|
||||
core::FrequencyKHz lastFmFrequency_;
|
||||
std::uint8_t volume_;
|
||||
std::uint8_t defaultFmAntCap_;
|
||||
std::uint8_t defaultDabAntCap_;
|
||||
std::optional<std::uint32_t> lastPlayedServiceId_;
|
||||
std::optional<std::uint32_t> lastPlayedComponentId_;
|
||||
};
|
||||
|
||||
@@ -139,6 +139,7 @@ TunerService::TunerService(core::ITuner& tuner)
|
||||
, lastFmFrequency_(defaultFmFrequency())
|
||||
, volume_(40U)
|
||||
, defaultFmAntCap_(0U)
|
||||
, defaultDabAntCap_(0U)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -147,6 +148,11 @@ void TunerService::setDefaultFmAntCap(std::uint8_t antCap) noexcept
|
||||
defaultFmAntCap_ = antCap;
|
||||
}
|
||||
|
||||
void TunerService::setDefaultDabAntCap(std::uint8_t antCap) noexcept
|
||||
{
|
||||
defaultDabAntCap_ = antCap;
|
||||
}
|
||||
|
||||
std::expected<core::TunerStatus, core::TunerError> TunerService::refreshStatus()
|
||||
{
|
||||
auto status = tuner_.readStatus();
|
||||
@@ -161,9 +167,11 @@ std::expected<core::TunerStatus, core::TunerError> TunerService::refreshStatus()
|
||||
}
|
||||
|
||||
std::expected<void, core::TunerError> TunerService::tuneDab(
|
||||
std::uint8_t freqIndex)
|
||||
std::uint8_t freqIndex, std::optional<std::uint8_t> antCap)
|
||||
{
|
||||
if (auto result = tuner_.tuneDab(freqIndex); !result) {
|
||||
if (auto result =
|
||||
tuner_.tuneDab(freqIndex, antCap.value_or(defaultDabAntCap_));
|
||||
!result) {
|
||||
return result;
|
||||
}
|
||||
lastDabIndex_ = freqIndex;
|
||||
|
||||
+132
-7
@@ -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
|
||||
|
||||
@@ -718,13 +718,516 @@ small once anything blocks even briefly.
|
||||
observed, on the low side). ~~Redo the ANTCAP sweep~~ — **done this
|
||||
session for FM** (see the ANTCAP antenna calibration feature commit);
|
||||
antcap=102 saved as the board's default, +6 to +11 dB RSSI/SNR across the
|
||||
band. DAB doesn't have an equivalent calibrated-default mechanism yet —
|
||||
worth adding the same idea (DAB_TUNE_FREQ also takes an ANTCAP argument
|
||||
per AN649) if DAB signal quality remains the limiting factor after this.
|
||||
band. ~~DAB doesn't have an equivalent calibrated-default mechanism yet~~
|
||||
— **added and swept 2026-08-20, see below; no default saved (auto-tune
|
||||
already best on the ensembles tested).**
|
||||
- Try a proper FM/DAB antenna to see how much of the crackle/noise clears
|
||||
up versus how much is inherent to the current antenna's gain/placement.
|
||||
- Investigate the intermittent multi-second HTTP unresponsiveness noted
|
||||
above — reproducible, not yet root-caused, not obviously related to any
|
||||
single change this session.
|
||||
- BT1035 boot-failure root cause still open (see section above) — non-fatal
|
||||
now, so it's no longer blocking, but still unexplained.
|
||||
now, so it's no longer blocking, but still unexplained. **Recurred
|
||||
2026-08-20, see below — still open, confirmed not caused by physical
|
||||
handling.**
|
||||
|
||||
## 2026-08-20 update: DAB ANTCAP override added and swept live; BT1035 "total UART silence" recurred
|
||||
|
||||
**DAB ANTCAP — implemented, built, flashed, swept live via the HTTP API.**
|
||||
Extended the ANTCAP override (AN649 Command 0x30 ARG4/5 for FM, Command
|
||||
0xB0 ARG4/5 for DAB) from FM-only to DAB, mirroring the existing FM
|
||||
mechanism end to end: `ITuner::tuneDab`/`Si4684Driver::tuneDab` gained an
|
||||
`antCap` parameter (was hardcoded `0x00`/auto); `TunerService` gained
|
||||
`defaultDabAntCap_`/`setDefaultDabAntCap()`; `Eeprom24aa` gained
|
||||
`readDabAntCap()`/`writeDabAntCap()` at word address 0x01 (FM stays at
|
||||
0x00); `HardwareBootstrap` gained `dabAntCapCalibration()`/
|
||||
`saveDabAntCapCalibration()`, loaded at boot alongside the FM one; the
|
||||
`net::AntennaCalibration` bridge gained `saveDab`; both `POST
|
||||
/api/tuner/tune` (one-shot override, `{"band":"dab","freq_index":N,
|
||||
"antcap":V}`) and `POST /api/tuner/calibrate-antenna` (persists to EEPROM,
|
||||
`{"band":"dab","antcap":V}`, `band` defaults to `"fm"` so old clients are
|
||||
unaffected) now accept DAB. Host build + 20/20 ctest + doxygen +
|
||||
check-manual-sync all green before flashing.
|
||||
|
||||
Swept live via the API (`freq_index` 0-128 step 8) against three real
|
||||
ensembles:
|
||||
- **freq_index 5** (weakest known ensemble, 7 dB CNR baseline from the
|
||||
2026-08-16 sweep): did not lock at all this session, at any ANTCAP
|
||||
including auto — signal currently below threshold, not a code issue
|
||||
(indices 22/23 locked normally in the same session).
|
||||
- **freq_index 23** (strongest, 21-26 dB): CNR jittered ±3 dB across the
|
||||
whole ANTCAP range with no discernible trend — already saturated, sweep
|
||||
can't discriminate on a signal this strong.
|
||||
- **freq_index 22** (medium, 16-20 dB): auto (0) and antcap=32 tied for
|
||||
best (20 dB CNR); antcap=72 and 80 caused total loss of lock (a dead
|
||||
zone to avoid); the rest of the range gave no systematic gain over auto,
|
||||
unlike FM's clean +6 to +11 dB improvement.
|
||||
|
||||
**Decision: left DAB on auto-tune, nothing saved to EEPROM.** Unlike FM,
|
||||
no ANTCAP value tested beat the chip's own auto-tune by a margin worth
|
||||
trusting. If DAB audio quality is still the limiting factor later, retest
|
||||
specifically on a weak ensemble (index 5 or similar) once it's receivable
|
||||
again — ANTCAP calibration matters most on weak signals, which is exactly
|
||||
the case that wasn't testable this session.
|
||||
|
||||
**BT1035 "total UART silence" recurred — same still-open issue as before,
|
||||
confirmed (again) not physical.** During the DAB sweep, the board was
|
||||
reset several times via opening a `pyserial` connection for log capture —
|
||||
each open triggers a hardware EN/reset pulse on this ESP32-S3 (confirmed:
|
||||
happens even with `dsrdtr=False, rtscts=False` and explicit
|
||||
`setDTR(False)`/`setRTS(False)` — this is the USB-native auto-reset
|
||||
circuit firing on port open, not a pyserial default that can be disabled
|
||||
from the Mac side). One of these resets left BT1035 silent: `no
|
||||
spontaneous UART bytes after hardware reset` on both boot attempts (2/2),
|
||||
then silent across all 8 probed baud rates (9600-921600). This is *not*
|
||||
the "banner arrives late" issue fixed 2026-08-20 earlier this same session
|
||||
(`kBootBannerWaitMs = 25000` was already in effect and made no
|
||||
difference) — it's the harder, total-silence failure mode already logged
|
||||
above (the "Unrelated finding from the same session" note before the
|
||||
2026-08-19 entry), recurring. Confirmed again this time that it is not
|
||||
caused by physical handling: a full physical power-off for 60 s did not
|
||||
recover it (Si4684/ADAU1701 both came back up fine on the same power
|
||||
cycle, ruling out a board-wide power issue). Root cause still not
|
||||
identified. `/api/bluetooth/status` and `/api/bluetooth/paired` correctly
|
||||
report `{"status":"error","reason":"at_timeout"}` while in this state; the
|
||||
rest of the device (tuner, web UI) stays usable per the existing
|
||||
non-fatal-BT1035-boot design.
|
||||
|
||||
## 2026-08-21 update: BT1035 total silence confirmed intermittent (not
|
||||
## hardware); background retry mitigation added
|
||||
|
||||
Follow-up session dedicated entirely to the "total UART silence" BT1035
|
||||
failure mode above. Summary: **confirmed intermittent on genuinely
|
||||
identical hardware, root cause narrowed to the module's internal crystal
|
||||
(not our PCB, not fixable by us), and mitigated (not fixed) with an
|
||||
indefinite background boot retry.**
|
||||
|
||||
**Diagnostic instrumentation (temporary, added then reverted this
|
||||
session)**: added `BT1035 AT TX: <line>` / `BT1035 UART RX RAW: <hex or
|
||||
<empty>>` / `BT1035 AT RESULT: OK|ERROR|TIMEOUT` logging around
|
||||
`Bt1035Driver::transmitAndCollect()`, and temporarily dropped
|
||||
`kBootAttempts` to 1 for single-attempt clarity. This confirmed the
|
||||
failure signature precisely: `AT` is transmitted, zero bytes ever come
|
||||
back (`<empty>`), timeout. Reverted via `git checkout` once the manual
|
||||
diagnosis was done — not kept in the codebase.
|
||||
|
||||
**Multimeter checks, all normal** (scope-level checks — crystal
|
||||
oscillation, power-on transient — remain out of reach without an
|
||||
oscilloscope):
|
||||
- VBAT_IN: 3.3V (datasheet range 3.0-4.2V) ✓
|
||||
- 1.8V_OUT (module's internal regulator): 1.8V ✓ — proves the module's
|
||||
own power management *is* running, it isn't simply unpowered
|
||||
- SYS_CTRL / RESET (post-boot): ~3.27V, matching the firmware's own GPIO
|
||||
readback log (`post-reset: SYS_CTRL=1 RESET=1`) ✓
|
||||
- BT1035 TX pin (module side) to GND: 3.29V, idle-HIGH, no short/float/
|
||||
reversed polarity ✓ (though idle-HIGH alone doesn't prove the module's
|
||||
firmware is executing — some pads default HIGH from reset state alone)
|
||||
- A 10kΩ pull-down the user had added on SYS_CTRL (matching the
|
||||
datasheet's own recommendation for an undriven pin) was checked and is
|
||||
not the cause — the ESP32 GPIO drives push-pull and its own readback
|
||||
confirms it reaches a valid HIGH regardless.
|
||||
|
||||
**Crystal location determined**: the BT1035 datasheet's own block diagram
|
||||
shows "32MHz Crystal" as an internal block of the QCC3056 die, and the
|
||||
DigiRadio schematic netlist (`Netlist_Schematic1_2026-08-07.asc`) has no
|
||||
XTAL_IN/XTAL_OUT pins wired to any external crystal for U11 — confirming
|
||||
the oscillator is sealed inside the Feasycom module, not on our PCB. This
|
||||
is why nothing on our side (layout, load caps, our firmware) can affect
|
||||
it; if the failure really is a marginal oscillator-startup margin, it's a
|
||||
property of that specific physical module unit (or the part's design
|
||||
tolerance in general).
|
||||
|
||||
**Decisive evidence of intermittency, not a dead unit**: across repeated
|
||||
reboots in the same session (physical power-cycles and serial-port-open
|
||||
resets, which also hard-reset this ESP32-S3's native USB-CDC), the
|
||||
identical physical module was observed to boot **completely successfully**
|
||||
at least once — spontaneous banner `+VER=FSC-BT1035,V6.1.1,20240521` +
|
||||
`+DEVSTAT=1`, then `AT` and `AT+AUXCFG=3` both answered `OK` — and to fail
|
||||
completely silently on other attempts, with no physical change in between.
|
||||
This rules out "defective/dead module" as an explanation; ordering a
|
||||
replacement module is therefore *not* a guaranteed fix, since the same
|
||||
physical unit demonstrably works when it works.
|
||||
|
||||
**UART loopback test attempt — inconclusive, logged for future
|
||||
reference.** Tried to isolate ESP32 vs. module by bridging the ESP32-S3's
|
||||
own GPIO40 (BT1035 UART TX)/GPIO41 (BT1035 UART RX) pins with a jumper
|
||||
held by hand on the ESP32 module's castellated pads (no series
|
||||
resistor/test point exists on this net per the schematic netlist — U8.33
|
||||
↔ U11.P$14 and U8.34 ↔ U11.P$13 directly, nothing else). Twice
|
||||
reproducibly, bridging those pins from cold boot caused the ESP32 itself
|
||||
to hang very early in boot (right after the bootloader's "Disabling RNG
|
||||
early entropy source" line, before `app_main()` even starts) — harmless
|
||||
(board recovers fully once the jumper is removed) but unexplained, and it
|
||||
sidesteps the actual test rather than answering it. Not pursued further
|
||||
this session given the practical difficulty of hand-holding a wire onto
|
||||
castellated pads without a proper SMD test hook. If retried: attach the
|
||||
jumper *after* the ESP32 has already booted past that early stage (there's
|
||||
a ~25s window before the BT1035 AT command is actually sent) rather than
|
||||
from a cold boot.
|
||||
|
||||
**Mitigation implemented: indefinite background boot retry.** Since the
|
||||
module's own internal fault (if that's what it is) isn't something we can
|
||||
fix, and since it demonstrably self-clears on a later attempt rather than
|
||||
needing repair, `main/hardware_bootstrap.cpp` now spawns a
|
||||
`bt1035RetryTask` FreeRTOS task whenever the initial `HardwareBootstrap::
|
||||
boot()`'s call to `Bt1035Driver::boot()` fails. The task loops calling
|
||||
`boot()` again with **no artificial delay** between attempts — each
|
||||
attempt already blocks for ~25-60s on its own (the banner wait times
|
||||
`kBootAttempts`, plus an 8-step baud-rate sweep on final failure), so no
|
||||
extra backoff is needed on top — until it succeeds, at which point it runs
|
||||
the same post-boot setup (device name, auto-reconnect) the normal success
|
||||
path does, then exits. The rest of the system (Wi-Fi, tuner, web UI) never
|
||||
blocks on this and stays fully usable throughout. Verified live: after a
|
||||
forced failure (2 attempts + baud sweep, ~62s), the retry task started
|
||||
immediately, the HTTP server and heartbeat came up normally in parallel,
|
||||
and the retry task began a fresh attempt right away without any pause.
|
||||
|
||||
**Open going forward**: root cause of the intermittent total-silence mode
|
||||
is still not identified — this session's diagnosis exhausted what's
|
||||
possible with a multimeter alone. Real progress would need either an
|
||||
oscilloscope on SYS_CTRL/RESET/crystal across several boots to correlate
|
||||
success/failure with power-on timing jitter, or a large-N automated
|
||||
reboot-cycle statistic (attempted this session via a `pyserial` script,
|
||||
but the ESP32-S3's native USB-CDC re-enumerating on every hardware reset
|
||||
made a fully unattended multi-cycle script unreliable — a naive read loop
|
||||
silently produced a false "0/5 success" result once across a reconnect
|
||||
window). A future attempt at that statistic needs to detect the USB path
|
||||
disappearing/reappearing and reopen the port, or use a separate
|
||||
hardware UART-to-USB adapter that doesn't disconnect when the target
|
||||
resets.
|
||||
|
||||
**Also discussed this session (not implemented, for a future hardware
|
||||
revision)**: whether a different/newer SoC could eliminate the need for
|
||||
the external BT1035 module entirely. Confirmed via web search that
|
||||
Espressif's new **ESP32-S31** (RISC-V, announced April 2026) has
|
||||
integrated **Bluetooth 5.4 with both LE and Classic (BR/EDR)** support —
|
||||
unlike the ESP32-S3 used today, which is BLE-only at the silicon level
|
||||
(confirmed: no Classic BT/A2DP hardware exists on S3, this is not a
|
||||
firmware limitation). An `ESP32-S31-WROOM-3` module also exists. This
|
||||
would be a significant main-MCU redesign, not a drop-in swap, and its
|
||||
ESP-IDF support maturity/availability wasn't independently verified this
|
||||
session — worth a dedicated evaluation before committing to it for a
|
||||
future hardware revision.
|
||||
|
||||
## 2026-08-21 follow-up: git archaeology on the boot-retry structure;
|
||||
## minimal patch to restore the validated single-attempt design
|
||||
|
||||
Separate follow-up session, requested specifically to re-derive the
|
||||
BT1035 boot regression analysis directly from git history rather than
|
||||
from further live hardware probing, per the project's own house rule
|
||||
(2026-08-14 postmortem): exhaust the code-path diff against a known-good
|
||||
commit before floating new hardware theories.
|
||||
|
||||
**Full commit archaeology** (`git log --follow` on
|
||||
`Bt1035Driver.cpp`):
|
||||
```
|
||||
6ca40f1 "all companion chips ready" — baseline, 0 known bugs
|
||||
6f7b6dd added a redundant AT+RESET right after the hardware reset pulse
|
||||
fd9d4ae (2026-08-15) fixed 6f7b6dd in one commit: removed the redundant
|
||||
AT+RESET AND introduced logRawUartBoot() for the first time,
|
||||
already at its final 3500ms window (the "1500ms too short"
|
||||
text in the report/commit message describes an intermediate
|
||||
value tried live during that debugging session, never itself
|
||||
committed) — 5/5 clean boots documented after this fix.
|
||||
3a58d33 (2026-08-20, this project's own earlier commit today) widened
|
||||
the banner wait 3500ms → 25000ms (real banner measured arriving
|
||||
up to ~18.5-42s post-reset) AND, in the same commit, introduced
|
||||
a NEW intra-boot() retry loop (kBootAttempts=2, only
|
||||
kBootRetryDelayMs=300ms between the two hardware reset pulses)
|
||||
that did not exist in fd9d4ae's validated design.
|
||||
```
|
||||
|
||||
**Finding**: comparing `fd9d4ae` (the last commit with a documented,
|
||||
validated 5/5 clean-boot run) against the working tree confirmed exactly
|
||||
three differences, only one of them structural:
|
||||
1. Banner wait 3500ms → 25000ms — justified by this session's own real
|
||||
measurements, kept.
|
||||
2. `GPIO_MODE_OUTPUT` → `GPIO_MODE_INPUT_OUTPUT` on RESET/SYS_CTRL —
|
||||
purely additive (enables `gpio_get_level()` readback for the
|
||||
pre-power/post-syscl/post-reset diagnostic logs), electrically
|
||||
neutral, kept.
|
||||
3. **A new intra-`boot()` retry loop with only 300ms between the two
|
||||
hardware reset pulses — this did not exist in the validated baseline.**
|
||||
The BT1035 datasheet's own "Reset Protection timeout (typically
|
||||
>1.8s)" (already gathered earlier this session) means a second
|
||||
SYS_CTRL/RESET pulse fired only 300ms after a failed attempt would not
|
||||
reliably reach a clean power-off state — risking re-interrupting the
|
||||
module mid bring-up, the same class of bug 6f7b6dd/fd9d4ae already
|
||||
dealt with once (redundant AT+RESET). This is the only difference
|
||||
flagged as a plausible contributor, not asserted as certain.
|
||||
|
||||
Also confirmed via repo-wide search: `AT+RESET` (`Bt1035AtCommand::Reset`)
|
||||
is referenced only in the unit test, never in production code; no other
|
||||
task/thread touches the BT1035 UART during its boot window
|
||||
(`savedSpeakerReconnectTask` only starts after `HardwareBootstrap::boot()`
|
||||
returns; the new `bt1035RetryTask` calls `boot()` sequentially, never
|
||||
concurrently). `logRawUartBoot()`'s single `uart_read_bytes()` call and
|
||||
the following `uart_flush_input()` were confirmed, both by code reading
|
||||
and by this session's own successful-boot log capture (banner appeared,
|
||||
then `AT`→`OK` immediately after, no stall), to not swallow or discard
|
||||
data that `runInitSequence()` would otherwise need — `runInitSequence()`
|
||||
does its own fresh TX/RX cycle regardless of what the banner-capture step
|
||||
saw.
|
||||
|
||||
**Minimal patch applied** (user-directed, exact scope agreed before
|
||||
touching code): removed the intra-`boot()` retry loop entirely —
|
||||
`boot()` now makes exactly one `resetAndInitOnce()` call per invocation,
|
||||
structurally identical to `fd9d4ae`. Removed `kBootAttempts` and
|
||||
`kBootRetryDelayMs` (dead after the loop's removal); `probeBaudRates()`'s
|
||||
log line adjusted accordingly (no longer references the removed
|
||||
attempt count). `kBootBannerWaitMs=25000` and the `GPIO_MODE_INPUT_OUTPUT`
|
||||
readback were explicitly left untouched. Retries now live exclusively one
|
||||
layer up, in `hardware::bt1035RetryTask` (`main/hardware_bootstrap.cpp`,
|
||||
added earlier this session), which only re-invokes a full, clean `boot()`
|
||||
call — never re-pulses the pins faster than one whole boot cycle apart.
|
||||
Host tests (20/20) and firmware build both green before flashing.
|
||||
|
||||
**Live result after flashing**: structurally the retry cadence is now
|
||||
clean — confirmed via serial log, each `bt1035RetryTask` iteration is
|
||||
spaced ~31.8s apart (25s banner wait + ~2s AT timeout + ~4s baud sweep,
|
||||
no extra gap), matching the intended single-attempt-per-call design
|
||||
exactly, versus the old back-to-back double-pulse. **However, a 20-minute
|
||||
monitoring window immediately after flashing captured 31 consecutive
|
||||
retry attempts, all silent — zero successes**, a worse hit rate in this
|
||||
specific sample than earlier in the day (which had at least one clean
|
||||
success among fewer attempts). This neither confirms nor refutes the
|
||||
Reset-Protection-timing hypothesis on its own — the patch is kept because
|
||||
it's structurally correct (matches the one historically validated design,
|
||||
removes the only unexplained difference from it), not because this
|
||||
sample proves it improved the success rate. The underlying intermittent
|
||||
root cause (most likely the module's internal, sealed 32MHz crystal
|
||||
startup margin — see the 2026-08-21 entry above) remains unresolved and
|
||||
would need an oscilloscope to pin down further.
|
||||
|
||||
## 2026-08-22 update: RESET#/SYS_CTRL redesign, CTS/RTS diagnostics,
|
||||
## PinScope report from a sibling project, Feasycom support escalation
|
||||
|
||||
**PinScope findings from a sibling "DigiRadio evolution" project with the
|
||||
same BT1035 wiring pattern** were reviewed for transferability. Checked
|
||||
each finding against our own schematic netlist (exact BT1035 pin numbers
|
||||
cross-referenced against the datasheet's own pin table) rather than
|
||||
assuming they apply:
|
||||
- **U16-001 (RESET# pulled to GND by a stray R67) and U16-002 (SYS_CTRL
|
||||
pulled HIGH by a stray R69): do NOT apply to our board.** Verified via
|
||||
netlist: our RESET# net (`GPIO17`) has only the ESP32 and the BT1035,
|
||||
no resistor; our SYS_CTRL pull-down (R12) genuinely goes to GND (pins
|
||||
1/22, confirmed GND in the datasheet), not a stray pull-up.
|
||||
- **U16-003 (VCHG/VCHG_SENSE unconnected): same on our board, presumably
|
||||
intentional** (no USB charging via the BT1035).
|
||||
- **U16-004 (RF_OUT/pin 51 floating): also true on our board** — but this
|
||||
is a genuinely open question, not a confirmed defect: the BT1035
|
||||
datasheet documents both an "Internal Antenna" (§9.2, on-board antenna,
|
||||
no RF_OUT routing needed, PCB keep-out area required instead) and
|
||||
"External Antenna" (§9.3, RF_OUT routed out) layout option, and we
|
||||
cannot tell from the datasheet alone which variant this specific module
|
||||
part/order uses. This affects actual Bluetooth RF range once the module
|
||||
boots — separate from, and does not explain, the intermittent
|
||||
total-silence boot symptom (RF_OUT is downstream of the digital
|
||||
baseband processor that generates the boot banner and answers AT
|
||||
commands).
|
||||
- The sibling report's "RESET#/SYS_CTRL/VCHG compound badly" warning
|
||||
does not transfer to us, since our RESET#/SYS_CTRL wiring is correct.
|
||||
|
||||
**RESET#/SYS_CTRL hardware-management redesign (diagnostic test,
|
||||
requested explicitly to see if external RESET# control was itself
|
||||
contributing to the intermittent failures):**
|
||||
- RESET# (pin 8) is no longer driven by this driver at all: reconfigured
|
||||
as a floating input (`GPIO_MODE_INPUT`, pull-up/pull-down both
|
||||
explicitly disabled), relying entirely on the BT1035's own datasheet-
|
||||
documented "fixed strong pull-up to VDD_IO" (§4.8). GPIO17 confirmed
|
||||
reading HIGH via this internal pull-up, live.
|
||||
- SYS_CTRL (pin 34) redesigned to perform a genuine LOW→HIGH power-cycle
|
||||
on *every* `resetAndInitOnce()` call (held LOW 2.5s — comfortably
|
||||
longer than the datasheet's "~1.8s typical" Reset Protection timeout —
|
||||
then HIGH for >=20ms per §4.7), rather than being asserted once ever at
|
||||
first boot and left alone: previously, every later retry from
|
||||
`bt1035RetryTask` was silently reusing an already-HIGH SYS_CTRL line
|
||||
and never actually power-cycling the module at all.
|
||||
- Both changes build/test clean and were confirmed live to behave exactly
|
||||
as designed (RESET# reads HIGH via internal pull-up; SYS_CTRL cadence
|
||||
matches the 2.5s+20ms design on every retry).
|
||||
|
||||
**Result: inconclusive-to-negative on hit rate.** Across a cumulative
|
||||
~45+ minutes of live monitoring after these changes (two separate
|
||||
sessions, dozens of retry attempts), **zero successful boots were
|
||||
observed** — no banner, ever, in this window. This is not better than,
|
||||
and arguably worse than, the small sample seen the same week under the
|
||||
*previous* (RESET#-driven) design, which did show at least one clean
|
||||
success among fewer attempts. This should not be read as proof the new
|
||||
design is wrong — the previous design also produced a 31-attempt/0-success
|
||||
streak in one session this same week — but it is also not evidence the
|
||||
redesign helped. Kept anyway because it is independently correct per the
|
||||
datasheet (RESET# genuinely can be left unconnected; SYS_CTRL retries
|
||||
should be genuine power-cycles), not because it demonstrably fixed
|
||||
anything.
|
||||
|
||||
**New CTS/RTS diagnostic instrumentation.** Discovered, previously
|
||||
unexamined this entire investigation: the board physically wires
|
||||
`board::pins::Bt1035Cts` (GPIO21 -> BT1035 pin 15, UART_CTS) and
|
||||
`board::pins::Bt1035Rts` (BT1035 pin 16, UART_RTS -> GPIO14) — both
|
||||
**named in `board_pins.hpp` since the pin's original definition, but
|
||||
never configured or used by any driver code**, and the UART is
|
||||
initialized with `UART_HW_FLOWCTRL_DISABLE`. Added both as read-only
|
||||
floating-input diagnostics (`Bt1035Pins::ctsGpio`/`rtsGpio`, logged
|
||||
before and after the banner-wait window in `resetAndInitOnce()`), purely
|
||||
to observe — never driven.
|
||||
|
||||
Datasheet research (not just speculation) on what this could mean:
|
||||
- §4.1 Table 4-1 lists flow control as one of several **configurable**
|
||||
UART settings ("Supports Automatic Flow Control (CTS and RTS lines)"),
|
||||
not stated as active by default.
|
||||
- No AT command to explicitly enable/disable flow control was found in
|
||||
the programming guide.
|
||||
- Pin 16 (UART_RTS/PIO2)'s documented **factory-default alternate
|
||||
function is "PA mute pin"** (`AT+MUTEPIO`'s own default parameter is
|
||||
PIO2) — i.e., out of the box this pin most likely isn't acting as RTS
|
||||
at all.
|
||||
- Live readings, across ~12 samples over 30+ minutes and multiple
|
||||
power-cycles: **CTS=HIGH, RTS=LOW, perfectly stable, zero variation.**
|
||||
This argues against a genuinely floating/noisy input (which would be
|
||||
expected to show at least some jitter across dozens of samples) —
|
||||
something is holding both at a fixed level, whether that's incidental
|
||||
ESP32 GPIO leakage, an internal pull inside the module, or the module
|
||||
actively driving its own RTS/PA_MUTE output. We do not yet have a
|
||||
reading from a *successful* boot to compare against, since none
|
||||
occurred in this session's remaining test window.
|
||||
|
||||
**Escalated to Feasycom support** (email drafted, not yet sent by the
|
||||
user) with: the full symptom description, everything ruled out this
|
||||
session (power rails, RESET#/SYS_CTRL sequencing variants), the CTS/RTS
|
||||
finding reframed as an open question rather than a confirmed cause (per
|
||||
the datasheet nuance above), and the RF_OUT/antenna-variant question.
|
||||
Decided to pause further live hardware experimentation until a reply is
|
||||
received, rather than keep varying RESET#/SYS_CTRL/timing parameters
|
||||
without new information — see the email draft (kept outside the repo, in
|
||||
the session's scratch directory) for the exact wording sent.
|
||||
|
||||
**How to apply, for a future session**: don't re-propose "try removing
|
||||
RESET# drive" or "try a genuine SYS_CTRL power-cycle on retry" as fresh
|
||||
ideas — both were tried this session, both are justified independently,
|
||||
neither showed a measurable improvement in a non-trivial sample. Don't
|
||||
assume CTS/RTS floating is confirmed as the cause either — the CTS=1/
|
||||
RTS=0 stability argues against pure floating-noise, and flow control may
|
||||
not even be engaged by default per the datasheet. The single most
|
||||
valuable next input is Feasycom's own answer, not another round of
|
||||
timing-parameter permutation.
|
||||
|
||||
## 2026-08-23 update: FM/DAB pitch-distortion root-caused and fixed (uncalibrated Si4684 crystal reference); a separate downstream audio-quality issue found and left open
|
||||
|
||||
**Symptom**: user reported FM+DAB audio hiss/distortion, later sharpened to
|
||||
"voce stonata" (mistuned/off-pitch voice), present on both bands.
|
||||
|
||||
**Elimination chain, each step verified on real hardware, not theory**:
|
||||
RF signal strength (new antenna, RSSI/SNR excellent — see the ANTCAP
|
||||
section above) → boot-time ADAU1701 DSP program load (added read-back
|
||||
verification to `SIGMA_WRITE_REGISTER_BLOCK`/`sigma_safeload_block`,
|
||||
confirmed clean on every boot and every runtime safeload) → ADAU1701 EQ
|
||||
band 0 ("fixed high-pass," never touched at runtime) — initially
|
||||
miscalculated as an unstable filter from a wrong fixed-point bit-width
|
||||
assumption (8.23 vs the chip's actual 5.23/28-bit format), corrected and
|
||||
confirmed stable via SigmaStudio's own live register capture connected to
|
||||
the device → ADAU1701 mixer (all input knobs centered, confirmed) →
|
||||
`DAB_ACF_ENABLE` (0xB500, found disabled with no citation; datasheet
|
||||
default is 3; tried enabling it — made things audibly *worse*, likely
|
||||
because COMF_NOISE_ENABLE literally injects synthetic noise on signal
|
||||
dips; reverted to 0x0000, matching what PE5PVB's independent
|
||||
SI4684-DAB-Receiver project also does deliberately — not the cause, but
|
||||
no longer an unexplained magic number) → **decisive test**: a 440 Hz tone
|
||||
generated by the ESP32 and written directly over the shared I2S bus
|
||||
(`main/esp32_i2s_test_tone.{hpp,cpp}`, `CONFIG_ESP32_I2S_TEST_TONE`) came
|
||||
through clean and perfectly in-tune, verified with a real tuner — isolated
|
||||
the pitch problem to the Si4684 itself, ruling out ADAU1701/mixer/I2S
|
||||
receiving/BT1035/speaker → TR_SIZE (0x7) and IBIAS (72 = 720µA) checked
|
||||
against AN649 Figure 13 ("Safe Range of Operation for a 19.2 MHz
|
||||
Crystal"), both comfortably inside the safe range for this crystal's ESR.
|
||||
|
||||
**Root cause**: `Si4684Driver::boot()`'s `xtalCtun=31`/`xtalIbias=72`
|
||||
defaults had only a vague "already verified live" justification — no
|
||||
actual measurement for *this* board's crystal (Abracon
|
||||
ABM8-19.200MHZ-10-1-U-T, CL=10pF decoded from the part number's own
|
||||
ordering-code table, two external 15pF load caps per the schematic) ever
|
||||
existed.
|
||||
|
||||
**Fix, two stages**:
|
||||
1. CTUN empirical trim by ear (AN649 §9.3 says trim by measurement; no
|
||||
oscilloscope available, and a multimeter's frequency counter reads the
|
||||
strong I2S LRCLK signal fine but returns 0 on the crystal pins
|
||||
themselves — too weak/high-impedance for a general-purpose meter to
|
||||
trigger on). Swept 31→5→0 (0 = the floor of the 0-63 range),
|
||||
monotonic improvement each step, still "less bad, not fixed" at the
|
||||
floor.
|
||||
2. **XTAL_FREQ precision trim using the chip's own measurement, no lab
|
||||
equipment needed**: real FM/DAB transmitters are GPS/rubidium-locked,
|
||||
so FM_RSQ_STATUS's FREQOFF field (AN649 Command 0x32 RESP8, signed,
|
||||
units of 2 PPM) directly reports the *receiver's* crystal error on any
|
||||
locked station. Added `Si4684Driver::recalibrateXtal()` (forces a full
|
||||
re-boot with new IBIAS/CTUN/XTAL_FREQ, no ESP32 restart), a new
|
||||
endpoint `POST /api/tuner/xtal-calibrate`, exposed FREQOFF as
|
||||
`"freqoff_ppm"` in `GET /api/tuner/status`, and
|
||||
`tools/si4684_xtal_calibration.py` to automate the trim loop
|
||||
(tune → average several FREQOFF samples → correct → repeat).
|
||||
**Non-obvious gotcha, found only by watching the first attempt
|
||||
diverge, not documented anywhere**: the correction sign is
|
||||
`xtal_freq *= (1 - ppm/1e6)`, not `(1 + ppm/1e6)` — the "tell it the
|
||||
truth" sign convention makes the error grow, not shrink (confirmed
|
||||
live: ppm went 28→60→122→254/no-lock across 3 iterations before the
|
||||
sign was flipped). Averaging + damping (0.6) were both needed for
|
||||
smooth convergence; a single raw FREQOFF sample has enough
|
||||
reception-noise jitter (~±20-35 ppm swings observed) to make an
|
||||
undamped loop oscillate instead of settling.
|
||||
|
||||
**Final calibrated values** (now the firmware default,
|
||||
`main/hardware_bootstrap.cpp`): `CTUN=0`, `XTAL_FREQ=19,199,750 Hz`
|
||||
(≈-13 ppm off the 19.2 MHz nominal). Converged residual: **-3.8 ppm at
|
||||
87.6 MHz, -3.0 ppm at 105.1 MHz** — consistent across two stations at
|
||||
opposite ends of the FM band (the cross-check AN649 itself recommends),
|
||||
confirming this really is the crystal reference and not something
|
||||
frequency-dependent. Down from **+70 ppm** uncorrected at nominal
|
||||
XTAL_FREQ. No physical hardware change (different load-cap values) ended
|
||||
up being necessary — contrary to what seemed likely after CTUN alone.
|
||||
|
||||
**User-confirmed result**: "migliorato moltissimo" (improved a lot) after
|
||||
this fix — the systematic pitch/tuning distortion is resolved.
|
||||
|
||||
### Still open: a separate downstream hiss/intelligibility issue, NOT the Si4684
|
||||
|
||||
After the crystal fix, the user still reported residual hiss and, more
|
||||
seriously, words being unintelligible on both FM and DAB. Recordings sent
|
||||
for spectral analysis showed no gross technical defects (no clipping, no
|
||||
dropouts, no dominant isolated resonance) — inconclusive from the
|
||||
recordings alone (phone-mic-through-air recordings are a poor tool for
|
||||
this specific symptom; room acoustics and mic response confound the
|
||||
signal). The user's direct listening judgement (confirmed repeatedly:
|
||||
"si sente ancora fruscio e le parole sono incomprensibili") is the
|
||||
ground truth here, not the recordings.
|
||||
|
||||
**Decisive test**: enabled `web_radio_stream` (internet radio via ESP32,
|
||||
`POST /api/streaming`) with a direct HTTP MP3 stream
|
||||
(`http://icecast.radiofrance.fr/franceinter-midfi.mp3`), routed through
|
||||
the same shared ADAU1701 mixer/EQ/output/BT1035/Bluetooth-speaker chain
|
||||
as FM/DAB but **never touching the Si4684 at all**. User confirmed:
|
||||
**same symptom** (hiss + unintelligible). This is different from the
|
||||
earlier synthetic-440Hz-tone test, which came through clean — the tone
|
||||
test used a trivial, CPU-cheap sine generator with no decode/buffering
|
||||
involved, so it never exercised whatever a real MP3-decode-under-WiFi-load
|
||||
pipeline does.
|
||||
|
||||
**Conclusion**: the pitch/tuning problem (fixed) and this hiss/
|
||||
intelligibility problem are two separate, independently-confirmed root
|
||||
causes that happened to co-occur and get conflated as "one bug" for most
|
||||
of this session. The Si4684/crystal is now cleared for *this* symptom —
|
||||
next session should look at: (a) `web_radio_stream`'s MP3 decode/I2S
|
||||
buffer-feed path for underrun/overrun under real WiFi jitter, since that
|
||||
was the actual reproducer, and (b) whether the same class of issue could
|
||||
independently affect the ADAU1701 mixer/EQ path under real dynamic
|
||||
program content generally (the passing tone test doesn't rule this out
|
||||
for FM/DAB specifically, only for a pure sine wave). Don't re-open the
|
||||
Si4684/crystal-calibration question for this symptom without new
|
||||
evidence — it's a different, still-unidentified mechanism.
|
||||
|
||||
**New permanent diagnostic tools from this session** (kept in the repo,
|
||||
not removed): `GET /api/tuner/status` now reports `"freqoff_ppm"` for FM;
|
||||
`POST /api/tuner/xtal-calibrate` for live Si4684 crystal re-trim without
|
||||
reflashing; `CONFIG_ESP32_I2S_TEST_TONE` Kconfig option (off by default)
|
||||
for isolating Si4684-specific vs. shared-downstream audio issues;
|
||||
`tools/si4684_xtal_calibration.py` and `tools/si4684_antenna_calibration.py`.
|
||||
|
||||
+19
-11
@@ -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.
|
||||
|
||||
@@ -8,6 +8,7 @@ idf_component_register(
|
||||
"antenna_calibration.cpp"
|
||||
"$<$<BOOL:${CONFIG_TEST_FIRMWARE}>:test_firmware.cpp>"
|
||||
"$<$<BOOL:${CONFIG_I2S_SDATA_PROBE}>:i2s_sdata_probe.cpp>"
|
||||
"$<$<BOOL:${CONFIG_ESP32_I2S_TEST_TONE}>:esp32_i2s_test_tone.cpp>"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration ota eeprom24aa webradio driver
|
||||
PRIV_REQUIRES esp_timer esp_http_client
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,13 +11,28 @@
|
||||
#include "antenna_calibration.hpp"
|
||||
|
||||
#include "hardware_bootstrap.hpp"
|
||||
#include "si4684/Si4684Tuner.hpp"
|
||||
|
||||
namespace antenna_calibration {
|
||||
|
||||
namespace {
|
||||
|
||||
/** Diagnostic-only, 2026-08-23: see net::AntennaCalibration::recalibrateXtal. */
|
||||
bool recalibrateXtal(std::uint8_t ibias, std::uint8_t ctun,
|
||||
std::uint32_t xtalFreqHz)
|
||||
{
|
||||
return static_cast<bool>(hardware::HardwareBootstrap::si4684Tuner()
|
||||
.recalibrateXtal(ibias, ctun, xtalFreqHz));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
net::AntennaCalibration& bridge() noexcept
|
||||
{
|
||||
static net::AntennaCalibration instance{
|
||||
.save = &hardware::HardwareBootstrap::saveFmAntCapCalibration,
|
||||
.saveDab = &hardware::HardwareBootstrap::saveDabAntCapCalibration,
|
||||
.recalibrateXtal = &recalibrateXtal,
|
||||
};
|
||||
return instance;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file esp32_i2s_test_tone.cpp
|
||||
* @brief esp32_i2s_test_tone implementation.
|
||||
*
|
||||
* DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
*
|
||||
* Copyright 2026 Michele Bigi
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
#include "esp32_i2s_test_tone.hpp"
|
||||
|
||||
#include "esp32_i2s_sink.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <numbers>
|
||||
|
||||
namespace esp32_i2s_test_tone {
|
||||
|
||||
namespace {
|
||||
constexpr int kSampleRateHz = 48000;
|
||||
constexpr int kFramesPerBlock = 256;
|
||||
/** writeSamples() wants top-aligned 24-bit in a 32-bit slot -- 8-bit shift. */
|
||||
constexpr int kTopAlignShift = 8;
|
||||
/** Modest amplitude: audible but not a full-scale blast. */
|
||||
constexpr float kAmplitude = 0.4F * 8388607.0F;
|
||||
constexpr float kTwoPi = 2.0F * std::numbers::pi_v<float>;
|
||||
} // namespace
|
||||
|
||||
[[noreturn]] void run(float freqHz)
|
||||
{
|
||||
esp32_i2s_sink::tryAcquire();
|
||||
|
||||
const float phaseStep = kTwoPi * freqHz / static_cast<float>(kSampleRateHz);
|
||||
float phase = 0.0F;
|
||||
std::int32_t buf[kFramesPerBlock * 2];
|
||||
|
||||
for (;;)
|
||||
{
|
||||
for (int i = 0; i < kFramesPerBlock; ++i)
|
||||
{
|
||||
phase += phaseStep;
|
||||
if (phase > kTwoPi)
|
||||
{
|
||||
phase -= kTwoPi;
|
||||
}
|
||||
const auto sample =
|
||||
static_cast<std::int32_t>(std::sin(phase) * kAmplitude)
|
||||
<< kTopAlignShift;
|
||||
buf[(i * 2) + 0] = sample;
|
||||
buf[(i * 2) + 1] = sample;
|
||||
}
|
||||
esp32_i2s_sink::writeSamples(buf, kFramesPerBlock * 2);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esp32_i2s_test_tone
|
||||
@@ -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
|
||||
@@ -32,6 +32,8 @@
|
||||
|
||||
#include "driver/spi_master.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
namespace hardware {
|
||||
|
||||
@@ -77,10 +79,13 @@ bt1035::Bt1035Driver gBt1035(
|
||||
.uartRx = board::pins::Bt1035UartRx,
|
||||
.resetGpio = board::pins::Bt1035Reset,
|
||||
.sysCtlGpio = board::pins::Bt1035SysCtl,
|
||||
.ctsGpio = board::pins::Bt1035Cts,
|
||||
.rtsGpio = board::pins::Bt1035Rts,
|
||||
});
|
||||
|
||||
core::DeviceIdentity gDeviceIdentity = core::DeviceIdentity::unknown();
|
||||
std::optional<std::uint8_t> gFmAntCapCalibration;
|
||||
std::optional<std::uint8_t> gDabAntCapCalibration;
|
||||
bool gReady = false;
|
||||
|
||||
/**
|
||||
@@ -95,6 +100,58 @@ bool gReady = false;
|
||||
busHandle,
|
||||
static_cast<std::uint8_t>(board::pins::Eeprom24aaAddr));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief applyBt1035PostBootSetup — device name + auto-reconnect, run
|
||||
* once after any successful BT1035 boot (first attempt or a
|
||||
* later background retry).
|
||||
*/
|
||||
void applyBt1035PostBootSetup()
|
||||
{
|
||||
if (auto nameResult = gBt1035.setDeviceName(gDeviceIdentity.bluetoothName());
|
||||
!nameResult) {
|
||||
ESP_LOGW(kTag, "BT1035 device name set failed");
|
||||
}
|
||||
if (auto reconnectResult = gBt1035.setAutoReconnect(3U); !reconnectResult) {
|
||||
ESP_LOGW(kTag, "BT1035 auto-reconnect set failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief bt1035RetryTask — keep retrying Bt1035Driver::boot() in the
|
||||
* background after the initial boot() attempt fails.
|
||||
*
|
||||
* @dname bt1035RetryTask
|
||||
* @pubstate loops gBt1035.boot() with no artificial delay between
|
||||
* attempts — each call already blocks for tens of seconds
|
||||
* (kBootBannerWaitMs's banner wait, times kBootAttempts), so no
|
||||
* extra backoff is added on top. Exits once boot() succeeds.
|
||||
*
|
||||
* Why: BT1035 boot failure has been observed to be intermittent on
|
||||
* identical, correctly-wired, correctly-powered hardware — the same
|
||||
* physical module has booted successfully and failed silently across
|
||||
* different attempts in the same session, with the crystal oscillator
|
||||
* inside the (sealed, non-serviceable) module the leading suspect. Since
|
||||
* the fault clears on a later attempt rather than needing repair, retrying
|
||||
* indefinitely in the background turns a permanent-until-manual-reboot
|
||||
* failure into a bounded, self-recovering delay.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-21
|
||||
*/
|
||||
void bt1035RetryTask(void* /*arg*/)
|
||||
{
|
||||
ESP_LOGW(kTag, "BT1035 background retry started");
|
||||
while (true) {
|
||||
if (auto result = gBt1035.boot(); result) {
|
||||
applyBt1035PostBootSetup();
|
||||
ESP_LOGI(kTag, "BT1035 background retry succeeded");
|
||||
break;
|
||||
}
|
||||
ESP_LOGW(kTag, "BT1035 background retry attempt failed, trying again");
|
||||
}
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::expected<void, HardwareBootError> HardwareBootstrap::boot()
|
||||
@@ -103,7 +160,21 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
|
||||
return {};
|
||||
}
|
||||
|
||||
if (auto tunerResult = gSi4684.boot(si4684::Si4684Band::Dab); !tunerResult) {
|
||||
// xtalCtun=0, xtalFreqHz=19199750 (2026-08-23): the compiled-in
|
||||
// defaults (ctun=31, xtal=19200000 nominal) were never measured
|
||||
// against this board's actual crystal (Abracon ABM8-19.200MHZ-10-1-U-T,
|
||||
// CL=10pF per part number, plus two external 15pF load caps per the
|
||||
// schematic). CTUN=0 was found audibly best via A/B listening (0/5/31),
|
||||
// then xtalFreqHz was trimmed properly using the chip's own FM_RSQ
|
||||
// FREQOFF measurement (tools/si4684_xtal_calibration.py) against two
|
||||
// real, GPS-locked broadcast carriers 87.6/105.1 MHz -- converged to
|
||||
// -3 to -4 ppm residual on both (cross-check confirms it's the
|
||||
// crystal, not something frequency-dependent), down from +70 ppm
|
||||
// uncorrected. See POST /api/tuner/xtal-calibrate to re-trim live if
|
||||
// this ever needs revisiting (e.g. after a board/crystal change).
|
||||
if (auto tunerResult =
|
||||
gSi4684.boot(si4684::Si4684Band::Dab, 72U, 0U, 19199750U);
|
||||
!tunerResult) {
|
||||
ESP_LOGE(kTag, "Si4684 boot failed: error %d", static_cast<int>(tunerResult.error()));
|
||||
return std::unexpected(HardwareBootError::Si4684BootFailed);
|
||||
}
|
||||
@@ -140,6 +211,19 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
|
||||
"auto-tune");
|
||||
}
|
||||
|
||||
if (auto antCap = eeprom.readDabAntCap(); antCap) {
|
||||
gDabAntCapCalibration = *antCap;
|
||||
if (gDabAntCapCalibration) {
|
||||
ESP_LOGI(kTag, "DAB ANTCAP calibration loaded: %u",
|
||||
static_cast<unsigned>(*gDabAntCapCalibration));
|
||||
} else {
|
||||
ESP_LOGI(kTag, "DAB ANTCAP not calibrated — using chip auto-tune");
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(kTag, "DAB ANTCAP calibration read failed — using chip "
|
||||
"auto-tune");
|
||||
}
|
||||
|
||||
if (auto audioResult = gAudioService.loadAndApply(); !audioResult) {
|
||||
ESP_LOGW(kTag, "ADAU1701 profile apply failed");
|
||||
}
|
||||
@@ -150,18 +234,15 @@ std::expected<void, HardwareBootError> HardwareBootstrap::boot()
|
||||
}
|
||||
|
||||
if (auto btResult = gBt1035.boot(); !btResult) {
|
||||
ESP_LOGE(kTag, "BT1035 boot failed — continuing without Bluetooth");
|
||||
ESP_LOGE(kTag, "BT1035 boot failed — continuing without Bluetooth, "
|
||||
"retrying in background");
|
||||
if (xTaskCreate(bt1035RetryTask, "bt1035_retry", 4096, nullptr, 3,
|
||||
nullptr)
|
||||
!= pdPASS) {
|
||||
ESP_LOGW(kTag, "BT1035 background retry task create failed");
|
||||
}
|
||||
} else {
|
||||
if (auto nameResult =
|
||||
gBt1035.setDeviceName(gDeviceIdentity.bluetoothName());
|
||||
!nameResult) {
|
||||
ESP_LOGW(kTag, "BT1035 device name set failed");
|
||||
}
|
||||
|
||||
if (auto reconnectResult = gBt1035.setAutoReconnect(3U);
|
||||
!reconnectResult) {
|
||||
ESP_LOGW(kTag, "BT1035 auto-reconnect set failed");
|
||||
}
|
||||
applyBt1035PostBootSetup();
|
||||
}
|
||||
|
||||
gReady = true;
|
||||
@@ -216,4 +297,22 @@ bool HardwareBootstrap::saveFmAntCapCalibration(std::uint8_t antCap)
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<std::uint8_t> HardwareBootstrap::dabAntCapCalibration() noexcept
|
||||
{
|
||||
return gDabAntCapCalibration;
|
||||
}
|
||||
|
||||
bool HardwareBootstrap::saveDabAntCapCalibration(std::uint8_t antCap)
|
||||
{
|
||||
eeprom24aa::Eeprom24aa eeprom = makeEeprom();
|
||||
if (auto written = eeprom.writeDabAntCap(antCap); !written) {
|
||||
ESP_LOGW(kTag, "DAB ANTCAP calibration write failed");
|
||||
return false;
|
||||
}
|
||||
gDabAntCapCalibration = antCap;
|
||||
ESP_LOGI(kTag, "DAB ANTCAP calibration saved: %u",
|
||||
static_cast<unsigned>(antCap));
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace hardware
|
||||
|
||||
@@ -168,6 +168,35 @@ public:
|
||||
* @date 2026-08-19
|
||||
*/
|
||||
[[nodiscard]] static bool saveFmAntCapCalibration(std::uint8_t antCap);
|
||||
|
||||
/**
|
||||
* @brief dabAntCapCalibration — saved DAB antenna calibration, if any.
|
||||
*
|
||||
* @dname dabAntCapCalibration
|
||||
* @return Calibrated ANTCAP (0-128) read from the 24AA025E48 during
|
||||
* boot(), or nullopt if never calibrated / the read failed.
|
||||
* @pubstate reads gDabAntCapCalibration; set once during boot().
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-20
|
||||
*/
|
||||
[[nodiscard]] static std::optional<std::uint8_t>
|
||||
dabAntCapCalibration() noexcept;
|
||||
|
||||
/**
|
||||
* @brief saveDabAntCapCalibration — persist a new DAB ANTCAP to EEPROM.
|
||||
*
|
||||
* @dname saveDabAntCapCalibration
|
||||
* @param antCap Value found via a calibration sweep (0-128).
|
||||
* @return true on success, false on an I2C failure.
|
||||
* @pubstate writes the 24AA025E48 user region and gDabAntCapCalibration.
|
||||
* Does not itself change any live tuner state — callers must
|
||||
* also call TunerService::setDefaultDabAntCap() to apply it.
|
||||
*
|
||||
* @author Michele Bigi
|
||||
* @date 2026-08-20
|
||||
*/
|
||||
[[nodiscard]] static bool saveDabAntCapCalibration(std::uint8_t antCap);
|
||||
};
|
||||
|
||||
} // namespace hardware
|
||||
|
||||
@@ -45,6 +45,10 @@
|
||||
#include "i2s_sdata_probe.hpp"
|
||||
#endif
|
||||
|
||||
#if CONFIG_ESP32_I2S_TEST_TONE
|
||||
#include "esp32_i2s_test_tone.hpp"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kTag[] = "digiradio";
|
||||
@@ -133,6 +137,14 @@ void heartbeatTask(void* arg)
|
||||
}
|
||||
}
|
||||
|
||||
#if CONFIG_ESP32_I2S_TEST_TONE
|
||||
[[noreturn]] void i2sTestToneTask(void* arg)
|
||||
{
|
||||
(void)arg;
|
||||
esp32_i2s_test_tone::run(440.0F);
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
@@ -184,6 +196,10 @@ extern "C" void app_main()
|
||||
antCap) {
|
||||
tunerService.setDefaultFmAntCap(*antCap);
|
||||
}
|
||||
if (auto antCap = hardware::HardwareBootstrap::dabAntCapCalibration();
|
||||
antCap) {
|
||||
tunerService.setDefaultDabAntCap(*antCap);
|
||||
}
|
||||
|
||||
static station::StationService stationService(store, tunerService);
|
||||
|
||||
@@ -209,6 +225,13 @@ extern "C" void app_main()
|
||||
"streaming will be unavailable");
|
||||
}
|
||||
|
||||
#if CONFIG_ESP32_I2S_TEST_TONE
|
||||
if (xTaskCreate(i2sTestToneTask, "i2s_test_tone", 4096, nullptr, 4,
|
||||
nullptr) != pdPASS) {
|
||||
ESP_LOGW(kTag, "ESP32 I2S test tone task create failed");
|
||||
}
|
||||
#endif
|
||||
|
||||
auto netResult = net::NetBootstrap::start(
|
||||
store,
|
||||
tunerService,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""si4684_antenna_calibration.py — AN851 Appendix A varactor-tuning sweep.
|
||||
|
||||
DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
|
||||
Copyright 2026 Michele Bigi
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
Runs the Si4684 antenna varactor calibration procedure described in AN851
|
||||
("Si468x AM/AMHD-FM/FMHD-DAB/DAB+ antenna/matching network design
|
||||
guidelines"), Appendix A, driving the device's existing HTTP tuner API
|
||||
instead of a bench Test_Get_RSSI command:
|
||||
|
||||
1. At each of several test frequencies, sweep ANTCAP from --antcap-min
|
||||
to --antcap-max (AN851: 1-128) via POST /api/tuner/tune, averaging
|
||||
--samples (AN851: 5) reads per step via GET /api/tuner/status.
|
||||
2. Pick the ANTCAP with the best average reading at each frequency.
|
||||
3. Linear-fit best_antcap(frequency_MHz) = (m/1000)*frequency_MHz + b,
|
||||
AN851's own formula, giving VARM (m, property 0x1710) and VARB
|
||||
(b, property 0x1711) as signed 16-bit integers.
|
||||
|
||||
Scope and limits (read before running):
|
||||
|
||||
* This firmware's HTTP API has no endpoint to write VARM/VARB directly
|
||||
(see components/net/src/SetupWebServer.cpp) -- only a single fixed
|
||||
ANTCAP override can be persisted, via POST /api/tuner/calibrate-antenna.
|
||||
This script only COMPUTES the fit and prints it; it does not write
|
||||
anything to the device. Applying the result means hand-editing the
|
||||
kFmTuneFeVarm/kFmTuneFeVarb (or the DAB kDabProps table) constants in
|
||||
components/drivers/si4684/src/Si4684Driver.cpp and reflashing.
|
||||
|
||||
* DAB caveat: DAB_TUNE_FREQ (AN649 Command 0xB0) addresses frequencies by
|
||||
an index into a chip-side table (default "European frequency list")
|
||||
loaded via DAB_SET_FREQ_LIST. AN649's copy in this repo does not publish
|
||||
that table's contents, and neither the firmware nor its HTTP API expose
|
||||
the real MHz for a given freq_index. This script does NOT assume any
|
||||
index<->MHz mapping -- for --band dab you must supply the real
|
||||
frequency for each index yourself (e.g. read off a known ensemble's
|
||||
published transmitter frequency). Do not guess; a wrong MHz value here
|
||||
silently produces a wrong VARM/VARB fit.
|
||||
|
||||
* DAB metric caveat: AN851 Appendix A calls for RSSI at each step, but
|
||||
this firmware's DAB status JSON does not expose RSSI (only
|
||||
dab.fic_quality and dab.cnr_db -- see core/TunerStatus.hpp). This
|
||||
script uses dab.cnr_db as the optimization metric for --band dab
|
||||
instead. That is a deliberate substitution, not a datasheet value --
|
||||
treat DAB fit results with more skepticism than FM ones.
|
||||
|
||||
* Known dead zone (docs/si4684-rf-investigation-report.md, 2026-08-20):
|
||||
DAB freq_index 22 lost lock entirely at antcap 72 and 80 on this board.
|
||||
This script does not skip any antcap value in range; a step with no
|
||||
lock is logged as "NO LOCK" and simply excluded from that point's
|
||||
average, not treated as a fatal error.
|
||||
|
||||
Usage:
|
||||
python3 tools/si4684_antenna_calibration.py --host digiradio-XXXXXX.local \\
|
||||
--band fm --point 88500 --point 98000 --point 107900
|
||||
|
||||
python3 tools/si4684_antenna_calibration.py --host 192.168.1.56 \\
|
||||
--band dab --point 5:174928 --point 12:198160 --point 23:225648 \\
|
||||
--raw-csv dab_sweep.csv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import socket
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_point(band: str, raw: str) -> dict:
|
||||
"""Parse one --point argument into a tune target for the given band."""
|
||||
if band == "dab":
|
||||
if ":" not in raw:
|
||||
raise ValueError(
|
||||
"DAB points must be 'freq_index:frequency_khz', e.g. 12:198160"
|
||||
)
|
||||
idx_s, khz_s = raw.split(":", 1)
|
||||
idx = int(idx_s)
|
||||
khz = int(khz_s)
|
||||
if not 0 <= idx <= 37:
|
||||
raise ValueError("freq_index must be 0-37 (core/TunerJson.cpp limit)")
|
||||
if khz <= 0:
|
||||
raise ValueError("frequency_khz must be positive")
|
||||
return {
|
||||
"index": idx,
|
||||
"khz": khz,
|
||||
"mhz": khz / 1000.0,
|
||||
"label": f"idx{idx}@{khz / 1000:.3f}MHz",
|
||||
}
|
||||
khz = int(raw)
|
||||
if not 87500 <= khz <= 108000:
|
||||
raise ValueError("FM frequency_khz should be within 87500-108000")
|
||||
return {"khz": khz, "mhz": khz / 1000.0, "label": f"{khz / 1000:.3f}MHz"}
|
||||
|
||||
|
||||
def tune_payload(band: str, point: dict, antcap: int) -> dict:
|
||||
if band == "dab":
|
||||
return {"band": "dab", "freq_index": point["index"], "antcap": antcap}
|
||||
return {"band": "fm", "frequency_khz": point["khz"], "antcap": antcap}
|
||||
|
||||
|
||||
def extract_metric(status: dict, band: str) -> Optional[float]:
|
||||
"""AN851's optimization metric: RSSI for FM, CNR for DAB (see docstring)."""
|
||||
if not status.get("locked", False):
|
||||
return None
|
||||
if band == "fm":
|
||||
fm = status.get("fm") or {}
|
||||
rssi = fm.get("rssi_dbuv")
|
||||
return float(rssi) if rssi is not None else None
|
||||
dab = status.get("dab") or {}
|
||||
cnr = dab.get("cnr_db")
|
||||
return float(cnr) if cnr is not None else None
|
||||
|
||||
|
||||
def http_request(
|
||||
host: str,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: Optional[dict],
|
||||
timeout: float,
|
||||
retries: int,
|
||||
) -> dict:
|
||||
url = f"http://{host}{path}"
|
||||
# The firmware's JSON parser (components/core/src/TunerJson.cpp) does
|
||||
# plain substring search for e.g. `"key":` and is not whitespace-
|
||||
# tolerant -- json.dumps()'s default ": "/", " separators break every
|
||||
# request with "invalid_json". Compact separators match what curl's
|
||||
# -d with no spaces sends, which the firmware does accept.
|
||||
data = json.dumps(payload, separators=(",", ":")).encode("utf-8") if payload is not None else None
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
last_err: Optional[BaseException] = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read()
|
||||
return json.loads(body) if body else {}
|
||||
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
|
||||
last_err = exc
|
||||
if attempt < retries:
|
||||
time.sleep(1.0 + attempt) # device is known to stall briefly under HTTP load
|
||||
raise RuntimeError(f"{method} {path} failed after {retries + 1} attempt(s): {last_err}")
|
||||
|
||||
|
||||
def sweep_point(
|
||||
host: str,
|
||||
band: str,
|
||||
point: dict,
|
||||
antcap_values: list,
|
||||
samples: int,
|
||||
settle_s: float,
|
||||
sample_gap_s: float,
|
||||
timeout: float,
|
||||
retries: int,
|
||||
raw_rows: Optional[list],
|
||||
) -> list:
|
||||
results = []
|
||||
for antcap in antcap_values:
|
||||
try:
|
||||
status = http_request(
|
||||
host, "POST", "/api/tuner/tune", tune_payload(band, point, antcap), timeout, retries
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f" antcap={antcap:3d} tune failed: {exc}", file=sys.stderr)
|
||||
results.append((antcap, None))
|
||||
continue
|
||||
|
||||
time.sleep(settle_s)
|
||||
readings = []
|
||||
first = extract_metric(status, band)
|
||||
if first is not None:
|
||||
readings.append(first)
|
||||
for _ in range(max(0, samples - 1)):
|
||||
time.sleep(sample_gap_s)
|
||||
try:
|
||||
status = http_request(host, "GET", "/api/tuner/status", None, timeout, retries)
|
||||
except RuntimeError as exc:
|
||||
print(f" antcap={antcap:3d} status read failed: {exc}", file=sys.stderr)
|
||||
continue
|
||||
reading = extract_metric(status, band)
|
||||
if reading is not None:
|
||||
readings.append(reading)
|
||||
|
||||
if raw_rows is not None:
|
||||
for i, reading in enumerate(readings):
|
||||
raw_rows.append(
|
||||
{"point": point["label"], "antcap": antcap, "sample": i, "metric": reading}
|
||||
)
|
||||
|
||||
avg = statistics.mean(readings) if readings else None
|
||||
state = "locked" if readings else "NO LOCK"
|
||||
avg_str = f"{avg:6.2f}" if avg is not None else " - "
|
||||
print(f" antcap={antcap:3d} samples={len(readings)}/{samples} avg={avg_str} [{state}]")
|
||||
results.append((antcap, avg))
|
||||
return results
|
||||
|
||||
|
||||
def linear_fit(xs: list, ys: list) -> Optional[tuple]:
|
||||
n = len(xs)
|
||||
if n < 2:
|
||||
return None
|
||||
mean_x = sum(xs) / n
|
||||
mean_y = sum(ys) / n
|
||||
num = sum((x - mean_x) * (y - mean_y) for x, y in zip(xs, ys))
|
||||
den = sum((x - mean_x) ** 2 for x in xs)
|
||||
if den == 0:
|
||||
return None
|
||||
slope = num / den
|
||||
intercept = mean_y - slope * mean_x
|
||||
return slope, intercept
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AN851 Appendix A ANTCAP sweep + VARM/VARB linear fit over the tuner HTTP API.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("--host", required=True, help="Device IP or mDNS host, e.g. digiradio-XXXXXX.local")
|
||||
parser.add_argument("--band", required=True, choices=["fm", "dab"])
|
||||
parser.add_argument(
|
||||
"--point",
|
||||
action="append",
|
||||
required=True,
|
||||
metavar="FREQ_KHZ|INDEX:FREQ_KHZ",
|
||||
help="FM: frequency in kHz (e.g. 98000). DAB: freq_index:frequency_khz "
|
||||
"(e.g. 12:198160) -- see script docstring for why the MHz value must "
|
||||
"come from you, not this script.",
|
||||
)
|
||||
parser.add_argument("--antcap-min", type=int, default=1)
|
||||
parser.add_argument("--antcap-max", type=int, default=128)
|
||||
parser.add_argument("--antcap-step", type=int, default=1)
|
||||
parser.add_argument(
|
||||
"--samples", type=int, default=5, help="Reads averaged per ANTCAP step (AN851 Appendix A: 5)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--settle-ms",
|
||||
type=int,
|
||||
default=150,
|
||||
help="Delay after tune before the first reading (engineering margin, not from AN851)",
|
||||
)
|
||||
parser.add_argument("--sample-gap-ms", type=int, default=80, help="Delay between repeated status reads")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout per request, seconds")
|
||||
parser.add_argument(
|
||||
"--retries", type=int, default=2, help="Retries per request (device is known to stall briefly)"
|
||||
)
|
||||
parser.add_argument("--raw-csv", type=Path, default=None, help="Optional path to dump every raw sample")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not 1 <= args.antcap_min <= args.antcap_max <= 128:
|
||||
print("error: antcap range must be within 1-128 (0 = auto, not part of the sweep)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
points = []
|
||||
for raw in args.point:
|
||||
try:
|
||||
points.append(parse_point(args.band, raw))
|
||||
except ValueError as exc:
|
||||
print(f"error: invalid --point {raw!r}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
antcap_values = list(range(args.antcap_min, args.antcap_max + 1, args.antcap_step))
|
||||
raw_rows: Optional[list] = [] if args.raw_csv else None
|
||||
|
||||
fit_points = []
|
||||
for point in points:
|
||||
print(f"\n=== sweeping {args.band} @ {point['label']} ===")
|
||||
results = sweep_point(
|
||||
args.host,
|
||||
args.band,
|
||||
point,
|
||||
antcap_values,
|
||||
args.samples,
|
||||
args.settle_ms / 1000.0,
|
||||
args.sample_gap_ms / 1000.0,
|
||||
args.timeout,
|
||||
args.retries,
|
||||
raw_rows,
|
||||
)
|
||||
valid = [(a, v) for a, v in results if v is not None]
|
||||
if not valid:
|
||||
print(f" WARNING: no locked reading anywhere -- excluding {point['label']} from the fit")
|
||||
continue
|
||||
best_antcap, best_val = max(valid, key=lambda t: t[1])
|
||||
print(f" best: antcap={best_antcap} ({best_val:.2f})")
|
||||
fit_points.append((point["mhz"], best_antcap, point["label"]))
|
||||
|
||||
if args.raw_csv:
|
||||
with args.raw_csv.open("w", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=["point", "antcap", "sample", "metric"])
|
||||
writer.writeheader()
|
||||
writer.writerows(raw_rows)
|
||||
print(f"\nraw samples written to {args.raw_csv}")
|
||||
|
||||
print(f"\n=== AN851 Appendix A fit (band={args.band}) ===")
|
||||
if len(fit_points) < 2:
|
||||
print(
|
||||
"Not enough locked points to fit VARM/VARB (need >= 2 usable frequencies). "
|
||||
"Try different --point values or check the antenna/signal."
|
||||
)
|
||||
return 1
|
||||
|
||||
xs = [p[0] for p in fit_points]
|
||||
ys = [p[1] for p in fit_points]
|
||||
fit = linear_fit(xs, ys)
|
||||
if fit is None:
|
||||
print("Fit failed (degenerate frequency set -- all points at the same MHz?).")
|
||||
return 1
|
||||
|
||||
slope, intercept = fit
|
||||
m = round(slope * 1000)
|
||||
b = round(intercept)
|
||||
m_ok = -32768 <= m <= 32767
|
||||
b_ok = -32768 <= b <= 32767
|
||||
|
||||
print(" points used: " + ", ".join(f"{label} -> antcap {a}" for _, a, label in fit_points))
|
||||
print(" varactor_value = (m/1000)*frequency_MHz + b [AN851 Appendix A]")
|
||||
print(f" m (slope x1000, property 0x1710) = {m}" + ("" if m_ok else " ** OUT OF int16 RANGE **"))
|
||||
print(f" b (intercept, property 0x1711) = {b}" + ("" if b_ok else " ** OUT OF int16 RANGE **"))
|
||||
if m_ok and b_ok:
|
||||
print(f" -> 0x1710 = 0x{m & 0xFFFF:04X} 0x1711 = 0x{b & 0xFFFF:04X}")
|
||||
print()
|
||||
print(" Not written to the device. To apply: edit the k{Fm,Dab}TuneFeVarm/")
|
||||
print(" k{Fm,Dab}TuneFeVarb constants in components/drivers/si4684/src/Si4684Driver.cpp")
|
||||
print(" (current values and AN851 Appendix B citation are next to them), reflash, then")
|
||||
print(" re-run this sweep with antcap=0 (auto) at the same points to confirm auto-tune")
|
||||
print(" now tracks the measured optimum.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""si4684_xtal_calibration.py — live Si4684 crystal trim via FM_RSQ FREQOFF.
|
||||
|
||||
DigiRadio firmware — https://github.com/manvalan/DigiRadio
|
||||
|
||||
Copyright 2026 Michele Bigi
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
Uses the Si4684's own FM_RSQ_STATUS FREQOFF field (AN649 Command 0x32,
|
||||
RESP8, signed offset in units of 2 PPM) as a precision frequency reference
|
||||
-- real FM broadcast transmitters are GPS/rubidium-locked, so a locked
|
||||
station's carrier reads the same PPM error as the receiver's own crystal
|
||||
reference error, no lab equipment required.
|
||||
|
||||
Procedure (matches AN649 §9.3's own recommendation to trim by measurement,
|
||||
just automated over HTTP instead of by ear):
|
||||
|
||||
1. Tune to a strong, stable FM station.
|
||||
2. Read GET /api/tuner/status -> fm.freqoff_ppm.
|
||||
3. new_xtal_freq_hz = 19200000 * (1 + ppm/1e6)
|
||||
4. POST /api/tuner/xtal-calibrate {"xtal_freq_hz": new_xtal_freq_hz}
|
||||
(this reboots the Si4684, no ESP32 restart, no persistence yet).
|
||||
5. Re-tune, re-read freqoff_ppm. Repeat until it converges near 0.
|
||||
6. Optionally cross-check on a second station at the other end of the
|
||||
band -- if the residual offset differs systematically between the two,
|
||||
the error isn't (only) the crystal; don't chase it further with this
|
||||
tool.
|
||||
|
||||
Nothing here is persisted to flash. Once you have a converged xtal_freq_hz,
|
||||
hand it to a human/Claude to bake into the Si4684Driver::boot() default
|
||||
call in main/hardware_bootstrap.cpp -- this script only calibrates the
|
||||
currently-running session.
|
||||
|
||||
Usage:
|
||||
python3 tools/si4684_xtal_calibration.py --host 192.168.1.62 \\
|
||||
--frequency-khz 87600
|
||||
|
||||
python3 tools/si4684_xtal_calibration.py --host 192.168.1.62 \\
|
||||
--frequency-khz 87600 --once # single read, no correction loop
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def http_request(host: str, method: str, path: str, payload: Optional[dict],
|
||||
timeout: float) -> dict:
|
||||
url = f"http://{host}{path}"
|
||||
# Compact separators: the firmware's hand-rolled JSON parser is not
|
||||
# whitespace-tolerant (confirmed bug in components/core/src/TunerJson.cpp).
|
||||
data = json.dumps(payload, separators=(",", ":")).encode() if payload is not None else None
|
||||
headers = {"Content-Type": "application/json"} if data is not None else {}
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read()
|
||||
return json.loads(body) if body else {}
|
||||
|
||||
|
||||
def tune_fm(host: str, frequency_khz: int, timeout: float) -> dict:
|
||||
return http_request(host, "POST", "/api/tuner/tune",
|
||||
{"band": "fm", "frequency_khz": frequency_khz}, timeout)
|
||||
|
||||
|
||||
def read_status(host: str, timeout: float) -> dict:
|
||||
return http_request(host, "GET", "/api/tuner/status", None, timeout)
|
||||
|
||||
|
||||
def recalibrate(host: str, xtal_freq_hz: int, ibias: int, ctun: int,
|
||||
timeout: float) -> dict:
|
||||
return http_request(
|
||||
host, "POST", "/api/tuner/xtal-calibrate",
|
||||
{"xtal_freq_hz": xtal_freq_hz, "ibias": ibias, "ctun": ctun}, timeout)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Trim Si4684 XTAL_FREQ live using FM_RSQ_STATUS FREQOFF "
|
||||
"-- no lab equipment, uses locked broadcast carriers as reference.")
|
||||
parser.add_argument("--host", required=True, help="Device IP or mDNS host")
|
||||
parser.add_argument("--frequency-khz", type=int, required=True,
|
||||
help="A strong, stable FM station to lock onto, e.g. 87600")
|
||||
parser.add_argument("--ibias", type=int, default=72, help="POWER_UP IBIAS (0-127)")
|
||||
parser.add_argument("--ctun", type=int, default=0,
|
||||
help="POWER_UP CTUN (0-63) -- default 0, the best value "
|
||||
"found by ear on 2026-08-23; leave alone unless you have "
|
||||
"a reason to also move it")
|
||||
parser.add_argument("--start-xtal-freq-hz", type=int, default=19200000,
|
||||
help="Starting XTAL_FREQ before the first correction")
|
||||
parser.add_argument("--max-iterations", type=int, default=6)
|
||||
parser.add_argument("--converge-ppm", type=float, default=1.0,
|
||||
help="Stop once |freqoff_ppm| is under this many ppm")
|
||||
parser.add_argument("--settle-s", type=float, default=1.0,
|
||||
help="Delay after tune/recalibrate before reading status")
|
||||
parser.add_argument("--samples", type=int, default=5,
|
||||
help="FREQOFF reads averaged per iteration (reduces "
|
||||
"reception-noise jitter in the ppm estimate)")
|
||||
parser.add_argument("--sample-gap-s", type=float, default=0.3)
|
||||
parser.add_argument("--damping", type=float, default=0.6,
|
||||
help="Fraction of the measured ppm correction applied "
|
||||
"per iteration (<1.0 avoids overshoot/oscillation "
|
||||
"around the true value)")
|
||||
parser.add_argument("--timeout", type=float, default=10.0)
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="Single read only, no correction loop")
|
||||
args = parser.parse_args()
|
||||
|
||||
xtal_freq_hz = args.start_xtal_freq_hz
|
||||
|
||||
for iteration in range(1 if args.once else args.max_iterations):
|
||||
try:
|
||||
recalibrate(args.host, xtal_freq_hz, args.ibias, args.ctun, args.timeout)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print(f"error: xtal-calibrate failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
time.sleep(args.settle_s)
|
||||
|
||||
try:
|
||||
status = tune_fm(args.host, args.frequency_khz, args.timeout)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print(f"error: tune failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
time.sleep(args.settle_s)
|
||||
|
||||
samples = []
|
||||
for s in range(args.samples):
|
||||
if s > 0:
|
||||
time.sleep(args.sample_gap_s)
|
||||
try:
|
||||
status = read_status(args.host, args.timeout)
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
print(f"error: status read failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
fm = status.get("fm") or {}
|
||||
if status.get("locked", False) and fm.get("freqoff_ppm") is not None:
|
||||
samples.append((fm["freqoff_ppm"], fm.get("rssi_dbuv"), fm.get("snr_db")))
|
||||
|
||||
if not samples:
|
||||
print(" no lock / no FREQOFF reading across all samples -- pick "
|
||||
"a stronger station and retry", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ppm_avg = sum(s[0] for s in samples) / len(samples)
|
||||
rssi = samples[-1][1]
|
||||
snr = samples[-1][2]
|
||||
print(f"iter {iteration}: xtal_freq_hz={xtal_freq_hz} "
|
||||
f"samples={len(samples)}/{args.samples} rssi={rssi} snr={snr} "
|
||||
f"freqoff_ppm_avg={ppm_avg:.1f} "
|
||||
f"raw={[s[0] for s in samples]}")
|
||||
|
||||
if args.once:
|
||||
return 0
|
||||
|
||||
if abs(ppm_avg) <= args.converge_ppm:
|
||||
print(f"\nConverged: xtal_freq_hz={xtal_freq_hz} "
|
||||
f"(residual {ppm_avg:.1f} ppm avg, ibias={args.ibias}, "
|
||||
f"ctun={args.ctun})")
|
||||
print("Not persisted -- to make this the boot default, edit the "
|
||||
"gSi4684.boot(...) call in main/hardware_bootstrap.cpp.")
|
||||
return 0
|
||||
|
||||
# Empirically determined 2026-08-23: correcting in the "+ppm"
|
||||
# direction diverges (each iteration made freqoff_ppm larger, not
|
||||
# smaller). The correct sign is "-ppm" -- confirmed by a live A/B
|
||||
# test, not derived from AN649 (which doesn't specify the relation
|
||||
# between XTAL_FREQ and FREQOFF's sign convention). Damping avoids
|
||||
# overshoot from single-reading reception noise.
|
||||
xtal_freq_hz = round(xtal_freq_hz * (1.0 - args.damping * ppm_avg / 1e6))
|
||||
|
||||
print(f"\nDid not converge within {args.max_iterations} iterations "
|
||||
f"(last xtal_freq_hz={xtal_freq_hz}). Try again, or check the "
|
||||
f"second-station cross-check described in this script's docstring "
|
||||
f"-- a residual that varies with frequency isn't the crystal.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 552 KiB |
Reference in New Issue
Block a user