Add igiRadio iOS/iPadOS app for DigiRadio control.
Introduces a new SwiftUI app with HTTP REST device control, mock mode for UI development, BLE-based discovery, and documented architecture aligned with the firmware API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# APP_ARCHITECTURE — igiRadio
|
||||
|
||||
## Stack
|
||||
|
||||
```
|
||||
SwiftUI Views
|
||||
↓ @Observable ViewModels
|
||||
DigiRadioService (protocol)
|
||||
↓
|
||||
RealDigiRadioService ──→ HTTPDigiRadioClient (URLSession)
|
||||
MockDigiRadioService ──→ dati in-memory
|
||||
|
||||
BLEProvisioningService (solo setup Wi‑Fi)
|
||||
↓ CoreBluetooth + protocomm (ESP Security1)
|
||||
```
|
||||
|
||||
## Principi
|
||||
|
||||
1. Le View **non** chiamano URLSession o CoreBluetooth direttamente.
|
||||
2. Un solo `DigiRadioState` osservabile aggiornato dal service.
|
||||
3. Errori tipizzati (`DigiRadioError`).
|
||||
4. Dependency injection via `AppEnvironment` in `igiRadioApp`.
|
||||
|
||||
## Connessione
|
||||
|
||||
| Fase | Meccanismo |
|
||||
|------|------------|
|
||||
| Prima configurazione | BLE provisioning **oppure** join SoftAP + POST /api/wifi |
|
||||
| Uso normale | mDNS `digiradio-xxxxxx.local` o IP manuale |
|
||||
| Base URL | `http://{host}/api/...` |
|
||||
|
||||
## Polling vs push
|
||||
|
||||
- Nessuna notifica BLE documentata per stato tuner.
|
||||
- `TunerViewModel` può pollare `GET /api/tuner/status` a intervallo configurabile quando la schermata radio è attiva.
|
||||
@@ -0,0 +1,45 @@
|
||||
# BLE_PROTOCOL — igiRadio
|
||||
|
||||
## Riepilogo
|
||||
|
||||
**DigiRadio non espone un protocollo BLE GATT documentato per il controllo radio/audio.**
|
||||
|
||||
Il BLE dell'ESP32-S3 è usato **solo** per il provisioning Wi‑Fi (setup iniziale).
|
||||
|
||||
---
|
||||
|
||||
## BLE Wi‑Fi Provisioning (documentato)
|
||||
|
||||
| Parametro | Valore |
|
||||
|-----------|--------|
|
||||
| Stack | ESP-IDF `wifi_provisioning` + `scheme_ble` |
|
||||
| Sicurezza | protocomm **Security1** |
|
||||
| Proof of possession | **Serial number** dispositivo (EUI-48 da EEPROM) |
|
||||
| Nome advertising | `DigiRadio-<suffix>` (es. `DigiRadio-CC4DB4`) |
|
||||
| Riferimento firmware | `components/net/src/BleProvisioning.cpp` |
|
||||
| Riferimento manuale | `ch-api.tex` §BLE provisioning |
|
||||
|
||||
### Flusso
|
||||
|
||||
1. Dispositivo in setup mode (no credenziali Wi‑Fi salvate)
|
||||
2. Advertise BLE con nome = SoftAP SSID
|
||||
3. App iOS invia credenziali Wi‑Fi via protocomm (Security1 + PoP)
|
||||
4. Firmware valida, salva in NVS, reboot in STA
|
||||
5. App passa a controllo via **HTTP** su LAN
|
||||
|
||||
### Implementazione igiRadio
|
||||
|
||||
- Usare **CoreBluetooth** solo per questa fase
|
||||
- Compatibile con app generiche "ESP BLE Provisioning" (stesso protocollo Espressif)
|
||||
- PoP richiesto dall'utente: serial da etichetta o da schermata setup SoftAP
|
||||
|
||||
### UNKNOWN
|
||||
|
||||
- UUID servizio/caratteristica custom DigiRadio per controllo: **non documentati**
|
||||
- Notifiche BLE stato tuner: **non documentate**
|
||||
|
||||
---
|
||||
|
||||
## Controllo dispositivo (post-provisioning)
|
||||
|
||||
Vedi `APP_ARCHITECTURE.md` — trasporto **HTTP REST** porta 80.
|
||||
@@ -0,0 +1,58 @@
|
||||
# DEVELOPMENT — igiRadio
|
||||
|
||||
## Requisiti
|
||||
|
||||
- macOS con **Xcode 15+** (testato Xcode 26.6)
|
||||
- iOS 17+ deployment target
|
||||
- Dispositivo DigiRadio su stessa rete Wi‑Fi (STA) o in setup SoftAP
|
||||
|
||||
## Aprire il progetto
|
||||
|
||||
```bash
|
||||
open /Users/michelebigi/Documents/Develop/DigiRadio/Software/APP/igiRadio/igiRadio.xcodeproj
|
||||
```
|
||||
|
||||
Rigenerare il progetto (se necessario):
|
||||
|
||||
```bash
|
||||
cd Software/APP/igiRadio
|
||||
python3 Scripts/generate_xcodeproj.py
|
||||
```
|
||||
|
||||
## Build e test da terminale
|
||||
|
||||
```bash
|
||||
cd Software/APP/igiRadio
|
||||
xcodebuild -scheme igiRadio -destination 'platform=iOS Simulator,name=iPhone 17' build test
|
||||
```
|
||||
|
||||
## Scheme
|
||||
|
||||
- **igiRadio** — app principale
|
||||
- **igiRadioTests** — unit test protocollo e state
|
||||
|
||||
## Mock vs reale
|
||||
|
||||
In **DEBUG**, `AppEnvironment` avvia in modalità mock (`MockDigiRadioService`).
|
||||
Disattivare in **Connessione → Modalità demo (Mock)** e inserire l'host HTTP reale.
|
||||
|
||||
## Test su hardware
|
||||
|
||||
1. Provisioning Wi‑Fi (BLE discovery in-app, o app ESP BLE Provisioning; oppure SoftAP `http://192.168.4.1`)
|
||||
2. In app: Impostazioni → Connessione → host `http://digiradio-<suffix>.local`
|
||||
3. Verificare `GET /api/health`
|
||||
|
||||
## Documentazione firmware
|
||||
|
||||
`/Users/michelebigi/Documents/Develop/DigiRadio/Software/docs/manual/ch-api.tex`
|
||||
|
||||
## Architettura runtime
|
||||
|
||||
```
|
||||
SwiftUI → ViewModel → DigiRadioService
|
||||
├── RealDigiRadioService → HTTPDigiRadioClient (REST JSON)
|
||||
└── MockDigiRadioService
|
||||
BLEProvisioningService (CoreBluetooth, solo discovery setup)
|
||||
```
|
||||
|
||||
Il controllo tuner/audio **non** usa GATT proprietario — vedi `BLE_PROTOCOL.md`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# DEVICE_STATE — DigiRadioState
|
||||
|
||||
Modello centrale in `Models/DigiRadioState.swift`.
|
||||
|
||||
## Sezioni
|
||||
|
||||
| Sezione | Campi documentati |
|
||||
|---------|-------------------|
|
||||
| `connection` | host, isConnected, lastError |
|
||||
| `health` | status, firmware, serialNumber, chips |
|
||||
| `tuner` | band, locked, volume, fm?, dab? |
|
||||
| `audio` | mixer, master, eq, enhancements |
|
||||
| `bluetooth` | booted, pairing, a2dpState, deviceName, speaker |
|
||||
| `stations` | [Station] |
|
||||
| `streaming` | enabled, url |
|
||||
|
||||
Campi non documentati nell'API **non** sono presenti nel modello.
|
||||
@@ -0,0 +1,151 @@
|
||||
# PROJECT_ANALYSIS — DigiRadio (fonte: documentazione firmware)
|
||||
|
||||
**Data analisi:** 2026-08-18
|
||||
**Fonte primaria:** `Software/docs/manual/` (non modificata)
|
||||
**Firmware di riferimento:** 0.8.5+
|
||||
|
||||
---
|
||||
|
||||
## 1. Documenti analizzati
|
||||
|
||||
| File | Contenuto |
|
||||
|------|-----------|
|
||||
| `ch-intro.tex` | Introduzione prodotto |
|
||||
| `ch-hardware.tex` | PCB, pinout, catena audio |
|
||||
| `ch-firmware.tex` | Architettura firmware ESP32-S3 |
|
||||
| `ch-si4684.tex` | Tuner FM/DAB, tune, seek, RDS, DLS |
|
||||
| `ch-adau1701.tex` | DSP SigmaStudio, mixer, EQ, safeload |
|
||||
| `ch-bt1035.tex` | Modulo BT classic A2DP (UART AT), I2S da ADAU |
|
||||
| `ch-sigmastudio.tex` | Design SigmaStudio |
|
||||
| `ch-api.tex` | **HTTP REST API** (protocollo app) |
|
||||
| `ch-classes.tex` | Classi firmware |
|
||||
| `ch-build.tex` | Build/flash |
|
||||
| `ch-licensing.tex` | Licenze |
|
||||
|
||||
Documentazione aggiuntiva letta: `Software/CLAUDE.md`, `Software/docs/security-flash-nvs.md`, `BleProvisioning.cpp`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Distinzione critica: BLE vs controllo
|
||||
|
||||
### Controllo dispositivo (tuner, audio, preset, BT config)
|
||||
|
||||
**Trasporto documentato:** HTTP JSON REST su **TCP porta 80** (Wi‑Fi).
|
||||
|
||||
- Setup mode: SoftAP `DigiRadio-<suffix>` → `http://192.168.4.1/`
|
||||
- STA mode: stessa API su IP LAN o **mDNS** `digiradio-<suffix>.local`
|
||||
- **NON esiste** un protocollo GATT proprietario documentato per tuner/audio/preset.
|
||||
|
||||
### BLE (ESP32-S3 onboard)
|
||||
|
||||
**Solo provisioning Wi‑Fi** in setup mode (`ch-api.tex` §BLE provisioning):
|
||||
|
||||
- Stack: ESP-IDF `wifi_provisioning` + `scheme_ble`
|
||||
- Sicurezza: **Security1** (protocomm)
|
||||
- **Proof of possession (PoP):** serial number dispositivo (stesso valore di `GET /api/health` → `serialNumber`)
|
||||
- **Nome servizio BLE:** SSID SoftAP = `DigiRadio-<suffix>` (es. `DigiRadio-CC4DB4`)
|
||||
- Su successo: credenziali salvate in NVS, reboot in STA
|
||||
- **Non è un endpoint HTTP** — nessuna REST call
|
||||
|
||||
### Bluetooth Audio (streaming verso speaker esterno)
|
||||
|
||||
**Modulo separato FSC-BT1035** (classic BR/EDR A2DP), controllato dal firmware via **UART AT**, esposto all'app solo tramite **HTTP** `/api/bluetooth/*`.
|
||||
|
||||
L'iPhone **non** si collega in A2DP al DigiRadio per ascoltare: il DigiRadio invia audio al Bose/Speaker via BT1035.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architettura hardware (sintesi)
|
||||
|
||||
```
|
||||
Si4684 (SPI) ──I2S──► ADAU1701 (I2S master 48 kHz) ──I2S──► BT1035 ──A2DP──► Speaker
|
||||
ESP32-S3 (Wi‑Fi/BLE, HTTP server, opz. I2S test)
|
||||
```
|
||||
|
||||
Sorgenti audio nel DSP: **Si4684** (radio), **ESP32 I2S** (stream locale / phone push).
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoint HTTP documentati (controllo app)
|
||||
|
||||
| Metodo | Path | Funzione |
|
||||
|--------|------|----------|
|
||||
| GET | `/api/health` | Stato, fw, serial, chip boot |
|
||||
| POST | `/api/wifi` | Provisioning STA (reboot) |
|
||||
| POST | `/api/wifi/scan` | Scan reti vicine |
|
||||
| GET | `/api/tuner/status` | Stato tuner FM/DAB |
|
||||
| GET | `/api/tuner/services` | Lista servizi DAB |
|
||||
| POST | `/api/tuner/tune` | Sintonia FM/DAB |
|
||||
| POST | `/api/tuner/play` | Play servizio DAB |
|
||||
| POST | `/api/tuner/seek` | Seek FM up/down |
|
||||
| POST | `/api/tuner/scan` | Scan singola stazione |
|
||||
| POST | `/api/tuner/scan/full` | Scan completo banda FM |
|
||||
| GET/PUT | `/api/audio/profile` | Profilo mixer/EQ/master |
|
||||
| POST | `/api/audio/reset` | Reset profilo factory |
|
||||
| POST | `/api/audio/stereo-enhance` | Enhancement stereo 0–100 |
|
||||
| POST | `/api/audio/bass-enhance` | Enhancement bass 0–100 |
|
||||
| POST | `/api/audio/beep` | Beep diagnostico ADAU |
|
||||
| GET | `/api/dsp/params` | Tabella parametri SigmaStudio |
|
||||
| PUT | `/api/dsp/param` | Scrittura cella raw (live) |
|
||||
| POST | `/api/dsp/program` | Upload blob DSP (reboot) |
|
||||
| POST | `/api/system/ota` | OTA firmware ESP32 (reboot) |
|
||||
| GET/POST | `/api/streaming` | Web radio MP3 URL |
|
||||
| PUT | `/api/stream/phone` | Push PCM stereo 48 kHz da phone |
|
||||
| GET | `/api/bluetooth/status` | Stato BT1035 |
|
||||
| POST | `/api/bluetooth/pair` | Modalità discoverable |
|
||||
| POST | `/api/bluetooth/pair/stop` | Esci pairing |
|
||||
| POST | `/api/bluetooth/disconnect` | Disconnect A2DP |
|
||||
| GET | `/api/bluetooth/paired` | Lista paired |
|
||||
| POST | `/api/bluetooth/scan` | Scan speaker vicini |
|
||||
| POST | `/api/bluetooth/connect` | Connect A2DP + opz. save |
|
||||
| GET/POST/DELETE | `/api/bluetooth/speaker` | Speaker default |
|
||||
| POST | `/api/bluetooth/reconnect` | Reconnect manuale |
|
||||
| POST | `/api/bluetooth/auto-reconnect` | Retry count 0–15 |
|
||||
| GET | `/api/stations` | Lista preset |
|
||||
| POST | `/api/stations` | Aggiungi preset |
|
||||
| POST | `/api/stations/remove` | Rimuovi preset |
|
||||
| POST | `/api/stations/reorder` | Riordina preset |
|
||||
| POST | `/api/stations/tune` | Richiama preset |
|
||||
|
||||
---
|
||||
|
||||
## 5. Funzionalità per schermata app
|
||||
|
||||
| Area | Supportato (documentato) | Note |
|
||||
|------|--------------------------|------|
|
||||
| Connessione Wi‑Fi | ✅ | BLE prov. + SoftAP + POST /api/wifi |
|
||||
| FM tune/seek/scan | ✅ | RSSI, SNR, RDS PS/RT |
|
||||
| DAB tune/play/services | ✅ | DLS, ensemble, service list |
|
||||
| Preset | ✅ | CRUD + reorder + recall |
|
||||
| Volume/mixer/EQ | ✅ | Via audio profile |
|
||||
| DSP raw params | ✅ | Escape hatch tecnico |
|
||||
| BT speaker pairing | ✅ | Via HTTP → BT1035 AT |
|
||||
| OTA firmware | ✅ | POST /api/system/ota |
|
||||
| OTA DSP | ✅ | POST /api/dsp/program |
|
||||
| Phone audio stream | ✅ | PUT /api/stream/phone |
|
||||
| Web radio stream | ✅ | POST /api/streaming |
|
||||
| Artwork album | ❌ | **UNKNOWN** — non in API |
|
||||
| Batteria dispositivo | ❌ | Alimentato da rete |
|
||||
| AUX/USB come sorgente UI | ❌ | Non esposto in API |
|
||||
| Notifiche push BLE stato | ❌ | Nessun GATT documentato |
|
||||
|
||||
---
|
||||
|
||||
## 6. Implicazioni per igiRadio
|
||||
|
||||
1. **DigiRadioService** → implementazione **HTTP REST** (URLSession), non GATT custom.
|
||||
2. **BLE layer** → solo **Wi‑Fi provisioning** (ESP protocomm Security1 + PoP = serial).
|
||||
3. **MockDigiRadioService** → stessa interfaccia, dati fittizi.
|
||||
4. **Discovery** → mDNS `.local`, IP manuale, o join SoftAP in setup.
|
||||
5. **Real-time** → polling selettivo su `/api/tuner/status` quando in radio; nessuna notifica BLE documentata.
|
||||
|
||||
---
|
||||
|
||||
## 7. UNKNOWN — specification required
|
||||
|
||||
| Voce | Motivo |
|
||||
|------|--------|
|
||||
| UUID GATT controllo tuner/audio | Non documentati — controllo è HTTP |
|
||||
| Artwork/metadata streaming | Non in API tuner |
|
||||
| Protocol version field | Solo `fw` in health |
|
||||
| Hardware revision API | Solo `serialNumber` + chip flags |
|
||||
@@ -0,0 +1,31 @@
|
||||
# UI_ARCHITECTURE — igiRadio
|
||||
|
||||
## iPhone
|
||||
|
||||
`TabView` con tab: Home, Radio, Presets, Audio, Settings.
|
||||
|
||||
## iPad
|
||||
|
||||
`NavigationSplitView`:
|
||||
- Sidebar: Home, FM, DAB, Presets, Bluetooth, Audio, Settings
|
||||
- Detail: contenuto selezionato
|
||||
|
||||
## Schermate
|
||||
|
||||
| Schermata | ViewModel |
|
||||
|-----------|-----------|
|
||||
| Connection | `ConnectionViewModel` |
|
||||
| Home | `HomeViewModel` |
|
||||
| FM | `FMViewModel` |
|
||||
| DAB | `DABViewModel` |
|
||||
| Presets | `PresetsViewModel` |
|
||||
| Audio | `AudioViewModel` |
|
||||
| Bluetooth | `BluetoothViewModel` |
|
||||
| Settings | `SettingsViewModel` |
|
||||
| Device Info | `DeviceViewModel` |
|
||||
| Diagnostics | `DiagnosticsViewModel` |
|
||||
| Firmware | `FirmwareViewModel` |
|
||||
|
||||
## Design System
|
||||
|
||||
`Components/DesignSystem/` — colori semantici, tipografia, card, slider, status dot.
|
||||
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate igiRadio.xcodeproj using PBXFileSystemSynchronizedRootGroup."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PROJECT = "igiRadio"
|
||||
BUNDLE = "com.digiradio.igiRadio"
|
||||
|
||||
def uid(n):
|
||||
# 24-char hex IDs (stable)
|
||||
import hashlib
|
||||
return hashlib.md5(f"igiRadio-{n}".encode()).hexdigest()[:24].upper()
|
||||
|
||||
IDS = {k: uid(k) for k in [
|
||||
"project", "main", "products", "app", "test", "app_product", "test_product",
|
||||
"sources", "resources", "fw_app", "fw_test", "test_sources", "test_fw",
|
||||
"sync_app", "sync_tests", "sync_exception", "proj_cfg", "app_cfg", "test_cfg",
|
||||
"dbg_proj", "rel_proj", "dbg_app", "rel_app", "dbg_test", "rel_test",
|
||||
"proxy", "dep",
|
||||
]}
|
||||
|
||||
pbx = f'''// !$*UTF8*$!
|
||||
{{
|
||||
\tarchiveVersion = 1;
|
||||
\tclasses = {{
|
||||
\t}};
|
||||
\tobjectVersion = 77;
|
||||
\tobjects = {{
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
\t\t{IDS["proxy"]} /* PBXContainerItemProxy */ = {{
|
||||
\t\t\tisa = PBXContainerItemProxy;
|
||||
\t\t\tcontainerPortal = {IDS["project"]} /* Project object */;
|
||||
\t\t\tproxyType = 1;
|
||||
\t\t\tremoteGlobalIDString = {IDS["app"]};
|
||||
\t\t\tremoteInfo = {PROJECT};
|
||||
\t\t}};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
\t\t{IDS["app_product"]} /* {PROJECT}.app */ = {{isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = {PROJECT}.app; sourceTree = BUILT_PRODUCTS_DIR; }};
|
||||
\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */ = {{isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = {PROJECT}Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }};
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */ = {{
|
||||
\t\t\tisa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
\t\t\tmembershipExceptions = (
|
||||
\t\t\t\tResources/Info.plist,
|
||||
\t\t\t);
|
||||
\t\t\ttarget = {IDS["app"]} /* {PROJECT} */;
|
||||
\t\t}};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
\t\t{IDS["sync_app"]} /* {PROJECT} */ = {{
|
||||
\t\t\tisa = PBXFileSystemSynchronizedRootGroup;
|
||||
\t\t\texceptions = (
|
||||
\t\t\t\t{IDS["sync_exception"]} /* Exceptions for "{PROJECT}" folder in "{PROJECT}" target */,
|
||||
\t\t\t);
|
||||
\t\t\tpath = {PROJECT};
|
||||
\t\t\tsourceTree = "<group>";
|
||||
\t\t}};
|
||||
\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */ = {{
|
||||
\t\t\tisa = PBXFileSystemSynchronizedRootGroup;
|
||||
\t\t\tpath = Tests/{PROJECT}Tests;
|
||||
\t\t\tsourceTree = "<group>";
|
||||
\t\t}};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
\t\t{IDS["fw_app"]} /* Frameworks */ = {{
|
||||
\t\t\tisa = PBXFrameworksBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
\t\t{IDS["test_fw"]} /* Frameworks */ = {{
|
||||
\t\t\tisa = PBXFrameworksBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
\t\t{IDS["products"]} /* Products */ = {{
|
||||
\t\t\tisa = PBXGroup;
|
||||
\t\t\tchildren = (
|
||||
\t\t\t\t{IDS["app_product"]} /* {PROJECT}.app */,
|
||||
\t\t\t\t{IDS["test_product"]} /* {PROJECT}Tests.xctest */,
|
||||
\t\t\t);
|
||||
\t\t\tname = Products;
|
||||
\t\t\tsourceTree = "<group>";
|
||||
\t\t}};
|
||||
\t\t{IDS["main"]} = {{
|
||||
\t\t\tisa = PBXGroup;
|
||||
\t\t\tchildren = (
|
||||
\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */,
|
||||
\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */,
|
||||
\t\t\t\t{IDS["products"]} /* Products */,
|
||||
\t\t\t);
|
||||
\t\t\tsourceTree = "<group>";
|
||||
\t\t}};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
\t\t{IDS["app"]} /* {PROJECT} */ = {{
|
||||
\t\t\tisa = PBXNativeTarget;
|
||||
\t\t\tbuildConfigurationList = {IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */;
|
||||
\t\t\tbuildPhases = (
|
||||
\t\t\t\t{IDS["sources"]} /* Sources */,
|
||||
\t\t\t\t{IDS["fw_app"]} /* Frameworks */,
|
||||
\t\t\t\t{IDS["resources"]} /* Resources */,
|
||||
\t\t\t);
|
||||
\t\t\tbuildRules = (
|
||||
\t\t\t);
|
||||
\t\t\tdependencies = (
|
||||
\t\t\t);
|
||||
\t\t\tfileSystemSynchronizedGroups = (
|
||||
\t\t\t\t{IDS["sync_app"]} /* {PROJECT} */,
|
||||
\t\t\t);
|
||||
\t\t\tname = {PROJECT};
|
||||
\t\t\tproductName = {PROJECT};
|
||||
\t\t\tproductReference = {IDS["app_product"]} /* {PROJECT}.app */;
|
||||
\t\t\tproductType = "com.apple.product-type.application";
|
||||
\t\t}};
|
||||
\t\t{IDS["test"]} /* {PROJECT}Tests */ = {{
|
||||
\t\t\tisa = PBXNativeTarget;
|
||||
\t\t\tbuildConfigurationList = {IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */;
|
||||
\t\t\tbuildPhases = (
|
||||
\t\t\t\t{IDS["test_sources"]} /* Sources */,
|
||||
\t\t\t\t{IDS["test_fw"]} /* Frameworks */,
|
||||
\t\t\t);
|
||||
\t\t\tbuildRules = (
|
||||
\t\t\t);
|
||||
\t\t\tdependencies = (
|
||||
\t\t\t\t{IDS["dep"]} /* PBXTargetDependency */,
|
||||
\t\t\t);
|
||||
\t\t\tfileSystemSynchronizedGroups = (
|
||||
\t\t\t\t{IDS["sync_tests"]} /* {PROJECT}Tests */,
|
||||
\t\t\t);
|
||||
\t\t\tname = {PROJECT}Tests;
|
||||
\t\t\tproductName = {PROJECT}Tests;
|
||||
\t\t\tproductReference = {IDS["test_product"]} /* {PROJECT}Tests.xctest */;
|
||||
\t\t\tproductType = "com.apple.product-type.bundle.unit-test";
|
||||
\t\t}};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
\t\t{IDS["project"]} /* Project object */ = {{
|
||||
\t\t\tisa = PBXProject;
|
||||
\t\t\tattributes = {{
|
||||
\t\t\t\tBuildIndependentTargetsInParallel = 1;
|
||||
\t\t\t\tLastSwiftUpdateCheck = 2600;
|
||||
\t\t\t\tLastUpgradeCheck = 2600;
|
||||
\t\t\t\tTargetAttributes = {{
|
||||
\t\t\t\t\t{IDS["app"]} = {{
|
||||
\t\t\t\t\t\tCreatedOnToolsVersion = 26.0;
|
||||
\t\t\t\t\t}};
|
||||
\t\t\t\t\t{IDS["test"]} = {{
|
||||
\t\t\t\t\t\tCreatedOnToolsVersion = 26.0;
|
||||
\t\t\t\t\t\tTestTargetID = {IDS["app"]};
|
||||
\t\t\t\t\t}};
|
||||
\t\t\t\t}};
|
||||
\t\t\t}};
|
||||
\t\t\tbuildConfigurationList = {IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */;
|
||||
\t\t\tcompatibilityVersion = "Xcode 16.0";
|
||||
\t\t\tdevelopmentRegion = en;
|
||||
\t\t\thasScannedForEncodings = 0;
|
||||
\t\t\tknownRegions = (
|
||||
\t\t\t\ten,
|
||||
\t\t\t\tBase,
|
||||
\t\t\t);
|
||||
\t\t\tmainGroup = {IDS["main"]};
|
||||
\t\t\tproductRefGroup = {IDS["products"]} /* Products */;
|
||||
\t\t\tprojectDirPath = "";
|
||||
\t\t\tprojectRoot = "";
|
||||
\t\t\ttargets = (
|
||||
\t\t\t\t{IDS["app"]} /* {PROJECT} */,
|
||||
\t\t\t\t{IDS["test"]} /* {PROJECT}Tests */,
|
||||
\t\t\t);
|
||||
\t\t}};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
\t\t{IDS["resources"]} /* Resources */ = {{
|
||||
\t\t\tisa = PBXResourcesBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
\t\t{IDS["sources"]} /* Sources */ = {{
|
||||
\t\t\tisa = PBXSourcesBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
\t\t{IDS["test_sources"]} /* Sources */ = {{
|
||||
\t\t\tisa = PBXSourcesBuildPhase;
|
||||
\t\t\tbuildActionMask = 2147483647;
|
||||
\t\t\tfiles = (
|
||||
\t\t\t);
|
||||
\t\t\trunOnlyForDeploymentPostprocessing = 0;
|
||||
\t\t}};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
\t\t{IDS["dep"]} /* PBXTargetDependency */ = {{
|
||||
\t\t\tisa = PBXTargetDependency;
|
||||
\t\t\ttarget = {IDS["app"]} /* {PROJECT} */;
|
||||
\t\t\ttargetProxy = {IDS["proxy"]} /* PBXContainerItemProxy */;
|
||||
\t\t}};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
\t\t{IDS["dbg_proj"]} /* Debug */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;
|
||||
\t\t\t\tCLANG_ENABLE_MODULES = YES;
|
||||
\t\t\t\tCOPY_PHASE_STRIP = NO;
|
||||
\t\t\t\tDEBUG_INFORMATION_FORMAT = dwarf;
|
||||
\t\t\t\tENABLE_TESTABILITY = YES;
|
||||
\t\t\t\tGCC_OPTIMIZATION_LEVEL = 0;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tMTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
\t\t\t\tONLY_ACTIVE_ARCH = YES;
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
\t\t\t\tSWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Debug;
|
||||
\t\t}};
|
||||
\t\t{IDS["rel_proj"]} /* Release */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tALWAYS_SEARCH_USER_PATHS = NO;
|
||||
\t\t\t\tCLANG_ENABLE_MODULES = YES;
|
||||
\t\t\t\tCOPY_PHASE_STRIP = NO;
|
||||
\t\t\t\tDEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
\t\t\t\tENABLE_NS_ASSERTIONS = NO;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tMTL_ENABLE_DEBUG_INFO = NO;
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSWIFT_COMPILATION_MODE = wholemodule;
|
||||
\t\t\t\tVALIDATE_PRODUCT = YES;
|
||||
\t\t\t}};
|
||||
\t\t\tname = Release;
|
||||
\t\t}};
|
||||
\t\t{IDS["dbg_app"]} /* Debug */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
\t\t\t\tCODE_SIGN_STYLE = Automatic;
|
||||
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
||||
\t\t\t\tENABLE_PREVIEWS = YES;
|
||||
\t\t\t\tGENERATE_INFOPLIST_FILE = NO;
|
||||
\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (
|
||||
\t\t\t\t\t"$(inherited)",
|
||||
\t\t\t\t\t"@executable_path/Frameworks",
|
||||
\t\t\t\t);
|
||||
\t\t\t\tMARKETING_VERSION = 1.0;
|
||||
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE};
|
||||
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;
|
||||
\t\t\t\tSWIFT_VERSION = 5.0;
|
||||
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Debug;
|
||||
\t\t}};
|
||||
\t\t{IDS["rel_app"]} /* Release */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
\t\t\t\tCODE_SIGN_STYLE = Automatic;
|
||||
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
||||
\t\t\t\tENABLE_PREVIEWS = YES;
|
||||
\t\t\t\tGENERATE_INFOPLIST_FILE = NO;
|
||||
\t\t\t\tINFOPLIST_FILE = {PROJECT}/Resources/Info.plist;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tLD_RUNPATH_SEARCH_PATHS = (
|
||||
\t\t\t\t\t"$(inherited)",
|
||||
\t\t\t\t\t"@executable_path/Frameworks",
|
||||
\t\t\t\t);
|
||||
\t\t\t\tMARKETING_VERSION = 1.0;
|
||||
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE};
|
||||
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = YES;
|
||||
\t\t\t\tSWIFT_VERSION = 5.0;
|
||||
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Release;
|
||||
\t\t}};
|
||||
\t\t{IDS["dbg_test"]} /* Debug */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)";
|
||||
\t\t\t\tCODE_SIGN_STYLE = Automatic;
|
||||
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
||||
\t\t\t\tGENERATE_INFOPLIST_FILE = YES;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tMARKETING_VERSION = 1.0;
|
||||
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests;
|
||||
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;
|
||||
\t\t\t\tSWIFT_VERSION = 5.0;
|
||||
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
|
||||
\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Debug;
|
||||
\t\t}};
|
||||
\t\t{IDS["rel_test"]} /* Release */ = {{
|
||||
\t\t\tisa = XCBuildConfiguration;
|
||||
\t\t\tbuildSettings = {{
|
||||
\t\t\t\tBUNDLE_LOADER = "$(TEST_HOST)";
|
||||
\t\t\t\tCODE_SIGN_STYLE = Automatic;
|
||||
\t\t\t\tCURRENT_PROJECT_VERSION = 1;
|
||||
\t\t\t\tGENERATE_INFOPLIST_FILE = YES;
|
||||
\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
\t\t\t\tMARKETING_VERSION = 1.0;
|
||||
\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {BUNDLE}Tests;
|
||||
\t\t\t\tPRODUCT_NAME = "$(TARGET_NAME)";
|
||||
\t\t\t\tSDKROOT = iphoneos;
|
||||
\t\t\t\tSUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
\t\t\t\tSWIFT_EMIT_LOC_STRINGS = NO;
|
||||
\t\t\t\tSWIFT_VERSION = 5.0;
|
||||
\t\t\t\tTARGETED_DEVICE_FAMILY = "1,2";
|
||||
\t\t\t\tTEST_HOST = "$(BUILT_PRODUCTS_DIR)/{PROJECT}.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/{PROJECT}";
|
||||
\t\t\t}};
|
||||
\t\t\tname = Release;
|
||||
\t\t}};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
\t\t{IDS["proj_cfg"]} /* Build configuration list for PBXProject "{PROJECT}" */ = {{
|
||||
\t\t\tisa = XCConfigurationList;
|
||||
\t\t\tbuildConfigurations = (
|
||||
\t\t\t\t{IDS["dbg_proj"]} /* Debug */,
|
||||
\t\t\t\t{IDS["rel_proj"]} /* Release */,
|
||||
\t\t\t);
|
||||
\t\t\tdefaultConfigurationIsVisible = 0;
|
||||
\t\t\tdefaultConfigurationName = Release;
|
||||
\t\t}};
|
||||
\t\t{IDS["app_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}" */ = {{
|
||||
\t\t\tisa = XCConfigurationList;
|
||||
\t\t\tbuildConfigurations = (
|
||||
\t\t\t\t{IDS["dbg_app"]} /* Debug */,
|
||||
\t\t\t\t{IDS["rel_app"]} /* Release */,
|
||||
\t\t\t);
|
||||
\t\t\tdefaultConfigurationIsVisible = 0;
|
||||
\t\t\tdefaultConfigurationName = Release;
|
||||
\t\t}};
|
||||
\t\t{IDS["test_cfg"]} /* Build configuration list for PBXNativeTarget "{PROJECT}Tests" */ = {{
|
||||
\t\t\tisa = XCConfigurationList;
|
||||
\t\t\tbuildConfigurations = (
|
||||
\t\t\t\t{IDS["dbg_test"]} /* Debug */,
|
||||
\t\t\t\t{IDS["rel_test"]} /* Release */,
|
||||
\t\t\t);
|
||||
\t\t\tdefaultConfigurationIsVisible = 0;
|
||||
\t\t\tdefaultConfigurationName = Release;
|
||||
\t\t}};
|
||||
/* End XCConfigurationList section */
|
||||
\t}};
|
||||
\trootObject = {IDS["project"]} /* Project object */;
|
||||
}}
|
||||
'''
|
||||
|
||||
out = ROOT / f"{PROJECT}.xcodeproj" / "project.pbxproj"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(pbx)
|
||||
|
||||
scheme_dir = ROOT / f"{PROJECT}.xcodeproj" / "xcshareddata" / "xcschemes"
|
||||
scheme_dir.mkdir(parents=True, exist_ok=True)
|
||||
scheme = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="2600" version="1.7">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['app']}" BuildableName="{PROJECT}.app" BlueprintName="{PROJECT}" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['test']}" BuildableName="{PROJECT}Tests.xctest" BlueprintName="{PROJECT}Tests" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{IDS['app']}" BuildableName="{PROJECT}.app" BlueprintName="{PROJECT}" ReferencedContainer="container:{PROJECT}.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
</Scheme>
|
||||
'''
|
||||
(scheme_dir / f"{PROJECT}.xcscheme").write_text(scheme)
|
||||
print(f"Wrote {out}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
@@ -0,0 +1,65 @@
|
||||
import XCTest
|
||||
@testable import igiRadio
|
||||
|
||||
final class HTTPDecoderTests: XCTestCase {
|
||||
func testDecodeHealth() throws {
|
||||
let json = """
|
||||
{"status":"ok","fw":"0.8.5","serialNumber":"CC4DB4MOCK01",
|
||||
"chips":{"si4684":true,"adau1701":true,"bt1035":true}}
|
||||
""".data(using: .utf8)!
|
||||
let health = try JSONDecoder.api.decode(HealthResponse.self, from: json)
|
||||
XCTAssertEqual(health.status, "ok")
|
||||
XCTAssertEqual(health.fw, "0.8.5")
|
||||
XCTAssertEqual(health.serialNumber, "CC4DB4MOCK01")
|
||||
XCTAssertTrue(health.chips.si4684)
|
||||
}
|
||||
|
||||
func testDecodeTunerFM() throws {
|
||||
let json = """
|
||||
{"booted":true,"band":"fm","locked":true,"volume":50,
|
||||
"fm":{"frequency_khz":102300,"rssi_dbuv":48,"snr_db":20,"stereo":true,
|
||||
"station_name":"Radio Test","radiotext":"Hello"},
|
||||
"dab":null}
|
||||
""".data(using: .utf8)!
|
||||
let tuner = try JSONDecoder.api.decode(TunerStatusResponse.self, from: json)
|
||||
XCTAssertEqual(tuner.band, "fm")
|
||||
XCTAssertEqual(tuner.fm?.frequencyKhz, 102300)
|
||||
XCTAssertEqual(tuner.fm?.stationName, "Radio Test")
|
||||
}
|
||||
|
||||
func testDecodeAudioProfile() throws {
|
||||
let json = """
|
||||
{"mixer":{"si4684_left_db":0,"si4684_right_db":0,"esp32_left_db":0,"esp32_right_db":0,
|
||||
"mix_left_db":0,"mix_right_db":0},
|
||||
"master":{"left_db":10,"right_db":10},
|
||||
"eq":[{"gain_db":0,"center_hz":40,"q":1.414}],
|
||||
"enhancements":{"stereo_level":5,"bass_level":7}}
|
||||
""".data(using: .utf8)!
|
||||
let profile = try JSONDecoder.api.decode(AudioProfileDTO.self, from: json)
|
||||
XCTAssertEqual(profile.master.leftDb, 10)
|
||||
XCTAssertEqual(profile.enhancements.bassLevel, 7)
|
||||
}
|
||||
|
||||
func testA2DPStateMapping() {
|
||||
XCTAssertEqual(A2DPState(apiValue: "streaming"), .streaming)
|
||||
XCTAssertEqual(A2DPState(apiValue: "unknown-value"), .unknown)
|
||||
}
|
||||
|
||||
func testBLENameToHost() {
|
||||
XCTAssertEqual(
|
||||
DigiRadioDiscoveryService.httpHost(fromBLEName: "DigiRadio-CC4DB4"),
|
||||
"http://digiradio-cc4db4.local"
|
||||
)
|
||||
XCTAssertNil(DigiRadioDiscoveryService.httpHost(fromBLEName: "OtherDevice"))
|
||||
}
|
||||
}
|
||||
|
||||
final class MockDigiRadioServiceTests: XCTestCase {
|
||||
func testMockConnectAndTune() async throws {
|
||||
let mock = MockDigiRadioService()
|
||||
try await mock.connect(host: "mock.local")
|
||||
XCTAssertTrue(mock.state.connection.isConnected)
|
||||
try await mock.tuneFM(frequencyKhz: 101_500)
|
||||
XCTAssertEqual(mock.state.tuner.fm?.frequencyKhz, 101_500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = D7ACBDA7D90DDE7BBA02A41B /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0938FB30D2E421C09D31973A;
|
||||
remoteInfo = igiRadio;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
366935DDA7048CE6717FCB93 /* igiRadio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = igiRadio.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = igiRadioTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
7D085337BF0EA6EB04337F45 /* Exceptions for "igiRadio" folder in "igiRadio" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Resources/Info.plist,
|
||||
);
|
||||
target = 0938FB30D2E421C09D31973A /* igiRadio */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
7D085337BF0EA6EB04337F45 /* Exceptions for "igiRadio" folder in "igiRadio" target */,
|
||||
);
|
||||
path = igiRadio;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
693436FA1780106DA05D06E3 /* igiRadioTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Tests/igiRadioTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
71F357EA3EEC95DAEFCF7345 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
0A8032441559F0A3B8F103F8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2761999F42D5E1910DD2B679 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
366935DDA7048CE6717FCB93 /* igiRadio.app */,
|
||||
97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C5D6659C8B3150288BA78279 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */,
|
||||
693436FA1780106DA05D06E3 /* igiRadioTests */,
|
||||
2761999F42D5E1910DD2B679 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
0938FB30D2E421C09D31973A /* igiRadio */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = ECA92BA517002122145704D1 /* Build configuration list for PBXNativeTarget "igiRadio" */;
|
||||
buildPhases = (
|
||||
DF1C5CA20CABFD7B8DA40E94 /* Sources */,
|
||||
71F357EA3EEC95DAEFCF7345 /* Frameworks */,
|
||||
3C8EE57839841131D4276B21 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
F7E3CB0B027CB68EA63A4AB8 /* igiRadio */,
|
||||
);
|
||||
name = igiRadio;
|
||||
productName = igiRadio;
|
||||
productReference = 366935DDA7048CE6717FCB93 /* igiRadio.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
48A9EB97D44FF615ADCA031A /* igiRadioTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */;
|
||||
buildPhases = (
|
||||
F994E2BFA3D135FCD316A95C /* Sources */,
|
||||
0A8032441559F0A3B8F103F8 /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
693436FA1780106DA05D06E3 /* igiRadioTests */,
|
||||
);
|
||||
name = igiRadioTests;
|
||||
productName = igiRadioTests;
|
||||
productReference = 97BD5E0DAD822A58093D141B /* igiRadioTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
D7ACBDA7D90DDE7BBA02A41B /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
0938FB30D2E421C09D31973A = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
48A9EB97D44FF615ADCA031A = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
TestTargetID = 0938FB30D2E421C09D31973A;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = C8A2C2BD01CBC6B9DFC6F199 /* Build configuration list for PBXProject "igiRadio" */;
|
||||
compatibilityVersion = "Xcode 16.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = C5D6659C8B3150288BA78279;
|
||||
productRefGroup = 2761999F42D5E1910DD2B679 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
0938FB30D2E421C09D31973A /* igiRadio */,
|
||||
48A9EB97D44FF615ADCA031A /* igiRadioTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
3C8EE57839841131D4276B21 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
DF1C5CA20CABFD7B8DA40E94 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F994E2BFA3D135FCD316A95C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
0E0716BAD2A3DFD42CA27715 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0938FB30D2E421C09D31973A /* igiRadio */;
|
||||
targetProxy = C050F319BDFADBF7CE6700F8 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
FDBD275B398C4976FD6BC80D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
54274B7C1572A50D5BE0DEA8 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
73C8BF7223EFFA7108969F77 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = igiRadio/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadio;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
FA1BD134133059F9163AC34F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = igiRadio/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadio;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
90735310944459417946066C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadioTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/igiRadio.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/igiRadio";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
E6AD029DFF2D9078F77BDCBA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.digiradio.igiRadioTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/igiRadio.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/igiRadio";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
C8A2C2BD01CBC6B9DFC6F199 /* Build configuration list for PBXProject "igiRadio" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
FDBD275B398C4976FD6BC80D /* Debug */,
|
||||
54274B7C1572A50D5BE0DEA8 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
ECA92BA517002122145704D1 /* Build configuration list for PBXNativeTarget "igiRadio" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
73C8BF7223EFFA7108969F77 /* Debug */,
|
||||
FA1BD134133059F9163AC34F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
61D281E9594B71DA372FEE34 /* Build configuration list for PBXNativeTarget "igiRadioTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
90735310944459417946066C /* Debug */,
|
||||
E6AD029DFF2D9078F77BDCBA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = D7ACBDA7D90DDE7BBA02A41B /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme LastUpgradeVersion="2600" version="1.7">
|
||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="0938FB30D2E421C09D31973A" BuildableName="igiRadio.app" BlueprintName="igiRadio" ReferencedContainer="container:igiRadio.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="48A9EB97D44FF615ADCA031A" BuildableName="igiRadioTests.xctest" BlueprintName="igiRadioTests" ReferencedContainer="container:igiRadio.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB">
|
||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="0938FB30D2E421C09D31973A" BuildableName="igiRadio.app" BlueprintName="igiRadio" ReferencedContainer="container:igiRadio.xcodeproj"/>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Dependency container for igiRadio.
|
||||
@Observable
|
||||
final class AppEnvironment {
|
||||
var useMockDevice: Bool
|
||||
private(set) var digiRadio: any DigiRadioService
|
||||
|
||||
var state: DigiRadioState {
|
||||
digiRadio.state
|
||||
}
|
||||
|
||||
init(useMockDevice: Bool = {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}()) {
|
||||
self.useMockDevice = useMockDevice
|
||||
self.digiRadio = useMockDevice ? MockDigiRadioService() : RealDigiRadioService()
|
||||
}
|
||||
|
||||
func setUseMock(_ enabled: Bool) {
|
||||
guard useMockDevice != enabled else { return }
|
||||
useMockDevice = enabled
|
||||
digiRadio = enabled ? MockDigiRadioService() : RealDigiRadioService()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct igiRadioApp: App {
|
||||
@State private var environment = AppEnvironment()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environment(environment)
|
||||
.task {
|
||||
#if DEBUG
|
||||
if environment.useMockDevice, !environment.state.connection.isConnected {
|
||||
try? await environment.digiRadio.connect(host: "mock.local")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
|
||||
enum IGITheme {
|
||||
static let cornerRadius: CGFloat = 16
|
||||
static let cardRadius: CGFloat = 20
|
||||
static let spacingXS: CGFloat = 4
|
||||
static let spacingS: CGFloat = 8
|
||||
static let spacingM: CGFloat = 16
|
||||
static let spacingL: CGFloat = 24
|
||||
static let spacingXL: CGFloat = 32
|
||||
|
||||
static let accent = Color.accentColor
|
||||
static let cardBackground = Color(.secondarySystemGroupedBackground)
|
||||
static let screenBackground = Color(.systemGroupedBackground)
|
||||
}
|
||||
|
||||
struct IGICard<Content: View>: View {
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding(IGITheme.spacingM)
|
||||
.background(IGITheme.cardBackground, in: RoundedRectangle(cornerRadius: IGITheme.cardRadius, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIStatusDot: View {
|
||||
var isConnected: Bool
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 10, height: 10)
|
||||
.accessibilityLabel(isConnected ? "Connesso" : "Non connesso")
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIPrimaryButton: View {
|
||||
var title: String
|
||||
var systemImage: String?
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label {
|
||||
Text(title)
|
||||
.fontWeight(.semibold)
|
||||
} icon: {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
|
||||
struct IGISignalBar: View {
|
||||
var level: Double
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.secondary.opacity(0.2))
|
||||
Capsule()
|
||||
.fill(Color.accentColor.gradient)
|
||||
.frame(width: proxy.size.width * max(0, min(1, level)))
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
.accessibilityLabel("Segnale")
|
||||
.accessibilityValue("\(Int(level * 100)) percento")
|
||||
}
|
||||
}
|
||||
|
||||
struct IGISectionHeader: View {
|
||||
var title: String
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIEmptyState: View {
|
||||
var title: String
|
||||
var message: String
|
||||
var systemImage: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(title).font(.title3.weight(.semibold))
|
||||
Text(message)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(IGITheme.spacingXL)
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIVolumeSlider: View {
|
||||
@Binding var value: Double
|
||||
var onEditingEnded: ((Double) -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingS) {
|
||||
HStack {
|
||||
Image(systemName: "speaker.fill")
|
||||
Slider(value: $value, in: 0 ... 100, step: 1) { editing in
|
||||
if !editing { onEditingEnded?(value) }
|
||||
}
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
}
|
||||
Text("Volume \(Int(value))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IGIScanningIndicator: View {
|
||||
@State private var rotation: Double = 0
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.accentColor.opacity(0.2), lineWidth: 4)
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.28)
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||
.rotationEffect(.degrees(rotation))
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
.onAppear {
|
||||
guard !reduceMotion else { return }
|
||||
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
|
||||
rotation = 360
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Ricerca in corso")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class MockDigiRadioService: DigiRadioService {
|
||||
private(set) var state = DigiRadioState()
|
||||
|
||||
init() {
|
||||
seed()
|
||||
}
|
||||
|
||||
func connect(host: String) async throws {
|
||||
try await delay()
|
||||
state.connection.host = host
|
||||
state.connection.isConnected = true
|
||||
state.connection.lastError = nil
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
state.connection = ConnectionState()
|
||||
seed()
|
||||
}
|
||||
|
||||
func refreshHealth() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshTunerStatus() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshAudioProfile() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshBluetoothStatus() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshStations() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func refreshStreaming() async throws {
|
||||
try await delay()
|
||||
}
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws {
|
||||
try await delay()
|
||||
state.tuner.band = .fm
|
||||
state.tuner.locked = true
|
||||
state.tuner.fm = FMTunerState(
|
||||
frequencyKhz: frequencyKhz,
|
||||
rssiDbuv: 48,
|
||||
snrDb: 22,
|
||||
stereo: true,
|
||||
stationName: "Radio Mock",
|
||||
radiotext: "igiRadio demo mode"
|
||||
)
|
||||
}
|
||||
|
||||
func tuneDAB(freqIndex: Int) async throws {
|
||||
try await delay()
|
||||
state.tuner.band = .dab
|
||||
state.tuner.locked = true
|
||||
state.tuner.dab = DABTunerState(
|
||||
freqIndex: freqIndex,
|
||||
ficQuality: 78,
|
||||
cnrDb: 24,
|
||||
playingServiceId: 101,
|
||||
playingComponentId: 1,
|
||||
dynamicLabel: "DAB Mock Ensemble"
|
||||
)
|
||||
}
|
||||
|
||||
func playDAB(serviceId: Int, componentId: Int) async throws {
|
||||
try await delay()
|
||||
state.tuner.dab?.playingServiceId = serviceId
|
||||
state.tuner.dab?.playingComponentId = componentId
|
||||
state.tuner.dab?.dynamicLabel = "Now playing mock service"
|
||||
}
|
||||
|
||||
func seekFM(direction: String) async throws {
|
||||
try await delay()
|
||||
guard var fm = state.tuner.fm else { return }
|
||||
let step = direction == "down" ? -100 : 100
|
||||
fm.frequencyKhz = max(64_000, min(108_000, fm.frequencyKhz + step))
|
||||
state.tuner.fm = fm
|
||||
}
|
||||
|
||||
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse {
|
||||
try await delay(1.2)
|
||||
return TunerScanResponse(
|
||||
status: "found",
|
||||
band: "fm",
|
||||
steps: 8,
|
||||
frequencyKhz: 101_500,
|
||||
stationName: name ?? "Mock FM",
|
||||
freqIndex: nil,
|
||||
serviceId: nil,
|
||||
componentId: nil
|
||||
)
|
||||
}
|
||||
|
||||
func scanFullFM() async throws -> [FMScanHit] {
|
||||
try await delay(1.5)
|
||||
return [
|
||||
FMScanHit(frequencyKhz: 97_500, rssiDbuv: 55, snrDb: 20, stationName: "Mock R1"),
|
||||
FMScanHit(frequencyKhz: 101_500, rssiDbuv: 48, snrDb: 18, stationName: "Mock R2"),
|
||||
FMScanHit(frequencyKhz: 102_300, rssiDbuv: 51, snrDb: 19, stationName: "Radio Italia")
|
||||
]
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Int) async throws {
|
||||
try await delay()
|
||||
let clamped = max(0, min(100, volume))
|
||||
state.tuner.volume = clamped
|
||||
state.audio.master.leftDb = Double(clamped)
|
||||
state.audio.master.rightDb = Double(clamped)
|
||||
}
|
||||
|
||||
func listDABServices() async throws -> [DABService] {
|
||||
try await delay()
|
||||
return [
|
||||
DABService(serviceId: 101, componentId: 1, label: "Mock Radio 1"),
|
||||
DABService(serviceId: 102, componentId: 1, label: "Mock Radio 2"),
|
||||
DABService(serviceId: 103, componentId: 1, label: "Mock Jazz")
|
||||
]
|
||||
}
|
||||
|
||||
func saveStation(_ station: Station) async throws {
|
||||
try await delay()
|
||||
state.stations.append(station)
|
||||
}
|
||||
|
||||
func removeStation(at index: Int) async throws {
|
||||
try await delay()
|
||||
guard state.stations.indices.contains(index) else { return }
|
||||
state.stations.remove(at: index)
|
||||
}
|
||||
|
||||
func reorderStation(from: Int, to: Int) async throws {
|
||||
try await delay()
|
||||
guard state.stations.indices.contains(from), state.stations.indices.contains(to) else { return }
|
||||
let item = state.stations.remove(at: from)
|
||||
state.stations.insert(item, at: to)
|
||||
}
|
||||
|
||||
func tuneStation(at index: Int) async throws {
|
||||
try await delay()
|
||||
guard state.stations.indices.contains(index) else { return }
|
||||
let station = state.stations[index]
|
||||
switch station.band {
|
||||
case .fm:
|
||||
if let freq = station.fmFrequencyKhz { try await tuneFM(frequencyKhz: freq) }
|
||||
case .dab:
|
||||
if let idx = station.dabFreqIndex { try await tuneDAB(freqIndex: idx) }
|
||||
if let sid = station.dabServiceId, let cid = station.dabComponentId {
|
||||
try await playDAB(serviceId: sid, componentId: cid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyAudioProfile(_ profile: AudioProfileDTO) async throws {
|
||||
try await delay()
|
||||
state.audio.mixer = profile.mixer
|
||||
state.audio.master = profile.master
|
||||
state.audio.eq = profile.eq
|
||||
state.audio.enhancements = profile.enhancements
|
||||
}
|
||||
|
||||
func setStereoEnhance(level: Int) async throws {
|
||||
try await delay()
|
||||
state.audio.enhancements.stereoLevel = level
|
||||
}
|
||||
|
||||
func setBassEnhance(level: Int) async throws {
|
||||
try await delay()
|
||||
state.audio.enhancements.bassLevel = level
|
||||
}
|
||||
|
||||
func resetAudio() async throws {
|
||||
try await delay()
|
||||
state.audio = AudioState()
|
||||
}
|
||||
|
||||
func scanBluetooth(seconds: Int) async throws {
|
||||
try await delay(0.8)
|
||||
state.bluetooth.nearbyDevices = [
|
||||
BluetoothDevice(index: 0, mac: "AA:BB:CC:DD:EE:FF", name: "Bose Solo II", rssiDbm: -52),
|
||||
BluetoothDevice(index: 1, mac: "11:22:33:44:55:66", name: "Living Room", rssiDbm: -68)
|
||||
]
|
||||
}
|
||||
|
||||
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
|
||||
try await delay()
|
||||
state.bluetooth.a2dpState = .streaming
|
||||
state.bluetooth.deviceName = name ?? "Speaker"
|
||||
if save {
|
||||
state.bluetooth.savedSpeaker = SavedSpeaker(mac: mac, name: name ?? "Speaker")
|
||||
}
|
||||
}
|
||||
|
||||
func reconnectBluetooth() async throws {
|
||||
try await delay()
|
||||
state.bluetooth.a2dpState = .streaming
|
||||
}
|
||||
|
||||
private func delay(_ seconds: Double = 0.35) async throws {
|
||||
try await Task.sleep(for: .seconds(seconds))
|
||||
}
|
||||
|
||||
private func seed() {
|
||||
state.health = HealthState(
|
||||
status: "ok",
|
||||
firmware: "0.8.5-mock",
|
||||
serialNumber: "CC4DB4MOCK01",
|
||||
chips: ChipHealth(si4684: true, adau1701: true, bt1035: true)
|
||||
)
|
||||
state.tuner = TunerState(
|
||||
booted: true,
|
||||
band: .fm,
|
||||
locked: true,
|
||||
volume: 42,
|
||||
fm: FMTunerState(
|
||||
frequencyKhz: 102_300,
|
||||
rssiDbuv: 51,
|
||||
snrDb: 19,
|
||||
stereo: true,
|
||||
stationName: "Radio Italia",
|
||||
radiotext: "Mock mode — nessun hardware collegato"
|
||||
),
|
||||
dab: nil
|
||||
)
|
||||
state.audio = AudioState(
|
||||
mixer: MixerState(),
|
||||
master: MasterVolumeState(leftDb: 42, rightDb: 42),
|
||||
eq: (0 ..< 6).map { i in
|
||||
EQBandState(index: i, gainDb: 0, centerHz: [40, 120, 400, 1000, 3000, 10000][i], q: 1.414)
|
||||
},
|
||||
enhancements: EnhancementsState(stereoLevel: 20, bassLevel: 15)
|
||||
)
|
||||
state.bluetooth = BluetoothState(
|
||||
booted: true,
|
||||
pairing: false,
|
||||
a2dpState: .streaming,
|
||||
deviceName: "Bose Solo II",
|
||||
autoReconnect: 5,
|
||||
savedSpeaker: SavedSpeaker(mac: "BC:87:FA:E6:9D:6E", name: "Bose Solo II")
|
||||
)
|
||||
state.stations = [
|
||||
Station(name: "Radio Italia", band: .fm, fmFrequencyKhz: 102_300),
|
||||
Station(name: "RDS", band: .fm, fmFrequencyKhz: 97_500)
|
||||
]
|
||||
state.streaming = StreamingState(enabled: false, url: "")
|
||||
state.connection.isConnected = true
|
||||
state.connection.host = "digiradio-mock.local"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
|
||||
/// Central device snapshot — only documented API fields.
|
||||
struct DigiRadioState: Equatable, Sendable {
|
||||
var connection = ConnectionState()
|
||||
var health = HealthState()
|
||||
var tuner = TunerState()
|
||||
var audio = AudioState()
|
||||
var bluetooth = BluetoothState()
|
||||
var stations: [Station] = []
|
||||
var streaming = StreamingState()
|
||||
}
|
||||
|
||||
struct ConnectionState: Equatable, Sendable {
|
||||
var host: String = ""
|
||||
var isConnected = false
|
||||
var isConnecting = false
|
||||
var lastError: String?
|
||||
}
|
||||
|
||||
struct HealthState: Equatable, Sendable {
|
||||
var status: String = ""
|
||||
var firmware: String = ""
|
||||
var serialNumber: String = ""
|
||||
var chips = ChipHealth()
|
||||
}
|
||||
|
||||
struct ChipHealth: Equatable, Sendable {
|
||||
var si4684 = false
|
||||
var adau1701 = false
|
||||
var bt1035 = false
|
||||
}
|
||||
|
||||
struct TunerState: Equatable, Sendable {
|
||||
var booted = false
|
||||
var band: TunerBand = .fm
|
||||
var locked = false
|
||||
var volume: Int = 0
|
||||
var fm: FMTunerState?
|
||||
var dab: DABTunerState?
|
||||
}
|
||||
|
||||
enum TunerBand: String, Codable, Sendable, CaseIterable {
|
||||
case fm
|
||||
case dab
|
||||
}
|
||||
|
||||
struct FMTunerState: Equatable, Sendable, Codable {
|
||||
var frequencyKhz: Int
|
||||
var rssiDbuv: Int?
|
||||
var snrDb: Int?
|
||||
var stereo: Bool?
|
||||
var stationName: String?
|
||||
var radiotext: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case frequencyKhz = "frequency_khz"
|
||||
case rssiDbuv = "rssi_dbuv"
|
||||
case snrDb = "snr_db"
|
||||
case stereo
|
||||
case stationName = "station_name"
|
||||
case radiotext
|
||||
}
|
||||
}
|
||||
|
||||
struct DABTunerState: Equatable, Sendable, Codable {
|
||||
var freqIndex: Int
|
||||
var ficQuality: Int?
|
||||
var cnrDb: Int?
|
||||
var playingServiceId: Int?
|
||||
var playingComponentId: Int?
|
||||
var dynamicLabel: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case freqIndex = "freq_index"
|
||||
case ficQuality = "fic_quality"
|
||||
case cnrDb = "cnr_db"
|
||||
case playingServiceId = "playing_service_id"
|
||||
case playingComponentId = "playing_component_id"
|
||||
case dynamicLabel = "dynamic_label"
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioState: Equatable, Sendable {
|
||||
var mixer = MixerState()
|
||||
var master = MasterVolumeState()
|
||||
var eq: [EQBandState] = []
|
||||
var enhancements = EnhancementsState()
|
||||
}
|
||||
|
||||
struct MixerState: Equatable, Sendable, Codable {
|
||||
var si4684LeftDb: Double = 0
|
||||
var si4684RightDb: Double = 0
|
||||
var esp32LeftDb: Double = 0
|
||||
var esp32RightDb: Double = 0
|
||||
var mixLeftDb: Double = 0
|
||||
var mixRightDb: Double = 0
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case si4684LeftDb = "si4684_left_db"
|
||||
case si4684RightDb = "si4684_right_db"
|
||||
case esp32LeftDb = "esp32_left_db"
|
||||
case esp32RightDb = "esp32_right_db"
|
||||
case mixLeftDb = "mix_left_db"
|
||||
case mixRightDb = "mix_right_db"
|
||||
}
|
||||
}
|
||||
|
||||
struct MasterVolumeState: Equatable, Sendable, Codable {
|
||||
var leftDb: Double = 0
|
||||
var rightDb: Double = 0
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case leftDb = "left_db"
|
||||
case rightDb = "right_db"
|
||||
}
|
||||
}
|
||||
|
||||
struct EQBandState: Equatable, Sendable, Codable, Identifiable {
|
||||
var id: Int { index }
|
||||
var index: Int
|
||||
var gainDb: Double
|
||||
var centerHz: Double
|
||||
var q: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case gainDb = "gain_db"
|
||||
case centerHz = "center_hz"
|
||||
case q
|
||||
}
|
||||
|
||||
init(index: Int, gainDb: Double, centerHz: Double, q: Double) {
|
||||
self.index = index
|
||||
self.gainDb = gainDb
|
||||
self.centerHz = centerHz
|
||||
self.q = q
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
index = 0
|
||||
gainDb = try container.decode(Double.self, forKey: .gainDb)
|
||||
centerHz = try container.decode(Double.self, forKey: .centerHz)
|
||||
q = try container.decode(Double.self, forKey: .q)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(gainDb, forKey: .gainDb)
|
||||
try container.encode(centerHz, forKey: .centerHz)
|
||||
try container.encode(q, forKey: .q)
|
||||
}
|
||||
}
|
||||
|
||||
struct EnhancementsState: Equatable, Sendable, Codable {
|
||||
var stereoLevel: Int = 0
|
||||
var bassLevel: Int = 0
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case stereoLevel = "stereo_level"
|
||||
case bassLevel = "bass_level"
|
||||
}
|
||||
}
|
||||
|
||||
struct BluetoothState: Equatable, Sendable {
|
||||
var booted = false
|
||||
var pairing = false
|
||||
var a2dpState: A2DPState = .unknown
|
||||
var deviceName: String = ""
|
||||
var autoReconnect: Int = 0
|
||||
var savedSpeaker: SavedSpeaker?
|
||||
var nearbyDevices: [BluetoothDevice] = []
|
||||
var pairedDevices: [BluetoothPairedDevice] = []
|
||||
}
|
||||
|
||||
enum A2DPState: String, Sendable {
|
||||
case standby, connecting, connected, streaming, paused, unknown
|
||||
|
||||
init(apiValue: String) {
|
||||
switch apiValue.lowercased() {
|
||||
case "standby": self = .standby
|
||||
case "connecting": self = .connecting
|
||||
case "connected": self = .connected
|
||||
case "streaming": self = .streaming
|
||||
case "paused": self = .paused
|
||||
default: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedSpeaker: Equatable, Sendable {
|
||||
var mac: String
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct BluetoothDevice: Equatable, Sendable, Identifiable {
|
||||
var id: String { mac }
|
||||
var index: Int
|
||||
var mac: String
|
||||
var name: String
|
||||
var rssiDbm: Int
|
||||
}
|
||||
|
||||
struct BluetoothPairedDevice: Equatable, Sendable, Identifiable {
|
||||
var id: String { mac }
|
||||
var index: Int
|
||||
var mac: String
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct Station: Equatable, Sendable, Codable, Identifiable {
|
||||
var id: String { "\(band.rawValue)-\(name)-\(fmFrequencyKhz ?? dabFreqIndex ?? 0)" }
|
||||
var name: String
|
||||
var band: TunerBand
|
||||
var dabFreqIndex: Int?
|
||||
var dabServiceId: Int?
|
||||
var dabComponentId: Int?
|
||||
var fmFrequencyKhz: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, band
|
||||
case dabFreqIndex = "dab_freq_index"
|
||||
case dabServiceId = "dab_service_id"
|
||||
case dabComponentId = "dab_component_id"
|
||||
case fmFrequencyKhz = "fm_frequency_khz"
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamingState: Equatable, Sendable, Codable {
|
||||
var enabled = false
|
||||
var url: String = ""
|
||||
}
|
||||
|
||||
struct DABService: Equatable, Sendable, Identifiable, Codable {
|
||||
var id: String { "\(serviceId)-\(componentId)" }
|
||||
var serviceId: Int
|
||||
var componentId: Int
|
||||
var label: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case serviceId = "service_id"
|
||||
case componentId = "component_id"
|
||||
case label
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.984",
|
||||
"green" : "0.447",
|
||||
"red" : "0.227"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "0.584",
|
||||
"red" : "0.376"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>igiRadio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>igiRadio usa Bluetooth per trovare DigiRadio in setup mode e configurare il Wi‑Fi.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>igiRadio si connette a DigiRadio sulla rete locale per controllare radio e audio.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,73 @@
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Discovers DigiRadio BLE provisioning endpoints (ESP-IDF wifi_provisioning / scheme_ble).
|
||||
/// Full credential exchange uses Espressif protocomm Security1 (PoP = device serial).
|
||||
/// This service performs discovery only; credential provisioning UI is documented in BLE_PROTOCOL.md.
|
||||
@Observable
|
||||
final class BLEProvisioningService: NSObject {
|
||||
enum Phase: Equatable {
|
||||
case idle
|
||||
case scanning
|
||||
case found(name: String, identifier: UUID)
|
||||
case unsupported
|
||||
case error(String)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .idle
|
||||
private(set) var discoveredDevices: [(name: String, id: UUID, rssi: Int)] = []
|
||||
|
||||
private var central: CBCentralManager?
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "BLE")
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
central = CBCentralManager(delegate: self, queue: .main)
|
||||
}
|
||||
|
||||
func startScan() {
|
||||
guard let central else { return }
|
||||
discoveredDevices = []
|
||||
phase = .scanning
|
||||
guard central.state == .poweredOn else {
|
||||
phase = .error("Bluetooth non disponibile")
|
||||
return
|
||||
}
|
||||
central.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
||||
logger.info("BLE scan started")
|
||||
}
|
||||
|
||||
func stopScan() {
|
||||
central?.stopScan()
|
||||
if case .scanning = phase {
|
||||
phase = discoveredDevices.isEmpty ? .idle : .found(name: discoveredDevices[0].name, identifier: discoveredDevices[0].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension BLEProvisioningService: CBCentralManagerDelegate {
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
if central.state != .poweredOn, case .scanning = phase {
|
||||
phase = .error("Bluetooth spento o non autorizzato")
|
||||
central.stopScan()
|
||||
}
|
||||
}
|
||||
|
||||
func centralManager(
|
||||
_ central: CBCentralManager,
|
||||
didDiscover peripheral: CBPeripheral,
|
||||
advertisementData: [String: Any],
|
||||
rssi RSSI: NSNumber
|
||||
) {
|
||||
let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? ""
|
||||
guard name.hasPrefix("DigiRadio") else { return }
|
||||
let entry = (name: name, id: peripheral.identifier, rssi: RSSI.intValue)
|
||||
if !discoveredDevices.contains(where: { $0.id == entry.id }) {
|
||||
discoveredDevices.append(entry)
|
||||
logger.debug("Found \(name, privacy: .public) RSSI \(RSSI.intValue)")
|
||||
}
|
||||
phase = .found(name: name, identifier: peripheral.identifier)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Maps BLE setup names (DigiRadio-XXXX) to HTTP hosts and verifies reachability.
|
||||
@Observable
|
||||
final class DigiRadioDiscoveryService {
|
||||
struct DiscoveredDevice: Identifiable, Equatable {
|
||||
var id: String { host }
|
||||
var name: String
|
||||
var host: String
|
||||
var isReachable: Bool
|
||||
var firmware: String?
|
||||
}
|
||||
|
||||
private(set) var devices: [DiscoveredDevice] = []
|
||||
private(set) var isSearching = false
|
||||
|
||||
private let ble = BLEProvisioningService()
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "Discovery")
|
||||
private var probeTasks: [Task<Void, Never>] = []
|
||||
|
||||
func start() {
|
||||
guard !isSearching else { return }
|
||||
isSearching = true
|
||||
devices = []
|
||||
ble.startScan()
|
||||
logger.info("Discovery started")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
isSearching = false
|
||||
ble.stopScan()
|
||||
probeTasks.forEach { $0.cancel() }
|
||||
probeTasks = []
|
||||
}
|
||||
|
||||
func refreshFromBLE() {
|
||||
let candidates = ble.discoveredDevices.compactMap { entry -> DiscoveredDevice? in
|
||||
guard let host = Self.httpHost(fromBLEName: entry.name) else { return nil }
|
||||
return DiscoveredDevice(name: entry.name, host: host, isReachable: false, firmware: nil)
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
guard !devices.contains(where: { $0.host == candidate.host }) else { continue }
|
||||
devices.append(candidate)
|
||||
probe(host: candidate.host)
|
||||
}
|
||||
}
|
||||
|
||||
func addManualHost(_ raw: String) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
var host = trimmed
|
||||
if !host.hasPrefix("http://") && !host.hasPrefix("https://") {
|
||||
host = "http://\(host)"
|
||||
}
|
||||
guard let url = URL(string: host), let schemeHost = url.host else { return }
|
||||
let normalized = "http://\(schemeHost)"
|
||||
guard !devices.contains(where: { $0.host == normalized }) else { return }
|
||||
let name = schemeHost
|
||||
devices.append(DiscoveredDevice(name: name, host: normalized, isReachable: false, firmware: nil))
|
||||
probe(host: normalized)
|
||||
}
|
||||
|
||||
static func httpHost(fromBLEName name: String) -> String? {
|
||||
// SoftAP / BLE name: DigiRadio-CC4DB4 → digiradio-cc4db4.local
|
||||
guard name.hasPrefix("DigiRadio-") else { return nil }
|
||||
let suffix = String(name.dropFirst("DigiRadio-".count)).lowercased()
|
||||
guard !suffix.isEmpty else { return nil }
|
||||
return "http://digiradio-\(suffix).local"
|
||||
}
|
||||
|
||||
private func probe(host: String) {
|
||||
let task = Task {
|
||||
let client = HTTPDigiRadioClient()
|
||||
do {
|
||||
try client.setHost(host)
|
||||
let health = try await client.fetchHealth()
|
||||
await MainActor.run {
|
||||
if let index = devices.firstIndex(where: { $0.host == host }) {
|
||||
devices[index].isReachable = health.status == "ok"
|
||||
devices[index].firmware = health.fw
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
if let index = devices.firstIndex(where: { $0.host == host }) {
|
||||
devices[index].isReachable = false
|
||||
}
|
||||
}
|
||||
logger.debug("Probe failed for \(host, privacy: .public): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
probeTasks.append(task)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
enum DigiRadioError: LocalizedError, Equatable {
|
||||
case notConnected
|
||||
case invalidHost
|
||||
case httpStatus(Int, String?)
|
||||
case decodingFailed
|
||||
case network(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "Non connesso a DigiRadio."
|
||||
case .invalidHost: return "Host non valido."
|
||||
case let .httpStatus(code, reason): return "HTTP \(code): \(reason ?? "errore")"
|
||||
case .decodingFailed: return "Risposta non valida dal dispositivo."
|
||||
case let .network(error): return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: DigiRadioError, rhs: DigiRadioError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.notConnected, .notConnected), (.invalidHost, .invalidHost), (.decodingFailed, .decodingFailed):
|
||||
return true
|
||||
case let (.httpStatus(a, b), .httpStatus(c, d)):
|
||||
return a == c && b == d
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level API for controlling DigiRadio (HTTP REST per firmware docs).
|
||||
protocol DigiRadioService: AnyObject {
|
||||
var state: DigiRadioState { get }
|
||||
|
||||
func connect(host: String) async throws
|
||||
func disconnect()
|
||||
func refreshHealth() async throws
|
||||
func refreshTunerStatus() async throws
|
||||
func refreshAudioProfile() async throws
|
||||
func refreshBluetoothStatus() async throws
|
||||
func refreshStations() async throws
|
||||
func refreshStreaming() async throws
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws
|
||||
func tuneDAB(freqIndex: Int) async throws
|
||||
func playDAB(serviceId: Int, componentId: Int) async throws
|
||||
func seekFM(direction: String) async throws
|
||||
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse
|
||||
func scanFullFM() async throws -> [FMScanHit]
|
||||
func setVolume(_ volume: Int) async throws
|
||||
func listDABServices() async throws -> [DABService]
|
||||
|
||||
func saveStation(_ station: Station) async throws
|
||||
func removeStation(at index: Int) async throws
|
||||
func reorderStation(from: Int, to: Int) async throws
|
||||
func tuneStation(at index: Int) async throws
|
||||
|
||||
func applyAudioProfile(_ profile: AudioProfileDTO) async throws
|
||||
func setStereoEnhance(level: Int) async throws
|
||||
func setBassEnhance(level: Int) async throws
|
||||
func resetAudio() async throws
|
||||
|
||||
func scanBluetooth(seconds: Int) async throws
|
||||
func connectBluetooth(mac: String, name: String?, save: Bool) async throws
|
||||
func reconnectBluetooth() async throws
|
||||
}
|
||||
|
||||
/// Wire DTO for PUT /api/audio/profile
|
||||
struct AudioProfileDTO: Codable, Equatable, Sendable {
|
||||
var mixer: MixerState
|
||||
var master: MasterVolumeState
|
||||
var eq: [EQBandState]
|
||||
var enhancements: EnhancementsState
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@Observable
|
||||
final class RealDigiRadioService: DigiRadioService {
|
||||
private(set) var state = DigiRadioState()
|
||||
private let client = HTTPDigiRadioClient()
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "Device")
|
||||
|
||||
func connect(host: String) async throws {
|
||||
state.connection.isConnecting = true
|
||||
state.connection.lastError = nil
|
||||
defer { state.connection.isConnecting = false }
|
||||
|
||||
try client.setHost(host)
|
||||
state.connection.host = host
|
||||
try await refreshHealth()
|
||||
state.connection.isConnected = true
|
||||
logger.info("Connected to \(host, privacy: .public)")
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
client.clearHost()
|
||||
state.connection = ConnectionState()
|
||||
}
|
||||
|
||||
func refreshHealth() async throws {
|
||||
let health = try await client.fetchHealth()
|
||||
state.health.status = health.status
|
||||
state.health.firmware = health.fw
|
||||
state.health.serialNumber = health.serialNumber
|
||||
state.health.chips = ChipHealth(
|
||||
si4684: health.chips.si4684,
|
||||
adau1701: health.chips.adau1701,
|
||||
bt1035: health.chips.bt1035
|
||||
)
|
||||
}
|
||||
|
||||
func refreshTunerStatus() async throws {
|
||||
let tuner = try await client.fetchTunerStatus()
|
||||
applyTuner(tuner)
|
||||
}
|
||||
|
||||
func refreshAudioProfile() async throws {
|
||||
let profile = try await client.fetchAudioProfile()
|
||||
state.audio.mixer = profile.mixer
|
||||
state.audio.master = profile.master
|
||||
state.audio.eq = profile.eq.enumerated().map { index, band in
|
||||
EQBandState(index: index, gainDb: band.gainDb, centerHz: band.centerHz, q: band.q)
|
||||
}
|
||||
state.audio.enhancements = profile.enhancements
|
||||
}
|
||||
|
||||
func refreshBluetoothStatus() async throws {
|
||||
let bt = try await client.fetchBluetoothStatus()
|
||||
state.bluetooth.booted = bt.booted
|
||||
state.bluetooth.pairing = bt.pairing
|
||||
state.bluetooth.a2dpState = A2DPState(apiValue: bt.a2dp)
|
||||
state.bluetooth.deviceName = bt.deviceName
|
||||
state.bluetooth.autoReconnect = bt.autoReconnect
|
||||
|
||||
let speaker = try await client.fetchSavedSpeaker()
|
||||
if speaker.configured, let mac = speaker.mac, let name = speaker.name {
|
||||
state.bluetooth.savedSpeaker = SavedSpeaker(mac: mac, name: name)
|
||||
} else {
|
||||
state.bluetooth.savedSpeaker = nil
|
||||
}
|
||||
}
|
||||
|
||||
func refreshStations() async throws {
|
||||
let list = try await client.fetchStations()
|
||||
state.stations = list.stations
|
||||
}
|
||||
|
||||
func refreshStreaming() async throws {
|
||||
state.streaming = try await client.fetchStreaming()
|
||||
}
|
||||
|
||||
func tuneFM(frequencyKhz: Int) async throws {
|
||||
let tuner = try await client.tune(body: TuneRequest(band: "fm", freqIndex: nil, frequencyKhz: frequencyKhz))
|
||||
applyTuner(tuner)
|
||||
}
|
||||
|
||||
func tuneDAB(freqIndex: Int) async throws {
|
||||
let tuner = try await client.tune(body: TuneRequest(band: "dab", freqIndex: freqIndex, frequencyKhz: nil))
|
||||
applyTuner(tuner)
|
||||
}
|
||||
|
||||
func playDAB(serviceId: Int, componentId: Int) async throws {
|
||||
try await client.playDAB(body: PlayRequest(serviceId: serviceId, componentId: componentId))
|
||||
try await refreshTunerStatus()
|
||||
}
|
||||
|
||||
func seekFM(direction: String) async throws {
|
||||
_ = try await client.seekFM(direction: direction)
|
||||
try await refreshTunerStatus()
|
||||
}
|
||||
|
||||
func scanFM(maxSteps: Int, name: String?) async throws -> TunerScanResponse {
|
||||
try await client.scanStation(body: TunerScanRequest(band: "fm", maxSteps: maxSteps, name: name))
|
||||
}
|
||||
|
||||
func scanFullFM() async throws -> [FMScanHit] {
|
||||
let response = try await client.scanFullFM()
|
||||
return response.stations
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Int) async throws {
|
||||
var profile = try await client.fetchAudioProfile()
|
||||
let clamped = max(0, min(100, volume))
|
||||
profile.master.leftDb = Double(clamped)
|
||||
profile.master.rightDb = Double(clamped)
|
||||
try await client.putAudioProfile(profile)
|
||||
state.tuner.volume = clamped
|
||||
state.audio.master = profile.master
|
||||
}
|
||||
|
||||
func listDABServices() async throws -> [DABService] {
|
||||
let response = try await client.fetchDABServices()
|
||||
return response.services
|
||||
}
|
||||
|
||||
func saveStation(_ station: Station) async throws {
|
||||
try await client.addStation(station)
|
||||
try await refreshStations()
|
||||
}
|
||||
|
||||
func removeStation(at index: Int) async throws {
|
||||
try await client.removeStation(index: index)
|
||||
try await refreshStations()
|
||||
}
|
||||
|
||||
func reorderStation(from: Int, to: Int) async throws {
|
||||
try await client.reorderStation(from: from, to: to)
|
||||
try await refreshStations()
|
||||
}
|
||||
|
||||
func tuneStation(at index: Int) async throws {
|
||||
try await client.tuneStation(index: index)
|
||||
try await refreshTunerStatus()
|
||||
}
|
||||
|
||||
func applyAudioProfile(_ profile: AudioProfileDTO) async throws {
|
||||
try await client.putAudioProfile(profile)
|
||||
state.audio.mixer = profile.mixer
|
||||
state.audio.master = profile.master
|
||||
state.audio.eq = profile.eq
|
||||
state.audio.enhancements = profile.enhancements
|
||||
}
|
||||
|
||||
func setStereoEnhance(level: Int) async throws {
|
||||
try await client.setEnhance(path: "/api/audio/stereo-enhance", level: level)
|
||||
state.audio.enhancements.stereoLevel = level
|
||||
}
|
||||
|
||||
func setBassEnhance(level: Int) async throws {
|
||||
try await client.setEnhance(path: "/api/audio/bass-enhance", level: level)
|
||||
state.audio.enhancements.bassLevel = level
|
||||
}
|
||||
|
||||
func resetAudio() async throws {
|
||||
try await client.resetAudio()
|
||||
try await refreshAudioProfile()
|
||||
}
|
||||
|
||||
func scanBluetooth(seconds: Int) async throws {
|
||||
let response = try await client.scanBluetooth(seconds: seconds)
|
||||
state.bluetooth.nearbyDevices = response.devices.map {
|
||||
BluetoothDevice(index: $0.index, mac: $0.mac, name: $0.name, rssiDbm: $0.rssiDbm)
|
||||
}
|
||||
}
|
||||
|
||||
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
|
||||
try await client.connectBluetooth(mac: mac, name: name, save: save)
|
||||
try await refreshBluetoothStatus()
|
||||
}
|
||||
|
||||
func reconnectBluetooth() async throws {
|
||||
try await client.reconnectBluetooth()
|
||||
try await refreshBluetoothStatus()
|
||||
}
|
||||
|
||||
private func applyTuner(_ tuner: TunerStatusResponse) {
|
||||
state.tuner.booted = tuner.booted
|
||||
state.tuner.band = TunerBand(rawValue: tuner.band) ?? .fm
|
||||
state.tuner.locked = tuner.locked
|
||||
state.tuner.volume = tuner.volume
|
||||
state.tuner.fm = tuner.fm
|
||||
state.tuner.dab = tuner.dab
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// HTTP REST client for DigiRadio firmware API (ch-api.tex).
|
||||
final class HTTPDigiRadioClient {
|
||||
private let session: URLSession
|
||||
private let logger = Logger(subsystem: "com.digiradio.igiRadio", category: "HTTP")
|
||||
private var baseURL: URL?
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func setHost(_ host: String) throws {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw DigiRadioError.invalidHost }
|
||||
var normalized = trimmed
|
||||
if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") {
|
||||
normalized = "http://\(normalized)"
|
||||
}
|
||||
guard let url = URL(string: normalized) else { throw DigiRadioError.invalidHost }
|
||||
baseURL = url
|
||||
}
|
||||
|
||||
func clearHost() {
|
||||
baseURL = nil
|
||||
}
|
||||
|
||||
var isConfigured: Bool { baseURL != nil }
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
func fetchHealth() async throws -> HealthResponse {
|
||||
try await get("/api/health")
|
||||
}
|
||||
|
||||
// MARK: - Tuner
|
||||
|
||||
func fetchTunerStatus() async throws -> TunerStatusResponse {
|
||||
try await get("/api/tuner/status")
|
||||
}
|
||||
|
||||
func fetchDABServices() async throws -> DABServicesResponse {
|
||||
try await get("/api/tuner/services")
|
||||
}
|
||||
|
||||
func tune(body: TuneRequest) async throws -> TunerStatusResponse {
|
||||
try await post("/api/tuner/tune", body: body)
|
||||
}
|
||||
|
||||
func playDAB(body: PlayRequest) async throws {
|
||||
let _: StatusResponse = try await post("/api/tuner/play", body: body)
|
||||
}
|
||||
|
||||
func seekFM(direction: String) async throws -> SeekResponse {
|
||||
try await post("/api/tuner/seek", body: SeekRequest(direction: direction))
|
||||
}
|
||||
|
||||
func scanStation(body: TunerScanRequest) async throws -> TunerScanResponse {
|
||||
try await post("/api/tuner/scan", body: body)
|
||||
}
|
||||
|
||||
func scanFullFM() async throws -> FMScanFullResponse {
|
||||
try await post("/api/tuner/scan/full", body: EmptyBody())
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
func fetchAudioProfile() async throws -> AudioProfileDTO {
|
||||
try await get("/api/audio/profile")
|
||||
}
|
||||
|
||||
func putAudioProfile(_ profile: AudioProfileDTO) async throws {
|
||||
let _: StatusResponse = try await put("/api/audio/profile", body: profile)
|
||||
}
|
||||
|
||||
func resetAudio() async throws {
|
||||
let _: StatusResponse = try await post("/api/audio/reset", body: EmptyBody())
|
||||
}
|
||||
|
||||
func setEnhance(path: String, level: Int) async throws {
|
||||
let _: StatusResponse = try await post(path, body: EnhanceRequest(level: level))
|
||||
}
|
||||
|
||||
// MARK: - Bluetooth
|
||||
|
||||
func fetchBluetoothStatus() async throws -> BluetoothStatusResponse {
|
||||
try await get("/api/bluetooth/status")
|
||||
}
|
||||
|
||||
func fetchSavedSpeaker() async throws -> SavedSpeakerResponse {
|
||||
try await get("/api/bluetooth/speaker")
|
||||
}
|
||||
|
||||
func scanBluetooth(seconds: Int) async throws -> BluetoothScanResponse {
|
||||
try await post("/api/bluetooth/scan", body: ScanRequest(seconds: seconds))
|
||||
}
|
||||
|
||||
func connectBluetooth(mac: String, name: String?, save: Bool) async throws {
|
||||
let _: StatusResponse = try await post(
|
||||
"/api/bluetooth/connect",
|
||||
body: ConnectRequest(mac: mac, name: name, save: save)
|
||||
)
|
||||
}
|
||||
|
||||
func reconnectBluetooth() async throws {
|
||||
let _: StatusResponse = try await post("/api/bluetooth/reconnect", body: EmptyBody())
|
||||
}
|
||||
|
||||
// MARK: - Stations
|
||||
|
||||
func fetchStations() async throws -> StationListResponse {
|
||||
try await get("/api/stations")
|
||||
}
|
||||
|
||||
func addStation(_ station: Station) async throws {
|
||||
let _: StatusResponse = try await post("/api/stations", body: station)
|
||||
}
|
||||
|
||||
func removeStation(index: Int) async throws {
|
||||
let _: StatusResponse = try await post("/api/stations/remove", body: IndexRequest(index: index))
|
||||
}
|
||||
|
||||
func reorderStation(from: Int, to: Int) async throws {
|
||||
let _: StatusResponse = try await post(
|
||||
"/api/stations/reorder",
|
||||
body: ReorderRequest(from: from, to: to)
|
||||
)
|
||||
}
|
||||
|
||||
func tuneStation(index: Int) async throws {
|
||||
let _: StatusResponse = try await post("/api/stations/tune", body: IndexRequest(index: index))
|
||||
}
|
||||
|
||||
// MARK: - Streaming
|
||||
|
||||
func fetchStreaming() async throws -> StreamingState {
|
||||
try await get("/api/streaming")
|
||||
}
|
||||
|
||||
// MARK: - HTTP helpers
|
||||
|
||||
private func url(for path: String) throws -> URL {
|
||||
guard let base = baseURL else { throw DigiRadioError.notConnected }
|
||||
guard let url = URL(string: path, relativeTo: base) else { throw DigiRadioError.invalidHost }
|
||||
return url
|
||||
}
|
||||
|
||||
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
let url = try url(for: path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
let url = try url(for: path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder.api.encode(body)
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func put<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
let url = try url(for: path)
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONEncoder.api.encode(body)
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
logger.debug("HTTP \(request.httpMethod ?? "?") \(request.url?.absoluteString ?? "")")
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw DigiRadioError.decodingFailed
|
||||
}
|
||||
guard (200 ... 299).contains(http.statusCode) else {
|
||||
let reason = String(data: data, encoding: .utf8)
|
||||
throw DigiRadioError.httpStatus(http.statusCode, reason)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder.api.decode(T.self, from: data)
|
||||
} catch {
|
||||
logger.error("Decode failed: \(error.localizedDescription)")
|
||||
throw DigiRadioError.decodingFailed
|
||||
}
|
||||
} catch let error as DigiRadioError {
|
||||
throw error
|
||||
} catch {
|
||||
throw DigiRadioError.network(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API DTOs
|
||||
|
||||
struct EmptyBody: Codable {}
|
||||
|
||||
struct HealthResponse: Codable {
|
||||
var status: String
|
||||
var fw: String
|
||||
var serialNumber: String
|
||||
var chips: ChipHealthResponse
|
||||
}
|
||||
|
||||
struct ChipHealthResponse: Codable {
|
||||
var si4684: Bool
|
||||
var adau1701: Bool
|
||||
var bt1035: Bool
|
||||
}
|
||||
|
||||
struct TunerStatusResponse: Codable {
|
||||
var booted: Bool
|
||||
var band: String
|
||||
var locked: Bool
|
||||
var volume: Int
|
||||
var fm: FMTunerState?
|
||||
var dab: DABTunerState?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case booted, band, locked, volume, fm, dab
|
||||
}
|
||||
}
|
||||
|
||||
extension TunerStatusResponse {
|
||||
func dabDecoded() -> DABTunerState? {
|
||||
guard let dab else { return nil }
|
||||
return DABTunerState(
|
||||
freqIndex: dab.freqIndex,
|
||||
ficQuality: dab.ficQuality,
|
||||
cnrDb: dab.cnrDb,
|
||||
playingServiceId: dab.playingServiceId,
|
||||
playingComponentId: dab.playingComponentId,
|
||||
dynamicLabel: dab.dynamicLabel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct DABServicesResponse: Codable {
|
||||
var services: [DABService]
|
||||
}
|
||||
|
||||
struct TuneRequest: Codable {
|
||||
var band: String
|
||||
var freqIndex: Int?
|
||||
var frequencyKhz: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case band
|
||||
case freqIndex = "freq_index"
|
||||
case frequencyKhz = "frequency_khz"
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayRequest: Codable {
|
||||
var serviceId: Int
|
||||
var componentId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case serviceId = "service_id"
|
||||
case componentId = "component_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct SeekRequest: Codable {
|
||||
var direction: String
|
||||
}
|
||||
|
||||
struct SeekResponse: Codable {
|
||||
var frequencyKhz: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case frequencyKhz = "frequency_khz"
|
||||
}
|
||||
}
|
||||
|
||||
struct EnhanceRequest: Codable {
|
||||
var level: Int
|
||||
}
|
||||
|
||||
struct StatusResponse: Codable {
|
||||
var status: String
|
||||
}
|
||||
|
||||
struct BluetoothStatusResponse: Codable {
|
||||
var booted: Bool
|
||||
var pairing: Bool
|
||||
var a2dp: String
|
||||
var deviceName: String
|
||||
var autoReconnect: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case booted, pairing, a2dp
|
||||
case deviceName = "device_name"
|
||||
case autoReconnect = "auto_reconnect"
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedSpeakerResponse: Codable {
|
||||
var configured: Bool
|
||||
var mac: String?
|
||||
var name: String?
|
||||
}
|
||||
|
||||
struct ScanRequest: Codable {
|
||||
var seconds: Int
|
||||
}
|
||||
|
||||
struct BluetoothScanResponse: Codable {
|
||||
var devices: [BluetoothScanDevice]
|
||||
}
|
||||
|
||||
struct BluetoothScanDevice: Codable {
|
||||
var index: Int
|
||||
var mac: String
|
||||
var name: String
|
||||
var rssiDbm: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case index, mac, name
|
||||
case rssiDbm = "rssi_dbm"
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectRequest: Codable {
|
||||
var mac: String
|
||||
var name: String?
|
||||
var save: Bool?
|
||||
}
|
||||
|
||||
struct StationListResponse: Codable {
|
||||
var stations: [Station]
|
||||
}
|
||||
|
||||
struct IndexRequest: Codable {
|
||||
var index: Int
|
||||
}
|
||||
|
||||
struct ReorderRequest: Codable {
|
||||
var from: Int
|
||||
var to: Int
|
||||
}
|
||||
|
||||
struct TunerScanRequest: Codable {
|
||||
var band: String
|
||||
var maxSteps: Int?
|
||||
var name: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case band
|
||||
case maxSteps = "max_steps"
|
||||
case name
|
||||
}
|
||||
}
|
||||
|
||||
struct TunerScanResponse: Codable {
|
||||
var status: String
|
||||
var band: String?
|
||||
var steps: Int?
|
||||
var frequencyKhz: Int?
|
||||
var stationName: String?
|
||||
var freqIndex: Int?
|
||||
var serviceId: Int?
|
||||
var componentId: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status, band, steps
|
||||
case frequencyKhz = "frequency_khz"
|
||||
case stationName = "station_name"
|
||||
case freqIndex = "freq_index"
|
||||
case serviceId = "service_id"
|
||||
case componentId = "component_id"
|
||||
}
|
||||
}
|
||||
|
||||
struct FMScanHit: Codable, Identifiable, Equatable {
|
||||
var id: Int { frequencyKhz }
|
||||
var frequencyKhz: Int
|
||||
var rssiDbuv: Int?
|
||||
var snrDb: Int?
|
||||
var stationName: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case frequencyKhz = "frequency_khz"
|
||||
case rssiDbuv = "rssi_dbuv"
|
||||
case snrDb = "snr_db"
|
||||
case stationName = "station_name"
|
||||
}
|
||||
}
|
||||
|
||||
struct FMScanFullResponse: Codable {
|
||||
var stations: [FMScanHit]
|
||||
}
|
||||
|
||||
extension JSONEncoder {
|
||||
static let api: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
return e
|
||||
}()
|
||||
}
|
||||
|
||||
extension JSONDecoder {
|
||||
static let api: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
return d
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class ConnectionViewModel {
|
||||
private let environment: AppEnvironment
|
||||
var hostInput = "digiradio-cc4db4.local"
|
||||
var isBusy = false
|
||||
var errorMessage: String?
|
||||
let discovery = DigiRadioDiscoveryService()
|
||||
|
||||
init(environment: AppEnvironment) {
|
||||
self.environment = environment
|
||||
if environment.useMockDevice {
|
||||
hostInput = "mock.local"
|
||||
}
|
||||
}
|
||||
|
||||
var state: DigiRadioState { environment.state }
|
||||
|
||||
func connect() async {
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
defer { isBusy = false }
|
||||
do {
|
||||
try await environment.digiRadio.connect(host: hostInput)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func connect(to host: String) async {
|
||||
hostInput = host.replacingOccurrences(of: "http://", with: "").replacingOccurrences(of: "https://", with: "")
|
||||
await connect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
environment.digiRadio.disconnect()
|
||||
}
|
||||
|
||||
func toggleMock(_ enabled: Bool) {
|
||||
environment.setUseMock(enabled)
|
||||
if enabled {
|
||||
hostInput = "mock.local"
|
||||
discovery.stop()
|
||||
Task { try? await environment.digiRadio.connect(host: hostInput) }
|
||||
} else {
|
||||
environment.digiRadio.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func startDiscovery() {
|
||||
discovery.start()
|
||||
discovery.addManualHost(hostInput)
|
||||
}
|
||||
|
||||
func stopDiscovery() {
|
||||
discovery.stop()
|
||||
}
|
||||
|
||||
func pollDiscovery() {
|
||||
discovery.refreshFromBLE()
|
||||
if !hostInput.isEmpty {
|
||||
discovery.addManualHost(hostInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class HomeViewModel {
|
||||
private let service: any DigiRadioService
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
init(service: any DigiRadioService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
var state: DigiRadioState { service.state }
|
||||
|
||||
func onAppear() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task {
|
||||
await refreshAll()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard state.connection.isConnected else { continue }
|
||||
try? await service.refreshTunerStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onDisappear() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
|
||||
func refreshAll() async {
|
||||
guard state.connection.isConnected else { return }
|
||||
try? await service.refreshHealth()
|
||||
try? await service.refreshTunerStatus()
|
||||
try? await service.refreshAudioProfile()
|
||||
try? await service.refreshBluetoothStatus()
|
||||
try? await service.refreshStations()
|
||||
}
|
||||
|
||||
func setVolume(_ value: Double) async {
|
||||
try? await service.setVolume(Int(value))
|
||||
}
|
||||
|
||||
func seekFM(_ direction: String) async {
|
||||
try? await service.seekFM(direction: direction)
|
||||
}
|
||||
|
||||
func tunePreset(at index: Int) async {
|
||||
try? await service.tuneStation(at: index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AudioView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var stereoLevel: Double = 0
|
||||
@State private var bassLevel: Double = 0
|
||||
@State private var masterVolume: Double = 0
|
||||
@State private var eqBands: [EQBandState] = []
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Volume master") {
|
||||
IGIVolumeSlider(value: $masterVolume) { value in
|
||||
Task { try? await environment.digiRadio.setVolume(Int(value)) }
|
||||
}
|
||||
}
|
||||
|
||||
Section("Enhancements") {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Stereo enhance")
|
||||
Slider(value: $stereoLevel, in: 0 ... 100, step: 1) { editing in
|
||||
if !editing {
|
||||
Task { try? await environment.digiRadio.setStereoEnhance(level: Int(stereoLevel)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Bass enhance")
|
||||
Slider(value: $bassLevel, in: 0 ... 100, step: 1) { editing in
|
||||
if !editing {
|
||||
Task { try? await environment.digiRadio.setBassEnhance(level: Int(bassLevel)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Equalizzatore (6 bande)") {
|
||||
ForEach($eqBands) { $band in
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(Int(band.centerHz)) Hz")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Slider(value: $band.gainDb, in: -12 ... 12, step: 0.5) { editing in
|
||||
if !editing { Task { await applyEQ() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Reset profilo audio", role: .destructive) {
|
||||
Task {
|
||||
try? await environment.digiRadio.resetAudio()
|
||||
syncFromState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Audio")
|
||||
.onAppear {
|
||||
Task {
|
||||
try? await environment.digiRadio.refreshAudioProfile()
|
||||
syncFromState()
|
||||
}
|
||||
}
|
||||
.onChange(of: environment.state.audio.enhancements.stereoLevel) { _, _ in syncFromState() }
|
||||
}
|
||||
|
||||
private func syncFromState() {
|
||||
let audio = environment.state.audio
|
||||
stereoLevel = Double(audio.enhancements.stereoLevel)
|
||||
bassLevel = Double(audio.enhancements.bassLevel)
|
||||
masterVolume = Double(environment.state.tuner.volume)
|
||||
eqBands = audio.eq
|
||||
}
|
||||
|
||||
private func applyEQ() async {
|
||||
let profile = AudioProfileDTO(
|
||||
mixer: environment.state.audio.mixer,
|
||||
master: environment.state.audio.master,
|
||||
eq: eqBands,
|
||||
enhancements: EnhancementsState(stereoLevel: Int(stereoLevel), bassLevel: Int(bassLevel))
|
||||
)
|
||||
try? await environment.digiRadio.applyAudioProfile(profile)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BLEProvisioningView: View {
|
||||
@State private var service = BLEProvisioningService()
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("Ricerca") {
|
||||
switch service.phase {
|
||||
case .idle:
|
||||
Text("Premi Avvia per cercare dispositivi DigiRadio in setup mode.")
|
||||
.foregroundStyle(.secondary)
|
||||
case .scanning:
|
||||
HStack {
|
||||
IGIScanningIndicator().frame(width: 28, height: 28)
|
||||
Text("Cerca DigiRadio...")
|
||||
}
|
||||
case let .found(name, _):
|
||||
Label("Trovato: \(name)", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .unsupported:
|
||||
Text("Provisioning BLE non ancora integrato in igiRadio.")
|
||||
case let .error(message):
|
||||
Text(message).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
if !service.discoveredDevices.isEmpty {
|
||||
Section("Dispositivi") {
|
||||
ForEach(service.discoveredDevices, id: \.id) { device in
|
||||
VStack(alignment: .leading) {
|
||||
Text(device.name).font(.headline)
|
||||
Text("RSSI \(device.rssi) dBm").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Avvia scansione") { service.startScan() }
|
||||
Button("Ferma scansione") { service.stopScan() }
|
||||
}
|
||||
|
||||
Section("Istruzioni") {
|
||||
Text("1. Metti DigiRadio in setup mode (SoftAP DigiRadio-<suffix>).")
|
||||
Text("2. Il dispositivo espone anche BLE provisioning ESP-IDF.")
|
||||
Text("3. Proof of Possession (PoP) = serial number del dispositivo.")
|
||||
Text("4. Dopo il join Wi‑Fi, usa la connessione HTTP in igiRadio.")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Section {
|
||||
Text("UNKNOWN — specification required: implementazione completa protocomm Security1 in-app (attualmente consigliata app ESP BLE Provisioning di Espressif).")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.navigationTitle("BLE Provisioning")
|
||||
.onDisappear { service.stopScan() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BluetoothView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var isScanning = false
|
||||
|
||||
var body: some View {
|
||||
let bt = environment.state.bluetooth
|
||||
Form {
|
||||
Section("Stato speaker (BT1035)") {
|
||||
LabeledContent("Modulo", value: bt.booted ? "Attivo" : "Non pronto")
|
||||
LabeledContent("A2DP", value: bt.a2dpState.rawValue.capitalized)
|
||||
LabeledContent("Dispositivo", value: bt.deviceName.isEmpty ? "—" : bt.deviceName)
|
||||
if let speaker = bt.savedSpeaker {
|
||||
LabeledContent("Speaker salvato", value: speaker.name)
|
||||
Text(speaker.mac).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Azioni") {
|
||||
Button(isScanning ? "Scansione..." : "Scansiona speaker vicini") {
|
||||
Task { await scan() }
|
||||
}
|
||||
.disabled(isScanning)
|
||||
|
||||
Button("Riconnetti speaker salvato") {
|
||||
Task { try? await environment.digiRadio.reconnectBluetooth() }
|
||||
}
|
||||
}
|
||||
|
||||
if !bt.nearbyDevices.isEmpty {
|
||||
Section("Dispositivi trovati") {
|
||||
ForEach(bt.nearbyDevices) { device in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(device.name).font(.headline)
|
||||
Text(device.mac).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(device.rssiDbm) dBm")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Connetti") {
|
||||
Task {
|
||||
try? await environment.digiRadio.connectBluetooth(
|
||||
mac: device.mac,
|
||||
name: device.name,
|
||||
save: true
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Questa sezione configura il modulo Bluetooth classic (BT1035) che invia audio A2DP verso un altoparlante esterno. È distinta dalla connessione BLE usata solo per il provisioning Wi‑Fi.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Bluetooth Audio")
|
||||
.onAppear { Task { try? await environment.digiRadio.refreshBluetoothStatus() } }
|
||||
}
|
||||
|
||||
private func scan() async {
|
||||
isScanning = true
|
||||
defer { isScanning = false }
|
||||
try? await environment.digiRadio.scanBluetooth(seconds: 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var viewModel: ConnectionViewModel?
|
||||
@State private var discoveryTimer: Timer?
|
||||
|
||||
var body: some View {
|
||||
let vm = viewModel ?? ConnectionViewModel(environment: environment)
|
||||
let state = environment.state
|
||||
|
||||
Form {
|
||||
Section("Dispositivo") {
|
||||
Toggle("Modalità demo (Mock)", isOn: Binding(
|
||||
get: { environment.useMockDevice },
|
||||
set: { vm.toggleMock($0) }
|
||||
))
|
||||
}
|
||||
|
||||
if !environment.useMockDevice {
|
||||
Section("Ricerca in rete") {
|
||||
if vm.discovery.isSearching {
|
||||
HStack {
|
||||
IGIScanningIndicator().frame(width: 28, height: 28)
|
||||
Text("Cerca DigiRadio...")
|
||||
}
|
||||
Button("Ferma ricerca") { stopDiscovery(vm) }
|
||||
} else {
|
||||
Button("Cerca dispositivi") { startDiscovery(vm) }
|
||||
}
|
||||
|
||||
if vm.discovery.devices.isEmpty {
|
||||
Text("Avvia la ricerca per trovare DigiRadio via BLE (setup) o verificare l'host inserito.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(vm.discovery.devices) { device in
|
||||
Button {
|
||||
Task { await vm.connect(to: device.host) }
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(device.name).font(.headline)
|
||||
Text(device.host).font(.caption).foregroundStyle(.secondary)
|
||||
if let fw = device.firmware {
|
||||
Text("FW \(fw)").font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if device.isReachable {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
} else {
|
||||
Image(systemName: "questionmark.circle").foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(vm.isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Connessione HTTP") {
|
||||
TextField("Host", text: Binding(
|
||||
get: { vm.hostInput },
|
||||
set: { vm.hostInput = $0 }
|
||||
))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
|
||||
if state.connection.isConnected {
|
||||
LabeledContent("Stato", value: "Connesso")
|
||||
LabeledContent("Host", value: state.connection.host)
|
||||
}
|
||||
|
||||
if let error = vm.errorMessage ?? state.connection.lastError {
|
||||
Text(error).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if state.connection.isConnected {
|
||||
Button("Disconnetti", role: .destructive) {
|
||||
vm.disconnect()
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task { await vm.connect() }
|
||||
} label: {
|
||||
if vm.isBusy {
|
||||
HStack {
|
||||
IGIScanningIndicator().frame(width: 24, height: 24)
|
||||
Text("Connessione...")
|
||||
}
|
||||
} else {
|
||||
Text("Connetti")
|
||||
}
|
||||
}
|
||||
.disabled(vm.isBusy)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Provisioning Wi‑Fi (BLE)") {
|
||||
NavigationLink("Configura Wi‑Fi via BLE") {
|
||||
BLEProvisioningView()
|
||||
}
|
||||
Text("DigiRadio espone BLE solo per il provisioning Wi‑Fi. Il nome BLE (es. DigiRadio-CC4DB4) corrisponde all'host HTTP digiradio-cc4db4.local.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Suggerimenti host") {
|
||||
Text("• Setup mode: http://192.168.4.1")
|
||||
Text("• STA mode: http://digiradio-<suffix>.local")
|
||||
Text("• Oppure IP LAN del dispositivo")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.navigationTitle("Connessione")
|
||||
.onAppear {
|
||||
if viewModel == nil { viewModel = vm }
|
||||
if environment.useMockDevice && !state.connection.isConnected {
|
||||
Task { await vm.connect() }
|
||||
}
|
||||
}
|
||||
.onDisappear { stopDiscovery(vm) }
|
||||
}
|
||||
|
||||
private func startDiscovery(_ vm: ConnectionViewModel) {
|
||||
vm.startDiscovery()
|
||||
discoveryTimer?.invalidate()
|
||||
discoveryTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
||||
vm.pollDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDiscovery(_ vm: ConnectionViewModel) {
|
||||
discoveryTimer?.invalidate()
|
||||
discoveryTimer = nil
|
||||
vm.stopDiscovery()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DABRadioView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var freqIndex: Double = 12
|
||||
@State private var services: [DABService] = []
|
||||
@State private var loadError: String?
|
||||
|
||||
var body: some View {
|
||||
let dab = environment.state.tuner.dab
|
||||
Form {
|
||||
Section("Ensemble") {
|
||||
Stepper("Freq index: \(Int(freqIndex))", value: Binding(
|
||||
get: { Int(freqIndex) },
|
||||
set: { freqIndex = Double($0) }
|
||||
), in: 0 ... 37)
|
||||
Button("Sintonizza ensemble") {
|
||||
Task { try? await environment.digiRadio.tuneDAB(freqIndex: Int(freqIndex)) }
|
||||
}
|
||||
}
|
||||
|
||||
if let dab {
|
||||
Section("Stato DAB") {
|
||||
LabeledContent("Freq index", value: "\(dab.freqIndex)")
|
||||
if let fic = dab.ficQuality { LabeledContent("FIC quality", value: "\(fic)") }
|
||||
if let cnr = dab.cnrDb { LabeledContent("CNR", value: "\(cnr) dB") }
|
||||
if let label = dab.dynamicLabel { LabeledContent("DLS", value: label) }
|
||||
}
|
||||
}
|
||||
|
||||
Section("Servizi") {
|
||||
if let loadError {
|
||||
Text(loadError).foregroundStyle(.red)
|
||||
}
|
||||
if services.isEmpty {
|
||||
Text("Nessun servizio caricato")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(services) { service in
|
||||
Button {
|
||||
Task {
|
||||
try? await environment.digiRadio.playDAB(
|
||||
serviceId: service.serviceId,
|
||||
componentId: service.componentId
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(service.label).font(.headline)
|
||||
Text("SID \(service.serviceId) · CID \(service.componentId)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Carica servizi") { Task { await loadServices() } }
|
||||
}
|
||||
}
|
||||
.navigationTitle("DAB")
|
||||
.onAppear {
|
||||
if let idx = environment.state.tuner.dab?.freqIndex {
|
||||
freqIndex = Double(idx)
|
||||
}
|
||||
Task { try? await environment.digiRadio.refreshTunerStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadServices() async {
|
||||
loadError = nil
|
||||
do {
|
||||
services = try await environment.digiRadio.listDABServices()
|
||||
} catch {
|
||||
loadError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var viewModel: HomeViewModel?
|
||||
@State private var volume: Double = 42
|
||||
|
||||
var body: some View {
|
||||
let state = environment.state
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: IGITheme.spacingL) {
|
||||
header(state: state)
|
||||
nowPlayingCard(state: state)
|
||||
transportControls
|
||||
volumeSection
|
||||
presetsSection(state: state)
|
||||
}
|
||||
.padding(IGITheme.spacingM)
|
||||
}
|
||||
.background(IGITheme.screenBackground)
|
||||
.navigationTitle("igiRadio")
|
||||
.safeAreaInset(edge: .top) {
|
||||
if !state.connection.isConnected {
|
||||
NavigationLink {
|
||||
ConnectionView()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "wifi.exclamationmark")
|
||||
Text("DigiRadio non connesso — tocca per collegare")
|
||||
.font(.subheadline.weight(.medium))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.padding(.horizontal, IGITheme.spacingM)
|
||||
.padding(.vertical, 10)
|
||||
.background(.orange.opacity(0.15))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink {
|
||||
ConnectionView()
|
||||
} label: {
|
||||
Label(state.connection.isConnected ? "Connesso" : "Connetti", systemImage: "link")
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if viewModel == nil {
|
||||
viewModel = HomeViewModel(service: environment.digiRadio)
|
||||
}
|
||||
viewModel?.onAppear()
|
||||
volume = Double(state.tuner.volume)
|
||||
}
|
||||
.onDisappear { viewModel?.onDisappear() }
|
||||
.onChange(of: environment.state.tuner.volume) { _, newValue in
|
||||
volume = Double(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func header(state: DigiRadioState) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("DigiRadio")
|
||||
.font(.largeTitle.bold())
|
||||
HStack(spacing: 8) {
|
||||
IGIStatusDot(isConnected: state.connection.isConnected)
|
||||
Text(state.connection.isConnected ? "Connesso" : "Non connesso")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if let rssi = state.tuner.fm?.rssiDbuv {
|
||||
VStack(alignment: .trailing) {
|
||||
Text("Segnale")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
IGISignalBar(level: Double(rssi) / 80.0)
|
||||
.frame(width: 120)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func nowPlayingCard(state: DigiRadioState) -> some View {
|
||||
IGICard {
|
||||
VStack(spacing: IGITheme.spacingM) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.accentColor.opacity(0.35), .purple.opacity(0.25)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(height: 220)
|
||||
Image(systemName: state.tuner.band == .fm ? "radio.fill" : "antenna.radiowaves.left.and.right")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(primaryTitle(state: state))
|
||||
.font(.title2.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(secondaryLine(state: state))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var transportControls: some View {
|
||||
HStack(spacing: IGITheme.spacingL) {
|
||||
Button {
|
||||
Task { await viewModel?.seekFM("down") }
|
||||
} label: {
|
||||
Image(systemName: "backward.fill")
|
||||
.font(.title2)
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
Task { await viewModel?.refreshAll() }
|
||||
} label: {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.largeTitle)
|
||||
.frame(width: 72, height: 72)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.clipShape(Circle())
|
||||
|
||||
Button {
|
||||
Task { await viewModel?.seekFM("up") }
|
||||
} label: {
|
||||
Image(systemName: "forward.fill")
|
||||
.font(.title2)
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var volumeSection: some View {
|
||||
IGICard {
|
||||
IGIVolumeSlider(value: $volume) { value in
|
||||
Task { await viewModel?.setVolume(value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func presetsSection(state: DigiRadioState) -> some View {
|
||||
IGISectionHeader(title: "Preset")
|
||||
if state.stations.isEmpty {
|
||||
IGIEmptyState(
|
||||
title: "Nessun preset",
|
||||
message: "Salva le tue stazioni preferite dalla schermata Preset.",
|
||||
systemImage: "star"
|
||||
)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: IGITheme.spacingS) {
|
||||
ForEach(Array(state.stations.enumerated()), id: \.offset) { index, station in
|
||||
Button {
|
||||
Task { await viewModel?.tunePreset(at: index) }
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(station.name)
|
||||
.font(.headline)
|
||||
Text(station.band.rawValue.uppercased())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding()
|
||||
.background(IGITheme.cardBackground, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func primaryTitle(state: DigiRadioState) -> String {
|
||||
switch state.tuner.band {
|
||||
case .fm:
|
||||
if let name = state.tuner.fm?.stationName, !name.isEmpty { return name }
|
||||
if let khz = state.tuner.fm?.frequencyKhz {
|
||||
return String(format: "%.2f MHz", Double(khz) / 1000.0)
|
||||
}
|
||||
return "FM"
|
||||
case .dab:
|
||||
return state.tuner.dab?.dynamicLabel ?? "DAB"
|
||||
}
|
||||
}
|
||||
|
||||
private func secondaryLine(state: DigiRadioState) -> String {
|
||||
switch state.tuner.band {
|
||||
case .fm:
|
||||
if let khz = state.tuner.fm?.frequencyKhz {
|
||||
return String(format: "%.2f MHz", Double(khz) / 1000.0)
|
||||
}
|
||||
return state.tuner.fm?.radiotext ?? "FM Radio"
|
||||
case .dab:
|
||||
if let cnr = state.tuner.dab?.cnrDb {
|
||||
return "CNR \(cnr) dB"
|
||||
}
|
||||
return "Digital Radio"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PresetsView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@State private var newName = ""
|
||||
@State private var newBand: TunerBand = .fm
|
||||
@State private var newFMKhz = "102300"
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(Array(environment.state.stations.enumerated()), id: \.offset) { index, station in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(station.name).font(.headline)
|
||||
Text(detail(for: station)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Play") {
|
||||
Task { try? await environment.digiRadio.tuneStation(at: index) }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
for index in offsets {
|
||||
Task { try? await environment.digiRadio.removeStation(at: index) }
|
||||
}
|
||||
}
|
||||
.onMove { from, to in
|
||||
guard let source = from.first else { return }
|
||||
Task { try? await environment.digiRadio.reorderStation(from: source, to: to) }
|
||||
}
|
||||
}
|
||||
|
||||
Section("Aggiungi preset") {
|
||||
TextField("Nome", text: $newName)
|
||||
Picker("Banda", selection: $newBand) {
|
||||
ForEach(TunerBand.allCases, id: \.self) { band in
|
||||
Text(band.rawValue.uppercased()).tag(band)
|
||||
}
|
||||
}
|
||||
if newBand == .fm {
|
||||
TextField("Frequenza kHz", text: $newFMKhz)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
Button("Salva") { Task { await savePreset() } }
|
||||
.disabled(newName.isEmpty)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Preset")
|
||||
.toolbar { EditButton() }
|
||||
.onAppear { Task { try? await environment.digiRadio.refreshStations() } }
|
||||
}
|
||||
|
||||
private func detail(for station: Station) -> String {
|
||||
switch station.band {
|
||||
case .fm:
|
||||
if let khz = station.fmFrequencyKhz {
|
||||
return String(format: "FM %.2f MHz", Double(khz) / 1000)
|
||||
}
|
||||
return "FM"
|
||||
case .dab:
|
||||
return "DAB index \(station.dabFreqIndex ?? 0)"
|
||||
}
|
||||
}
|
||||
|
||||
private func savePreset() async {
|
||||
let station: Station
|
||||
switch newBand {
|
||||
case .fm:
|
||||
guard let khz = Int(newFMKhz) else { return }
|
||||
station = Station(name: newName, band: .fm, fmFrequencyKhz: khz)
|
||||
case .dab:
|
||||
station = Station(
|
||||
name: newName,
|
||||
band: .dab,
|
||||
dabFreqIndex: Int(environment.state.tuner.dab?.freqIndex ?? 0),
|
||||
dabServiceId: environment.state.tuner.dab?.playingServiceId,
|
||||
dabComponentId: environment.state.tuner.dab?.playingComponentId
|
||||
)
|
||||
}
|
||||
try? await environment.digiRadio.saveStation(station)
|
||||
newName = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if horizontalSizeClass == .regular {
|
||||
IPadRootView()
|
||||
} else {
|
||||
IPhoneRootView()
|
||||
}
|
||||
}
|
||||
.tint(IGITheme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPhoneRootView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
NavigationStack { HomeView() }
|
||||
.tabItem { Label("Home", systemImage: "house.fill") }
|
||||
|
||||
NavigationStack { FMRadioView() }
|
||||
.tabItem { Label("FM", systemImage: "dot.radiowaves.left.and.right") }
|
||||
|
||||
NavigationStack { DABRadioView() }
|
||||
.tabItem { Label("DAB", systemImage: "antenna.radiowaves.left.and.right") }
|
||||
|
||||
NavigationStack { PresetsView() }
|
||||
.tabItem { Label("Preset", systemImage: "star.fill") }
|
||||
|
||||
NavigationStack { SettingsRootView() }
|
||||
.tabItem { Label("Impostazioni", systemImage: "gearshape.fill") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadRootView: View {
|
||||
@State private var selection: SidebarItem? = .home
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(SidebarItem.allCases, selection: $selection) { item in
|
||||
NavigationLink(value: item) {
|
||||
Label(item.title, systemImage: item.systemImage)
|
||||
}
|
||||
}
|
||||
.navigationTitle("igiRadio")
|
||||
} detail: {
|
||||
switch selection ?? .home {
|
||||
case .home: HomeView()
|
||||
case .fm: FMRadioView()
|
||||
case .dab: DABRadioView()
|
||||
case .bluetooth: BluetoothView()
|
||||
case .audio: AudioView()
|
||||
case .presets: PresetsView()
|
||||
case .settings: SettingsRootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarItem: String, CaseIterable, Identifiable {
|
||||
case home, fm, dab, bluetooth, audio, presets, settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home: "Home"
|
||||
case .fm: "FM"
|
||||
case .dab: "DAB"
|
||||
case .bluetooth: "Bluetooth"
|
||||
case .audio: "Audio"
|
||||
case .presets: "Preset"
|
||||
case .settings: "Impostazioni"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .home: "house.fill"
|
||||
case .fm: "dot.radiowaves.left.and.right"
|
||||
case .dab: "antenna.radiowaves.left.and.right"
|
||||
case .bluetooth: "dot.radiowaves.left.and.right"
|
||||
case .audio: "waveform"
|
||||
case .presets: "star.fill"
|
||||
case .settings: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsRootView: View {
|
||||
var body: some View {
|
||||
List {
|
||||
Section("DigiRadio") {
|
||||
NavigationLink("Connessione") { ConnectionView() }
|
||||
NavigationLink("Informazioni dispositivo") { DeviceInfoView() }
|
||||
NavigationLink("Diagnostica") { DiagnosticsView() }
|
||||
NavigationLink("Firmware") { FirmwareView() }
|
||||
}
|
||||
Section("Radio") {
|
||||
NavigationLink("FM") { FMRadioView() }
|
||||
NavigationLink("DAB") { DABRadioView() }
|
||||
NavigationLink("Preset") { PresetsView() }
|
||||
}
|
||||
Section("Audio") {
|
||||
NavigationLink("Profilo audio") { AudioView() }
|
||||
NavigationLink("Bluetooth speaker") { BluetoothView() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Impostazioni")
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceInfoView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
|
||||
var body: some View {
|
||||
let health = environment.state.health
|
||||
let chips = health.chips
|
||||
Form {
|
||||
LabeledContent("Nome", value: "DigiRadio")
|
||||
LabeledContent("Firmware", value: health.firmware.isEmpty ? "—" : health.firmware)
|
||||
LabeledContent("Serial number", value: health.serialNumber.isEmpty ? "—" : health.serialNumber)
|
||||
LabeledContent("Stato", value: health.status.isEmpty ? "—" : health.status)
|
||||
Section("Chip") {
|
||||
LabeledContent("Si4684", value: chips.si4684 ? "OK" : "—")
|
||||
LabeledContent("ADAU1701", value: chips.adau1701 ? "OK" : "—")
|
||||
LabeledContent("BT1035", value: chips.bt1035 ? "OK" : "—")
|
||||
}
|
||||
Section("Connessione") {
|
||||
LabeledContent("Host", value: environment.state.connection.host.isEmpty ? "—" : environment.state.connection.host)
|
||||
LabeledContent("HTTP", value: environment.state.connection.isConnected ? "Connesso" : "Non connesso")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Dispositivo")
|
||||
.onAppear { Task { try? await environment.digiRadio.refreshHealth() } }
|
||||
}
|
||||
}
|
||||
|
||||
struct DiagnosticsView: View {
|
||||
@Environment(AppEnvironment.self) private var environment
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Tuner") {
|
||||
LabeledContent("Booted", value: environment.state.tuner.booted ? "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() } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user