From 15963039ea857fe95c859cccabb800cacebf0d96 Mon Sep 17 00:00:00 2001 From: Carlo Date: Fri, 9 Oct 2026 21:00:59 +0200 Subject: [PATCH] harbour-aira 0.1.0: client Sailfish per pompa di calore Aira (bridge HTTP + stato e comandi ACS) --- .gitignore | 16 ++ README.md | 88 +++++++++++ docs/PIANO.md | 95 ++++++++++++ docs/PROTOCOL.md | 95 ++++++++++++ docs/architettura.svg | 35 +++++ harbour-aira.pro | 48 ++++++ icons/108x108/harbour-aira.png | Bin 0 -> 4991 bytes icons/128x128/harbour-aira.png | Bin 0 -> 6015 bytes icons/172x172/harbour-aira.png | Bin 0 -> 1960 bytes icons/86x86/harbour-aira.png | Bin 0 -> 4063 bytes qml/components/InfoRow.qml | 35 +++++ qml/cover/CoverPage.qml | 55 +++++++ qml/harbour-aira.qml | 17 +++ qml/pages/MainPage.qml | 268 +++++++++++++++++++++++++++++++++ qml/pages/SettingsPage.qml | 116 ++++++++++++++ rpm/harbour-aira.desktop | 21 +++ rpm/harbour-aira.spec | 67 +++++++++ scripts/make_icons.py | 75 +++++++++ src/airajson.cpp | 108 +++++++++++++ src/airajson.h | 42 ++++++ src/apiclient.cpp | 196 ++++++++++++++++++++++++ src/apiclient.h | 66 ++++++++ src/main.cpp | 41 +++++ src/settings.cpp | 54 +++++++ src/settings.h | 39 +++++ tests/core_test.cpp | 93 ++++++++++++ tests/core_test.pro | 16 ++ 27 files changed, 1686 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/PIANO.md create mode 100644 docs/PROTOCOL.md create mode 100644 docs/architettura.svg create mode 100644 harbour-aira.pro create mode 100644 icons/108x108/harbour-aira.png create mode 100644 icons/128x128/harbour-aira.png create mode 100644 icons/172x172/harbour-aira.png create mode 100644 icons/86x86/harbour-aira.png create mode 100644 qml/components/InfoRow.qml create mode 100644 qml/cover/CoverPage.qml create mode 100644 qml/harbour-aira.qml create mode 100644 qml/pages/MainPage.qml create mode 100644 qml/pages/SettingsPage.qml create mode 100644 rpm/harbour-aira.desktop create mode 100644 rpm/harbour-aira.spec create mode 100644 scripts/make_icons.py create mode 100644 src/airajson.cpp create mode 100644 src/airajson.h create mode 100644 src/apiclient.cpp create mode 100644 src/apiclient.h create mode 100644 src/main.cpp create mode 100644 src/settings.cpp create mode 100644 src/settings.h create mode 100644 tests/core_test.cpp create mode 100644 tests/core_test.pro diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fa5967 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# artefatti di build +build-*/ +*.o +*.so +moc_* +Makefile +RPMS/ +tests/build/ +*.rpm + +# tarball di consegna (rigenerabili con git archive) +*.tar.gz + +# temporanei +*~ +*.bak diff --git a/README.md b/README.md new file mode 100644 index 0000000..9975517 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# harbour-aira + +App nativa Sailfish OS per una pompa di calore **Aira Home**: mostra lo stato +dell'impianto e invia i comandi dell'acqua calda sanitaria. + +![architettura](docs/architettura.svg) + +## Perché c'è un bridge (e non una chiamata diretta al cloud) + +Il cloud Aira espone **gRPC su HTTP/2**. Qt 5.6 — il toolkit su cui gira ogni +app Sailfish — **non ha HTTP/2**, quindi un client nativo non può parlarci +direttamente. Le alternative (impacchettare `grpcio` per aarch64 con +PyOtherSide, oppure riscrivere Cognito + gRPC in C++) sono fragili e costose. + +Quindi la logica resta **sul server**, dove Python e i token già funzionano, e +l'app è un client sottile che parla HTTP/1.1 + JSON. Effetto collaterale +gradito: credenziali e token non stanno mai sul telefono. + +``` + Jolla (QML/C++ Qt 5.6) --HTTP/1.1 + JSON--> bridge sul server --gRPC--> cloud Aira + (~/workspace/Carlo/Aira/ + aira_bridge.py) +``` + +## Il bridge + +Vive in `~/workspace/Carlo/Aira/` (fuori da questo repo: contiene dati +personali e non va pubblicato). + +```sh +cd ~/workspace/Carlo/Aira +.venv/bin/python aira_bridge.py --port 8790 # ascolta su 0.0.0.0:8790 +``` + +Al primo avvio genera il token in `~/.hermes/profiles/carlo/aira_bridge.json` +(permessi 600): è il valore da incollare nelle impostazioni dell'app. +API completa in [docs/PROTOCOL.md](docs/PROTOCOL.md). + +## Impostazioni nell'app + +Indirizzo e token si configurano nella pagina **Impostazioni** (QSettings in +`~/.config/harbour/aira/aira.conf`): niente dati personali nel binario. + +## Build + +Servono l'SDK Sailfish (`sfdk`) e un target aarch64 (Jolla Phone 2026): + +```sh +sfdk target install SailfishOS-5.0.0.62-aarch64 +sfdk target use SailfishOS-5.0.0.62-aarch64 +sfdk build # RPM in RPMS/ +sfdk deploy # sul dispositivo collegato +``` + +Sorgenti dal tarball: `tar xzf harbour-aira-0.1.0.tar.gz` e compilare in una +cartella nuova (mai sopra una estrazione precedente: i Makefile restano stale). + +### Verifica locale, senza SDK + +```sh +cd tests && mkdir -p build && cd build +~/Qt/5.15.2/gcc_64/bin/qmake ../core_test.pro && make && ./core_test +``` + +Copre la logica pura (`src/airajson.cpp`): normalizzazione dell'indirizzo, +lettura del riassunto, etichette di stato, corpo dei comandi, messaggi +d'errore. **La UI Silica si verifica solo sul dispositivo.** + +## Struttura + +``` +harbour-aira.pro progetto qmake (icona, qml, docs) +src/airajson.{h,cpp} logica pura, testabile senza SDK +src/apiclient.{h,cpp} QNetworkAccessManager + watchdog di timeout +src/settings.{h,cpp} QSettings su AppConfigLocation (sandbox SailJail) +src/main.cpp context properties: appSettings, api +qml/harbour-aira.qml ApplicationWindow + copertina +qml/pages/MainPage.qml dashboard e comandi +qml/pages/SettingsPage.qml +qml/components/InfoRow.qml +tests/core_test.cpp verifiche del nucleo (headless) +docs/PIANO.md piano di progetto e scelte +docs/PROTOCOL.md API del bridge +``` + +## Licenza + +MIT. diff --git a/docs/PIANO.md b/docs/PIANO.md new file mode 100644 index 0000000..40e8db9 --- /dev/null +++ b/docs/PIANO.md @@ -0,0 +1,95 @@ +# harbour-aira — app Sailfish per la pompa di calore Aira + +Piano scritto il 9 ottobre 2026, prima di scrivere la UI (convenzione di lavoro). + +## 1. Stato verificato sul campo + +| Cosa | Esito | +|---|---| +| Accesso cloud Aira | **funziona** — `pyairahome` 2.3.0, pool Cognito `eu-north-1_cnqyjWtbz` (indice 0), token salvati in `~/.hermes/profiles/carlo/aira_tokens.json` (600) | +| Lettura impianto | **funziona** — esterna, interna, ACS attuale/obiettivo, modalità, curve zone, firmware, errori | +| Bridge HTTP sul server | **scritto e provato** — `~/workspace/Carlo/Aira/aira_bridge.py`, v0.1.0 | +| Bluetooth sul server | **assente** — nessun adattatore HCI (esclude la via locale BLE su questa macchina) | +| SDK Sailfish (`sfdk`) | **assente qui** — build e deploy li fa Carlo dal suo PC | +| Jolla Phone 2026 | non raggiungibile dalla rete del server: deploy solo dal PC | + +## 2. Perché serve un bridge e non una chiamata diretta + +Il cloud Aira espone **gRPC su HTTP/2**. Qt 5.6 — il toolkit su cui gira Sailfish — +**non ha HTTP/2**, quindi un'app nativa non può parlarci direttamente. Vie alternative +valutate e scartate: + +- **PyOtherSide + pyairahome dentro l'app**: servirebbe impacchettare `grpcio` e `bleak` + compilati per aarch64/SFOS, con l'RPM che diventa fragile a ogni aggiornamento. +- **Cognito + gRPC riscritti in C++**: HTTP/2 e protobuf da zero. Molto lavoro, molto rischio. + +Scelta: **la logica resta sul server** (dove Python e i token già funzionano), l'app è un +client sottile. Vantaggio collaterale: credenziali e token non stanno mai sul telefono. + +``` + Jolla (QML/C++ Qt 5.6) --HTTP/1.1 + JSON--> bridge sul server --gRPC--> cloud Aira + (pyairahome + token) +``` + +## 3. Protocollo del bridge (v1 — già implementato) + +Autenticazione: `Authorization: Bearer ` su tutto tranne `/health`. +Token generato al primo avvio in `~/.hermes/profiles/carlo/aira_bridge.json` (600). + +| Metodo | Rotta | Risposta | +|---|---|---| +| GET | `/health` | `{ok, version}` — nessuna auth, per la diagnostica | +| GET | `/summary` | valori leggibili: temperature, ACS, modalità, flag, errori | +| GET | `/state` | stato completo grezzo (42 campi) | +| GET | `/commands` | elenco comandi ammessi + parametri | +| POST | `/command` | `{"id": "...", "value": ...}` | + +Comandi ammessi in v1: `ping`, `set_hot_water_target` (45–60 °C), `hot_water_boost_on/off`, +`hot_water_heating_on/off`, `away_mode_on/off`, `night_mode_1h`, `force_heating_on/off`, +`heating_on/off`, `legionella_cycle`. + +**Whitelist voluta**: la libreria espone ~90 comandi, inclusi `FactoryReset`, +`InstallFirmware`, `RebootDevice`, `RotateCertificate`. Quelli restano fuori di proposito: +un client mobile non deve poter riformattare l'impianto per un tap sbagliato. + +## 4. Architettura dell'app + +- `harbour-aira.pro` / `rpm/` / `icons/` dai template consolidati della skill. +- **C++ minimo**: `Settings` (QSettings su `AppConfigLocation`, obbligatorio per la sandbox + SailJail) e `ApiClient` (QNetworkAccessManager + watchdog di timeout 15 s, JSON parsing). + Esposti come context properties: `appSettings`, `api`. +- **QML**: `MainPage` (dashboard), `SettingsPage` (indirizzo bridge + token), `CoverPage`. +- Nessun dato personale nel binario: indirizzo e token si configurano nell'app + (le repo sono pubbliche su Gitea). + +## 5. Fasi + +- **F0 — ponte (fatto)**: bridge scritto, provato su `/health`, `/summary`, `/commands`, + errori 400/401 verificati. Nessun comando inviato all'impianto. +- **F1 — v0.1 in sola lettura**: dashboard con esterna/interna/ACS, modalità, stato ACS, + pull-to-refresh, copertina con le temperature. È già utile e non può fare danni. +- **F2 — comandi**: ACS obiettivo (slider 45–60), boost, away, night mode. Conferme con + `RemorseItem`/`RemorsePopup` per le azioni che durano. +- **F3 — servizio e rete**: unità systemd per il bridge (avvio automatico), poi la via di + accesso dal telefono (VPN WireGuard già attiva, oppure sottodominio HTTPS dietro reverse + proxy: in quel caso servono DNS e certificato, che Carlo applica dal pannello). + +## 6. Rischi noti + +- **Dipendenza dal server**: se il bridge è spento, l'app non vede nulla. Mitigazione: unità + systemd con riavvio automatico (F3). +- **Scadenza credenziali**: i token Cognito si rinnovano da soli per settimane; quando il + refresh token scade serve rilanciare `setup_creds.py` sul server. L'app deve mostrare un + errore chiaro, non un retry infinito. +- **Assenza di test sulla UI**: senza SDK né device raggiungibile da qui, la UI Silica si + verifica solo sul telefono di Carlo (ciclo consolidato: io consegno il tarball, lui compila + e incolla i log). +- **Token del bridge**: è l'unica credenziale sul telefono; va trattato come una password e + revocabile rigenerando `aira_bridge.json`. + +## 7. Decisioni aperte + +1. **Come raggiungere il bridge dal telefono**: VPN WireGuard (già attiva, zero configurazione + nuova) oppure sottodominio HTTPS pubblico con reverse proxy. +2. **Ambito della v0.1**: sola lettura (consigliata) oppure lettura + comandi ACS subito. +3. **Nome visibile dell'app**: default proposto "Aira" (pacchetto `harbour-aira`). diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..6444859 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,95 @@ +# API del bridge + +Servizio: `~/workspace/Carlo/Aira/aira_bridge.py` (Python, solo `pyairahome`). +Traduce HTTP/1.1 + JSON in gRPC verso il cloud Aira. Tutte le rotte tranne +`/health` richiedono: + +``` +Authorization: Bearer +``` + +Token: generato al primo avvio in `~/.hermes/profiles/carlo/aira_bridge.json`. + +## GET /health + +Nessuna autenticazione: serve a distinguere "server irraggiungibile" da +"token sbagliato". + +```json +{"ok": true, "version": "0.1.0"} +``` + +## GET /summary + +Valori pronti per la dashboard. + +```json +{ + "ok": true, + "summary": { + "outdoor_temperature": 18.1, + "indoor_temperature": 22.5, + "dhw_current": 51.0, + "dhw_target": 55.0, + "operating_status": "OPERATING_STATUS_AUTO", + "away_mode": false, + "night_mode": false, + "manual_mode": false, + "force_heating": true, + "inline_heater": false, + "led_pattern": "LED_PATTERN_NORMAL", + "zones": 1, + "errors_count": 0, + "pump_updated": "2026-10-09 18:52:17.199340" + } +} +``` + +## GET /state + +Stato completo e grezzo dal cloud (42 campi: curve climatiche per zona, +versioni firmware, schedulazioni, errori). Per diagnostica. + +## GET /commands + +Elenco dei comandi ammessi con i loro parametri. + +## POST /command + +```json +{"id": "set_hot_water_target", "value": 55} +``` + +Risposta: `{"ok": true, "id": "...", "result": {...}}`. + +### Comandi ammessi + +| id | value | effetto | +|---|---|---| +| `ping` | — | verifica che l'impianto risponda | +| `set_hot_water_target` | 45–60 | temperatura ACS obiettivo | +| `hot_water_boost_on` / `hot_water_boost_off` | — | boost ACS una tantum | +| `hot_water_heating_on` / `hot_water_heating_off` | — | riscaldamento ACS | +| `away_mode_on` / `away_mode_off` | — | modalità assenza | +| `night_mode_1h` | — | night mode per un'ora | +| `force_heating_on` / `force_heating_off` | — | riscaldamento forzato | +| `heating_on` / `heating_off` | — | funzione riscaldamento | +| `legionella_cycle` | — | ciclo antilegionella | + +### Perché una whitelist + +La libreria espone ~90 comandi, fra cui `FactoryReset`, `InstallFirmware`, +`RebootDevice`, `RotateCertificate`, `SetWifiCredentials`. Restano **fuori di +proposito**: un client mobile non deve poter riformattare l'impianto per un tap +sbagliato. + +## Errori + +| stato | significato | +|---|---| +| 400 | comando non ammesso, oppure valore fuori intervallo | +| 401 | token mancante o errato | +| 404 | rotta sconosciuta | +| 502 | il bridge non ha potuto parlare con il cloud (dettaglio in `error`) | + +Formato: `{"ok": false, "error": "..."}`. diff --git a/docs/architettura.svg b/docs/architettura.svg new file mode 100644 index 0000000..d59590e --- /dev/null +++ b/docs/architettura.svg @@ -0,0 +1,35 @@ + + + + + + + + + + Jolla · Sailfish + harbour-aira (QML/C++ + Qt 5.6, solo HTTP/1.1) + + + Bridge (server) + aira_bridge.py + pyairahome + token + + + Cloud Aira + gRPC su HTTP/2 + Cognito + engagementbff + + + JSON + + + gRPC + diff --git a/harbour-aira.pro b/harbour-aira.pro new file mode 100644 index 0000000..33c5ceb --- /dev/null +++ b/harbour-aira.pro @@ -0,0 +1,48 @@ +TEMPLATE = app +TARGET = harbour-aira + +CONFIG += sailfishapp +CONFIG += c++11 + +# Il sailfishapp.prf del kit 5.1 NON aggiunge da solo gli include QtQuick/QtQml +# (sintomo: "QQuickView: No such file or directory"). +QT += quick qml +QT += network + +SOURCES += \ + src/main.cpp \ + src/settings.cpp \ + src/apiclient.cpp \ + src/airajson.cpp + +HEADERS += \ + src/settings.h \ + src/apiclient.h \ + src/airajson.h + +OTHER_FILES += \ + qml/harbour-aira.qml \ + qml/pages/*.qml \ + qml/cover/*.qml \ + rpm/harbour-aira.spec \ + rpm/harbour-aira.desktop \ + docs/*.md + +DISTFILES += $$OTHER_FILES + +# Niente catalogo di traduzione in 0.1.0: le stringhe sorgente sono in italiano +# (app personale). Se un giorno serve lo store Harbour si aggiungono i .ts e la +# compilazione con lrelease dentro lo spec, come negli altri progetti. + +# Icone: struttura standard hicolor, path assoluti (gli install manuali con +# path relativi falliscono quando il tar sorgente e' incompleto). +icons86.path = /usr/share/icons/hicolor/86x86/apps +icons86.files = $$PWD/icons/86x86/harbour-aira.png +icons108.path = /usr/share/icons/hicolor/108x108/apps +icons108.files = $$PWD/icons/108x108/harbour-aira.png +icons128.path = /usr/share/icons/hicolor/128x128/apps +icons128.files = $$PWD/icons/128x128/harbour-aira.png +icons172.path = /usr/share/icons/hicolor/172x172/apps +icons172.files = $$PWD/icons/172x172/harbour-aira.png + +INSTALLS += icons86 icons108 icons128 icons172 diff --git a/icons/108x108/harbour-aira.png b/icons/108x108/harbour-aira.png new file mode 100644 index 0000000000000000000000000000000000000000..5605de35fdba2d7b05eb262ef32742cd02a541d6 GIT binary patch literal 4991 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 JPDHLkV1h%Uw)_A9 literal 0 HcmV?d00001 diff --git a/icons/128x128/harbour-aira.png b/icons/128x128/harbour-aira.png new file mode 100644 index 0000000000000000000000000000000000000000..53cf86b82fea831a2b0f604561cee45a2860a51c GIT binary patch literal 6015 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 literal 0 HcmV?d00001 diff --git a/icons/172x172/harbour-aira.png b/icons/172x172/harbour-aira.png new file mode 100644 index 0000000000000000000000000000000000000000..587b6abdbb7ee416ab1a0a522836a7ddddff861f GIT binary patch literal 1960 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 RRw@7h002ovPDHLkV1n$%(%b+5 literal 0 HcmV?d00001 diff --git a/qml/components/InfoRow.qml b/qml/components/InfoRow.qml new file mode 100644 index 0000000..487622e --- /dev/null +++ b/qml/components/InfoRow.qml @@ -0,0 +1,35 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +// Riga "etichetta a sinistra, valore a destra". +// NON si usa ListItemLayout: non esiste sul kit Silica 5.1 / Qt 5.6 e la pagina +// che lo contiene non si carica ("Impossibile caricare la pagina"). +Item { + id: row + + property string label: "" + property string value: "" + + width: parent ? parent.width : 0 + height: labelItem.height + Theme.paddingSmall + + Label { + id: labelItem + x: Theme.horizontalPageMargin + width: parent.width * 0.55 + text: row.label + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + truncationMode: TruncationMode.Fade + } + + Label { + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + width: parent.width * 0.4 + horizontalAlignment: Text.AlignRight + text: row.value + font.pixelSize: Theme.fontSizeSmall + truncationMode: TruncationMode.Fade + } +} diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml new file mode 100644 index 0000000..cd5905d --- /dev/null +++ b/qml/cover/CoverPage.qml @@ -0,0 +1,55 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 + +// Copertina: legge api.summary (context property C++), quindi non dipende +// dallo scope delle property della pagina principale. +CoverBackground { + id: cover + + function t(key) { + var v = api.summary[key] + return api.formatValue(v) + } + + Label { + id: titleLabel + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Theme.paddingLarge + text: qsTr("Aira") + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeLarge + } + + Column { + anchors.centerIn: parent + width: parent.width - 2 * Theme.paddingLarge + spacing: Theme.paddingSmall + + Label { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: cover.t("dhw_current") + " °C" + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeHuge + } + + Label { + width: parent.width + horizontalAlignment: Text.AlignHCenter + text: qsTr("ACS · obiettivo %1 °C").arg(cover.t("dhw_target")) + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + truncationMode: TruncationMode.Fade + } + } + + Label { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Theme.paddingMedium + text: qsTr("est. %1 °C").arg(cover.t("outdoor_temperature")) + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + } +} diff --git a/qml/harbour-aira.qml b/qml/harbour-aira.qml new file mode 100644 index 0000000..7b3bf78 --- /dev/null +++ b/qml/harbour-aira.qml @@ -0,0 +1,17 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "pages" +import "cover" + +ApplicationWindow { + id: app + + initialPage: Component { MainPage { } } + + // La copertina ha bisogno di un'istanza con id richiamata da `cover:`. + cover: coverPage + CoverPage { id: coverPage } + + // Nessun allowedOrientations qui: a livello di window limita TUTTE le + // pagine (intersezione), anche quelle che dichiarano Orientation.All. +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml new file mode 100644 index 0000000..cb1a3ac --- /dev/null +++ b/qml/pages/MainPage.qml @@ -0,0 +1,268 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 +import "../components" + +// Dashboard dell'impianto. Legge api.summary (QVariantMap dal C++), quindi non +// dipende dallo scope delle property del main qml. +Page { + id: page + + allowedOrientations: Orientation.All + + // obiettivo ACS scelto dall'utente (non sovrascritto dal refresh finche' + // non lo tocca, altrimenti lo slider "salta" a ogni lettura) + property int targetAcs: 55 + property bool targetAcsTouched: false + property string notice: "" + property bool noticeIsError: false + + function openSettings() { + pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) + } + + function showNotice(msg, isError) { + page.notice = msg + page.noticeIsError = isError === true + noticeTimer.restart() + } + + function raw(key) { + return api.summary[key] + } + + function temp(key) { + return api.formatValue(page.raw(key)) + } + + function flag(key) { + return page.raw(key) ? qsTr("sì") : qsTr("no") + } + + function count(key) { + var v = page.raw(key) + if (v === undefined || v === null) + return "—" + return String(Math.round(Number(v))) + } + + // "2026-10-09 18:52:17.199340" -> "18:52" + function shortTime(s) { + if (!s) + return "—" + var parts = String(s).split(" ") + if (parts.length < 2) + return String(s) + return parts[1].substring(0, 5) + } + + Timer { + id: noticeTimer + interval: 4000 + onTriggered: page.notice = "" + } + + RemorseItem { id: remorse } + + Connections { + target: api + onErrorOccurred: page.showNotice(message, true) + onCommandFinished: page.showNotice(message, !ok) + onSummaryChanged: { + if (!page.targetAcsTouched) { + var v = page.raw("dhw_target") + if (v !== undefined && v !== null) + page.targetAcs = Math.round(Number(v)) + } + } + } + + SilicaFlickable { + id: flick + anchors.fill: parent + contentHeight: col.height + Theme.paddingLarge + + // I menu pulley stanno DENTRO la vista: come figli diretti della Page + // fanno deref su null (TypeError + crash). + PullDownMenu { + MenuItem { + text: qsTr("Aggiorna") + onClicked: api.refresh() + } + MenuItem { + text: qsTr("Impostazioni") + onClicked: page.openSettings() + } + } + + Column { + id: col + width: parent.width + spacing: Theme.paddingSmall + + PageHeader { title: qsTr("Aira") } + + // ---- avvisi ---------------------------------------------------- // + Label { + visible: page.notice.length > 0 + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.notice + color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + + // ---- bridge non configurato ------------------------------------ // + Label { + visible: !api.configured + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("Bridge non configurato. Apri le impostazioni e inserisci l'indirizzo del server e il token.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + + Button { + visible: !api.configured + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Impostazioni") + onClicked: page.openSettings() + } + + // ---- acqua calda ---------------------------------------------- // + Label { + visible: api.configured + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Acqua calda sanitaria") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + } + + Label { + visible: api.configured + anchors.horizontalCenter: parent.horizontalCenter + text: page.temp("dhw_current") + " °C" + color: Theme.primaryColor + font.pixelSize: Theme.fontSizeHuge + } + + ProgressBar { + visible: api.configured + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 4 * Theme.horizontalPageMargin + minimumValue: 20 + maximumValue: 65 + value: { + var v = page.raw("dhw_current") + return (v === undefined || v === null) ? 0 : Number(v) + } + } + + Label { + visible: api.configured + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("obiettivo %1 °C").arg(page.temp("dhw_target")) + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + } + + // ---- stato impianto ------------------------------------------- // + SectionHeader { visible: api.configured; text: qsTr("Impianto") } + + InfoRow { visible: api.configured; label: qsTr("Temperatura esterna"); value: page.temp("outdoor_temperature") + " °C" } + InfoRow { visible: api.configured; label: qsTr("Temperatura interna"); value: page.temp("indoor_temperature") + " °C" } + InfoRow { visible: api.configured; label: qsTr("Modalità"); value: api.modeLabel(page.raw("operating_status")) } + InfoRow { visible: api.configured; label: qsTr("Assenza"); value: page.flag("away_mode") } + InfoRow { visible: api.configured; label: qsTr("Night mode"); value: page.flag("night_mode") } + InfoRow { visible: api.configured; label: qsTr("Riscaldamento forzato"); value: page.flag("force_heating") } + InfoRow { visible: api.configured; label: qsTr("Resistenze integrative"); value: page.flag("inline_heater") } + InfoRow { visible: api.configured; label: qsTr("Zone"); value: page.count("zones") } + InfoRow { visible: api.configured; label: qsTr("Errori"); value: page.count("errors_count") } + InfoRow { visible: api.configured; label: qsTr("Aggiornato alle"); value: page.shortTime(page.raw("pump_updated")) } + + // ---- comandi --------------------------------------------------- // + SectionHeader { visible: api.configured; text: qsTr("Comandi") } + + Slider { + visible: api.configured + width: parent.width + label: qsTr("Obiettivo acqua calda") + minimumValue: 45 + maximumValue: 60 + stepSize: 1 + value: page.targetAcs + valueText: value + " °C" + onValueChanged: { + page.targetAcsTouched = true + page.targetAcs = Math.round(value) + } + } + + Button { + id: setAcsButton + visible: api.configured + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + } + text: qsTr("Imposta obiettivo %1 °C").arg(page.targetAcs) + onClicked: remorse.execute(setAcsButton, + qsTr("Obiettivo ACS a %1 °C").arg(page.targetAcs), + function() { api.sendCommand("set_hot_water_target", page.targetAcs) }) + } + + Button { + id: boostOnButton + visible: api.configured + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + } + text: qsTr("Avvia boost ACS") + onClicked: remorse.execute(boostOnButton, qsTr("Avvio del boost ACS"), + function() { api.sendCommand("hot_water_boost_on") }) + } + + Button { + id: boostOffButton + visible: api.configured + anchors { + left: parent.left + right: parent.right + leftMargin: Theme.horizontalPageMargin + rightMargin: Theme.horizontalPageMargin + } + text: qsTr("Ferma boost ACS") + onClicked: remorse.execute(boostOffButton, qsTr("Arresto del boost ACS"), + function() { api.sendCommand("hot_water_boost_off") }) + } + + BusyIndicator { + visible: api.busy + anchors.horizontalCenter: parent.horizontalCenter + size: BusyIndicatorSize.Small + running: true + } + + Label { + visible: api.configured + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("I comandi vengono inviati al bridge e da lì all'impianto: possono richiedere qualche secondo.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + } + } + + Component.onCompleted: { + if (api.configured) + api.refresh() + } +} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml new file mode 100644 index 0000000..3b1c599 --- /dev/null +++ b/qml/pages/SettingsPage.qml @@ -0,0 +1,116 @@ +import QtQuick 2.2 +import Sailfish.Silica 1.0 + +// Indirizzo e token del bridge. Si salvano all'uscita dalla pagina, alla +// pressione di Invio e col pulsante Salva: NON a ogni tasto (QSettings.sync() +// su ogni carattere consuma scritture senza motivo). +Page { + id: page + + allowedOrientations: Orientation.All + + property string urlText: appSettings.baseUrl + property string tokenText: appSettings.token + property string testResult: "" + property bool testOk: false + + function save() { + appSettings.baseUrl = urlText.trim() + appSettings.token = tokenText.trim() + } + + function testAndReport() { + save() + page.testResult = qsTr("verifico…") + api.testConnection() + } + + onStatusChanged: { + if (status === PageStatus.Inactive) + save() + } + + Connections { + target: api + onConnectionTested: { + page.testOk = ok + page.testResult = message + } + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: col.height + Theme.paddingLarge + + Column { + id: col + width: parent.width + spacing: Theme.paddingMedium + + PageHeader { title: qsTr("Impostazioni") } + + TextField { + id: urlField + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + label: qsTr("Indirizzo del bridge") + placeholderText: "192.168.2.50:8790" + text: page.urlText + onTextChanged: if (text !== page.urlText) page.urlText = text + EnterKey.enabled: true + EnterKey.iconSource: "image://theme/icon-m-enter-accept" + EnterKey.onClicked: page.save() + } + + // PasswordField: mette l'icona nativa mostra/nascondi, niente + // TextField con echoMode gestito a mano. + PasswordField { + id: tokenField + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + label: qsTr("Token del bridge") + showEchoModeToggle: true + text: page.tokenText + onTextChanged: if (text !== page.tokenText) page.tokenText = text + EnterKey.enabled: true + EnterKey.iconSource: "image://theme/icon-m-enter-accept" + EnterKey.onClicked: page.save() + } + + Label { + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: qsTr("Indirizzo e token si leggono sul server: il token è in ~/.hermes/profiles/carlo/aira_bridge.json. Senza schema viene usato http://.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeExtraSmall + wrapMode: Text.Wrap + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Salva") + onClicked: { + page.save() + page.testResult = qsTr("impostazioni salvate") + page.testOk = true + } + } + + Button { + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Prova connessione") + onClicked: page.testAndReport() + } + + Label { + visible: page.testResult.length > 0 + x: Theme.horizontalPageMargin + width: parent.width - 2 * Theme.horizontalPageMargin + text: page.testResult + color: page.testOk ? Theme.highlightColor : Theme.errorColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + } + } + } +} diff --git a/rpm/harbour-aira.desktop b/rpm/harbour-aira.desktop new file mode 100644 index 0000000..e6afccb --- /dev/null +++ b/rpm/harbour-aira.desktop @@ -0,0 +1,21 @@ +[Desktop Entry] +Name=Aira +Name[it_IT]=Aira +Comment=Control your Aira heat pump +Comment[it_IT]=Controlla la pompa di calore Aira +Exec=harbour-aira +Icon=harbour-aira +Type=Application +X-Nemo-Application-Type=silica-qt5 +Categories=Utility; + +# Profilo SailJail esplicito: senza [X-Sailjail] la sandbox di Sailfish 5.x usa +# il profilo di default, che NASCONDE ~/.config/harbour -> indirizzo e token +# non sarebbero persistenti quando l'app viene avviata dal launcher. +# OrganizationName/ApplicationName devono combaciare con +# QCoreApplication::setOrganizationName/setApplicationName in main.cpp. +# Internet e' necessario: l'app parla con il bridge via HTTP. +[X-Sailjail] +Permissions=Internet +OrganizationName=harbour +ApplicationName=aira diff --git a/rpm/harbour-aira.spec b/rpm/harbour-aira.spec new file mode 100644 index 0000000..1c08a93 --- /dev/null +++ b/rpm/harbour-aira.spec @@ -0,0 +1,67 @@ +Name: harbour-aira +Summary: Control your Aira heat pump through a local bridge +Version: 0.1.0 +Release: 1 +Group: Qt/Qt +License: MIT +URL: https://git.hackatoniclife.com/kaneda/harbour-aira +Source0: %{name}-%{version}.tar.gz +Source1: %{name}.desktop + +Requires: sailfishsilica-qt5 >= 0.10.9 +BuildRequires: pkgconfig(sailfishapp) >= 1.0.2 +BuildRequires: pkgconfig(Qt5Core) +BuildRequires: pkgconfig(Qt5Qml) +BuildRequires: pkgconfig(Qt5Quick) +BuildRequires: pkgconfig(Qt5Network) +BuildRequires: desktop-file-utils + +%global debug_package %{nil} + +%description +Native Sailfish OS client for an Aira Home heat pump. + +It does not talk to the Aira cloud directly: the cloud API is gRPC over HTTP/2 +and Qt 5.6 has no HTTP/2. A small bridge on the user's server (Python, +pyairahome) keeps the credentials and exposes the plant as JSON over HTTP/1.1. +The app shows the current state and sends the hot-water commands. + +Address and token of the bridge live in the app settings (AppConfigLocation); +no personal data is stored in the binary. + +%changelog +* Fri Oct 09 2026 Carlo - 0.1.0-1 +- prima release: stato impianto, obiettivo ACS, boost ACS + +%prep +%setup -q -n %{name}-%{version} + +%build +%qmake5 +make %{?_smp_mflags} + +%install +make install INSTALL_ROOT=%{buildroot} +mkdir -p %{buildroot}%{_datadir}/applications + +# Il desktop file si cerca con find: sfdk usa i sorgenti live (shadow build) e +# la cartella sorgente puo' chiamarsi harbour-aira-0.1.0 (tarball), harbour-aira +# (clone) o harbour-aira-main (zip di Gitea). +DESKTOP=$(find %{_builddir}/.. -maxdepth 4 -name '%{name}.desktop' 2>/dev/null | head -1) +if [ -z "$DESKTOP" ] && [ -f %{SOURCE1} ]; then DESKTOP=%{SOURCE1}; fi +if [ -z "$DESKTOP" ]; then + echo "ERROR: %{name}.desktop not found" >&2 + exit 1 +fi +install -m 0644 "$DESKTOP" %{buildroot}%{_datadir}/applications/%{name}.desktop +desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop || true + +%files +%defattr(-,root,root,-) +%{_bindir}/%{name} +%{_datadir}/applications/%{name}.desktop +%{_datadir}/icons/hicolor/86x86/apps/%{name}.png +%{_datadir}/icons/hicolor/108x108/apps/%{name}.png +%{_datadir}/icons/hicolor/128x128/apps/%{name}.png +%{_datadir}/icons/hicolor/172x172/apps/%{name}.png +%{_datadir}/%{name}/qml diff --git a/scripts/make_icons.py b/scripts/make_icons.py new file mode 100644 index 0000000..32947d6 --- /dev/null +++ b/scripts/make_icons.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Genera le icone di harbour-aira (86/108/128/172 px) in icons//. + +Disegno originale (nessun logo di terzi): sfondo blu sfumato, goccia bianca e +un punto caldo arancione — acqua calda + pompa di calore. +Uso: python3 scripts/make_icons.py +""" +from PIL import Image, ImageDraw + +import pathlib + +SIZES = [86, 108, 128, 172] +BASE = 172 # si disegna grande e si ridimensiona: bordi puliti a ogni taglia + +TOP = (12, 62, 130) # blu profondo +BOTTOM = (24, 148, 190) # azzurro +WARM = (255, 138, 46) # arancione + + +def rounded_mask(size, radius): + m = Image.new("L", (size, size), 0) + ImageDraw.Draw(m).rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=255) + return m + + +def gradient(size): + img = Image.new("RGB", (1, size)) + d = ImageDraw.Draw(img) + for y in range(size): + t = y / max(1, size - 1) + d.point((0, y), tuple(int(TOP[i] + (BOTTOM[i] - TOP[i]) * t) for i in range(3))) + return img.resize((size, size)) + + +def draw_icon(size): + img = gradient(size).convert("RGBA") + d = ImageDraw.Draw(img) + + # goccia: cerchio + triangolo verso l'alto + cx = size * 0.5 + cy = size * 0.60 + r = size * 0.19 + d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 255, 255, 235)) + d.polygon([(cx, cy - 2.35 * r), (cx - r * 0.98, cy + r * 0.10), (cx + r * 0.98, cy + r * 0.10)], + fill=(255, 255, 255, 235)) + # ricentra la goccia: il triangolo allunga verso l'alto + shift = -size * 0.05 + img = img.transform(img.size, Image.AFFINE, (1, 0, 0, 0, 1, shift)) + + d = ImageDraw.Draw(img) + # punto caldo in basso a destra, dentro la goccia + wr = size * 0.085 + wx, wy = cx + r * 0.10, cy + r * 0.30 + for i in range(int(wr), 0, -1): + a = int(255 * (1 - i / wr) + 90) + d.ellipse([wx - i, wy - i, wx + i, wy + i], fill=WARM + (a,)) + + return img + + +def main(): + master = draw_icon(BASE) + master.putalpha(rounded_mask(BASE, int(BASE * 0.22))) + + for s in SIZES: + out = master.resize((s, s), Image.LANCZOS) + outdir = pathlib.Path(f"icons/{s}x{s}") + outdir.mkdir(parents=True, exist_ok=True) + path = outdir / "harbour-aira.png" + out.save(path) + print("scritto", path) + + +if __name__ == "__main__": + main() diff --git a/src/airajson.cpp b/src/airajson.cpp new file mode 100644 index 0000000..d6f912d --- /dev/null +++ b/src/airajson.cpp @@ -0,0 +1,108 @@ +#include "airajson.h" + +#include +#include +#include +#include + +namespace AiraJson { + +QString normalizeBaseUrl(const QString &raw) +{ + QString s = raw.trimmed(); + if (s.isEmpty()) + return QString(); + + if (!s.startsWith(QLatin1String("http://")) && !s.startsWith(QLatin1String("https://"))) + s.prepend(QLatin1String("http://")); + + while (s.endsWith(QLatin1Char('/'))) + s.chop(1); + + return s; +} + +QVariantMap parseSummary(const QByteArray &body, bool *ok) +{ + if (ok) + *ok = false; + + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(body, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) + return QVariantMap(); + + const QJsonObject root = doc.object(); + if (root.value(QStringLiteral("ok")).toBool() == false) + return QVariantMap(); + + const QJsonObject summary = root.value(QStringLiteral("summary")).toObject(); + if (summary.isEmpty()) + return QVariantMap(); + + if (ok) + *ok = true; + return summary.toVariantMap(); +} + +QString modeLabel(const QString &operatingStatus) +{ + const QString s = operatingStatus; + if (s == QLatin1String("OPERATING_STATUS_AUTO")) + return QStringLiteral("Automatico"); + if (s == QLatin1String("OPERATING_STATUS_HEATING")) + return QStringLiteral("Riscaldamento"); + if (s == QLatin1String("OPERATING_STATUS_COOLING")) + return QStringLiteral("Raffrescamento"); + if (s == QLatin1String("OPERATING_STATUS_HOT_WATER")) + return QStringLiteral("Acqua calda"); + if (s == QLatin1String("OPERATING_STATUS_STANDBY")) + return QStringLiteral("Stand-by"); + if (s == QLatin1String("OPERATING_STATUS_OFF")) + return QStringLiteral("Spento"); + if (s.isEmpty()) + return QStringLiteral("—"); + // Sconosciuto: togliamo il prefisso e mostriamo il resto, minuscolo. + QString rest = s; + rest.remove(QLatin1String("OPERATING_STATUS_")); + return rest.toLower(); +} + +QString formatValue(const QVariant &value) +{ + if (!value.isValid() || value.isNull()) + return QStringLiteral("—"); + + bool isNumber = false; + const double d = value.toDouble(&isNumber); + if (isNumber) + return QString::number(d, 'f', 1); + + const QString s = value.toString(); + return s.isEmpty() ? QStringLiteral("—") : s; +} + +QByteArray commandPayload(const QString &id, const QVariant &value) +{ + QJsonObject o; + o.insert(QStringLiteral("id"), id); + if (value.isValid() && !value.isNull()) + o.insert(QStringLiteral("value"), QJsonValue::fromVariant(value)); + return QJsonDocument(o).toJson(QJsonDocument::Compact); +} + +QString errorMessage(const QByteArray &body, int httpStatus) +{ + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(body, &err); + if (err.error == QJsonParseError::NoError && doc.isObject()) { + const QString msg = doc.object().value(QStringLiteral("error")).toString(); + if (!msg.isEmpty()) + return msg; + } + if (httpStatus == 0) + return QStringLiteral("server non raggiungibile"); + return QStringLiteral("errore HTTP %1").arg(httpStatus); +} + +} // namespace AiraJson diff --git a/src/airajson.h b/src/airajson.h new file mode 100644 index 0000000..45148c1 --- /dev/null +++ b/src/airajson.h @@ -0,0 +1,42 @@ +#ifndef AIRAJSON_H +#define AIRAJSON_H + +// Funzioni PURE (solo QtCore, nessun QObject): sono la parte dell'app +// verificabile senza SDK, con l'harness locale in tests/core_test.cpp. +// Il bridge HTTP parla JSON; qui stanno normalizzazione dell'indirizzo, +// lettura del riassunto e costruzione del corpo dei comandi. + +#include +#include +#include +#include + +namespace AiraJson { + +// "192.168.2.15:8790" -> "http://192.168.2.15:8790" +// "" -> "" (nessun indirizzo: il client non fa richieste) +// toglie gli slash finali e gli spazi; preserva schema e porta espliciti. +QString normalizeBaseUrl(const QString &raw); + +// Legge il corpo di GET /summary nel dizionario usato dalla UI. +// In caso di JSON non valido o di risposta di errore ritorna un dizionario +// vuoto e mette *ok a false. +QVariantMap parseSummary(const QByteArray &body, bool *ok); + +// Codice di stato Aira ("OPERATING_STATUS_AUTO") in etichetta italiana. +QString modeLabel(const QString &operatingStatus); + +// Temperatura leggibile: "51,0" oppure "—" se il valore manca. +QString formatValue(const QVariant &value); + +// Corpo JSON per POST /command. value viene omesso se nullo (i comandi +// semplici non hanno parametri). +QByteArray commandPayload(const QString &id, const QVariant &value); + +// Messaggio d'errore leggibile da una risposta del bridge +// ({"ok":false,"error":"..."}), con fallback sullo stato HTTP. +QString errorMessage(const QByteArray &body, int httpStatus); + +} // namespace AiraJson + +#endif // AIRAJSON_H diff --git a/src/apiclient.cpp b/src/apiclient.cpp new file mode 100644 index 0000000..cd76539 --- /dev/null +++ b/src/apiclient.cpp @@ -0,0 +1,196 @@ +#include "apiclient.h" +#include "airajson.h" +#include "settings.h" + +#include +#include +#include +#include +#include + +ApiClient::ApiClient(Settings *settings, QObject *parent) + : QObject(parent) + , m_settings(settings) + , m_busy(false) +{ + if (m_settings) { + // La UI deve riflettere subito il passaggio da "non configurato" a + // "configurato" quando si salvano indirizzo o token. + connect(m_settings, &Settings::baseUrlChanged, this, &ApiClient::configuredChanged); + connect(m_settings, &Settings::tokenChanged, this, &ApiClient::configuredChanged); + } +} + +bool ApiClient::configured() const +{ + if (!m_settings) + return false; + return !AiraJson::normalizeBaseUrl(m_settings->baseUrl()).isEmpty() + && !m_settings->token().trimmed().isEmpty(); +} + +void ApiClient::setBusy(bool value) +{ + if (value == m_busy) + return; + m_busy = value; + emit busyChanged(); +} + +void ApiClient::armTimeout(QNetworkReply *reply, int seconds) +{ + // QNetworkAccessManager non applica NESSUN timeout: senza watchdog una + // richiesta verso un host irraggiungibile resta appesa e la UI sembra morta. + QTimer *t = new QTimer(reply); // figlio della reply: si pulisce da solo + t->setSingleShot(true); + t->setInterval(seconds * 1000); + connect(t, &QTimer::timeout, reply, [reply]() { + if (reply->isRunning()) + reply->abort(); + }); + connect(reply, &QNetworkReply::finished, t, &QTimer::stop); + t->start(); +} + +QNetworkReply *ApiClient::send(const QByteArray &verb, const QString &path, + const QByteArray &body, int timeoutSec) +{ + const QString base = AiraJson::normalizeBaseUrl(m_settings ? m_settings->baseUrl() : QString()); + if (base.isEmpty()) { + emit errorOccurred(QStringLiteral("Indirizzo del bridge non configurato")); + return 0; + } + + const QUrl url(base + path); + QNetworkRequest request(url); // variabile intermedia: evita il most-vexing-parse + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request.setRawHeader("Authorization", "Bearer " + m_settings->token().toUtf8()); + + QNetworkReply *reply = 0; + if (verb == "GET") + reply = m_nam.get(request); + else + reply = m_nam.post(request, body); + + armTimeout(reply, timeoutSec); + return reply; +} + +void ApiClient::refresh() +{ + if (!configured()) { + emit errorOccurred(QStringLiteral("Configura indirizzo e token del bridge")); + return; + } + + setBusy(true); + QNetworkReply *reply = send("GET", QStringLiteral("/summary"), QByteArray(), 15); + if (!reply) { + setBusy(false); + return; + } + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + setBusy(false); + + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray body = reply->readAll(); + + if (reply->error() != QNetworkReply::NoError || status != 200) { + const QString msg = (reply->error() == QNetworkReply::OperationCanceledError) + ? QStringLiteral("Server non raggiungibile (timeout)") + : AiraJson::errorMessage(body, status); + qWarning() << "[api] summary non riuscito:" << status << msg; + emit errorOccurred(msg); + return; + } + + bool ok = false; + const QVariantMap summary = AiraJson::parseSummary(body, &ok); + if (!ok) { + qWarning() << "[api] riassunto non interpretabile"; + emit errorOccurred(QStringLiteral("Risposta del bridge non interpretabile")); + return; + } + m_summary = summary; + emit summaryChanged(summary); + }); +} + +void ApiClient::testConnection() +{ + const QString base = AiraJson::normalizeBaseUrl(m_settings ? m_settings->baseUrl() : QString()); + if (base.isEmpty()) { + emit connectionTested(false, QStringLiteral("Inserisci l'indirizzo del bridge")); + return; + } + + // /health non richiede token: cosi' si distingue "server irraggiungibile" + // da "token sbagliato". + const QUrl url(base + QStringLiteral("/health")); + QNetworkRequest request(url); // variabile intermedia (most vexing parse) + QNetworkReply *reply = m_nam.get(request); + armTimeout(reply, 10); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError || status != 200) { + const QString msg = (reply->error() == QNetworkReply::OperationCanceledError) + ? QStringLiteral("Timeout: nessuna risposta") + : QStringLiteral("Irraggiungibile (HTTP %1)").arg(status); + emit connectionTested(false, msg); + return; + } + emit connectionTested(true, QStringLiteral("Bridge raggiungibile")); + }); +} + +QString ApiClient::modeLabel(const QString &operatingStatus) const +{ + return AiraJson::modeLabel(operatingStatus); +} + +QString ApiClient::formatValue(const QVariant &value) const +{ + return AiraJson::formatValue(value); +} + +void ApiClient::sendCommand(const QString &id, const QVariant &value) +{ + if (!configured()) { + emit commandFinished(false, QStringLiteral("Configura indirizzo e token del bridge")); + return; + } + + const QByteArray payload = AiraJson::commandPayload(id, value); + qDebug() << "[api] comando:" << id; // mai i valori: qui potrebbe passare altro + + QNetworkReply *reply = send("POST", QStringLiteral("/command"), payload, 30); + if (!reply) { + emit commandFinished(false, QStringLiteral("Bridge non configurato")); + return; + } + + setBusy(true); + connect(reply, &QNetworkReply::finished, this, [this, reply, id]() { + reply->deleteLater(); + setBusy(false); + + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray body = reply->readAll(); + + if (reply->error() != QNetworkReply::NoError || status != 200) { + const QString msg = (reply->error() == QNetworkReply::OperationCanceledError) + ? QStringLiteral("Timeout: comando non inviato") + : AiraJson::errorMessage(body, status); + qWarning() << "[api] comando" << id << "fallito:" << status << msg; + emit commandFinished(false, msg); + return; + } + + emit commandFinished(true, QStringLiteral("Comando inviato")); + refresh(); // i valori letti cambiano: rileggo + }); +} diff --git a/src/apiclient.h b/src/apiclient.h new file mode 100644 index 0000000..f64431b --- /dev/null +++ b/src/apiclient.h @@ -0,0 +1,66 @@ +#ifndef APICLIENT_H +#define APICLIENT_H + +#include +#include +#include +#include + +class QNetworkReply; +class Settings; + +// Client HTTP verso il bridge sul server (~/workspace/Carlo/Aira/aira_bridge.py). +// Non parla con il cloud Aira: Qt 5.6 non ha HTTP/2, quindi il gRPC resta sul +// server e qui arrivano solo JSON. +class ApiClient : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(bool configured READ configured NOTIFY configuredChanged) + // Ultimo riassunto letto. Sta in C++ (context property) e non nel QML: + // le property dell'ApplicationWindow NON sono visibili alle pagine dello + // pageStack, quindi la dashboard e la copertina non potrebbero condividerle. + Q_PROPERTY(QVariantMap summary READ summary NOTIFY summaryChanged) + +public: + explicit ApiClient(Settings *settings, QObject *parent = 0); + + bool busy() const { return m_busy; } + bool configured() const; + QVariantMap summary() const { return m_summary; } + + // Scarica il riassunto e lo emette con summaryChanged(). + Q_INVOKABLE void refresh(); + + // Prova GET /health (non richiede token) e risponde con connectionTested(). + Q_INVOKABLE void testConnection(); + + // Invia un comando: sendCommand("set_hot_water_target", 55). + Q_INVOKABLE void sendCommand(const QString &id, const QVariant &value = QVariant()); + + // Formattazione per la UI: unica fonte di verita' in C++ (testata in + // tests/core_test.cpp), niente duplicazione di logica nel QML. + Q_INVOKABLE QString modeLabel(const QString &operatingStatus) const; + Q_INVOKABLE QString formatValue(const QVariant &value) const; + +signals: + void busyChanged(); + void configuredChanged(); + void summaryChanged(const QVariantMap &summary); + void commandFinished(bool ok, const QString &message); + void connectionTested(bool ok, const QString &message); + void errorOccurred(const QString &message); + +private: + QNetworkReply *send(const QByteArray &verb, const QString &path, + const QByteArray &body, int timeoutSec); + void armTimeout(QNetworkReply *reply, int seconds); + void setBusy(bool value); + + Settings *m_settings; + QNetworkAccessManager m_nam; + bool m_busy; + QVariantMap m_summary; +}; + +#endif // APICLIENT_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..0a850df --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,41 @@ +// main.cpp — punti che NON vanno cambiati: +// * pathTo() per il QML: un QUrl relativo si risolve contro la CWD e dal +// launcher la prima schermata resta BIANCA senza errori; +// * org/app allineati alla sezione [X-Sailjail] del desktop file, altrimenti +// la sandbox nasconde ~/.config/harbour/aira e le impostazioni non persistono; +// * context property (appSettings, api) invece di singleton. +#include +#include +#include +#include +#include +#include + +#include "apiclient.h" +#include "settings.h" + +Q_DECL_EXPORT int main(int argc, char *argv[]) +{ + QGuiApplication *app = SailfishApp::application(argc, argv); + + app->setOrganizationName(QStringLiteral("harbour")); + app->setApplicationName(QStringLiteral("aira")); + app->setApplicationVersion(QStringLiteral("0.1.0")); + + Settings settings; + ApiClient api(&settings); + + QQuickView *view = SailfishApp::createView(); + + // Prima riga del log: dice subito se il device sta girando la build nuova + // o una stale (trappola nota del build engine). + qDebug() << "harbour-aira v0.1.0 build" << __DATE__ << __TIME__; + + view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings); + view->rootContext()->setContextProperty(QStringLiteral("api"), &api); + + view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-aira.qml"))); + view->show(); + + return app->exec(); +} diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..b442be0 --- /dev/null +++ b/src/settings.cpp @@ -0,0 +1,54 @@ +#include "settings.h" + +#include +#include +#include + +QString Settings::configPath() +{ + // Path canonico per la sandbox SailJail: con org/app impostati in main.cpp + // (harbour/aira) AppConfigLocation = ~/.config/harbour/aira, l'unica zona + // config persistente tra gli avvii. + const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + QDir().mkpath(dir); + return dir + QStringLiteral("/aira.conf"); +} + +Settings::Settings(QObject *parent) + : QObject(parent) + , m_settings(configPath(), QSettings::IniFormat) +{ + // Diagnostica: senza questa riga un problema di persistenza resta invisibile. + qDebug() << "Settings file:" << m_settings.fileName(); + if (m_settings.status() != QSettings::NoError) + qDebug() << "Settings WARNING: file di configurazione non accessibile"; + qDebug() << "Settings read: baseUrl=[" << baseUrl() << "] token impostato:" << !token().isEmpty(); +} + +QString Settings::baseUrl() const +{ + return m_settings.value(QStringLiteral("baseUrl"), QString()).toString(); +} + +void Settings::setBaseUrl(const QString &value) +{ + if (value == baseUrl()) + return; + m_settings.setValue(QStringLiteral("baseUrl"), value); + m_settings.sync(); // le chiusure brusche perdono il flush del distruttore + emit baseUrlChanged(); +} + +QString Settings::token() const +{ + return m_settings.value(QStringLiteral("token"), QString()).toString(); +} + +void Settings::setToken(const QString &value) +{ + if (value == token()) + return; + m_settings.setValue(QStringLiteral("token"), value); + m_settings.sync(); + emit tokenChanged(); +} diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..c2891e2 --- /dev/null +++ b/src/settings.h @@ -0,0 +1,39 @@ +#ifndef SETTINGS_H +#define SETTINGS_H + +#include +#include + +// Preferenze di harbour-aira, persistite in AppConfigLocation +// (~/.config/harbour/aira/aira.conf) perche' la sandbox SailJail rende +// persistente SOLO quella zona: con QSettings org/app le impostazioni +// sparivano quando l'app veniva avviata dal launcher. +// +// baseUrl indirizzo del bridge, es. "192.168.2.50:8790" (schema opzionale) +// token token del bridge (Authorization: Bearer ...) +class Settings : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged) + Q_PROPERTY(QString token READ token WRITE setToken NOTIFY tokenChanged) + +public: + explicit Settings(QObject *parent = 0); + + QString baseUrl() const; + void setBaseUrl(const QString &value); + + QString token() const; + void setToken(const QString &value); + +signals: + void baseUrlChanged(); + void tokenChanged(); + +private: + static QString configPath(); + QSettings m_settings; +}; + +#endif // SETTINGS_H diff --git a/tests/core_test.cpp b/tests/core_test.cpp new file mode 100644 index 0000000..6624b0c --- /dev/null +++ b/tests/core_test.cpp @@ -0,0 +1,93 @@ +// Verifica locale del nucleo di harbour-aira, senza SDK Sailfish. +// Copre la parte pura (src/airajson.cpp): normalizzazione dell'indirizzo, +// lettura del riassunto, etichette di stato, corpo dei comandi, messaggi +// d'errore. Il networking e la UI si verificano sul device. +// +// Build: ~/Qt/5.15.2/gcc_64/bin/qmake tests/core_test.pro && make && ./core_test +#include +#include +#include +#include +#include + +#include "../src/airajson.h" + +static int failures = 0; +static int checks = 0; + +static void check(bool cond, const QString &what) +{ + ++checks; + if (!cond) { + ++failures; + qWarning() << "FALLITO:" << what; + } else { + qInfo() << "ok:" << what; + } +} + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + + // ---- normalizeBaseUrl ------------------------------------------------- // + check(AiraJson::normalizeBaseUrl("192.168.2.50:8790") == "http://192.168.2.50:8790", + "host:porta riceve lo schema http"); + check(AiraJson::normalizeBaseUrl("http://192.168.2.50:8790") == "http://192.168.2.50:8790", + "schema http preservato"); + check(AiraJson::normalizeBaseUrl("https://aira.example.tld/bridge/") == "https://aira.example.tld/bridge", + "https preservato, slash finale tolto"); + check(AiraJson::normalizeBaseUrl(" 192.168.2.50:8790// ") == "http://192.168.2.50:8790", + "spazi e slash ripetuti normalizzati"); + check(AiraJson::normalizeBaseUrl("").isEmpty(), "stringa vuota resta vuota (nessuna richiesta)"); + check(AiraJson::normalizeBaseUrl(" ").isEmpty(), "solo spazi = vuoto"); + + // ---- parseSummary ----------------------------------------------------- // + const QByteArray good = R"({"ok":true,"summary":{"outdoor_temperature":18.1,"dhw_current":51.0,"dhw_target":55.0,"operating_status":"OPERATING_STATUS_AUTO","away_mode":false,"errors_count":0}})"; + bool ok = false; + QVariantMap m = AiraJson::parseSummary(good, &ok); + check(ok, "riassunto valido riconosciuto"); + check(qFuzzyCompare(m.value("dhw_current").toDouble(), 51.0), "dhw_current letto"); + check(m.value("operating_status").toString() == "OPERATING_STATUS_AUTO", "operating_status letto"); + + QVariantMap bad = AiraJson::parseSummary("non sono json", &ok); + check(!ok && bad.isEmpty(), "JSON non valido -> vuoto con ok=false"); + + QVariantMap err = AiraJson::parseSummary(R"({"ok":false,"error":"token errato"})", &ok); + check(!ok && err.isEmpty(), "risposta di errore -> vuoto"); + check(AiraJson::parseSummary(R"({"ok":true})", &ok).isEmpty() && !ok, + "ok=true senza summary -> vuoto"); + + // ---- modeLabel -------------------------------------------------------- // + check(AiraJson::modeLabel("OPERATING_STATUS_AUTO") == "Automatico", "AUTO -> Automatico"); + check(AiraJson::modeLabel("OPERATING_STATUS_HOT_WATER") == "Acqua calda", "acqua calda"); + check(AiraJson::modeLabel("") == "—", "stato vuoto -> trattino"); + check(AiraJson::modeLabel("OPERATING_STATUS_DEFROST") == "defrost", + "stato sconosciuto: prefisso tolto, minuscolo"); + + // ---- formatValue ------------------------------------------------------ // + check(AiraJson::formatValue(QVariant(51.0)) == "51.0", "numero a un decimale"); + check(AiraJson::formatValue(QVariant(18.14)) == "18.1", "arrotondamento a un decimale"); + check(AiraJson::formatValue(QVariant()) == "—", "valore assente -> trattino"); + check(AiraJson::formatValue(QVariant(QString())) == "—", "stringa vuota -> trattino"); + + // ---- commandPayload --------------------------------------------------- // + const QJsonObject c1 = QJsonDocument::fromJson( + AiraJson::commandPayload("set_hot_water_target", 55)).object(); + check(c1.value("id").toString() == "set_hot_water_target", "id nel corpo del comando"); + check(c1.value("value").toInt() == 55, "valore numerico nel corpo"); + + const QJsonObject c2 = QJsonDocument::fromJson( + AiraJson::commandPayload("hot_water_boost_on", QVariant())).object(); + check(c2.contains("value") == false, "comando senza parametri: nessun campo value"); + + // ---- errorMessage ----------------------------------------------------- // + check(AiraJson::errorMessage(R"({"ok":false,"error":"token mancante o errato"})", 401) + == "token mancante o errato", "messaggio d'errore dal corpo"); + check(AiraJson::errorMessage("", 502) == "errore HTTP 502", "fallback sullo stato HTTP"); + check(AiraJson::errorMessage("", 0) == "server non raggiungibile", "stato 0 = irraggiungibile"); + + qInfo() << "----"; + qInfo() << checks - failures << "/" << checks << "verifiche superate"; + return failures == 0 ? 0 : 1; +} diff --git a/tests/core_test.pro b/tests/core_test.pro new file mode 100644 index 0000000..27aa5c0 --- /dev/null +++ b/tests/core_test.pro @@ -0,0 +1,16 @@ +QT += core +QT -= gui + +CONFIG += console c++11 +CONFIG -= app_bundle + +TARGET = core_test + +INCLUDEPATH += ../src + +SOURCES += \ + core_test.cpp \ + ../src/airajson.cpp + +HEADERS += \ + ../src/airajson.h