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