Add igiRadio iOS/iPadOS app for DigiRadio control.

Introduces a new SwiftUI app with HTTP REST device control, mock mode for UI development, BLE-based discovery, and documented architecture aligned with the firmware API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 15:45:55 +02:00
co-authored by Cursor
parent 3244b9f084
commit 6087bb5297
35 changed files with 4067 additions and 0 deletions
@@ -0,0 +1,35 @@
# APP_ARCHITECTURE — igiRadio
## Stack
```
SwiftUI Views
↓ @Observable ViewModels
DigiRadioService (protocol)
RealDigiRadioService ──→ HTTPDigiRadioClient (URLSession)
MockDigiRadioService ──→ dati in-memory
BLEProvisioningService (solo setup WiFi)
↓ CoreBluetooth + protocomm (ESP Security1)
```
## Principi
1. Le View **non** chiamano URLSession o CoreBluetooth direttamente.
2. Un solo `DigiRadioState` osservabile aggiornato dal service.
3. Errori tipizzati (`DigiRadioError`).
4. Dependency injection via `AppEnvironment` in `igiRadioApp`.
## Connessione
| Fase | Meccanismo |
|------|------------|
| Prima configurazione | BLE provisioning **oppure** join SoftAP + POST /api/wifi |
| Uso normale | mDNS `digiradio-xxxxxx.local` o IP manuale |
| Base URL | `http://{host}/api/...` |
## Polling vs push
- Nessuna notifica BLE documentata per stato tuner.
- `TunerViewModel` può pollare `GET /api/tuner/status` a intervallo configurabile quando la schermata radio è attiva.
@@ -0,0 +1,45 @@
# BLE_PROTOCOL — igiRadio
## Riepilogo
**DigiRadio non espone un protocollo BLE GATT documentato per il controllo radio/audio.**
Il BLE dell'ESP32-S3 è usato **solo** per il provisioning WiFi (setup iniziale).
---
## BLE WiFi Provisioning (documentato)
| Parametro | Valore |
|-----------|--------|
| Stack | ESP-IDF `wifi_provisioning` + `scheme_ble` |
| Sicurezza | protocomm **Security1** |
| Proof of possession | **Serial number** dispositivo (EUI-48 da EEPROM) |
| Nome advertising | `DigiRadio-<suffix>` (es. `DigiRadio-CC4DB4`) |
| Riferimento firmware | `components/net/src/BleProvisioning.cpp` |
| Riferimento manuale | `ch-api.tex` §BLE provisioning |
### Flusso
1. Dispositivo in setup mode (no credenziali WiFi salvate)
2. Advertise BLE con nome = SoftAP SSID
3. App iOS invia credenziali WiFi via protocomm (Security1 + PoP)
4. Firmware valida, salva in NVS, reboot in STA
5. App passa a controllo via **HTTP** su LAN
### Implementazione igiRadio
- Usare **CoreBluetooth** solo per questa fase
- Compatibile con app generiche "ESP BLE Provisioning" (stesso protocollo Espressif)
- PoP richiesto dall'utente: serial da etichetta o da schermata setup SoftAP
### UNKNOWN
- UUID servizio/caratteristica custom DigiRadio per controllo: **non documentati**
- Notifiche BLE stato tuner: **non documentate**
---
## Controllo dispositivo (post-provisioning)
Vedi `APP_ARCHITECTURE.md` — trasporto **HTTP REST** porta 80.
@@ -0,0 +1,58 @@
# DEVELOPMENT — igiRadio
## Requisiti
- macOS con **Xcode 15+** (testato Xcode 26.6)
- iOS 17+ deployment target
- Dispositivo DigiRadio su stessa rete WiFi (STA) o in setup SoftAP
## Aprire il progetto
```bash
open /Users/michelebigi/Documents/Develop/DigiRadio/Software/APP/igiRadio/igiRadio.xcodeproj
```
Rigenerare il progetto (se necessario):
```bash
cd Software/APP/igiRadio
python3 Scripts/generate_xcodeproj.py
```
## Build e test da terminale
```bash
cd Software/APP/igiRadio
xcodebuild -scheme igiRadio -destination 'platform=iOS Simulator,name=iPhone 17' build test
```
## Scheme
- **igiRadio** — app principale
- **igiRadioTests** — unit test protocollo e state
## Mock vs reale
In **DEBUG**, `AppEnvironment` avvia in modalità mock (`MockDigiRadioService`).
Disattivare in **Connessione → Modalità demo (Mock)** e inserire l'host HTTP reale.
## Test su hardware
1. Provisioning WiFi (BLE discovery in-app, o app ESP BLE Provisioning; oppure SoftAP `http://192.168.4.1`)
2. In app: Impostazioni → Connessione → host `http://digiradio-<suffix>.local`
3. Verificare `GET /api/health`
## Documentazione firmware
`/Users/michelebigi/Documents/Develop/DigiRadio/Software/docs/manual/ch-api.tex`
## Architettura runtime
```
SwiftUI → ViewModel → DigiRadioService
├── RealDigiRadioService → HTTPDigiRadioClient (REST JSON)
└── MockDigiRadioService
BLEProvisioningService (CoreBluetooth, solo discovery setup)
```
Il controllo tuner/audio **non** usa GATT proprietario — vedi `BLE_PROTOCOL.md`.
@@ -0,0 +1,17 @@
# DEVICE_STATE — DigiRadioState
Modello centrale in `Models/DigiRadioState.swift`.
## Sezioni
| Sezione | Campi documentati |
|---------|-------------------|
| `connection` | host, isConnected, lastError |
| `health` | status, firmware, serialNumber, chips |
| `tuner` | band, locked, volume, fm?, dab? |
| `audio` | mixer, master, eq, enhancements |
| `bluetooth` | booted, pairing, a2dpState, deviceName, speaker |
| `stations` | [Station] |
| `streaming` | enabled, url |
Campi non documentati nell'API **non** sono presenti nel modello.
@@ -0,0 +1,151 @@
# PROJECT_ANALYSIS — DigiRadio (fonte: documentazione firmware)
**Data analisi:** 2026-08-18
**Fonte primaria:** `Software/docs/manual/` (non modificata)
**Firmware di riferimento:** 0.8.5+
---
## 1. Documenti analizzati
| File | Contenuto |
|------|-----------|
| `ch-intro.tex` | Introduzione prodotto |
| `ch-hardware.tex` | PCB, pinout, catena audio |
| `ch-firmware.tex` | Architettura firmware ESP32-S3 |
| `ch-si4684.tex` | Tuner FM/DAB, tune, seek, RDS, DLS |
| `ch-adau1701.tex` | DSP SigmaStudio, mixer, EQ, safeload |
| `ch-bt1035.tex` | Modulo BT classic A2DP (UART AT), I2S da ADAU |
| `ch-sigmastudio.tex` | Design SigmaStudio |
| `ch-api.tex` | **HTTP REST API** (protocollo app) |
| `ch-classes.tex` | Classi firmware |
| `ch-build.tex` | Build/flash |
| `ch-licensing.tex` | Licenze |
Documentazione aggiuntiva letta: `Software/CLAUDE.md`, `Software/docs/security-flash-nvs.md`, `BleProvisioning.cpp`.
---
## 2. Distinzione critica: BLE vs controllo
### Controllo dispositivo (tuner, audio, preset, BT config)
**Trasporto documentato:** HTTP JSON REST su **TCP porta 80** (WiFi).
- Setup mode: SoftAP `DigiRadio-<suffix>``http://192.168.4.1/`
- STA mode: stessa API su IP LAN o **mDNS** `digiradio-<suffix>.local`
- **NON esiste** un protocollo GATT proprietario documentato per tuner/audio/preset.
### BLE (ESP32-S3 onboard)
**Solo provisioning WiFi** in setup mode (`ch-api.tex` §BLE provisioning):
- Stack: ESP-IDF `wifi_provisioning` + `scheme_ble`
- Sicurezza: **Security1** (protocomm)
- **Proof of possession (PoP):** serial number dispositivo (stesso valore di `GET /api/health``serialNumber`)
- **Nome servizio BLE:** SSID SoftAP = `DigiRadio-<suffix>` (es. `DigiRadio-CC4DB4`)
- Su successo: credenziali salvate in NVS, reboot in STA
- **Non è un endpoint HTTP** — nessuna REST call
### Bluetooth Audio (streaming verso speaker esterno)
**Modulo separato FSC-BT1035** (classic BR/EDR A2DP), controllato dal firmware via **UART AT**, esposto all'app solo tramite **HTTP** `/api/bluetooth/*`.
L'iPhone **non** si collega in A2DP al DigiRadio per ascoltare: il DigiRadio invia audio al Bose/Speaker via BT1035.
---
## 3. Architettura hardware (sintesi)
```
Si4684 (SPI) ──I2S──► ADAU1701 (I2S master 48 kHz) ──I2S──► BT1035 ──A2DP──► Speaker
ESP32-S3 (WiFi/BLE, HTTP server, opz. I2S test)
```
Sorgenti audio nel DSP: **Si4684** (radio), **ESP32 I2S** (stream locale / phone push).
---
## 4. Endpoint HTTP documentati (controllo app)
| Metodo | Path | Funzione |
|--------|------|----------|
| GET | `/api/health` | Stato, fw, serial, chip boot |
| POST | `/api/wifi` | Provisioning STA (reboot) |
| POST | `/api/wifi/scan` | Scan reti vicine |
| GET | `/api/tuner/status` | Stato tuner FM/DAB |
| GET | `/api/tuner/services` | Lista servizi DAB |
| POST | `/api/tuner/tune` | Sintonia FM/DAB |
| POST | `/api/tuner/play` | Play servizio DAB |
| POST | `/api/tuner/seek` | Seek FM up/down |
| POST | `/api/tuner/scan` | Scan singola stazione |
| POST | `/api/tuner/scan/full` | Scan completo banda FM |
| GET/PUT | `/api/audio/profile` | Profilo mixer/EQ/master |
| POST | `/api/audio/reset` | Reset profilo factory |
| POST | `/api/audio/stereo-enhance` | Enhancement stereo 0100 |
| POST | `/api/audio/bass-enhance` | Enhancement bass 0100 |
| POST | `/api/audio/beep` | Beep diagnostico ADAU |
| GET | `/api/dsp/params` | Tabella parametri SigmaStudio |
| PUT | `/api/dsp/param` | Scrittura cella raw (live) |
| POST | `/api/dsp/program` | Upload blob DSP (reboot) |
| POST | `/api/system/ota` | OTA firmware ESP32 (reboot) |
| GET/POST | `/api/streaming` | Web radio MP3 URL |
| PUT | `/api/stream/phone` | Push PCM stereo 48 kHz da phone |
| GET | `/api/bluetooth/status` | Stato BT1035 |
| POST | `/api/bluetooth/pair` | Modalità discoverable |
| POST | `/api/bluetooth/pair/stop` | Esci pairing |
| POST | `/api/bluetooth/disconnect` | Disconnect A2DP |
| GET | `/api/bluetooth/paired` | Lista paired |
| POST | `/api/bluetooth/scan` | Scan speaker vicini |
| POST | `/api/bluetooth/connect` | Connect A2DP + opz. save |
| GET/POST/DELETE | `/api/bluetooth/speaker` | Speaker default |
| POST | `/api/bluetooth/reconnect` | Reconnect manuale |
| POST | `/api/bluetooth/auto-reconnect` | Retry count 015 |
| GET | `/api/stations` | Lista preset |
| POST | `/api/stations` | Aggiungi preset |
| POST | `/api/stations/remove` | Rimuovi preset |
| POST | `/api/stations/reorder` | Riordina preset |
| POST | `/api/stations/tune` | Richiama preset |
---
## 5. Funzionalità per schermata app
| Area | Supportato (documentato) | Note |
|------|--------------------------|------|
| Connessione WiFi | ✅ | BLE prov. + SoftAP + POST /api/wifi |
| FM tune/seek/scan | ✅ | RSSI, SNR, RDS PS/RT |
| DAB tune/play/services | ✅ | DLS, ensemble, service list |
| Preset | ✅ | CRUD + reorder + recall |
| Volume/mixer/EQ | ✅ | Via audio profile |
| DSP raw params | ✅ | Escape hatch tecnico |
| BT speaker pairing | ✅ | Via HTTP → BT1035 AT |
| OTA firmware | ✅ | POST /api/system/ota |
| OTA DSP | ✅ | POST /api/dsp/program |
| Phone audio stream | ✅ | PUT /api/stream/phone |
| Web radio stream | ✅ | POST /api/streaming |
| Artwork album | ❌ | **UNKNOWN** — non in API |
| Batteria dispositivo | ❌ | Alimentato da rete |
| AUX/USB come sorgente UI | ❌ | Non esposto in API |
| Notifiche push BLE stato | ❌ | Nessun GATT documentato |
---
## 6. Implicazioni per igiRadio
1. **DigiRadioService** → implementazione **HTTP REST** (URLSession), non GATT custom.
2. **BLE layer** → solo **WiFi provisioning** (ESP protocomm Security1 + PoP = serial).
3. **MockDigiRadioService** → stessa interfaccia, dati fittizi.
4. **Discovery** → mDNS `.local`, IP manuale, o join SoftAP in setup.
5. **Real-time** → polling selettivo su `/api/tuner/status` quando in radio; nessuna notifica BLE documentata.
---
## 7. UNKNOWN — specification required
| Voce | Motivo |
|------|--------|
| UUID GATT controllo tuner/audio | Non documentati — controllo è HTTP |
| Artwork/metadata streaming | Non in API tuner |
| Protocol version field | Solo `fw` in health |
| Hardware revision API | Solo `serialNumber` + chip flags |
@@ -0,0 +1,31 @@
# UI_ARCHITECTURE — igiRadio
## iPhone
`TabView` con tab: Home, Radio, Presets, Audio, Settings.
## iPad
`NavigationSplitView`:
- Sidebar: Home, FM, DAB, Presets, Bluetooth, Audio, Settings
- Detail: contenuto selezionato
## Schermate
| Schermata | ViewModel |
|-----------|-----------|
| Connection | `ConnectionViewModel` |
| Home | `HomeViewModel` |
| FM | `FMViewModel` |
| DAB | `DABViewModel` |
| Presets | `PresetsViewModel` |
| Audio | `AudioViewModel` |
| Bluetooth | `BluetoothViewModel` |
| Settings | `SettingsViewModel` |
| Device Info | `DeviceViewModel` |
| Diagnostics | `DiagnosticsViewModel` |
| Firmware | `FirmwareViewModel` |
## Design System
`Components/DesignSystem/` — colori semantici, tipografia, card, slider, status dot.
@@ -0,0 +1,421 @@
#!/usr/bin/env python3
"""Generate igiRadio.xcodeproj using PBXFileSystemSynchronizedRootGroup."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PROJECT = "igiRadio"
BUNDLE = "com.digiradio.igiRadio"
def uid(n):
# 24-char hex IDs (stable)
import hashlib
return hashlib.md5(f"igiRadio-{n}".encode()).hexdigest()[:24].upper()
IDS = {k: uid(k) for k in [
"project", "main", "products", "app", "test", "app_product", "test_product",
"sources", "resources", "fw_app", "fw_test", "test_sources", "test_fw",
"sync_app", "sync_tests", "sync_exception", "proj_cfg", "app_cfg", "test_cfg",
"dbg_proj", "rel_proj", "dbg_app", "rel_app", "dbg_test", "rel_test",
"proxy", "dep",
]}
pbx = f'''// !$*UTF8*$!
{{
\tarchiveVersion = 1;
\tclasses = {{
\t}};
\tobjectVersion = 77;
\tobjects = {{
/* Begin PBXBuildFile section */
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
\t\t{IDS["proxy"]} /* PBXContainerItemProxy */ = {{
\t\t\tisa = PBXContainerItemProxy;
\t\t\tcontainerPortal = {IDS["project"]} /* Project object */;
\t\t\tproxyType = 1;
\t\t\tremoteGlobalIDString = {IDS["app"]};
\t\t\tremoteInfo = {PROJECT};
\t\t}};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
\t\t{IDS["app_product"]} /* {PROJECT}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT}.app; sourceTree = BUILT_PRODUCTS_DIR; }};
\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = {PROJECT}Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }};
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */ = {{
\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;
\t\t\tmembershipExceptions = (
\t\t\t\tResources/Info.plist,
\t\t\t);
\t\t\ttarget = {IDS["app"]} /* {PROJECT} */;
\t\t}};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
\t\t{IDS["sync_app"]} /* {PROJECT} */ = {{
\t\t\tisa = PBXFileSystemSynchronizedRootGroup;
\t\t\texceptions = (
\t\t\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */,
\t\t\t);
\t\t\tpath = {PROJECT};
\t\t\tsourceTree = "<group>";
\t\t}};
\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */ = {{
\t\t\tisa = PBXFileSystemSynchronizedRootGroup;
\t\t\tpath = Tests/{PROJECT}Tests;
\t\t\tsourceTree = "<group>";
\t\t}};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
\t\t{IDS["fw_app"]} /* Frameworks */ = {{
\t\t\tisa = PBXFrameworksBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
\t\t{IDS["test_fw"]} /* Frameworks */ = {{
\t\t\tisa = PBXFrameworksBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
\t\t{IDS["products"]} /* Products */ = {{
\t\t\tisa = PBXGroup;
\t\t\tchildren = (
\t\t\t\t{IDS["app_product"]} /* {PROJECT}.app */,
\t\t\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */,
\t\t\t);
\t\t\tname = Products;
\t\t\tsourceTree = "<group>";
\t\t}};
\t\t{IDS["main"]} = {{
\t\t\tisa = PBXGroup;
\t\t\tchildren = (
\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */,
\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */,
\t\t\t\t{IDS["products"]} /* Products */,
\t\t\t);
\t\t\tsourceTree = "<group>";
\t\t}};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
\t\t{IDS["app"]} /* {PROJECT} */ = {{
\t\t\tisa = PBXNativeTarget;
\t\t\tbuildConfigurationList = {IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */;
\t\t\tbuildPhases = (
\t\t\t\t{IDS["sources"]} /* Sources */,
\t\t\t\t{IDS["fw_app"]} /* Frameworks */,
\t\t\t\t{IDS["resources"]} /* Resources */,
\t\t\t);
\t\t\tbuildRules = (
\t\t\t);
\t\t\tdependencies = (
\t\t\t);
\t\t\tfileSystemSynchronizedGroups = (
\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */,
\t\t\t);
\t\t\tname = {PROJECT};
\t\t\tproductName = {PROJECT};
\t\t\tproductReference = {IDS["app_product"]} /* {PROJECT}.app */;
\t\t\tproductType = "com.apple.product-type.application";
\t\t}};
\t\t{IDS["test"]} /* {PROJECT}Tests */ = {{
\t\t\tisa = PBXNativeTarget;
\t\t\tbuildConfigurationList = {IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */;
\t\t\tbuildPhases = (
\t\t\t\t{IDS["test_sources"]} /* Sources */,
\t\t\t\t{IDS["test_fw"]} /* Frameworks */,
\t\t\t);
\t\t\tbuildRules = (
\t\t\t);
\t\t\tdependencies = (
\t\t\t\t{IDS["dep"]} /* PBXTargetDependency */,
\t\t\t);
\t\t\tfileSystemSynchronizedGroups = (
\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */,
\t\t\t);
\t\t\tname = {PROJECT}Tests;
\t\t\tproductName = {PROJECT}Tests;
\t\t\tproductReference = {IDS["test_product"]} /* {PROJECT}Tests.xctest */;
\t\t\tproductType = "com.apple.product-type.bundle.unit-test";
\t\t}};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
\t\t{IDS["project"]} /* Project object */ = {{
\t\t\tisa = PBXProject;
\t\t\tattributes = {{
\t\t\t\tBuildIndependentTargetsInParallel = 1;
\t\t\t\tLastSwiftUpdateCheck = 2600;
\t\t\t\tLastUpgradeCheck = 2600;
\t\t\t\tTargetAttributes = {{
\t\t\t\t\t{IDS["app"]} = {{
\t\t\t\t\t\tCreatedOnToolsVersion = 26.0;
\t\t\t\t\t}};
\t\t\t\t\t{IDS["test"]} = {{
\t\t\t\t\t\tCreatedOnToolsVersion = 26.0;
\t\t\t\t\t\tTestTargetID = {IDS["app"]};
\t\t\t\t\t}};
\t\t\t\t}};
\t\t\t}};
\t\t\tbuildConfigurationList = {IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */;
\t\t\tcompatibilityVersion = "Xcode 16.0";
\t\t\tdevelopmentRegion = en;
\t\t\thasScannedForEncodings = 0;
\t\t\tknownRegions = (
\t\t\t\ten,
\t\t\t\tBase,
\t\t\t);
\t\t\tmainGroup = {IDS["main"]};
\t\t\tproductRefGroup = {IDS["products"]} /* Products */;
\t\t\tprojectDirPath = "";
\t\t\tprojectRoot = "";
\t\t\ttargets = (
\t\t\t\t{IDS["app"]} /* {PROJECT} */,
\t\t\t\t{IDS["test"]} /* {PROJECT}Tests */,
\t\t\t);
\t\t}};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
\t\t{IDS["resources"]} /* Resources */ = {{
\t\t\tisa = PBXResourcesBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
\t\t{IDS["sources"]} /* Sources */ = {{
\t\t\tisa = PBXSourcesBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
\t\t{IDS["test_sources"]} /* Sources */ = {{
\t\t\tisa = PBXSourcesBuildPhase;
\t\t\tbuildActionMask = 2147483647;
\t\t\tfiles = (
\t\t\t);
\t\t\trunOnlyForDeploymentPostprocessing = 0;
\t\t}};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
\t\t{IDS["dep"]} /* PBXTargetDependency */ = {{
\t\t\tisa = PBXTargetDependency;
\t\t\ttarget = {IDS["app"]} /* {PROJECT} */;
\t\t\ttargetProxy = {IDS["proxy"]} /* PBXContainerItemProxy */;
\t\t}};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
\t\t{IDS["dbg_proj"]} /* Debug */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;
\t\t\t\tCLANG_ENABLE_MODULES = YES;
\t\t\t\tCOPY_PHASE_STRIP = NO;
\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;
\t\t\t\tENABLE_TESTABILITY = YES;
\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
\t\t\t\tONLY_ACTIVE_ARCH = YES;
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = "-Onone";
\t\t\t}};
\t\t\tname = Debug;
\t\t}};
\t\t{IDS["rel_proj"]} /* Release */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;
\t\t\t\tCLANG_ENABLE_MODULES = YES;
\t\t\t\tCOPY_PHASE_STRIP = NO;
\t\t\t\tDEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
\t\t\t\tENABLE_NS_ASSERTIONS = NO;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;
\t\t\t\tVALIDATE_PRODUCT = YES;
\t\t\t}};
\t\t\tname = Release;
\t\t}};
\t\t{IDS["dbg_app"]} /* Debug */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
\t\t\t\tCODE_SIGN_STYLE = Automatic;
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
\t\t\t\tENABLE_PREVIEWS = YES;
\t\t\t\tGENERATE_INFOPLIST_FILE = NO;
\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (
\t\t\t\t\t"$(inherited)",
\t\t\t\t\t"@executable_path/Frameworks",
\t\t\t\t);
\t\t\t\tMARKETING_VERSION = 1.0;
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE};
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;
\t\t\t\tSWIFT_VERSION = 5.0;
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
\t\t\t}};
\t\t\tname = Debug;
\t\t}};
\t\t{IDS["rel_app"]} /* Release */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
\t\t\t\tCODE_SIGN_STYLE = Automatic;
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
\t\t\t\tENABLE_PREVIEWS = YES;
\t\t\t\tGENERATE_INFOPLIST_FILE = NO;
\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (
\t\t\t\t\t"$(inherited)",
\t\t\t\t\t"@executable_path/Frameworks",
\t\t\t\t);
\t\t\t\tMARKETING_VERSION = 1.0;
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE};
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;
\t\t\t\tSWIFT_VERSION = 5.0;
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
\t\t\t}};
\t\t\tname = Release;
\t\t}};
\t\t{IDS["dbg_test"]} /* Debug */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)";
\t\t\t\tCODE_SIGN_STYLE = Automatic;
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
\t\t\t\tGENERATE_INFOPLIST_FILE = YES;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tMARKETING_VERSION = 1.0;
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests;
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;
\t\t\t\tSWIFT_VERSION = 5.0;
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}";
\t\t\t}};
\t\t\tname = Debug;
\t\t}};
\t\t{IDS["rel_test"]} /* Release */ = {{
\t\t\tisa = XCBuildConfiguration;
\t\t\tbuildSettings = {{
\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)";
\t\t\t\tCODE_SIGN_STYLE = Automatic;
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
\t\t\t\tGENERATE_INFOPLIST_FILE = YES;
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
\t\t\t\tMARKETING_VERSION = 1.0;
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests;
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
\t\t\t\tSDKROOT = iphoneos;
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;
\t\t\t\tSWIFT_VERSION = 5.0;
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}";
\t\t\t}};
\t\t\tname = Release;
\t\t}};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
\t\t{IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */ = {{
\t\t\tisa = XCConfigurationList;
\t\t\tbuildConfigurations = (
\t\t\t\t{IDS["dbg_proj"]} /* Debug */,
\t\t\t\t{IDS["rel_proj"]} /* Release */,
\t\t\t);
\t\t\tdefaultConfigurationIsVisible = 0;
\t\t\tdefaultConfigurationName = Release;
\t\t}};
\t\t{IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */ = {{
\t\t\tisa = XCConfigurationList;
\t\t\tbuildConfigurations = (
\t\t\t\t{IDS["dbg_app"]} /* Debug */,
\t\t\t\t{IDS["rel_app"]} /* Release */,
\t\t\t);
\t\t\tdefaultConfigurationIsVisible = 0;
\t\t\tdefaultConfigurationName = Release;
\t\t}};
\t\t{IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */ = {{
\t\t\tisa = XCConfigurationList;
\t\t\tbuildConfigurations = (
\t\t\t\t{IDS["dbg_test"]} /* Debug */,
\t\t\t\t{IDS["rel_test"]} /* Release */,
\t\t\t);
\t\t\tdefaultConfigurationIsVisible = 0;
\t\t\tdefaultConfigurationName = Release;
\t\t}};
/* End XCConfigurationList section */
\t}};
\trootObject = {IDS["project"]} /* Project object */;
}}
'''
out = ROOT / f"{PROJECT}.xcodeproj" / "project.pbxproj"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(pbx)
scheme_dir = ROOT / f"{PROJECT}.xcodeproj" / "xcshareddata" / "xcschemes"
scheme_dir.mkdir(parents=True, exist_ok=True)
scheme = f'''<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="2600" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['app']}" BuildableName="{PROJECT}.app" BlueprintName="{PROJECT}" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['test']}" BuildableName="{PROJECT}Tests.xctest" BlueprintName="{PROJECT}Tests" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['app']}" BuildableName="{PROJECT}.app" BlueprintName="{PROJECT}" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
</Scheme>
'''
(scheme_dir / f"{PROJECT}.xcscheme").write_text(scheme)
print(f"Wrote {out}")
if __name__ == "__main__":
pass
@@ -0,0 +1,65 @@
import XCTest
@testable import igiRadio
final class HTTPDecoderTests: XCTestCase {
func testDecodeHealth() throws {
let json = """
{"status":"ok","fw":"0.8.5","serialNumber":"CC4DB4MOCK01",
"chips":{"si4684":true,"adau1701":true,"bt1035":true}}
""".data(using: .utf8)!
let health = try JSONDecoder.api.decode(HealthResponse.self, from: json)
XCTAssertEqual(health.status, "ok")
XCTAssertEqual(health.fw, "0.8.5")
XCTAssertEqual(health.serialNumber, "CC4DB4MOCK01")
XCTAssertTrue(health.chips.si4684)
}
func testDecodeTunerFM() throws {
let json = """
{"booted":true,"band":"fm","locked":true,"volume":50,
"fm":{"frequency_khz":102300,"rssi_dbuv":48,"snr_db":20,"stereo":true,
"station_name":"Radio Test","radiotext":"Hello"},
"dab":null}
""".data(using: .utf8)!
let tuner = try JSONDecoder.api.decode(TunerStatusResponse.self, from: json)
XCTAssertEqual(tuner.band, "fm")
XCTAssertEqual(tuner.fm?.frequencyKhz, 102300)
XCTAssertEqual(tuner.fm?.stationName, "Radio Test")
}
func testDecodeAudioProfile() throws {
let json = """
{"mixer":{"si4684_left_db":0,"si4684_right_db":0,"esp32_left_db":0,"esp32_right_db":0,
"mix_left_db":0,"mix_right_db":0},
"master":{"left_db":10,"right_db":10},
"eq":[{"gain_db":0,"center_hz":40,"q":1.414}],
"enhancements":{"stereo_level":5,"bass_level":7}}
""".data(using: .utf8)!
let profile = try JSONDecoder.api.decode(AudioProfileDTO.self, from: json)
XCTAssertEqual(profile.master.leftDb, 10)
XCTAssertEqual(profile.enhancements.bassLevel, 7)
}
func testA2DPStateMapping() {
XCTAssertEqual(A2DPState(apiValue: "streaming"), .streaming)
XCTAssertEqual(A2DPState(apiValue: "unknown-value"), .unknown)
}
func testBLENameToHost() {
XCTAssertEqual(
DigiRadioDiscoveryService.httpHost(fromBLEName: "DigiRadio-CC4DB4"),
"http://digiradio-cc4db4.local"
)
XCTAssertNil(DigiRadioDiscoveryService.httpHost(fromBLEName: "OtherDevice"))
}
}
final class MockDigiRadioServiceTests: XCTestCase {
func testMockConnectAndTune() async throws {
let mock = MockDigiRadioService()
try await mock.connect(host: "mock.local")
XCTAssertTrue(mock.state.connection.isConnected)
try await mock.tuneFM(frequencyKhz: 101_500)
XCTAssertEqual(mock.state.tuner.fm?.frequencyKhz, 101_500)
}
}
@@ -0,0 +1,363 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
/* End PBXBuildFile section */
/* 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 /* Exceptions for "igiRadio" folder in "igiRadio" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Resources/Info.plist,
);
target = 0938FB30D2E421C09D31973A /* igiRadio */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
7D085337BF0EA6EB04337F45 /* Exceptions for "igiRadio" folder in "igiRadio" target */,
);
path = igiRadio;
sourceTree = "<group>";
};
693436FA1780106DA05D06E3 /* igiRadioTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Tests/igiRadioTests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
71F357EA3EEC95DAEFCF7345 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
0A8032441559F0A3B8F103F8 /* 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 /* 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 /* 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 */
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;
};
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;
};
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;
};
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;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
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;
};
61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
90735310944459417946066C /* Debug */,
E6AD029DFF2D9078F77BDCBA /* 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,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,259 @@
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 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,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, 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, Sendable, Codable {
var stereoLevel: Int = 0
var bassLevel: Int = 0
enum CodingKeys: String, CodingKey {
case stereoLevel = "stereo_level"
case bassLevel = "bass_level"
}
}
struct BluetoothState: Equatable, Sendable {
var booted = false
var pairing = false
var a2dpState: A2DPState = .unknown
var deviceName: String = ""
var autoReconnect: Int = 0
var savedSpeaker: SavedSpeaker?
var nearbyDevices: [BluetoothDevice] = []
var pairedDevices: [BluetoothPairedDevice] = []
}
enum A2DPState: String, Sendable {
case standby, connecting, connected, streaming, paused, unknown
init(apiValue: String) {
switch apiValue.lowercased() {
case "standby": self = .standby
case "connecting": self = .connecting
case "connected": self = .connected
case "streaming": self = .streaming
case "paused": self = .paused
default: self = .unknown
}
}
}
struct SavedSpeaker: Equatable, Sendable {
var mac: String
var name: String
}
struct BluetoothDevice: Equatable, Sendable, Identifiable {
var id: String { mac }
var index: Int
var mac: String
var name: String
var rssiDbm: Int
}
struct BluetoothPairedDevice: Equatable, Sendable, Identifiable {
var id: String { mac }
var index: Int
var mac: String
var name: String
}
struct Station: Equatable, Sendable, Codable, Identifiable {
var id: String { "\(band.rawValue)-\(name)-\(fmFrequencyKhz ?? dabFreqIndex ?? 0)" }
var name: String
var band: TunerBand
var dabFreqIndex: Int?
var dabServiceId: Int?
var dabComponentId: Int?
var fmFrequencyKhz: Int?
enum CodingKeys: String, CodingKey {
case name, band
case dabFreqIndex = "dab_freq_index"
case dabServiceId = "dab_service_id"
case dabComponentId = "dab_component_id"
case fmFrequencyKhz = "fm_frequency_khz"
}
}
struct StreamingState: Equatable, Sendable, Codable {
var enabled = false
var url: String = ""
}
struct DABService: Equatable, Sendable, Identifiable, Codable {
var id: String { "\(serviceId)-\(componentId)" }
var serviceId: Int
var componentId: Int
var label: String
enum CodingKeys: String, CodingKey {
case serviceId = "service_id"
case componentId = "component_id"
case label
}
}
@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.984",
"green" : "0.447",
"red" : "0.227"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.584",
"red" : "0.376"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>igiRadio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>igiRadio usa Bluetooth per trovare DigiRadio in setup mode e configurare il WiFi.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>igiRadio si connette a DigiRadio sulla rete locale per controllare radio e audio.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,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,75 @@
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 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,192 @@
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 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,411 @@
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")
}
// 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,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,86 @@
import SwiftUI
struct AudioView: View {
@Environment(AppEnvironment.self) private var environment
@State private var stereoLevel: Double = 0
@State private var bassLevel: Double = 0
@State private var masterVolume: Double = 0
@State private var eqBands: [EQBandState] = []
var body: some View {
Form {
Section("Volume master") {
IGIVolumeSlider(value: $masterVolume) { value in
Task { try? await environment.digiRadio.setVolume(Int(value)) }
}
}
Section("Enhancements") {
VStack(alignment: .leading) {
Text("Stereo enhance")
Slider(value: $stereoLevel, in: 0 ... 100, step: 1) { editing in
if !editing {
Task { try? await environment.digiRadio.setStereoEnhance(level: Int(stereoLevel)) }
}
}
}
VStack(alignment: .leading) {
Text("Bass enhance")
Slider(value: $bassLevel, in: 0 ... 100, step: 1) { editing in
if !editing {
Task { try? await environment.digiRadio.setBassEnhance(level: Int(bassLevel)) }
}
}
}
}
Section("Equalizzatore (6 bande)") {
ForEach($eqBands) { $band in
VStack(alignment: .leading) {
Text("\(Int(band.centerHz)) Hz")
.font(.caption)
.foregroundStyle(.secondary)
Slider(value: $band.gainDb, in: -12 ... 12, step: 0.5) { editing in
if !editing { Task { await applyEQ() } }
}
}
}
}
Section {
Button("Reset profilo audio", role: .destructive) {
Task {
try? await environment.digiRadio.resetAudio()
syncFromState()
}
}
}
}
.navigationTitle("Audio")
.onAppear {
Task {
try? await environment.digiRadio.refreshAudioProfile()
syncFromState()
}
}
.onChange(of: environment.state.audio.enhancements.stereoLevel) { _, _ in syncFromState() }
}
private func syncFromState() {
let audio = environment.state.audio
stereoLevel = Double(audio.enhancements.stereoLevel)
bassLevel = Double(audio.enhancements.bassLevel)
masterVolume = Double(environment.state.tuner.volume)
eqBands = audio.eq
}
private func applyEQ() async {
let profile = AudioProfileDTO(
mixer: environment.state.audio.mixer,
master: environment.state.audio.master,
eq: eqBands,
enhancements: EnhancementsState(stereoLevel: Int(stereoLevel), bassLevel: Int(bassLevel))
)
try? await environment.digiRadio.applyAudioProfile(profile)
}
}
@@ -0,0 +1,62 @@
import SwiftUI
struct BLEProvisioningView: View {
@State private var service = BLEProvisioningService()
var body: some View {
List {
Section("Ricerca") {
switch service.phase {
case .idle:
Text("Premi Avvia per cercare dispositivi DigiRadio in setup mode.")
.foregroundStyle(.secondary)
case .scanning:
HStack {
IGIScanningIndicator().frame(width: 28, height: 28)
Text("Cerca DigiRadio...")
}
case let .found(name, _):
Label("Trovato: \(name)", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
case .unsupported:
Text("Provisioning BLE non ancora integrato in igiRadio.")
case let .error(message):
Text(message).foregroundStyle(.red)
}
}
if !service.discoveredDevices.isEmpty {
Section("Dispositivi") {
ForEach(service.discoveredDevices, id: \.id) { device in
VStack(alignment: .leading) {
Text(device.name).font(.headline)
Text("RSSI \(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
}
}
}
}
Section {
Button("Avvia scansione") { service.startScan() }
Button("Ferma scansione") { service.stopScan() }
}
Section("Istruzioni") {
Text("1. Metti DigiRadio in setup mode (SoftAP DigiRadio-<suffix>).")
Text("2. Il dispositivo espone anche BLE provisioning ESP-IDF.")
Text("3. Proof of Possession (PoP) = serial number del dispositivo.")
Text("4. Dopo il join WiFi, usa la connessione HTTP in igiRadio.")
}
.font(.footnote)
.foregroundStyle(.secondary)
Section {
Text("UNKNOWN — specification required: implementazione completa protocomm Security1 in-app (attualmente consigliata app ESP BLE Provisioning di Espressif).")
.font(.footnote)
.foregroundStyle(.orange)
}
}
.navigationTitle("BLE Provisioning")
.onDisappear { service.stopScan() }
}
}
@@ -0,0 +1,73 @@
import SwiftUI
struct BluetoothView: View {
@Environment(AppEnvironment.self) private var environment
@State private var isScanning = false
var body: some View {
let bt = environment.state.bluetooth
Form {
Section("Stato speaker (BT1035)") {
LabeledContent("Modulo", value: bt.booted ? "Attivo" : "Non pronto")
LabeledContent("A2DP", value: bt.a2dpState.rawValue.capitalized)
LabeledContent("Dispositivo", value: bt.deviceName.isEmpty ? "" : bt.deviceName)
if let speaker = bt.savedSpeaker {
LabeledContent("Speaker salvato", value: speaker.name)
Text(speaker.mac).font(.caption).foregroundStyle(.secondary)
}
}
Section("Azioni") {
Button(isScanning ? "Scansione..." : "Scansiona speaker vicini") {
Task { await scan() }
}
.disabled(isScanning)
Button("Riconnetti speaker salvato") {
Task { try? await environment.digiRadio.reconnectBluetooth() }
}
}
if !bt.nearbyDevices.isEmpty {
Section("Dispositivi trovati") {
ForEach(bt.nearbyDevices) { device in
HStack {
VStack(alignment: .leading) {
Text(device.name).font(.headline)
Text(device.mac).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("\(device.rssiDbm) dBm")
.font(.caption)
.foregroundStyle(.secondary)
Button("Connetti") {
Task {
try? await environment.digiRadio.connectBluetooth(
mac: device.mac,
name: device.name,
save: true
)
}
}
.buttonStyle(.borderedProminent)
}
}
}
}
Section {
Text("Questa sezione configura il modulo Bluetooth classic (BT1035) che invia audio A2DP verso un altoparlante esterno. È distinta dalla connessione BLE usata solo per il provisioning WiFi.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Bluetooth Audio")
.onAppear { Task { try? await environment.digiRadio.refreshBluetoothStatus() } }
}
private func scan() async {
isScanning = true
defer { isScanning = false }
try? await environment.digiRadio.scanBluetooth(seconds: 8)
}
}
@@ -0,0 +1,144 @@
import SwiftUI
struct ConnectionView: View {
@Environment(AppEnvironment.self) private var environment
@State private var viewModel: ConnectionViewModel?
@State private var discoveryTimer: Timer?
var body: some View {
let vm = viewModel ?? ConnectionViewModel(environment: environment)
let state = environment.state
Form {
Section("Dispositivo") {
Toggle("Modalità demo (Mock)", isOn: Binding(
get: { environment.useMockDevice },
set: { vm.toggleMock($0) }
))
}
if !environment.useMockDevice {
Section("Ricerca in rete") {
if vm.discovery.isSearching {
HStack {
IGIScanningIndicator().frame(width: 28, height: 28)
Text("Cerca DigiRadio...")
}
Button("Ferma ricerca") { stopDiscovery(vm) }
} else {
Button("Cerca dispositivi") { startDiscovery(vm) }
}
if vm.discovery.devices.isEmpty {
Text("Avvia la ricerca per trovare DigiRadio via BLE (setup) o verificare l'host inserito.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(vm.discovery.devices) { device in
Button {
Task { await vm.connect(to: device.host) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(device.name).font(.headline)
Text(device.host).font(.caption).foregroundStyle(.secondary)
if let fw = device.firmware {
Text("FW \(fw)").font(.caption2).foregroundStyle(.secondary)
}
}
Spacer()
if device.isReachable {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
} else {
Image(systemName: "questionmark.circle").foregroundStyle(.orange)
}
}
}
.disabled(vm.isBusy)
}
}
}
}
Section("Connessione HTTP") {
TextField("Host", text: Binding(
get: { vm.hostInput },
set: { vm.hostInput = $0 }
))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
if state.connection.isConnected {
LabeledContent("Stato", value: "Connesso")
LabeledContent("Host", value: state.connection.host)
}
if let error = vm.errorMessage ?? state.connection.lastError {
Text(error).foregroundStyle(.red)
}
}
Section {
if state.connection.isConnected {
Button("Disconnetti", role: .destructive) {
vm.disconnect()
}
} else {
Button {
Task { await vm.connect() }
} label: {
if vm.isBusy {
HStack {
IGIScanningIndicator().frame(width: 24, height: 24)
Text("Connessione...")
}
} else {
Text("Connetti")
}
}
.disabled(vm.isBusy)
}
}
Section("Provisioning WiFi (BLE)") {
NavigationLink("Configura WiFi via BLE") {
BLEProvisioningView()
}
Text("DigiRadio espone BLE solo per il provisioning WiFi. Il nome BLE (es. DigiRadio-CC4DB4) corrisponde all'host HTTP digiradio-cc4db4.local.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Suggerimenti host") {
Text("• Setup mode: http://192.168.4.1")
Text("• STA mode: http://digiradio-<suffix>.local")
Text("• Oppure IP LAN del dispositivo")
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.navigationTitle("Connessione")
.onAppear {
if viewModel == nil { viewModel = vm }
if environment.useMockDevice && !state.connection.isConnected {
Task { await vm.connect() }
}
}
.onDisappear { stopDiscovery(vm) }
}
private func startDiscovery(_ vm: ConnectionViewModel) {
vm.startDiscovery()
discoveryTimer?.invalidate()
discoveryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
vm.pollDiscovery()
}
}
private func stopDiscovery(_ vm: ConnectionViewModel) {
discoveryTimer?.invalidate()
discoveryTimer = nil
vm.stopDiscovery()
}
}
@@ -0,0 +1,77 @@
import SwiftUI
struct DABRadioView: View {
@Environment(AppEnvironment.self) private var environment
@State private var freqIndex: Double = 12
@State private var services: [DABService] = []
@State private var loadError: String?
var body: some View {
let dab = environment.state.tuner.dab
Form {
Section("Ensemble") {
Stepper("Freq index: \(Int(freqIndex))", value: Binding(
get: { Int(freqIndex) },
set: { freqIndex = Double($0) }
), in: 0 ... 37)
Button("Sintonizza ensemble") {
Task { try? await environment.digiRadio.tuneDAB(freqIndex: Int(freqIndex)) }
}
}
if let dab {
Section("Stato DAB") {
LabeledContent("Freq index", value: "\(dab.freqIndex)")
if let fic = dab.ficQuality { LabeledContent("FIC quality", value: "\(fic)") }
if let cnr = dab.cnrDb { LabeledContent("CNR", value: "\(cnr) dB") }
if let label = dab.dynamicLabel { LabeledContent("DLS", value: label) }
}
}
Section("Servizi") {
if let loadError {
Text(loadError).foregroundStyle(.red)
}
if services.isEmpty {
Text("Nessun servizio caricato")
.foregroundStyle(.secondary)
} else {
ForEach(services) { service in
Button {
Task {
try? await environment.digiRadio.playDAB(
serviceId: service.serviceId,
componentId: service.componentId
)
}
} label: {
VStack(alignment: .leading) {
Text(service.label).font(.headline)
Text("SID \(service.serviceId) · CID \(service.componentId)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
Button("Carica servizi") { Task { await loadServices() } }
}
}
.navigationTitle("DAB")
.onAppear {
if let idx = environment.state.tuner.dab?.freqIndex {
freqIndex = Double(idx)
}
Task { try? await environment.digiRadio.refreshTunerStatus() }
}
}
private func loadServices() async {
loadError = nil
do {
services = try await environment.digiRadio.listDABServices()
} catch {
loadError = error.localizedDescription
}
}
}
@@ -0,0 +1,150 @@
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
Form {
Section {
VStack(spacing: IGITheme.spacingM) {
Text(String(format: "%.2f", frequencyMHz))
.font(.system(size: 56, weight: .bold, design: .rounded))
.monospacedDigit()
.accessibilityLabel("Frequenza \(frequencyMHz) megahertz")
Slider(value: $frequencyMHz, in: 64 ... 108, step: 0.05)
HStack {
Button("") { frequencyMHz = max(64, frequencyMHz - 0.1) }
Spacer()
Button("Sintonizza") { Task { await tune() } }
.buttonStyle(.borderedProminent)
.disabled(isTuning)
Spacer()
Button("+") { frequencyMHz = min(108, frequencyMHz + 0.1) }
}
}
.padding(.vertical, IGITheme.spacingS)
}
if let fm {
Section("Stato") {
LabeledContent("Frequenza", value: String(format: "%.2f MHz", Double(fm.frequencyKhz) / 1000))
if let rssi = fm.rssiDbuv { LabeledContent("RSSI", value: "\(rssi) dBµV") }
if let snr = fm.snrDb { LabeledContent("SNR", value: "\(snr) dB") }
if let stereo = fm.stereo {
LabeledContent("Stereo", value: stereo ? "" : "Mono")
}
}
if let ps = fm.stationName, !ps.isEmpty {
Section("RDS") {
LabeledContent("PS", value: ps)
if let rt = fm.radiotext, !rt.isEmpty {
Text(rt).font(.body)
}
}
}
}
Section("Seek") {
Button("Seek su") { Task { try? await environment.digiRadio.seekFM(direction: "up") } }
Button("Seek giù") { Task { try? await environment.digiRadio.seekFM(direction: "down") } }
}
Section("Scan") {
if isScanning {
HStack {
IGIScanningIndicator().frame(width: 24, height: 24)
Text("Scansione in corso…")
}
}
if let scanError {
Text(scanError).foregroundStyle(.red)
}
Button("Cerca prossima stazione") {
Task { await scanNext() }
}
.disabled(isScanning)
Button("Scansione completa banda FM") {
Task { await scanFull() }
}
.disabled(isScanning)
if !scanResults.isEmpty {
ForEach(scanResults) { hit in
Button {
Task { try? await environment.digiRadio.tuneFM(frequencyKhz: hit.frequencyKhz) }
} label: {
HStack {
VStack(alignment: .leading) {
Text(hit.stationName ?? "Stazione FM")
.font(.headline)
Text(String(format: "%.2f MHz", Double(hit.frequencyKhz) / 1000))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let rssi = hit.rssiDbuv {
Text("\(rssi) dBµV")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
}
.navigationTitle("FM")
.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 { frequencyMHz = Double(khz) / 1000.0 }
}
}
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,221 @@
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)
transportControls
volumeSection
presetsSection(state: state)
}
.padding(IGITheme.spacingM)
}
.background(IGITheme.screenBackground)
.navigationTitle("igiRadio")
.safeAreaInset(edge: .top) {
if !state.connection.isConnected {
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)
}
}
.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)
}
.onDisappear { viewModel?.onDisappear() }
.onChange(of: environment.state.tuner.volume) { _, newValue in
volume = Double(newValue)
}
}
@ViewBuilder
private func header(state: DigiRadioState) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("DigiRadio")
.font(.largeTitle.bold())
HStack(spacing: 8) {
IGIStatusDot(isConnected: state.connection.isConnected)
Text(state.connection.isConnected ? "Connesso" : "Non connesso")
.foregroundStyle(.secondary)
}
}
Spacer()
if let rssi = state.tuner.fm?.rssiDbuv {
VStack(alignment: .trailing) {
Text("Segnale")
.font(.caption)
.foregroundStyle(.secondary)
IGISignalBar(level: Double(rssi) / 80.0)
.frame(width: 120)
}
}
}
}
@ViewBuilder
private func nowPlayingCard(state: DigiRadioState) -> some View {
IGICard {
VStack(spacing: IGITheme.spacingM) {
ZStack {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(
LinearGradient(
colors: [.accentColor.opacity(0.35), .purple.opacity(0.25)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(height: 220)
Image(systemName: state.tuner.band == .fm ? "radio.fill" : "antenna.radiowaves.left.and.right")
.font(.system(size: 64))
.foregroundStyle(.white.opacity(0.9))
}
VStack(spacing: 6) {
Text(primaryTitle(state: state))
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text(secondaryLine(state: state))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
}
}
private var transportControls: some View {
HStack(spacing: IGITheme.spacingL) {
Button {
Task { await viewModel?.seekFM("down") }
} label: {
Image(systemName: "backward.fill")
.font(.title2)
.frame(width: 56, height: 56)
}
.buttonStyle(.bordered)
Button {
Task { await viewModel?.refreshAll() }
} label: {
Image(systemName: "play.fill")
.font(.largeTitle)
.frame(width: 72, height: 72)
}
.buttonStyle(.borderedProminent)
.clipShape(Circle())
Button {
Task { await viewModel?.seekFM("up") }
} label: {
Image(systemName: "forward.fill")
.font(.title2)
.frame(width: 56, height: 56)
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity)
}
private var volumeSection: some View {
IGICard {
IGIVolumeSlider(value: $volume) { value in
Task { await viewModel?.setVolume(value) }
}
}
}
@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
Button {
Task { await viewModel?.tunePreset(at: index) }
} label: {
VStack(alignment: .leading) {
Text(station.name)
.font(.headline)
Text(station.band.rawValue.uppercased())
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(IGITheme.cardBackground, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
}
}
}
}
}
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 {
return String(format: "%.2f MHz", Double(khz) / 1000.0)
}
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,95 @@
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 { PresetsView() }
.tabItem { Label("Preset", systemImage: "star.fill") }
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 .bluetooth: BluetoothView()
case .audio: AudioView()
case .presets: PresetsView()
case .settings: SettingsRootView()
}
}
}
}
enum SidebarItem: String, CaseIterable, Identifiable {
case home, fm, dab, bluetooth, audio, presets, settings
var id: String { rawValue }
var title: String {
switch self {
case .home: "Home"
case .fm: "FM"
case .dab: "DAB"
case .bluetooth: "Bluetooth"
case .audio: "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 .bluetooth: "dot.radiowaves.left.and.right"
case .audio: "waveform"
case .presets: "star.fill"
case .settings: "gearshape.fill"
}
}
}
@@ -0,0 +1,100 @@
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("Preset") { PresetsView() }
}
Section("Audio") {
NavigationLink("Profilo audio") { AudioView() }
NavigationLink("Bluetooth speaker") { BluetoothView() }
}
}
.navigationTitle("Impostazioni")
}
}
struct DeviceInfoView: View {
@Environment(AppEnvironment.self) private var environment
var body: some View {
let health = environment.state.health
let chips = health.chips
Form {
LabeledContent("Nome", value: "DigiRadio")
LabeledContent("Firmware", value: health.firmware.isEmpty ? "" : health.firmware)
LabeledContent("Serial number", value: health.serialNumber.isEmpty ? "" : health.serialNumber)
LabeledContent("Stato", value: health.status.isEmpty ? "" : health.status)
Section("Chip") {
LabeledContent("Si4684", value: chips.si4684 ? "OK" : "")
LabeledContent("ADAU1701", value: chips.adau1701 ? "OK" : "")
LabeledContent("BT1035", value: chips.bt1035 ? "OK" : "")
}
Section("Connessione") {
LabeledContent("Host", value: environment.state.connection.host.isEmpty ? "" : environment.state.connection.host)
LabeledContent("HTTP", value: environment.state.connection.isConnected ? "Connesso" : "Non connesso")
}
}
.navigationTitle("Dispositivo")
.onAppear { Task { try? await environment.digiRadio.refreshHealth() } }
}
}
struct DiagnosticsView: View {
@Environment(AppEnvironment.self) private var environment
var body: some View {
Form {
Section("Tuner") {
LabeledContent("Booted", value: environment.state.tuner.booted ? "" : "No")
LabeledContent("Banda", value: environment.state.tuner.band.rawValue.uppercased())
LabeledContent("Locked", value: environment.state.tuner.locked ? "" : "No")
}
Section("Bluetooth") {
LabeledContent("A2DP", value: environment.state.bluetooth.a2dpState.rawValue)
LabeledContent("Pairing", value: environment.state.bluetooth.pairing ? "" : "No")
}
Section {
Button("Aggiorna snapshot") {
Task {
try? await environment.digiRadio.refreshHealth()
try? await environment.digiRadio.refreshTunerStatus()
try? await environment.digiRadio.refreshBluetoothStatus()
}
}
}
}
.navigationTitle("Diagnostica")
}
}
struct FirmwareView: View {
@Environment(AppEnvironment.self) private var environment
var body: some View {
Form {
Section("Versione corrente") {
LabeledContent("Firmware", value: environment.state.health.firmware.isEmpty ? "" : environment.state.health.firmware)
}
Section("Aggiornamento OTA") {
Text("L'aggiornamento firmware è supportato via POST /api/system/ota con un file .bin ESP-IDF valido. L'interfaccia di upload file sarà aggiunta in una versione successiva.")
.font(.footnote)
.foregroundStyle(.secondary)
Text("UNKNOWN — specification required: progress reporting durante OTA stream.")
.font(.footnote)
.foregroundStyle(.orange)
}
}
.navigationTitle("Firmware")
.onAppear { Task { try? await environment.digiRadio.refreshHealth() } }
}
}