harbour-aira 0.1.0: client Sailfish per pompa di calore Aira (bridge HTTP + stato e comandi ACS)

This commit is contained in:
Carlo
2026-10-09 21:00:59 +02:00
commit 15963039ea
27 changed files with 1686 additions and 0 deletions
+16
View File
@@ -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
+88
View File
@@ -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.
+95
View File
@@ -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 <token>` 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`).
+95
View File
@@ -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>
```
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": "..."}`.
+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="200" viewBox="0 0 720 200">
<style>
.box { fill: #10243c; stroke: #3f6d8f; stroke-width: 2; rx: 10; }
.title { fill: #eaf2f8; font-family: sans-serif; font-size: 16px; font-weight: bold; }
.sub { fill: #9fb6c9; font-family: sans-serif; font-size: 12px; }
.arrow { stroke: #7fc8e8; stroke-width: 2; fill: none; marker-end: url(#a); }
.lbl { fill: #7fc8e8; font-family: sans-serif; font-size: 11px; }
</style>
<defs>
<marker id="a" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto">
<path d="M0,0 L10,4 L0,8 z" fill="#7fc8e8"/>
</marker>
</defs>
<rect class="box" x="20" y="60" width="180" height="80"/>
<text class="title" x="110" y="92" text-anchor="middle">Jolla · Sailfish</text>
<text class="sub" x="110" y="112" text-anchor="middle">harbour-aira (QML/C++</text>
<text class="sub" x="110" y="128" text-anchor="middle">Qt 5.6, solo HTTP/1.1)</text>
<rect class="box" x="270" y="60" width="180" height="80"/>
<text class="title" x="360" y="92" text-anchor="middle">Bridge (server)</text>
<text class="sub" x="360" y="112" text-anchor="middle">aira_bridge.py</text>
<text class="sub" x="360" y="128" text-anchor="middle">pyairahome + token</text>
<rect class="box" x="520" y="60" width="180" height="80"/>
<text class="title" x="610" y="92" text-anchor="middle">Cloud Aira</text>
<text class="sub" x="610" y="112" text-anchor="middle">gRPC su HTTP/2</text>
<text class="sub" x="610" y="128" text-anchor="middle">Cognito + engagementbff</text>
<line class="arrow" x1="200" y1="100" x2="266" y2="100"/>
<text class="lbl" x="233" y="90" text-anchor="middle">JSON</text>
<line class="arrow" x1="450" y1="100" x2="516" y2="100"/>
<text class="lbl" x="483" y="90" text-anchor="middle">gRPC</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+48
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+35
View File
@@ -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
}
}
+55
View File
@@ -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
}
}
+17
View File
@@ -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.
}
+268
View File
@@ -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()
}
}
+116
View File
@@ -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
}
}
}
}
+21
View File
@@ -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
+67
View File
@@ -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 <carlo@carlobaratto.it> - 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
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Genera le icone di harbour-aira (86/108/128/172 px) in icons/<size>/.
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()
+108
View File
@@ -0,0 +1,108 @@
#include "airajson.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QUrl>
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
+42
View File
@@ -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 <QByteArray>
#include <QString>
#include <QVariant>
#include <QVariantMap>
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
+196
View File
@@ -0,0 +1,196 @@
#include "apiclient.h"
#include "airajson.h"
#include "settings.h"
#include <QDebug>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QTimer>
#include <QUrl>
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
});
}
+66
View File
@@ -0,0 +1,66 @@
#ifndef APICLIENT_H
#define APICLIENT_H
#include <QNetworkAccessManager>
#include <QObject>
#include <QVariant>
#include <QVariantMap>
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
+41
View File
@@ -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 <QCoreApplication>
#include <QDebug>
#include <QGuiApplication>
#include <QQmlContext>
#include <QQuickView>
#include <sailfishapp.h>
#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();
}
+54
View File
@@ -0,0 +1,54 @@
#include "settings.h"
#include <QDebug>
#include <QDir>
#include <QStandardPaths>
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();
}
+39
View File
@@ -0,0 +1,39 @@
#ifndef SETTINGS_H
#define SETTINGS_H
#include <QObject>
#include <QSettings>
// 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
+93
View File
@@ -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 <QCoreApplication>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDebug>
#include <QStringList>
#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;
}
+16
View File
@@ -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