harbour-aira 0.1.0: client Sailfish per pompa di calore Aira (bridge HTTP + stato e comandi ACS)
This commit is contained in:
+16
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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`).
|
||||||
@@ -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": "..."}`.
|
||||||
@@ -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 |
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user