diff --git a/Software/APP/igiRadio/Documentation/APP_ARCHITECTURE.md b/Software/APP/igiRadio/Documentation/APP_ARCHITECTURE.md new file mode 100644 index 0000000..2cec32b --- /dev/null +++ b/Software/APP/igiRadio/Documentation/APP_ARCHITECTURE.md @@ -0,0 +1,35 @@ +# APP_ARCHITECTURE — igiRadio + +## Stack + +``` +SwiftUI Views + ↓ @Observable ViewModels +DigiRadioService (protocol) + ↓ +RealDigiRadioService ──→ HTTPDigiRadioClient (URLSession) +MockDigiRadioService ──→ dati in-memory + +BLEProvisioningService (solo setup Wi‑Fi) + ↓ CoreBluetooth + protocomm (ESP Security1) +``` + +## Principi + +1. Le View **non** chiamano URLSession o CoreBluetooth direttamente. +2. Un solo `DigiRadioState` osservabile aggiornato dal service. +3. Errori tipizzati (`DigiRadioError`). +4. Dependency injection via `AppEnvironment` in `igiRadioApp`. + +## Connessione + +| Fase | Meccanismo | +|------|------------| +| Prima configurazione | BLE provisioning **oppure** join SoftAP + POST /api/wifi | +| Uso normale | mDNS `digiradio-xxxxxx.local` o IP manuale | +| Base URL | `http://{host}/api/...` | + +## Polling vs push + +- Nessuna notifica BLE documentata per stato tuner. +- `TunerViewModel` può pollare `GET /api/tuner/status` a intervallo configurabile quando la schermata radio è attiva. diff --git a/Software/APP/igiRadio/Documentation/BLE_PROTOCOL.md b/Software/APP/igiRadio/Documentation/BLE_PROTOCOL.md new file mode 100644 index 0000000..49eeb6e --- /dev/null +++ b/Software/APP/igiRadio/Documentation/BLE_PROTOCOL.md @@ -0,0 +1,45 @@ +# BLE_PROTOCOL — igiRadio + +## Riepilogo + +**DigiRadio non espone un protocollo BLE GATT documentato per il controllo radio/audio.** + +Il BLE dell'ESP32-S3 è usato **solo** per il provisioning Wi‑Fi (setup iniziale). + +--- + +## BLE Wi‑Fi Provisioning (documentato) + +| Parametro | Valore | +|-----------|--------| +| Stack | ESP-IDF `wifi_provisioning` + `scheme_ble` | +| Sicurezza | protocomm **Security1** | +| Proof of possession | **Serial number** dispositivo (EUI-48 da EEPROM) | +| Nome advertising | `DigiRadio-` (es. `DigiRadio-CC4DB4`) | +| Riferimento firmware | `components/net/src/BleProvisioning.cpp` | +| Riferimento manuale | `ch-api.tex` §BLE provisioning | + +### Flusso + +1. Dispositivo in setup mode (no credenziali Wi‑Fi salvate) +2. Advertise BLE con nome = SoftAP SSID +3. App iOS invia credenziali Wi‑Fi via protocomm (Security1 + PoP) +4. Firmware valida, salva in NVS, reboot in STA +5. App passa a controllo via **HTTP** su LAN + +### Implementazione igiRadio + +- Usare **CoreBluetooth** solo per questa fase +- Compatibile con app generiche "ESP BLE Provisioning" (stesso protocollo Espressif) +- PoP richiesto dall'utente: serial da etichetta o da schermata setup SoftAP + +### UNKNOWN + +- UUID servizio/caratteristica custom DigiRadio per controllo: **non documentati** +- Notifiche BLE stato tuner: **non documentate** + +--- + +## Controllo dispositivo (post-provisioning) + +Vedi `APP_ARCHITECTURE.md` — trasporto **HTTP REST** porta 80. diff --git a/Software/APP/igiRadio/Documentation/DEVELOPMENT.md b/Software/APP/igiRadio/Documentation/DEVELOPMENT.md new file mode 100644 index 0000000..84b3e79 --- /dev/null +++ b/Software/APP/igiRadio/Documentation/DEVELOPMENT.md @@ -0,0 +1,58 @@ +# DEVELOPMENT — igiRadio + +## Requisiti + +- macOS con **Xcode 15+** (testato Xcode 26.6) +- iOS 17+ deployment target +- Dispositivo DigiRadio su stessa rete Wi‑Fi (STA) o in setup SoftAP + +## Aprire il progetto + +```bash +open /Users/michelebigi/Documents/Develop/DigiRadio/Software/APP/igiRadio/igiRadio.xcodeproj +``` + +Rigenerare il progetto (se necessario): + +```bash +cd Software/APP/igiRadio +python3 Scripts/generate_xcodeproj.py +``` + +## Build e test da terminale + +```bash +cd Software/APP/igiRadio +xcodebuild -scheme igiRadio -destination 'platform=iOS Simulator,name=iPhone 17' build test +``` + +## Scheme + +- **igiRadio** — app principale +- **igiRadioTests** — unit test protocollo e state + +## Mock vs reale + +In **DEBUG**, `AppEnvironment` avvia in modalità mock (`MockDigiRadioService`). +Disattivare in **Connessione → Modalità demo (Mock)** e inserire l'host HTTP reale. + +## Test su hardware + +1. Provisioning Wi‑Fi (BLE discovery in-app, o app ESP BLE Provisioning; oppure SoftAP `http://192.168.4.1`) +2. In app: Impostazioni → Connessione → host `http://digiradio-.local` +3. Verificare `GET /api/health` + +## Documentazione firmware + +`/Users/michelebigi/Documents/Develop/DigiRadio/Software/docs/manual/ch-api.tex` + +## Architettura runtime + +``` +SwiftUI → ViewModel → DigiRadioService + ├── RealDigiRadioService → HTTPDigiRadioClient (REST JSON) + └── MockDigiRadioService +BLEProvisioningService (CoreBluetooth, solo discovery setup) +``` + +Il controllo tuner/audio **non** usa GATT proprietario — vedi `BLE_PROTOCOL.md`. diff --git a/Software/APP/igiRadio/Documentation/DEVICE_STATE.md b/Software/APP/igiRadio/Documentation/DEVICE_STATE.md new file mode 100644 index 0000000..9977b93 --- /dev/null +++ b/Software/APP/igiRadio/Documentation/DEVICE_STATE.md @@ -0,0 +1,17 @@ +# DEVICE_STATE — DigiRadioState + +Modello centrale in `Models/DigiRadioState.swift`. + +## Sezioni + +| Sezione | Campi documentati | +|---------|-------------------| +| `connection` | host, isConnected, lastError | +| `health` | status, firmware, serialNumber, chips | +| `tuner` | band, locked, volume, fm?, dab? | +| `audio` | mixer, master, eq, enhancements | +| `bluetooth` | booted, pairing, a2dpState, deviceName, speaker | +| `stations` | [Station] | +| `streaming` | enabled, url | + +Campi non documentati nell'API **non** sono presenti nel modello. diff --git a/Software/APP/igiRadio/Documentation/PROJECT_ANALYSIS.md b/Software/APP/igiRadio/Documentation/PROJECT_ANALYSIS.md new file mode 100644 index 0000000..98ae23d --- /dev/null +++ b/Software/APP/igiRadio/Documentation/PROJECT_ANALYSIS.md @@ -0,0 +1,151 @@ +# PROJECT_ANALYSIS — DigiRadio (fonte: documentazione firmware) + +**Data analisi:** 2026-08-18 +**Fonte primaria:** `Software/docs/manual/` (non modificata) +**Firmware di riferimento:** 0.8.5+ + +--- + +## 1. Documenti analizzati + +| File | Contenuto | +|------|-----------| +| `ch-intro.tex` | Introduzione prodotto | +| `ch-hardware.tex` | PCB, pinout, catena audio | +| `ch-firmware.tex` | Architettura firmware ESP32-S3 | +| `ch-si4684.tex` | Tuner FM/DAB, tune, seek, RDS, DLS | +| `ch-adau1701.tex` | DSP SigmaStudio, mixer, EQ, safeload | +| `ch-bt1035.tex` | Modulo BT classic A2DP (UART AT), I2S da ADAU | +| `ch-sigmastudio.tex` | Design SigmaStudio | +| `ch-api.tex` | **HTTP REST API** (protocollo app) | +| `ch-classes.tex` | Classi firmware | +| `ch-build.tex` | Build/flash | +| `ch-licensing.tex` | Licenze | + +Documentazione aggiuntiva letta: `Software/CLAUDE.md`, `Software/docs/security-flash-nvs.md`, `BleProvisioning.cpp`. + +--- + +## 2. Distinzione critica: BLE vs controllo + +### Controllo dispositivo (tuner, audio, preset, BT config) + +**Trasporto documentato:** HTTP JSON REST su **TCP porta 80** (Wi‑Fi). + +- Setup mode: SoftAP `DigiRadio-` → `http://192.168.4.1/` +- STA mode: stessa API su IP LAN o **mDNS** `digiradio-.local` +- **NON esiste** un protocollo GATT proprietario documentato per tuner/audio/preset. + +### BLE (ESP32-S3 onboard) + +**Solo provisioning Wi‑Fi** in setup mode (`ch-api.tex` §BLE provisioning): + +- Stack: ESP-IDF `wifi_provisioning` + `scheme_ble` +- Sicurezza: **Security1** (protocomm) +- **Proof of possession (PoP):** serial number dispositivo (stesso valore di `GET /api/health` → `serialNumber`) +- **Nome servizio BLE:** SSID SoftAP = `DigiRadio-` (es. `DigiRadio-CC4DB4`) +- Su successo: credenziali salvate in NVS, reboot in STA +- **Non è un endpoint HTTP** — nessuna REST call + +### Bluetooth Audio (streaming verso speaker esterno) + +**Modulo separato FSC-BT1035** (classic BR/EDR A2DP), controllato dal firmware via **UART AT**, esposto all'app solo tramite **HTTP** `/api/bluetooth/*`. + +L'iPhone **non** si collega in A2DP al DigiRadio per ascoltare: il DigiRadio invia audio al Bose/Speaker via BT1035. + +--- + +## 3. Architettura hardware (sintesi) + +``` +Si4684 (SPI) ──I2S──► ADAU1701 (I2S master 48 kHz) ──I2S──► BT1035 ──A2DP──► Speaker +ESP32-S3 (Wi‑Fi/BLE, HTTP server, opz. I2S test) +``` + +Sorgenti audio nel DSP: **Si4684** (radio), **ESP32 I2S** (stream locale / phone push). + +--- + +## 4. Endpoint HTTP documentati (controllo app) + +| Metodo | Path | Funzione | +|--------|------|----------| +| GET | `/api/health` | Stato, fw, serial, chip boot | +| POST | `/api/wifi` | Provisioning STA (reboot) | +| POST | `/api/wifi/scan` | Scan reti vicine | +| GET | `/api/tuner/status` | Stato tuner FM/DAB | +| GET | `/api/tuner/services` | Lista servizi DAB | +| POST | `/api/tuner/tune` | Sintonia FM/DAB | +| POST | `/api/tuner/play` | Play servizio DAB | +| POST | `/api/tuner/seek` | Seek FM up/down | +| POST | `/api/tuner/scan` | Scan singola stazione | +| POST | `/api/tuner/scan/full` | Scan completo banda FM | +| GET/PUT | `/api/audio/profile` | Profilo mixer/EQ/master | +| POST | `/api/audio/reset` | Reset profilo factory | +| POST | `/api/audio/stereo-enhance` | Enhancement stereo 0–100 | +| POST | `/api/audio/bass-enhance` | Enhancement bass 0–100 | +| POST | `/api/audio/beep` | Beep diagnostico ADAU | +| GET | `/api/dsp/params` | Tabella parametri SigmaStudio | +| PUT | `/api/dsp/param` | Scrittura cella raw (live) | +| POST | `/api/dsp/program` | Upload blob DSP (reboot) | +| POST | `/api/system/ota` | OTA firmware ESP32 (reboot) | +| GET/POST | `/api/streaming` | Web radio MP3 URL | +| PUT | `/api/stream/phone` | Push PCM stereo 48 kHz da phone | +| GET | `/api/bluetooth/status` | Stato BT1035 | +| POST | `/api/bluetooth/pair` | Modalità discoverable | +| POST | `/api/bluetooth/pair/stop` | Esci pairing | +| POST | `/api/bluetooth/disconnect` | Disconnect A2DP | +| GET | `/api/bluetooth/paired` | Lista paired | +| POST | `/api/bluetooth/scan` | Scan speaker vicini | +| POST | `/api/bluetooth/connect` | Connect A2DP + opz. save | +| GET/POST/DELETE | `/api/bluetooth/speaker` | Speaker default | +| POST | `/api/bluetooth/reconnect` | Reconnect manuale | +| POST | `/api/bluetooth/auto-reconnect` | Retry count 0–15 | +| GET | `/api/stations` | Lista preset | +| POST | `/api/stations` | Aggiungi preset | +| POST | `/api/stations/remove` | Rimuovi preset | +| POST | `/api/stations/reorder` | Riordina preset | +| POST | `/api/stations/tune` | Richiama preset | + +--- + +## 5. Funzionalità per schermata app + +| Area | Supportato (documentato) | Note | +|------|--------------------------|------| +| Connessione Wi‑Fi | ✅ | BLE prov. + SoftAP + POST /api/wifi | +| FM tune/seek/scan | ✅ | RSSI, SNR, RDS PS/RT | +| DAB tune/play/services | ✅ | DLS, ensemble, service list | +| Preset | ✅ | CRUD + reorder + recall | +| Volume/mixer/EQ | ✅ | Via audio profile | +| DSP raw params | ✅ | Escape hatch tecnico | +| BT speaker pairing | ✅ | Via HTTP → BT1035 AT | +| OTA firmware | ✅ | POST /api/system/ota | +| OTA DSP | ✅ | POST /api/dsp/program | +| Phone audio stream | ✅ | PUT /api/stream/phone | +| Web radio stream | ✅ | POST /api/streaming | +| Artwork album | ❌ | **UNKNOWN** — non in API | +| Batteria dispositivo | ❌ | Alimentato da rete | +| AUX/USB come sorgente UI | ❌ | Non esposto in API | +| Notifiche push BLE stato | ❌ | Nessun GATT documentato | + +--- + +## 6. Implicazioni per igiRadio + +1. **DigiRadioService** → implementazione **HTTP REST** (URLSession), non GATT custom. +2. **BLE layer** → solo **Wi‑Fi provisioning** (ESP protocomm Security1 + PoP = serial). +3. **MockDigiRadioService** → stessa interfaccia, dati fittizi. +4. **Discovery** → mDNS `.local`, IP manuale, o join SoftAP in setup. +5. **Real-time** → polling selettivo su `/api/tuner/status` quando in radio; nessuna notifica BLE documentata. + +--- + +## 7. UNKNOWN — specification required + +| Voce | Motivo | +|------|--------| +| UUID GATT controllo tuner/audio | Non documentati — controllo è HTTP | +| Artwork/metadata streaming | Non in API tuner | +| Protocol version field | Solo `fw` in health | +| Hardware revision API | Solo `serialNumber` + chip flags | diff --git a/Software/APP/igiRadio/Documentation/UI_ARCHITECTURE.md b/Software/APP/igiRadio/Documentation/UI_ARCHITECTURE.md new file mode 100644 index 0000000..0ab521f --- /dev/null +++ b/Software/APP/igiRadio/Documentation/UI_ARCHITECTURE.md @@ -0,0 +1,31 @@ +# UI_ARCHITECTURE — igiRadio + +## iPhone + +`TabView` con tab: Home, Radio, Presets, Audio, Settings. + +## iPad + +`NavigationSplitView`: +- Sidebar: Home, FM, DAB, Presets, Bluetooth, Audio, Settings +- Detail: contenuto selezionato + +## Schermate + +| Schermata | ViewModel | +|-----------|-----------| +| Connection | `ConnectionViewModel` | +| Home | `HomeViewModel` | +| FM | `FMViewModel` | +| DAB | `DABViewModel` | +| Presets | `PresetsViewModel` | +| Audio | `AudioViewModel` | +| Bluetooth | `BluetoothViewModel` | +| Settings | `SettingsViewModel` | +| Device Info | `DeviceViewModel` | +| Diagnostics | `DiagnosticsViewModel` | +| Firmware | `FirmwareViewModel` | + +## Design System + +`Components/DesignSystem/` — colori semantici, tipografia, card, slider, status dot. diff --git a/Software/APP/igiRadio/Scripts/generate_xcodeproj.py b/Software/APP/igiRadio/Scripts/generate_xcodeproj.py new file mode 100644 index 0000000..875b0a3 --- /dev/null +++ b/Software/APP/igiRadio/Scripts/generate_xcodeproj.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""Generate igiRadio.xcodeproj using PBXFileSystemSynchronizedRootGroup.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PROJECT = "igiRadio" +BUNDLE = "com.digiradio.igiRadio" + +def uid(n): + # 24-char hex IDs (stable) + import hashlib + return hashlib.md5(f"igiRadio-{n}".encode()).hexdigest()[:24].upper() + +IDS = {k: uid(k) for k in [ + "project", "main", "products", "app", "test", "app_product", "test_product", + "sources", "resources", "fw_app", "fw_test", "test_sources", "test_fw", + "sync_app", "sync_tests", "sync_exception", "proj_cfg", "app_cfg", "test_cfg", + "dbg_proj", "rel_proj", "dbg_app", "rel_app", "dbg_test", "rel_test", + "proxy", "dep", +]} + +pbx = f'''// !$*UTF8*$! +{{ +\tarchiveVersion = 1; +\tclasses = {{ +\t}}; +\tobjectVersion = 77; +\tobjects = {{ + +/* Begin PBXBuildFile section */ +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ +\t\t{IDS["proxy"]} /* PBXContainerItemProxy */ = {{ +\t\t\tisa = PBXContainerItemProxy; +\t\t\tcontainerPortal = {IDS["project"]} /* Project object */; +\t\t\tproxyType = 1; +\t\t\tremoteGlobalIDString = {IDS["app"]}; +\t\t\tremoteInfo = {PROJECT}; +\t\t}}; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ +\t\t{IDS["app_product"]} /* {PROJECT}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT}.app; sourceTree = BUILT_PRODUCTS_DIR; }}; +\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = {PROJECT}Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }}; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ +\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */ = {{ +\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet; +\t\t\tmembershipExceptions = ( +\t\t\t\tResources/Info.plist, +\t\t\t); +\t\t\ttarget = {IDS["app"]} /* {PROJECT} */; +\t\t}}; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ +\t\t{IDS["sync_app"]} /* {PROJECT} */ = {{ +\t\t\tisa = PBXFileSystemSynchronizedRootGroup; +\t\t\texceptions = ( +\t\t\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */, +\t\t\t); +\t\t\tpath = {PROJECT}; +\t\t\tsourceTree = ""; +\t\t}}; +\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */ = {{ +\t\t\tisa = PBXFileSystemSynchronizedRootGroup; +\t\t\tpath = Tests/{PROJECT}Tests; +\t\t\tsourceTree = ""; +\t\t}}; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ +\t\t{IDS["fw_app"]} /* Frameworks */ = {{ +\t\t\tisa = PBXFrameworksBuildPhase; +\t\t\tbuildActionMask = 2147483647; +\t\t\tfiles = ( +\t\t\t); +\t\t\trunOnlyForDeploymentPostprocessing = 0; +\t\t}}; +\t\t{IDS["test_fw"]} /* Frameworks */ = {{ +\t\t\tisa = PBXFrameworksBuildPhase; +\t\t\tbuildActionMask = 2147483647; +\t\t\tfiles = ( +\t\t\t); +\t\t\trunOnlyForDeploymentPostprocessing = 0; +\t\t}}; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ +\t\t{IDS["products"]} /* Products */ = {{ +\t\t\tisa = PBXGroup; +\t\t\tchildren = ( +\t\t\t\t{IDS["app_product"]} /* {PROJECT}.app */, +\t\t\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */, +\t\t\t); +\t\t\tname = Products; +\t\t\tsourceTree = ""; +\t\t}}; +\t\t{IDS["main"]} = {{ +\t\t\tisa = PBXGroup; +\t\t\tchildren = ( +\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */, +\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */, +\t\t\t\t{IDS["products"]} /* Products */, +\t\t\t); +\t\t\tsourceTree = ""; +\t\t}}; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ +\t\t{IDS["app"]} /* {PROJECT} */ = {{ +\t\t\tisa = PBXNativeTarget; +\t\t\tbuildConfigurationList = {IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */; +\t\t\tbuildPhases = ( +\t\t\t\t{IDS["sources"]} /* Sources */, +\t\t\t\t{IDS["fw_app"]} /* Frameworks */, +\t\t\t\t{IDS["resources"]} /* Resources */, +\t\t\t); +\t\t\tbuildRules = ( +\t\t\t); +\t\t\tdependencies = ( +\t\t\t); +\t\t\tfileSystemSynchronizedGroups = ( +\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */, +\t\t\t); +\t\t\tname = {PROJECT}; +\t\t\tproductName = {PROJECT}; +\t\t\tproductReference = {IDS["app_product"]} /* {PROJECT}.app */; +\t\t\tproductType = "com.apple.product-type.application"; +\t\t}}; +\t\t{IDS["test"]} /* {PROJECT}Tests */ = {{ +\t\t\tisa = PBXNativeTarget; +\t\t\tbuildConfigurationList = {IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */; +\t\t\tbuildPhases = ( +\t\t\t\t{IDS["test_sources"]} /* Sources */, +\t\t\t\t{IDS["test_fw"]} /* Frameworks */, +\t\t\t); +\t\t\tbuildRules = ( +\t\t\t); +\t\t\tdependencies = ( +\t\t\t\t{IDS["dep"]} /* PBXTargetDependency */, +\t\t\t); +\t\t\tfileSystemSynchronizedGroups = ( +\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */, +\t\t\t); +\t\t\tname = {PROJECT}Tests; +\t\t\tproductName = {PROJECT}Tests; +\t\t\tproductReference = {IDS["test_product"]} /* {PROJECT}Tests.xctest */; +\t\t\tproductType = "com.apple.product-type.bundle.unit-test"; +\t\t}}; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ +\t\t{IDS["project"]} /* Project object */ = {{ +\t\t\tisa = PBXProject; +\t\t\tattributes = {{ +\t\t\t\tBuildIndependentTargetsInParallel = 1; +\t\t\t\tLastSwiftUpdateCheck = 2600; +\t\t\t\tLastUpgradeCheck = 2600; +\t\t\t\tTargetAttributes = {{ +\t\t\t\t\t{IDS["app"]} = {{ +\t\t\t\t\t\tCreatedOnToolsVersion = 26.0; +\t\t\t\t\t}}; +\t\t\t\t\t{IDS["test"]} = {{ +\t\t\t\t\t\tCreatedOnToolsVersion = 26.0; +\t\t\t\t\t\tTestTargetID = {IDS["app"]}; +\t\t\t\t\t}}; +\t\t\t\t}}; +\t\t\t}}; +\t\t\tbuildConfigurationList = {IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */; +\t\t\tcompatibilityVersion = "Xcode 16.0"; +\t\t\tdevelopmentRegion = en; +\t\t\thasScannedForEncodings = 0; +\t\t\tknownRegions = ( +\t\t\t\ten, +\t\t\t\tBase, +\t\t\t); +\t\t\tmainGroup = {IDS["main"]}; +\t\t\tproductRefGroup = {IDS["products"]} /* Products */; +\t\t\tprojectDirPath = ""; +\t\t\tprojectRoot = ""; +\t\t\ttargets = ( +\t\t\t\t{IDS["app"]} /* {PROJECT} */, +\t\t\t\t{IDS["test"]} /* {PROJECT}Tests */, +\t\t\t); +\t\t}}; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ +\t\t{IDS["resources"]} /* Resources */ = {{ +\t\t\tisa = PBXResourcesBuildPhase; +\t\t\tbuildActionMask = 2147483647; +\t\t\tfiles = ( +\t\t\t); +\t\t\trunOnlyForDeploymentPostprocessing = 0; +\t\t}}; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ +\t\t{IDS["sources"]} /* Sources */ = {{ +\t\t\tisa = PBXSourcesBuildPhase; +\t\t\tbuildActionMask = 2147483647; +\t\t\tfiles = ( +\t\t\t); +\t\t\trunOnlyForDeploymentPostprocessing = 0; +\t\t}}; +\t\t{IDS["test_sources"]} /* Sources */ = {{ +\t\t\tisa = PBXSourcesBuildPhase; +\t\t\tbuildActionMask = 2147483647; +\t\t\tfiles = ( +\t\t\t); +\t\t\trunOnlyForDeploymentPostprocessing = 0; +\t\t}}; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ +\t\t{IDS["dep"]} /* PBXTargetDependency */ = {{ +\t\t\tisa = PBXTargetDependency; +\t\t\ttarget = {IDS["app"]} /* {PROJECT} */; +\t\t\ttargetProxy = {IDS["proxy"]} /* PBXContainerItemProxy */; +\t\t}}; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ +\t\t{IDS["dbg_proj"]} /* Debug */ = {{ +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = {{ +\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO; +\t\t\t\tCLANG_ENABLE_MODULES = YES; +\t\t\t\tCOPY_PHASE_STRIP = NO; +\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf; +\t\t\t\tENABLE_TESTABILITY = YES; +\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0; +\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; +\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; +\t\t\t\tONLY_ACTIVE_ARCH = YES; +\t\t\t\tSDKROOT = iphoneos; +\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; +\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = "-Onone"; +\t\t\t}}; +\t\t\tname = Debug; +\t\t}}; +\t\t{IDS["rel_proj"]} /* Release */ = {{ +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = {{ +\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO; +\t\t\t\tCLANG_ENABLE_MODULES = YES; +\t\t\t\tCOPY_PHASE_STRIP = NO; +\t\t\t\tDEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; +\t\t\t\tENABLE_NS_ASSERTIONS = NO; +\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; +\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO; +\t\t\t\tSDKROOT = iphoneos; +\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule; +\t\t\t\tVALIDATE_PRODUCT = YES; +\t\t\t}}; +\t\t\tname = Release; +\t\t}}; +\t\t{IDS["dbg_app"]} /* Debug */ = {{ +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = {{ +\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; +\t\t\t\tCODE_SIGN_STYLE = Automatic; +\t\t\t\tCURRENT_PROJECT_VERSION = 1; +\t\t\t\tENABLE_PREVIEWS = YES; +\t\t\t\tGENERATE_INFOPLIST_FILE = NO; +\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist; +\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; +\t\t\t\tLD_RUNPATH_SEARCH_PATHS = ( +\t\t\t\t\t"$(inherited)", +\t\t\t\t\t"@executable_path/Frameworks", +\t\t\t\t); +\t\t\t\tMARKETING_VERSION = 1.0; +\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}; +\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)"; +\t\t\t\tSDKROOT = iphoneos; +\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; +\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES; +\t\t\t\tSWIFT_VERSION = 5.0; +\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; +\t\t\t}}; +\t\t\tname = Debug; +\t\t}}; +\t\t{IDS["rel_app"]} /* Release */ = {{ +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = {{ +\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; +\t\t\t\tCODE_SIGN_STYLE = Automatic; +\t\t\t\tCURRENT_PROJECT_VERSION = 1; +\t\t\t\tENABLE_PREVIEWS = YES; +\t\t\t\tGENERATE_INFOPLIST_FILE = NO; +\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist; +\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; +\t\t\t\tLD_RUNPATH_SEARCH_PATHS = ( +\t\t\t\t\t"$(inherited)", +\t\t\t\t\t"@executable_path/Frameworks", +\t\t\t\t); +\t\t\t\tMARKETING_VERSION = 1.0; +\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}; +\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)"; +\t\t\t\tSDKROOT = iphoneos; +\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; +\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES; +\t\t\t\tSWIFT_VERSION = 5.0; +\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; +\t\t\t}}; +\t\t\tname = Release; +\t\t}}; +\t\t{IDS["dbg_test"]} /* Debug */ = {{ +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = {{ +\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)"; +\t\t\t\tCODE_SIGN_STYLE = Automatic; +\t\t\t\tCURRENT_PROJECT_VERSION = 1; +\t\t\t\tGENERATE_INFOPLIST_FILE = YES; +\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; +\t\t\t\tMARKETING_VERSION = 1.0; +\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests; +\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)"; +\t\t\t\tSDKROOT = iphoneos; +\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; +\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO; +\t\t\t\tSWIFT_VERSION = 5.0; +\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; +\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}"; +\t\t\t}}; +\t\t\tname = Debug; +\t\t}}; +\t\t{IDS["rel_test"]} /* Release */ = {{ +\t\t\tisa = XCBuildConfiguration; +\t\t\tbuildSettings = {{ +\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)"; +\t\t\t\tCODE_SIGN_STYLE = Automatic; +\t\t\t\tCURRENT_PROJECT_VERSION = 1; +\t\t\t\tGENERATE_INFOPLIST_FILE = YES; +\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0; +\t\t\t\tMARKETING_VERSION = 1.0; +\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests; +\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)"; +\t\t\t\tSDKROOT = iphoneos; +\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; +\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO; +\t\t\t\tSWIFT_VERSION = 5.0; +\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2"; +\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}"; +\t\t\t}}; +\t\t\tname = Release; +\t\t}}; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ +\t\t{IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */ = {{ +\t\t\tisa = XCConfigurationList; +\t\t\tbuildConfigurations = ( +\t\t\t\t{IDS["dbg_proj"]} /* Debug */, +\t\t\t\t{IDS["rel_proj"]} /* Release */, +\t\t\t); +\t\t\tdefaultConfigurationIsVisible = 0; +\t\t\tdefaultConfigurationName = Release; +\t\t}}; +\t\t{IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */ = {{ +\t\t\tisa = XCConfigurationList; +\t\t\tbuildConfigurations = ( +\t\t\t\t{IDS["dbg_app"]} /* Debug */, +\t\t\t\t{IDS["rel_app"]} /* Release */, +\t\t\t); +\t\t\tdefaultConfigurationIsVisible = 0; +\t\t\tdefaultConfigurationName = Release; +\t\t}}; +\t\t{IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */ = {{ +\t\t\tisa = XCConfigurationList; +\t\t\tbuildConfigurations = ( +\t\t\t\t{IDS["dbg_test"]} /* Debug */, +\t\t\t\t{IDS["rel_test"]} /* Release */, +\t\t\t); +\t\t\tdefaultConfigurationIsVisible = 0; +\t\t\tdefaultConfigurationName = Release; +\t\t}}; +/* End XCConfigurationList section */ +\t}}; +\trootObject = {IDS["project"]} /* Project object */; +}} +''' + +out = ROOT / f"{PROJECT}.xcodeproj" / "project.pbxproj" +out.parent.mkdir(parents=True, exist_ok=True) +out.write_text(pbx) + +scheme_dir = ROOT / f"{PROJECT}.xcodeproj" / "xcshareddata" / "xcschemes" +scheme_dir.mkdir(parents=True, exist_ok=True) +scheme = f''' + + + + + + + + + + + + + + + + + + + + + +''' +(scheme_dir / f"{PROJECT}.xcscheme").write_text(scheme) +print(f"Wrote {out}") + +if __name__ == "__main__": + pass diff --git a/Software/APP/igiRadio/Tests/igiRadioTests/igiRadioTests.swift b/Software/APP/igiRadio/Tests/igiRadioTests/igiRadioTests.swift new file mode 100644 index 0000000..9b86fe1 --- /dev/null +++ b/Software/APP/igiRadio/Tests/igiRadioTests/igiRadioTests.swift @@ -0,0 +1,65 @@ +import XCTest +@testable import igiRadio + +final class HTTPDecoderTests: XCTestCase { + func testDecodeHealth() throws { + let json = """ + {"status":"ok","fw":"0.8.5","serialNumber":"CC4DB4MOCK01", + "chips":{"si4684":true,"adau1701":true,"bt1035":true}} + """.data(using: .utf8)! + let health = try JSONDecoder.api.decode(HealthResponse.self, from: json) + XCTAssertEqual(health.status, "ok") + XCTAssertEqual(health.fw, "0.8.5") + XCTAssertEqual(health.serialNumber, "CC4DB4MOCK01") + XCTAssertTrue(health.chips.si4684) + } + + func testDecodeTunerFM() throws { + let json = """ + {"booted":true,"band":"fm","locked":true,"volume":50, + "fm":{"frequency_khz":102300,"rssi_dbuv":48,"snr_db":20,"stereo":true, + "station_name":"Radio Test","radiotext":"Hello"}, + "dab":null} + """.data(using: .utf8)! + let tuner = try JSONDecoder.api.decode(TunerStatusResponse.self, from: json) + XCTAssertEqual(tuner.band, "fm") + XCTAssertEqual(tuner.fm?.frequencyKhz, 102300) + XCTAssertEqual(tuner.fm?.stationName, "Radio Test") + } + + func testDecodeAudioProfile() throws { + let json = """ + {"mixer":{"si4684_left_db":0,"si4684_right_db":0,"esp32_left_db":0,"esp32_right_db":0, + "mix_left_db":0,"mix_right_db":0}, + "master":{"left_db":10,"right_db":10}, + "eq":[{"gain_db":0,"center_hz":40,"q":1.414}], + "enhancements":{"stereo_level":5,"bass_level":7}} + """.data(using: .utf8)! + let profile = try JSONDecoder.api.decode(AudioProfileDTO.self, from: json) + XCTAssertEqual(profile.master.leftDb, 10) + XCTAssertEqual(profile.enhancements.bassLevel, 7) + } + + func testA2DPStateMapping() { + XCTAssertEqual(A2DPState(apiValue: "streaming"), .streaming) + XCTAssertEqual(A2DPState(apiValue: "unknown-value"), .unknown) + } + + func testBLENameToHost() { + XCTAssertEqual( + DigiRadioDiscoveryService.httpHost(fromBLEName: "DigiRadio-CC4DB4"), + "http://digiradio-cc4db4.local" + ) + XCTAssertNil(DigiRadioDiscoveryService.httpHost(fromBLEName: "OtherDevice")) + } +} + +final class MockDigiRadioServiceTests: XCTestCase { + func testMockConnectAndTune() async throws { + let mock = MockDigiRadioService() + try await mock.connect(host: "mock.local") + XCTAssertTrue(mock.state.connection.isConnected) + try await mock.tuneFM(frequencyKhz: 101_500) + XCTAssertEqual(mock.state.tuner.fm?.frequencyKhz, 101_500) + } +} diff --git a/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj b/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj new file mode 100644 index 0000000..cd105b9 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio.xcodeproj/project.pbxproj @@ -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 = ""; + }; + 693436FA1780106DA05D06E3 /* igiRadioTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Tests/igiRadioTests; + sourceTree = ""; + }; +/* 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 = ""; + }; + C5D6659C8B3150288BA78279 = { + isa = PBXGroup; + children = ( + F7E3CB0B027CB68EA63A4AB8 /* igiRadio */, + 693436FA1780106DA05D06E3 /* igiRadioTests */, + 2761999F42D5E1910DD2B679 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0938FB30D2E421C09D31973A /* igiRadio */ = { + isa = PBXNativeTarget; + buildConfigurationList = ECA92BA517002122145704D1 /* Build configuration list for PBXNativeTarget "igiRadio" */; + buildPhases = ( + DF1C5CA20CABFD7B8DA40E94 /* Sources */, + 71F357EA3EEC95DAEFCF7345 /* Frameworks */, + 3C8EE57839841131D4276B21 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + F7E3CB0B027CB68EA63A4AB8 /* igiRadio */, + ); + name = igiRadio; + productName = igiRadio; + productReference = 366935DDA7048CE6717FCB93 /* igiRadio.app */; + productType = "com.apple.product-type.application"; + }; + 48A9EB97D44FF615ADCA031A /* igiRadioTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */; + buildPhases = ( + F994E2BFA3D135FCD316A95C /* Sources */, + 0A8032441559F0A3B8F103F8 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 693436FA1780106DA05D06E3 /* 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 */; +} diff --git a/Software/APP/igiRadio/igiRadio.xcodeproj/xcshareddata/xcschemes/igiRadio.xcscheme b/Software/APP/igiRadio/igiRadio.xcodeproj/xcshareddata/xcschemes/igiRadio.xcscheme new file mode 100644 index 0000000..e5d884e --- /dev/null +++ b/Software/APP/igiRadio/igiRadio.xcodeproj/xcshareddata/xcschemes/igiRadio.xcscheme @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Software/APP/igiRadio/igiRadio/App/AppEnvironment.swift b/Software/APP/igiRadio/igiRadio/App/AppEnvironment.swift new file mode 100644 index 0000000..412089d --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/App/AppEnvironment.swift @@ -0,0 +1,30 @@ +import Foundation +import Observation + +/// Dependency container for igiRadio. +@Observable +final class AppEnvironment { + var useMockDevice: Bool + private(set) var digiRadio: any DigiRadioService + + var state: DigiRadioState { + digiRadio.state + } + + init(useMockDevice: Bool = { + #if DEBUG + true + #else + false + #endif + }()) { + self.useMockDevice = useMockDevice + self.digiRadio = useMockDevice ? MockDigiRadioService() : RealDigiRadioService() + } + + func setUseMock(_ enabled: Bool) { + guard useMockDevice != enabled else { return } + useMockDevice = enabled + digiRadio = enabled ? MockDigiRadioService() : RealDigiRadioService() + } +} diff --git a/Software/APP/igiRadio/igiRadio/App/igiRadioApp.swift b/Software/APP/igiRadio/igiRadio/App/igiRadioApp.swift new file mode 100644 index 0000000..8a34c86 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/App/igiRadioApp.swift @@ -0,0 +1,20 @@ +import SwiftUI + +@main +struct igiRadioApp: App { + @State private var environment = AppEnvironment() + + var body: some Scene { + WindowGroup { + RootView() + .environment(environment) + .task { + #if DEBUG + if environment.useMockDevice, !environment.state.connection.isConnected { + try? await environment.digiRadio.connect(host: "mock.local") + } + #endif + } + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Components/DesignSystem.swift b/Software/APP/igiRadio/igiRadio/Components/DesignSystem.swift new file mode 100644 index 0000000..872b132 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Components/DesignSystem.swift @@ -0,0 +1,150 @@ +import SwiftUI + +enum IGITheme { + static let cornerRadius: CGFloat = 16 + static let cardRadius: CGFloat = 20 + static let spacingXS: CGFloat = 4 + static let spacingS: CGFloat = 8 + static let spacingM: CGFloat = 16 + static let spacingL: CGFloat = 24 + static let spacingXL: CGFloat = 32 + + static let accent = Color.accentColor + static let cardBackground = Color(.secondarySystemGroupedBackground) + static let screenBackground = Color(.systemGroupedBackground) +} + +struct IGICard: View { + @ViewBuilder var content: () -> Content + + var body: some View { + content() + .padding(IGITheme.spacingM) + .background(IGITheme.cardBackground, in: RoundedRectangle(cornerRadius: IGITheme.cardRadius, style: .continuous)) + } +} + +struct IGIStatusDot: View { + var isConnected: Bool + + var body: some View { + Circle() + .fill(isConnected ? Color.green : Color.orange) + .frame(width: 10, height: 10) + .accessibilityLabel(isConnected ? "Connesso" : "Non connesso") + } +} + +struct IGIPrimaryButton: View { + var title: String + var systemImage: String? + var action: () -> Void + + var body: some View { + Button(action: action) { + Label { + Text(title) + .fontWeight(.semibold) + } icon: { + if let systemImage { + Image(systemName: systemImage) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } +} + +struct IGISignalBar: View { + var level: Double + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.2)) + Capsule() + .fill(Color.accentColor.gradient) + .frame(width: proxy.size.width * max(0, min(1, level))) + } + } + .frame(height: 8) + .accessibilityLabel("Segnale") + .accessibilityValue("\(Int(level * 100)) percento") + } +} + +struct IGISectionHeader: View { + var title: String + + var body: some View { + Text(title) + .font(.title3.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct IGIEmptyState: View { + var title: String + var message: String + var systemImage: String + + var body: some View { + VStack(spacing: IGITheme.spacingM) { + Image(systemName: systemImage) + .font(.system(size: 44)) + .foregroundStyle(.secondary) + Text(title).font(.title3.weight(.semibold)) + Text(message) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + } + .padding(IGITheme.spacingXL) + } +} + +struct IGIVolumeSlider: View { + @Binding var value: Double + var onEditingEnded: ((Double) -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: IGITheme.spacingS) { + HStack { + Image(systemName: "speaker.fill") + Slider(value: $value, in: 0 ... 100, step: 1) { editing in + if !editing { onEditingEnded?(value) } + } + Image(systemName: "speaker.wave.3.fill") + } + Text("Volume \(Int(value))") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +struct IGIScanningIndicator: View { + @State private var rotation: Double = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + Circle() + .stroke(Color.accentColor.opacity(0.2), lineWidth: 4) + Circle() + .trim(from: 0, to: 0.28) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(rotation)) + } + .frame(width: 56, height: 56) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + rotation = 360 + } + } + .accessibilityLabel("Ricerca in corso") + } +} diff --git a/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift b/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift new file mode 100644 index 0000000..e3ae4be --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Mock/MockDigiRadioService.swift @@ -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" + } +} diff --git a/Software/APP/igiRadio/igiRadio/Models/DigiRadioState.swift b/Software/APP/igiRadio/igiRadio/Models/DigiRadioState.swift new file mode 100644 index 0000000..06a9ea0 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Models/DigiRadioState.swift @@ -0,0 +1,245 @@ +import Foundation + +/// Central device snapshot — only documented API fields. +struct DigiRadioState: Equatable, Sendable { + var connection = ConnectionState() + var health = HealthState() + var tuner = TunerState() + var audio = AudioState() + var bluetooth = BluetoothState() + var stations: [Station] = [] + var streaming = StreamingState() +} + +struct ConnectionState: Equatable, Sendable { + var host: String = "" + var isConnected = false + var isConnecting = false + var lastError: String? +} + +struct HealthState: Equatable, Sendable { + var status: String = "" + var firmware: String = "" + var serialNumber: String = "" + var chips = ChipHealth() +} + +struct ChipHealth: Equatable, Sendable { + var si4684 = false + var adau1701 = false + var bt1035 = false +} + +struct TunerState: Equatable, Sendable { + var booted = false + var band: TunerBand = .fm + var locked = false + var volume: Int = 0 + var fm: FMTunerState? + var dab: DABTunerState? +} + +enum TunerBand: String, Codable, Sendable, CaseIterable { + case fm + case dab +} + +struct FMTunerState: Equatable, Sendable, Codable { + var frequencyKhz: Int + var rssiDbuv: Int? + var snrDb: Int? + var stereo: Bool? + var stationName: String? + var radiotext: String? + + enum CodingKeys: String, CodingKey { + case frequencyKhz = "frequency_khz" + case rssiDbuv = "rssi_dbuv" + case snrDb = "snr_db" + case stereo + case stationName = "station_name" + case radiotext + } +} + +struct DABTunerState: Equatable, Sendable, Codable { + var freqIndex: Int + var ficQuality: Int? + var cnrDb: Int? + var playingServiceId: Int? + var playingComponentId: Int? + var dynamicLabel: String? + + enum CodingKeys: String, CodingKey { + case freqIndex = "freq_index" + case ficQuality = "fic_quality" + case cnrDb = "cnr_db" + case playingServiceId = "playing_service_id" + case playingComponentId = "playing_component_id" + case dynamicLabel = "dynamic_label" + } +} + +struct AudioState: Equatable, Sendable { + var mixer = MixerState() + var master = MasterVolumeState() + var eq: [EQBandState] = [] + var enhancements = EnhancementsState() +} + +struct MixerState: Equatable, Sendable, Codable { + var si4684LeftDb: Double = 0 + var si4684RightDb: Double = 0 + var esp32LeftDb: Double = 0 + var esp32RightDb: Double = 0 + var mixLeftDb: Double = 0 + var mixRightDb: Double = 0 + + enum CodingKeys: String, CodingKey { + case si4684LeftDb = "si4684_left_db" + case si4684RightDb = "si4684_right_db" + case esp32LeftDb = "esp32_left_db" + case esp32RightDb = "esp32_right_db" + case mixLeftDb = "mix_left_db" + case mixRightDb = "mix_right_db" + } +} + +struct MasterVolumeState: Equatable, Sendable, Codable { + var leftDb: Double = 0 + var rightDb: Double = 0 + + enum CodingKeys: String, CodingKey { + case leftDb = "left_db" + case rightDb = "right_db" + } +} + +struct EQBandState: Equatable, 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 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..6da0343 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.984", + "green" : "0.447", + "red" : "0.227" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.584", + "red" : "0.376" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/Contents.json b/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Resources/Info.plist b/Software/APP/igiRadio/igiRadio/Resources/Info.plist new file mode 100644 index 0000000..af75307 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Resources/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + igiRadio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSBluetoothAlwaysUsageDescription + igiRadio usa Bluetooth per trovare DigiRadio in setup mode e configurare il Wi‑Fi. + NSLocalNetworkUsageDescription + igiRadio si connette a DigiRadio sulla rete locale per controllare radio e audio. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Software/APP/igiRadio/igiRadio/Services/Bluetooth/BLEProvisioningService.swift b/Software/APP/igiRadio/igiRadio/Services/Bluetooth/BLEProvisioningService.swift new file mode 100644 index 0000000..f9d4bd3 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Bluetooth/BLEProvisioningService.swift @@ -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) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioDiscoveryService.swift b/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioDiscoveryService.swift new file mode 100644 index 0000000..978816b --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioDiscoveryService.swift @@ -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] = [] + + func start() { + guard !isSearching else { return } + isSearching = true + devices = [] + ble.startScan() + logger.info("Discovery started") + } + + func stop() { + isSearching = false + ble.stopScan() + probeTasks.forEach { $0.cancel() } + probeTasks = [] + } + + func refreshFromBLE() { + let candidates = ble.discoveredDevices.compactMap { entry -> DiscoveredDevice? in + guard let host = Self.httpHost(fromBLEName: entry.name) else { return nil } + return DiscoveredDevice(name: entry.name, host: host, isReachable: false, firmware: nil) + } + + for candidate in candidates { + guard !devices.contains(where: { $0.host == candidate.host }) else { continue } + devices.append(candidate) + probe(host: candidate.host) + } + } + + func addManualHost(_ raw: String) { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + var host = trimmed + if !host.hasPrefix("http://") && !host.hasPrefix("https://") { + host = "http://\(host)" + } + guard let url = URL(string: host), let schemeHost = url.host else { return } + let normalized = "http://\(schemeHost)" + guard !devices.contains(where: { $0.host == normalized }) else { return } + let name = schemeHost + devices.append(DiscoveredDevice(name: name, host: normalized, isReachable: false, firmware: nil)) + probe(host: normalized) + } + + static func httpHost(fromBLEName name: String) -> String? { + // SoftAP / BLE name: DigiRadio-CC4DB4 → digiradio-cc4db4.local + guard name.hasPrefix("DigiRadio-") else { return nil } + let suffix = String(name.dropFirst("DigiRadio-".count)).lowercased() + guard !suffix.isEmpty else { return nil } + return "http://digiradio-\(suffix).local" + } + + private func probe(host: String) { + let task = Task { + let client = HTTPDigiRadioClient() + do { + try client.setHost(host) + let health = try await client.fetchHealth() + await MainActor.run { + if let index = devices.firstIndex(where: { $0.host == host }) { + devices[index].isReachable = health.status == "ok" + devices[index].firmware = health.fw + } + } + } catch { + await MainActor.run { + if let index = devices.firstIndex(where: { $0.host == host }) { + devices[index].isReachable = false + } + } + logger.debug("Probe failed for \(host, privacy: .public): \(error.localizedDescription)") + } + } + probeTasks.append(task) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioService.swift b/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioService.swift new file mode 100644 index 0000000..3ee7306 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Device/DigiRadioService.swift @@ -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 +} diff --git a/Software/APP/igiRadio/igiRadio/Services/Device/RealDigiRadioService.swift b/Software/APP/igiRadio/igiRadio/Services/Device/RealDigiRadioService.swift new file mode 100644 index 0000000..7cbc61e --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/Device/RealDigiRadioService.swift @@ -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 + } +} diff --git a/Software/APP/igiRadio/igiRadio/Services/DigiRadioProtocol/HTTPDigiRadioClient.swift b/Software/APP/igiRadio/igiRadio/Services/DigiRadioProtocol/HTTPDigiRadioClient.swift new file mode 100644 index 0000000..40d52f9 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Services/DigiRadioProtocol/HTTPDigiRadioClient.swift @@ -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(_ path: String) async throws -> T { + let url = try url(for: path) + var request = URLRequest(url: url) + request.httpMethod = "GET" + return try await perform(request) + } + + private func post(_ path: String, body: B) async throws -> T { + let url = try url(for: path) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder.api.encode(body) + return try await perform(request) + } + + private func put(_ path: String, body: B) async throws -> T { + let url = try url(for: path) + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder.api.encode(body) + return try await perform(request) + } + + private func perform(_ request: URLRequest) async throws -> T { + logger.debug("HTTP \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "")") + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw DigiRadioError.decodingFailed + } + guard (200 ... 299).contains(http.statusCode) else { + let reason = String(data: data, encoding: .utf8) + throw DigiRadioError.httpStatus(http.statusCode, reason) + } + do { + return try JSONDecoder.api.decode(T.self, from: data) + } catch { + logger.error("Decode failed: \(error.localizedDescription)") + throw DigiRadioError.decodingFailed + } + } catch let error as DigiRadioError { + throw error + } catch { + throw DigiRadioError.network(error) + } + } +} + +// MARK: - API DTOs + +struct EmptyBody: Codable {} + +struct HealthResponse: Codable { + var status: String + var fw: String + var serialNumber: String + var chips: ChipHealthResponse +} + +struct ChipHealthResponse: Codable { + var si4684: Bool + var adau1701: Bool + var bt1035: Bool +} + +struct TunerStatusResponse: Codable { + var booted: Bool + var band: String + var locked: Bool + var volume: Int + var fm: FMTunerState? + var dab: DABTunerState? + + enum CodingKeys: String, CodingKey { + case booted, band, locked, volume, fm, dab + } +} + +extension TunerStatusResponse { + func dabDecoded() -> DABTunerState? { + guard let dab else { return nil } + return DABTunerState( + freqIndex: dab.freqIndex, + ficQuality: dab.ficQuality, + cnrDb: dab.cnrDb, + playingServiceId: dab.playingServiceId, + playingComponentId: dab.playingComponentId, + dynamicLabel: dab.dynamicLabel + ) + } +} + +struct DABServicesResponse: Codable { + var services: [DABService] +} + +struct TuneRequest: Codable { + var band: String + var freqIndex: Int? + var frequencyKhz: Int? + + enum CodingKeys: String, CodingKey { + case band + case freqIndex = "freq_index" + case frequencyKhz = "frequency_khz" + } +} + +struct PlayRequest: Codable { + var serviceId: Int + var componentId: Int + + enum CodingKeys: String, CodingKey { + case serviceId = "service_id" + case componentId = "component_id" + } +} + +struct SeekRequest: Codable { + var direction: String +} + +struct SeekResponse: Codable { + var frequencyKhz: Int? + + enum CodingKeys: String, CodingKey { + case frequencyKhz = "frequency_khz" + } +} + +struct EnhanceRequest: Codable { + var level: Int +} + +struct StatusResponse: Codable { + var status: String +} + +struct BluetoothStatusResponse: Codable { + var booted: Bool + var pairing: Bool + var a2dp: String + var deviceName: String + var autoReconnect: Int + + enum CodingKeys: String, CodingKey { + case booted, pairing, a2dp + case deviceName = "device_name" + case autoReconnect = "auto_reconnect" + } +} + +struct SavedSpeakerResponse: Codable { + var configured: Bool + var mac: String? + var name: String? +} + +struct ScanRequest: Codable { + var seconds: Int +} + +struct BluetoothScanResponse: Codable { + var devices: [BluetoothScanDevice] +} + +struct BluetoothScanDevice: Codable { + var index: Int + var mac: String + var name: String + var rssiDbm: Int + + enum CodingKeys: String, CodingKey { + case index, mac, name + case rssiDbm = "rssi_dbm" + } +} + +struct ConnectRequest: Codable { + var mac: String + var name: String? + var save: Bool? +} + +struct StationListResponse: Codable { + var stations: [Station] +} + +struct IndexRequest: Codable { + var index: Int +} + +struct ReorderRequest: Codable { + var from: Int + var to: Int +} + +struct TunerScanRequest: Codable { + var band: String + var maxSteps: Int? + var name: String? + + enum CodingKeys: String, CodingKey { + case band + case maxSteps = "max_steps" + case name + } +} + +struct TunerScanResponse: Codable { + var status: String + var band: String? + var steps: Int? + var frequencyKhz: Int? + var stationName: String? + var freqIndex: Int? + var serviceId: Int? + var componentId: Int? + + enum CodingKeys: String, CodingKey { + case status, band, steps + case frequencyKhz = "frequency_khz" + case stationName = "station_name" + case freqIndex = "freq_index" + case serviceId = "service_id" + case componentId = "component_id" + } +} + +struct FMScanHit: Codable, Identifiable, Equatable { + var id: Int { frequencyKhz } + var frequencyKhz: Int + var rssiDbuv: Int? + var snrDb: Int? + var stationName: String? + + enum CodingKeys: String, CodingKey { + case frequencyKhz = "frequency_khz" + case rssiDbuv = "rssi_dbuv" + case snrDb = "snr_db" + case stationName = "station_name" + } +} + +struct FMScanFullResponse: Codable { + var stations: [FMScanHit] +} + +extension JSONEncoder { + static let api: JSONEncoder = { + let e = JSONEncoder() + return e + }() +} + +extension JSONDecoder { + static let api: JSONDecoder = { + let d = JSONDecoder() + return d + }() +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/ConnectionViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/ConnectionViewModel.swift new file mode 100644 index 0000000..5c76c74 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/ConnectionViewModel.swift @@ -0,0 +1,67 @@ +import Foundation +import Observation + +@Observable +final class ConnectionViewModel { + private let environment: AppEnvironment + var hostInput = "digiradio-cc4db4.local" + var isBusy = false + var errorMessage: String? + let discovery = DigiRadioDiscoveryService() + + init(environment: AppEnvironment) { + self.environment = environment + if environment.useMockDevice { + hostInput = "mock.local" + } + } + + var state: DigiRadioState { environment.state } + + func connect() async { + isBusy = true + errorMessage = nil + defer { isBusy = false } + do { + try await environment.digiRadio.connect(host: hostInput) + } catch { + errorMessage = error.localizedDescription + } + } + + func connect(to host: String) async { + hostInput = host.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "") + await connect() + } + + func disconnect() { + environment.digiRadio.disconnect() + } + + func toggleMock(_ enabled: Bool) { + environment.setUseMock(enabled) + if enabled { + hostInput = "mock.local" + discovery.stop() + Task { try? await environment.digiRadio.connect(host: hostInput) } + } else { + environment.digiRadio.disconnect() + } + } + + func startDiscovery() { + discovery.start() + discovery.addManualHost(hostInput) + } + + func stopDiscovery() { + discovery.stop() + } + + func pollDiscovery() { + discovery.refreshFromBLE() + if !hostInput.isEmpty { + discovery.addManualHost(hostInput) + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/ViewModels/HomeViewModel.swift b/Software/APP/igiRadio/igiRadio/ViewModels/HomeViewModel.swift new file mode 100644 index 0000000..73f7d22 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/ViewModels/HomeViewModel.swift @@ -0,0 +1,52 @@ +import Foundation +import Observation + +@Observable +final class HomeViewModel { + private let service: any DigiRadioService + private var refreshTask: Task? + + init(service: any DigiRadioService) { + self.service = service + } + + var state: DigiRadioState { service.state } + + func onAppear() { + refreshTask?.cancel() + refreshTask = Task { + await refreshAll() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + guard state.connection.isConnected else { continue } + try? await service.refreshTunerStatus() + } + } + } + + func onDisappear() { + refreshTask?.cancel() + refreshTask = nil + } + + func refreshAll() async { + guard state.connection.isConnected else { return } + try? await service.refreshHealth() + try? await service.refreshTunerStatus() + try? await service.refreshAudioProfile() + try? await service.refreshBluetoothStatus() + try? await service.refreshStations() + } + + func setVolume(_ value: Double) async { + try? await service.setVolume(Int(value)) + } + + func seekFM(_ direction: String) async { + try? await service.seekFM(direction: direction) + } + + func tunePreset(at index: Int) async { + try? await service.tuneStation(at: index) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/AudioView.swift b/Software/APP/igiRadio/igiRadio/Views/AudioView.swift new file mode 100644 index 0000000..d5f21f2 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/AudioView.swift @@ -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) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/BLEProvisioningView.swift b/Software/APP/igiRadio/igiRadio/Views/BLEProvisioningView.swift new file mode 100644 index 0000000..e4261ae --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/BLEProvisioningView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct BLEProvisioningView: View { + @State private var service = BLEProvisioningService() + + var body: some View { + List { + Section("Ricerca") { + switch service.phase { + case .idle: + Text("Premi Avvia per cercare dispositivi DigiRadio in setup mode.") + .foregroundStyle(.secondary) + case .scanning: + HStack { + IGIScanningIndicator().frame(width: 28, height: 28) + Text("Cerca DigiRadio...") + } + case let .found(name, _): + Label("Trovato: \(name)", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + case .unsupported: + Text("Provisioning BLE non ancora integrato in igiRadio.") + case let .error(message): + Text(message).foregroundStyle(.red) + } + } + + if !service.discoveredDevices.isEmpty { + Section("Dispositivi") { + ForEach(service.discoveredDevices, id: \.id) { device in + VStack(alignment: .leading) { + Text(device.name).font(.headline) + Text("RSSI \(device.rssi) dBm").font(.caption).foregroundStyle(.secondary) + } + } + } + } + + Section { + Button("Avvia scansione") { service.startScan() } + Button("Ferma scansione") { service.stopScan() } + } + + Section("Istruzioni") { + Text("1. Metti DigiRadio in setup mode (SoftAP DigiRadio-).") + Text("2. Il dispositivo espone anche BLE provisioning ESP-IDF.") + Text("3. Proof of Possession (PoP) = serial number del dispositivo.") + Text("4. Dopo il join Wi‑Fi, usa la connessione HTTP in igiRadio.") + } + .font(.footnote) + .foregroundStyle(.secondary) + + Section { + Text("UNKNOWN — specification required: implementazione completa protocomm Security1 in-app (attualmente consigliata app ESP BLE Provisioning di Espressif).") + .font(.footnote) + .foregroundStyle(.orange) + } + } + .navigationTitle("BLE Provisioning") + .onDisappear { service.stopScan() } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/BluetoothView.swift b/Software/APP/igiRadio/igiRadio/Views/BluetoothView.swift new file mode 100644 index 0000000..74420f2 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/BluetoothView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct BluetoothView: View { + @Environment(AppEnvironment.self) private var environment + @State private var isScanning = false + + var body: some View { + let bt = environment.state.bluetooth + Form { + Section("Stato speaker (BT1035)") { + LabeledContent("Modulo", value: bt.booted ? "Attivo" : "Non pronto") + LabeledContent("A2DP", value: bt.a2dpState.rawValue.capitalized) + LabeledContent("Dispositivo", value: bt.deviceName.isEmpty ? "—" : bt.deviceName) + if let speaker = bt.savedSpeaker { + LabeledContent("Speaker salvato", value: speaker.name) + Text(speaker.mac).font(.caption).foregroundStyle(.secondary) + } + } + + Section("Azioni") { + Button(isScanning ? "Scansione..." : "Scansiona speaker vicini") { + Task { await scan() } + } + .disabled(isScanning) + + Button("Riconnetti speaker salvato") { + Task { try? await environment.digiRadio.reconnectBluetooth() } + } + } + + if !bt.nearbyDevices.isEmpty { + Section("Dispositivi trovati") { + ForEach(bt.nearbyDevices) { device in + HStack { + VStack(alignment: .leading) { + Text(device.name).font(.headline) + Text(device.mac).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text("\(device.rssiDbm) dBm") + .font(.caption) + .foregroundStyle(.secondary) + Button("Connetti") { + Task { + try? await environment.digiRadio.connectBluetooth( + mac: device.mac, + name: device.name, + save: true + ) + } + } + .buttonStyle(.borderedProminent) + } + } + } + } + + Section { + Text("Questa sezione configura il modulo Bluetooth classic (BT1035) che invia audio A2DP verso un altoparlante esterno. È distinta dalla connessione BLE usata solo per il provisioning Wi‑Fi.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Bluetooth Audio") + .onAppear { Task { try? await environment.digiRadio.refreshBluetoothStatus() } } + } + + private func scan() async { + isScanning = true + defer { isScanning = false } + try? await environment.digiRadio.scanBluetooth(seconds: 8) + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/ConnectionView.swift b/Software/APP/igiRadio/igiRadio/Views/ConnectionView.swift new file mode 100644 index 0000000..ffe6674 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/ConnectionView.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct ConnectionView: View { + @Environment(AppEnvironment.self) private var environment + @State private var viewModel: ConnectionViewModel? + @State private var discoveryTimer: Timer? + + var body: some View { + let vm = viewModel ?? ConnectionViewModel(environment: environment) + let state = environment.state + + Form { + Section("Dispositivo") { + Toggle("Modalità demo (Mock)", isOn: Binding( + get: { environment.useMockDevice }, + set: { vm.toggleMock($0) } + )) + } + + if !environment.useMockDevice { + Section("Ricerca in rete") { + if vm.discovery.isSearching { + HStack { + IGIScanningIndicator().frame(width: 28, height: 28) + Text("Cerca DigiRadio...") + } + Button("Ferma ricerca") { stopDiscovery(vm) } + } else { + Button("Cerca dispositivi") { startDiscovery(vm) } + } + + if vm.discovery.devices.isEmpty { + Text("Avvia la ricerca per trovare DigiRadio via BLE (setup) o verificare l'host inserito.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(vm.discovery.devices) { device in + Button { + Task { await vm.connect(to: device.host) } + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(device.name).font(.headline) + Text(device.host).font(.caption).foregroundStyle(.secondary) + if let fw = device.firmware { + Text("FW \(fw)").font(.caption2).foregroundStyle(.secondary) + } + } + Spacer() + if device.isReachable { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } else { + Image(systemName: "questionmark.circle").foregroundStyle(.orange) + } + } + } + .disabled(vm.isBusy) + } + } + } + } + + Section("Connessione HTTP") { + TextField("Host", text: Binding( + get: { vm.hostInput }, + set: { vm.hostInput = $0 } + )) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + + if state.connection.isConnected { + LabeledContent("Stato", value: "Connesso") + LabeledContent("Host", value: state.connection.host) + } + + if let error = vm.errorMessage ?? state.connection.lastError { + Text(error).foregroundStyle(.red) + } + } + + Section { + if state.connection.isConnected { + Button("Disconnetti", role: .destructive) { + vm.disconnect() + } + } else { + Button { + Task { await vm.connect() } + } label: { + if vm.isBusy { + HStack { + IGIScanningIndicator().frame(width: 24, height: 24) + Text("Connessione...") + } + } else { + Text("Connetti") + } + } + .disabled(vm.isBusy) + } + } + + Section("Provisioning Wi‑Fi (BLE)") { + NavigationLink("Configura Wi‑Fi via BLE") { + BLEProvisioningView() + } + Text("DigiRadio espone BLE solo per il provisioning Wi‑Fi. Il nome BLE (es. DigiRadio-CC4DB4) corrisponde all'host HTTP digiradio-cc4db4.local.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Section("Suggerimenti host") { + Text("• Setup mode: http://192.168.4.1") + Text("• STA mode: http://digiradio-.local") + Text("• Oppure IP LAN del dispositivo") + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .navigationTitle("Connessione") + .onAppear { + if viewModel == nil { viewModel = vm } + if environment.useMockDevice && !state.connection.isConnected { + Task { await vm.connect() } + } + } + .onDisappear { stopDiscovery(vm) } + } + + private func startDiscovery(_ vm: ConnectionViewModel) { + vm.startDiscovery() + discoveryTimer?.invalidate() + discoveryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + vm.pollDiscovery() + } + } + + private func stopDiscovery(_ vm: ConnectionViewModel) { + discoveryTimer?.invalidate() + discoveryTimer = nil + vm.stopDiscovery() + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/DABRadioView.swift b/Software/APP/igiRadio/igiRadio/Views/DABRadioView.swift new file mode 100644 index 0000000..fc248f2 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/DABRadioView.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct DABRadioView: View { + @Environment(AppEnvironment.self) private var environment + @State private var freqIndex: Double = 12 + @State private var services: [DABService] = [] + @State private var loadError: String? + + var body: some View { + let dab = environment.state.tuner.dab + Form { + Section("Ensemble") { + Stepper("Freq index: \(Int(freqIndex))", value: Binding( + get: { Int(freqIndex) }, + set: { freqIndex = Double($0) } + ), in: 0 ... 37) + Button("Sintonizza ensemble") { + Task { try? await environment.digiRadio.tuneDAB(freqIndex: Int(freqIndex)) } + } + } + + if let dab { + Section("Stato DAB") { + LabeledContent("Freq index", value: "\(dab.freqIndex)") + if let fic = dab.ficQuality { LabeledContent("FIC quality", value: "\(fic)") } + if let cnr = dab.cnrDb { LabeledContent("CNR", value: "\(cnr) dB") } + if let label = dab.dynamicLabel { LabeledContent("DLS", value: label) } + } + } + + Section("Servizi") { + if let loadError { + Text(loadError).foregroundStyle(.red) + } + if services.isEmpty { + Text("Nessun servizio caricato") + .foregroundStyle(.secondary) + } else { + ForEach(services) { service in + Button { + Task { + try? await environment.digiRadio.playDAB( + serviceId: service.serviceId, + componentId: service.componentId + ) + } + } label: { + VStack(alignment: .leading) { + Text(service.label).font(.headline) + Text("SID \(service.serviceId) · CID \(service.componentId)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + Button("Carica servizi") { Task { await loadServices() } } + } + } + .navigationTitle("DAB") + .onAppear { + if let idx = environment.state.tuner.dab?.freqIndex { + freqIndex = Double(idx) + } + Task { try? await environment.digiRadio.refreshTunerStatus() } + } + } + + private func loadServices() async { + loadError = nil + do { + services = try await environment.digiRadio.listDABServices() + } catch { + loadError = error.localizedDescription + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift b/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift new file mode 100644 index 0000000..9c5c8b7 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/FMRadioView.swift @@ -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 ? "Sì" : "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 + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/HomeView.swift b/Software/APP/igiRadio/igiRadio/Views/HomeView.swift new file mode 100644 index 0000000..ed1aaa6 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/HomeView.swift @@ -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" + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/PresetsView.swift b/Software/APP/igiRadio/igiRadio/Views/PresetsView.swift new file mode 100644 index 0000000..3712e81 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/PresetsView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct PresetsView: View { + @Environment(AppEnvironment.self) private var environment + @State private var newName = "" + @State private var newBand: TunerBand = .fm + @State private var newFMKhz = "102300" + + var body: some View { + List { + Section { + ForEach(Array(environment.state.stations.enumerated()), id: \.offset) { index, station in + HStack { + VStack(alignment: .leading) { + Text(station.name).font(.headline) + Text(detail(for: station)).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("Play") { + Task { try? await environment.digiRadio.tuneStation(at: index) } + } + .buttonStyle(.bordered) + } + } + .onDelete { offsets in + for index in offsets { + Task { try? await environment.digiRadio.removeStation(at: index) } + } + } + .onMove { from, to in + guard let source = from.first else { return } + Task { try? await environment.digiRadio.reorderStation(from: source, to: to) } + } + } + + Section("Aggiungi preset") { + TextField("Nome", text: $newName) + Picker("Banda", selection: $newBand) { + ForEach(TunerBand.allCases, id: \.self) { band in + Text(band.rawValue.uppercased()).tag(band) + } + } + if newBand == .fm { + TextField("Frequenza kHz", text: $newFMKhz) + .keyboardType(.numberPad) + } + Button("Salva") { Task { await savePreset() } } + .disabled(newName.isEmpty) + } + } + .navigationTitle("Preset") + .toolbar { EditButton() } + .onAppear { Task { try? await environment.digiRadio.refreshStations() } } + } + + private func detail(for station: Station) -> String { + switch station.band { + case .fm: + if let khz = station.fmFrequencyKhz { + return String(format: "FM %.2f MHz", Double(khz) / 1000) + } + return "FM" + case .dab: + return "DAB index \(station.dabFreqIndex ?? 0)" + } + } + + private func savePreset() async { + let station: Station + switch newBand { + case .fm: + guard let khz = Int(newFMKhz) else { return } + station = Station(name: newName, band: .fm, fmFrequencyKhz: khz) + case .dab: + station = Station( + name: newName, + band: .dab, + dabFreqIndex: Int(environment.state.tuner.dab?.freqIndex ?? 0), + dabServiceId: environment.state.tuner.dab?.playingServiceId, + dabComponentId: environment.state.tuner.dab?.playingComponentId + ) + } + try? await environment.digiRadio.saveStation(station) + newName = "" + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/RootView.swift b/Software/APP/igiRadio/igiRadio/Views/RootView.swift new file mode 100644 index 0000000..d11c887 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/RootView.swift @@ -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" + } + } +} diff --git a/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift b/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift new file mode 100644 index 0000000..f841ed9 --- /dev/null +++ b/Software/APP/igiRadio/igiRadio/Views/SettingsViews.swift @@ -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 ? "Sì" : "No") + LabeledContent("Banda", value: environment.state.tuner.band.rawValue.uppercased()) + LabeledContent("Locked", value: environment.state.tuner.locked ? "Sì" : "No") + } + Section("Bluetooth") { + LabeledContent("A2DP", value: environment.state.bluetooth.a2dpState.rawValue) + LabeledContent("Pairing", value: environment.state.bluetooth.pairing ? "Sì" : "No") + } + Section { + Button("Aggiorna snapshot") { + Task { + try? await environment.digiRadio.refreshHealth() + try? await environment.digiRadio.refreshTunerStatus() + try? await environment.digiRadio.refreshBluetoothStatus() + } + } + } + } + .navigationTitle("Diagnostica") + } +} + +struct FirmwareView: View { + @Environment(AppEnvironment.self) private var environment + + var body: some View { + Form { + Section("Versione corrente") { + LabeledContent("Firmware", value: environment.state.health.firmware.isEmpty ? "—" : environment.state.health.firmware) + } + Section("Aggiornamento OTA") { + Text("L'aggiornamento firmware è supportato via POST /api/system/ota con un file .bin ESP-IDF valido. L'interfaccia di upload file sarà aggiunta in una versione successiva.") + .font(.footnote) + .foregroundStyle(.secondary) + Text("UNKNOWN — specification required: progress reporting durante OTA stream.") + .font(.footnote) + .foregroundStyle(.orange) + } + } + .navigationTitle("Firmware") + .onAppear { Task { try? await environment.digiRadio.refreshHealth() } } + } +}