0.5.0: riordino come l'app ufficiale (home, Dati, Impianto)

- home: temperatura esterna e in casa/assente in alto, messaggio di stato,
  scheda impianto con temperatura stanza e acqua calda; comandi sotto
- pagina Dati: calore prodotto, elettricita' usata, COP acqua calda e
  riscaldamento, risparmio, CO2 evitata, Smart Tariff Control (mese per mese)
- pagina Impianto: stato, termostato a parete, versioni software, errori
- bridge 0.4.0: rotta /stats (servizio statistiche del cloud) e blocco
  dispositivo in /summary (ID breve, versioni, termostato)
- C++: parseStats, formatNumber, formatMoney, monthLabel (separatore e
  valuta secondo la lingua); 61/61 verifiche del nucleo
- anteprime aggiornate (dati, impianto) in inglese e italiano
This commit is contained in:
2026-10-10 08:59:03 +02:00
parent ec7333a185
commit 4f279a18b0
30 changed files with 2355 additions and 353 deletions
+56 -16
View File
@@ -13,15 +13,43 @@ dell'impianto e invia i comandi dell'acqua calda sanitaria e del riscaldamento
L'interfaccia è in **inglese e italiano**: di serie segue la lingua del telefono L'interfaccia è in **inglese e italiano**: di serie segue la lingua del telefono
e ripiega sull'inglese; la si può scegliere anche nelle impostazioni dell'app. e ripiega sull'inglese; la si può scegliere anche nelle impostazioni dell'app.
## Cosa mostra
- **Home**, ordinata come l'app ufficiale: temperatura esterna e *in casa /
assente* in alto, una frase di stato in chiaro ("Relax: la pompa di calore
funziona regolarmente"), poi la **scheda dell'impianto** con le due letture che
contano (temperatura stanza e acqua calda, ognuna con il suo obiettivo).
Toccando la scheda si apre la pagina dell'impianto. Sotto, i comandi:
obiettivo della casa con slider 5–30 °C (passi da 0,5 °C) e conferma prima
dell'invio, obiettivo ACS (45–60 °C) e i pulsanti del boost
- **Dati**: calore prodotto, elettricità usata, efficienza (COP) di acqua calda e
riscaldamento, risparmio in bolletta, CO₂ evitata e risparmio Smart Tariff
Control, mese per mese. Sono i numeri che Aira calcola nel cloud, gli stessi
della scheda *Dati* dell'app ufficiale
- **Impianto**: stato (connessione, modalità, assenza/notte/riscaldamento
forzato, temperature esterna e interna, zone, ultima lettura), accessori (il
termostato a parete con temperatura, umidità, segnale, batteria), versioni del
software e numero di errori
- **Altri comandi**: assenza, night mode per un'ora, riscaldamento forzato,
funzione riscaldamento on/off, ciclo legionella, ACS on/off e verifica
impianto, con lo stato attuale in cima
- **Copertina** con la temperatura della casa
- Indirizzo e token del bridge stanno nelle impostazioni dell'app (QSettings),
mai nel binario
![architettura](docs/architettura.svg) ![architettura](docs/architettura.svg)
Anteprime del layout (valori veri dell'impianto, **non** screenshot — vedi Anteprime del layout (valori veri dell'impianto, **non** screenshot — vedi
*Stato*). Queste sono in italiano; quelle in inglese stanno in *Stato*). Queste sono in italiano; quelle in inglese stanno in
`docs/anteprima/`: `docs/anteprima/`:
| Home | Altri comandi | Impostazioni | Copertina | | Home | Dati | Impianto |
|---|---|---|---| |---|---|---|
| ![home](docs/anteprima/it/dashboard.png) | ![comandi](docs/anteprima/it/comandi.png) | ![impostazioni](docs/anteprima/it/impostazioni.png) | ![copertina](docs/anteprima/it/copertina.png) | | ![home](docs/anteprima/it/dashboard.png) | ![dati](docs/anteprima/it/dati.png) | ![impianto](docs/anteprima/it/impianto.png) |
| Altri comandi | Impostazioni | Copertina |
|---|---|---|
| ![comandi](docs/anteprima/it/comandi.png) | ![impostazioni](docs/anteprima/it/impostazioni.png) | ![copertina](docs/anteprima/it/copertina.png) |
## La lingua dell'interfaccia ## La lingua dell'interfaccia
@@ -106,29 +134,37 @@ cd tests && mkdir -p build && cd build
``` ```
Copre la logica pura (`src/airajson.cpp`): normalizzazione dell'indirizzo, Copre la logica pura (`src/airajson.cpp`): normalizzazione dell'indirizzo,
lettura del riassunto, etichette di stato, corpo dei comandi, messaggi lettura del riassunto e delle statistiche, etichette di stato, corpo dei comandi,
d'errore. **La UI Silica si verifica solo sul dispositivo.** messaggi d'errore e la formattazione di numeri, importi e mesi della pagina Dati.
**La UI Silica si verifica solo sul dispositivo.**
## Stato ## Stato
- **39/39** verifiche del nucleo, compilazione senza warning - **61/61** verifiche del nucleo, compilazione senza warning
- **27/27** verifiche offline del bridge (`bridge/test_bridge_commands.py`) - **48/48** verifiche offline del bridge (`bridge/test_bridge_commands.py`),
- **99/99** stringhe tradotte nel catalogo italiano: il test carica il `.qm` comprese le conversioni Wh→kWh e degli importi delle statistiche
- **170/170** stringhe tradotte nel catalogo italiano: il test carica il `.qm`
compilato come fa `main.cpp` e verifica che in inglese l'etichetta di stato sia compilato come fa `main.cpp` e verifica che in inglese l'etichetta di stato sia
la sorgente e in italiano quella tradotta, e che togliendo il catalogo si torni la sorgente e in italiano quella tradotta, e che togliendo il catalogo si torni
all'inglese all'inglese
- numeri formattati con `QLocale`: il separatore decimale segue la lingua - numeri, importi e nomi dei mesi formattati con `QLocale`: separatore decimale e
(verificato per entrambe) posizione del simbolo di valuta seguono la lingua (verificato per entrambe)
- `qmllint` pulito su tutti i QML (valida solo la sintassi: senza il modulo - `qmllint` pulito su tutti i QML (valida solo la sintassi: senza il modulo
Silica una proprietà inesistente passa in silenzio) Silica una proprietà inesistente passa in silenzio)
- provata dal vivo in **sola lettura** su un impianto Aira Home - provata dal vivo in **sola lettura** su un impianto Aira Home, statistiche
comprese: coincidono con quelle mostrate dall'app ufficiale per lo stesso
periodo (calore prodotto 70,8 kWh, elettricità 29,3 kWh, COP acqua calda 4,42,
risparmio Smart Tariff Control 2,71 €)
- **l'app non è ancora stata compilata né eseguita su un telefono**: le immagini - **l'app non è ancora stata compilata né eseguita su un telefono**: le immagini
in `docs/anteprima/` sono anteprime di layout costruite con i valori veri in `docs/anteprima/` sono anteprime di layout costruite con i valori veri
dell'impianto, non screenshot dell'impianto, non screenshot
Non c'è ancora la UI per il raffrescamento (il bridge sa farlo), né orari e Non c'è ancora la UI per il raffrescamento (il bridge sa farlo), né orari e
curve; non è nella Jolla Store: è un client non ufficiale che parla con le API curve; le versioni del software si vedono in sola lettura. La pagina Dati dipende
Aira usando il proprio account. dal servizio statistiche di Aira: su un impianto appena installato qualche mese
non ha ancora dati e l'app scrive *nessun dato* invece del numero. Non è nella
Jolla Store: è un client non ufficiale che parla con le API Aira usando il proprio
account.
## Struttura ## Struttura
@@ -141,16 +177,20 @@ src/translator.{h,cpp} lingua dell'interfaccia: locale di sistema, ripiego
sull'inglese, ricarica della UI al cambio sull'inglese, ricarica della UI al cambio
src/main.cpp context properties: appSettings, api, translator src/main.cpp context properties: appSettings, api, translator
qml/harbour-aira.qml ApplicationWindow + copertina qml/harbour-aira.qml ApplicationWindow + copertina
qml/pages/MainPage.qml dashboard e comandi acqua/riscaldamento qml/pages/MainPage.qml home: stato, scheda impianto, riscaldamento e acqua
qml/pages/DataPage.qml energia, efficienza e risparmi
qml/pages/PlantPage.qml stato, accessori, versioni, errori
qml/pages/CommandsPage.qml gli altri comandi, con lo stato attuale in cima qml/pages/CommandsPage.qml gli altri comandi, con lo stato attuale in cima
qml/pages/SettingsPage.qml qml/pages/SettingsPage.qml
qml/components/InfoRow.qml qml/components/InfoRow.qml etichetta a sinistra, valore a destra
qml/components/ValueRow.qml titolo e sottotitolo a sinistra, valore grande a destra
qml/components/ActionButton.qml pulsante di comando, con conferma opzionale qml/components/ActionButton.qml pulsante di comando, con conferma opzionale
tests/core_test.cpp verifiche del nucleo (headless) tests/core_test.cpp verifiche del nucleo (headless)
bridge/aira_bridge.py servizio HTTP/1.1 + JSON verso il cloud (gRPC) bridge/aira_bridge.py servizio HTTP/1.1 + JSON verso il cloud (gRPC)
bridge/setup_creds.py salva le credenziali Aira (file 600) bridge/setup_creds.py salva le credenziali Aira (file 600)
bridge/aira_probe.py sonda di sola lettura: login + stato impianto bridge/aira_probe.py sonda di sola lettura: login + stato impianto
bridge/test_bridge_commands.py verifiche offline dei comandi (27 controlli) bridge/aira_stats_probe.py sonda di sola lettura delle statistiche del cloud
bridge/test_bridge_commands.py verifiche offline dei comandi e delle statistiche (48 controlli)
bridge/harbour-aira-bridge.service unit systemd utente bridge/harbour-aira-bridge.service unit systemd utente
bridge/README.md istruzioni di installazione del bridge (inglese) bridge/README.md istruzioni di installazione del bridge (inglese)
docs/PIANO.md piano di progetto e scelte docs/PIANO.md piano di progetto e scelte
+49 -25
View File
@@ -9,12 +9,21 @@ status, domestic hot water, and the house target temperature per zone.
## What it does ## What it does
- **Home**: house temperature and room temperature at the top, target with a - **Home**, organised like the official Aira app: outdoor temperature and
5–30 °C slider (0.5 °C steps) and a confirmation before the command is sent; *at home / away* at the top, a plain-language status line ("Relax: the heat
domestic hot water (current, target, boost) in its own section below; plant pump is running smoothly"), then the **plant card** with the two readings that
status at the bottom (outdoor and indoor temperature, operating mode, matter (room temperature and domestic hot water, each with its target).
away/night/manual mode, inline heater, number of zones, errors, time of the Tapping the card opens the plant page. Below that the controls: house target
last reading) with a 5–30 °C slider (0.5 °C steps) and a confirmation before the command
leaves, domestic hot water target (45–60 °C) and the boost buttons
- **Data** page: heat produced, electricity used, efficiency (COP) for hot water
and heating, savings on the bill, CO₂ avoided and the Smart Tariff Control
saving, month by month. These are the numbers Aira computes in the cloud,
exactly as in the official app's *Data* tab
- **Plant** page: status (connection, mode, away/night/forced heating, outdoor
and indoor temperature, zones, last reading), accessories (the wall thermostat
with temperature, humidity, signal, battery), software versions and the error
count
- **Other commands** page: away mode, night mode for one hour, force heating, - **Other commands** page: away mode, night mode for one hour, force heating,
heating function on/off, legionella cycle, DHW heating on/off and a plant heating function on/off, legionella cycle, DHW heating on/off and a plant
check — with the current state at the top, so nothing is pressed blindly check — with the current state at the top, so nothing is pressed blindly
@@ -30,9 +39,13 @@ status, domestic hot water, and the house target temperature per zone.
Layout previews (rendered with the real values the plant returned, **not** Layout previews (rendered with the real values the plant returned, **not**
screenshots — see *Status*): screenshots — see *Status*):
| Home | Other commands | Settings | Cover | | Home | Data | Plant |
|---|---|---|---| |---|---|---|
| ![home](docs/anteprima/dashboard.png) | ![commands](docs/anteprima/comandi.png) | ![settings](docs/anteprima/impostazioni.png) | ![cover](docs/anteprima/copertina.png) | | ![home](docs/anteprima/dashboard.png) | ![data](docs/anteprima/dati.png) | ![plant](docs/anteprima/impianto.png) |
| Other commands | Settings | Cover |
|---|---|---|
| ![commands](docs/anteprima/comandi.png) | ![settings](docs/anteprima/impostazioni.png) | ![cover](docs/anteprima/copertina.png) |
## Why there is a bridge ## Why there is a bridge
@@ -104,26 +117,31 @@ cd tests && mkdir -p build && cd build
~/Qt/5.15.2/gcc_64/bin/qmake ../core_test.pro && make && ./core_test ~/Qt/5.15.2/gcc_64/bin/qmake ../core_test.pro && make && ./core_test
``` ```
Covers the pure logic (`src/airajson.cpp`): address normalisation, summary Covers the pure logic (`src/airajson.cpp`): address normalisation, summary and
parsing, mode labels, command payloads, error messages. statistics parsing, mode labels, command payloads, error messages, and the
number/currency/month formatting used by the Data page.
**The Silica UI can only be verified on a device.** **The Silica UI can only be verified on a device.**
## Status ## Status
Verified so far: Verified so far:
- **39/39** core checks (`tests/core_test.cpp`), compiled with no warnings - **61/61** core checks (`tests/core_test.cpp`), compiled with no warnings
- **27/27** bridge checks, offline (`bridge/test_bridge_commands.py`) — - **48/48** bridge checks, offline (`bridge/test_bridge_commands.py`) —
including that a zone command always carries every zone, and that absurd including that a zone command always carries every zone, that absurd values
values are refused (40 °C, zone 3, text) are refused (40 °C, zone 3, text), and the Wh→kWh / money conversions of the
- **99/99** strings translated in the Italian catalogue, checked by loading the statistics
compiled `.qm` in the test the same way `main.cpp` does: source English in, - **170/170** strings translated in the Italian catalogue, checked by loading
Italian out, and back to English when the catalogue is removed the compiled `.qm` in the test the same way `main.cpp` does: source English
- numbers formatted through `QLocale`, so the decimal separator follows the in, Italian out, and back to English when the catalogue is removed
language (verified for both) - numbers, amounts and month names formatted through `QLocale`, so the decimal
separator and the currency position follow the language (verified for both)
- `qmllint` clean on every QML file. Note it validates syntax only: without the - `qmllint` clean on every QML file. Note it validates syntax only: without the
Silica module an invalid Silica property passes silently Silica module an invalid Silica property passes silently
- read-only queries against one real Aira Home unit, through the bridge - read-only queries against one real Aira Home unit, through the bridge —
including the cloud statistics, which match what the official app shows for
the same period (heat produced 70.8 kWh, electricity 29.3 kWh, hot-water COP
4.42, Smart Tariff Control saving €2.71)
- the bridge runs as a systemd user unit and comes back by itself after a kill - the bridge runs as a systemd user unit and comes back by itself after a kill
Not verified: **the app has not been built or run on a phone yet** — the first Not verified: **the app has not been built or run on a phone yet** — the first
@@ -144,8 +162,11 @@ Caveats:
- tested against **one** unit so far (firmware: outdoor unit 1.30.0, system - tested against **one** unit so far (firmware: outdoor unit 1.30.0, system
6.10.0, climate control 3.9.12); other units and other countries may expose 6.10.0, climate control 3.9.12); other units and other countries may expose
different fields different fields
- cooling is reachable from the bridge but has no UI yet; schedules/curves and - cooling is reachable from the bridge but has no UI yet; schedules/curves are
firmware info are not implemented not implemented, and firmware versions are shown read-only
- the Data page depends on Aira's own statistics service: on a very new
installation some months have no data yet (the app then writes *no data*
instead of a number)
- only a whitelist of commands is exposed: factory reset, firmware updates, - only a whitelist of commands is exposed: factory reset, firmware updates,
reboots and Wi-Fi provisioning are deliberately unreachable reboots and Wi-Fi provisioning are deliberately unreachable
- no command is ever sent to the plant by the tests - no command is ever sent to the plant by the tests
@@ -171,10 +192,13 @@ src/settings.{h,cpp} QSettings in AppConfigLocation (SailJail sandbox)
src/translator.{h,cpp} UI language: system locale, English fallback, reload src/translator.{h,cpp} UI language: system locale, English fallback, reload
src/main.cpp context properties: appSettings, api, translator src/main.cpp context properties: appSettings, api, translator
qml/harbour-aira.qml ApplicationWindow + cover qml/harbour-aira.qml ApplicationWindow + cover
qml/pages/MainPage.qml dashboard and water/heating commands qml/pages/MainPage.qml home: status, plant card, heating and water
qml/pages/DataPage.qml energy, efficiency and savings
qml/pages/PlantPage.qml status, accessories, versions, errors
qml/pages/CommandsPage.qml the other commands, current state on top qml/pages/CommandsPage.qml the other commands, current state on top
qml/pages/SettingsPage.qml qml/pages/SettingsPage.qml
qml/components/InfoRow.qml qml/components/InfoRow.qml label on the left, value on the right
qml/components/ValueRow.qml title + subtitle left, big value right
qml/components/ActionButton.qml command button, optional confirmation qml/components/ActionButton.qml command button, optional confirmation
tests/core_test.cpp core checks (headless) tests/core_test.cpp core checks (headless)
bridge/ local HTTP bridge -> see bridge/README.md bridge/ local HTTP bridge -> see bridge/README.md
+6 -3
View File
@@ -109,8 +109,9 @@ not traverse the input chain, so it always works).
| Route | Auth | Purpose | | Route | Auth | Purpose |
|---|---|---| |---|---|---|
| `GET /health` | none | `{ok, version}` — liveness, used above all for debugging | | `GET /health` | none | `{ok, version}` — liveness, used above all for debugging |
| `GET /summary` | token | dashboard values (house/DHW temperatures, targets, modes, errors) | | `GET /summary` | token | dashboard values (house/DHW temperatures, targets, modes, errors), the device block (short ID, software versions, connection) and the wall thermostat (zone, temperature, humidity, signal, battery) |
| `GET /state` | token | full raw plant state, exactly as the cloud reports it | | `GET /state` | token | full raw plant state, exactly as the cloud reports it |
| `GET /stats` | token | the numbers Aira computes in the cloud: hot-water and heating COP month by month, heat produced, electricity used, savings on the bill, CO₂ avoided, Smart Tariff Control saving. Cached for 10 minutes |
| `GET /commands` | token | allowed commands with their parameters and ranges | | `GET /commands` | token | allowed commands with their parameters and ranges |
| `POST /command` | token | `{"id": "...", "value": 21.5, "zone": 1}` | | `POST /command` | token | `{"id": "...", "value": 21.5, "zone": 1}` |
@@ -134,7 +135,8 @@ Two things learned on a real pump, both worth knowing:
python test_bridge_commands.py python test_bridge_commands.py
``` ```
27 checks on the protobuf payloads and on the summary parsing — no network, and 48 checks on the protobuf payloads, the summary parsing (including the device
block) and the statistics conversions — no network, and
**no command is sent to the pump**. **no command is sent to the pump**.
## If login fails ## If login fails
@@ -156,7 +158,8 @@ Try one pool at a time: repeated failures trigger a temporary lockout.
| `aira_bridge.py` | the service: HTTP/1.1 + JSON in, gRPC to Aira out | | `aira_bridge.py` | the service: HTTP/1.1 + JSON in, gRPC to Aira out |
| `setup_creds.py` | asks for the Aira credentials and saves them (600) | | `setup_creds.py` | asks for the Aira credentials and saves them (600) |
| `aira_probe.py` | read-only check that login and the cloud channel work | | `aira_probe.py` | read-only check that login and the cloud channel work |
| `test_bridge_commands.py` | offline tests of the command payloads | | `aira_stats_probe.py` | read-only dump of the cloud statistics (COP, savings, insights), no command sent |
| `test_bridge_commands.py` | offline tests of the command payloads, the summary parsing and the statistics conversions |
| `harbour-aira-bridge.service` | systemd user unit | | `harbour-aira-bridge.service` | systemd user unit |
| `requirements.txt` | `pyairahome`, pinned to the tested version | | `requirements.txt` | `pyairahome`, pinned to the tested version |
+220 -1
View File
@@ -53,7 +53,7 @@ CREDS = BASE / "aira.json"
TOKENS = BASE / "aira_tokens.json" TOKENS = BASE / "aira_tokens.json"
POOL_CFG = BASE / "aira_config.json" POOL_CFG = BASE / "aira_config.json"
VERSION = "0.3.0" VERSION = "0.4.0"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -97,6 +97,10 @@ class AiraSession:
# Ultimo stato letto: serve a rimandare alla pompa il valore attuale # Ultimo stato letto: serve a rimandare alla pompa il valore attuale
# della zona che NON si sta modificando (mandarla a zero la azzererebbe). # della zona che NON si sta modificando (mandarla a zero la azzererebbe).
self._states = None self._states = None
# Statistiche: sono chiamate piu' lente e cambiano lentamente, quindi si
# tengono in cache (il telefono puo' chiederle a ogni apertura di pagina).
self._stats = None
self._stats_at = 0.0
def _login(self): def _login(self):
from pyairahome import AiraHome from pyairahome import AiraHome
@@ -156,6 +160,26 @@ class AiraSession:
def last_states(self): def last_states(self):
return self._states return self._states
def household_id(self) -> str:
devs = self.call(lambda a: a.cloud.get_devices())["devices"]
if not devs:
raise RuntimeError("nessun impianto associato all'account")
return (devs[0].get("device_id") or {}).get("household_id", {}).get("value", "")
def stats(self, ttl: int = 600) -> dict:
"""Statistiche dal servizio di sola lettura, con cache (default 10 minuti)."""
import time
now = time.time()
if self._stats is not None and (now - self._stats_at) < ttl:
return self._stats
did = self.device_id()
hid = self.household_id()
out = self.call(lambda a: build_stats(a, did, hid))
self._stats, self._stats_at = out, now
return out
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# riassunto leggibile # riassunto leggibile
@@ -170,6 +194,27 @@ def summarize(st: dict) -> dict:
return d return d
errs = st.get("errors") or [] errs = st.get("errors") or []
# ---- dati del dispositivo (per la pagina "Impianto": accessori + riepilogo,
# come la scheda "Accessori" e il "Riepilogo impostazioni" dell'app ufficiale)
hp_id = g("heat_pump_id", "value") or ""
versions = st.get("versions") or {}
thermos = st.get("thermostats") or []
t0 = thermos[0] if thermos and isinstance(thermos[0], dict) else {}
t_upd = t0.get("last_update") or {}
t_zone = str(t0.get("zone") or "")
def zona_numero(nome: str) -> int:
"""'ZONE_1' -> 1 (0 se non riconosciuta)."""
coda = nome.rsplit("_", 1)[-1]
return int(coda) if coda.isdigit() else 0
def decimo(valore):
"""Il termostato manda i decimali come interi: 228 -> 22.8, 734 -> 73.4."""
if not isinstance(valore, (int, float)):
return None
return round(float(valore) / 10.0, 1)
return { return {
"outdoor_temperature": g("current_outdoor_temperature"), "outdoor_temperature": g("current_outdoor_temperature"),
"indoor_temperature": g("deprecated_current_indoor_temperature"), "indoor_temperature": g("deprecated_current_indoor_temperature"),
@@ -205,9 +250,180 @@ def summarize(st: dict) -> dict:
"zone2_cooling_setpoint": g("zone_setpoints_cooling", "zone_2"), "zone2_cooling_setpoint": g("zone_setpoints_cooling", "zone_2"),
"errors_count": len(errs) if isinstance(errs, list) else 0, "errors_count": len(errs) if isinstance(errs, list) else 0,
"pump_updated": g("aws_iot_received_time") or st.get("time"), "pump_updated": g("aws_iot_received_time") or st.get("time"),
# Identificativo e versioni: l'app ufficiale mostra le prime 6 cifre
# dell'UUID come "Dispositivo ID".
"device_id": hp_id[:6] if hp_id else None,
"device_uuid": hp_id or None,
"version_platform": versions.get("linux_build_id"),
"version_connectivity": versions.get("connectivity_manager"),
"version_climate_control": versions.get("climate_control_villa"),
"version_outdoor_unit": versions.get("outdoor_unit_application"),
"version_outdoor_eeprom": versions.get("outdoor_unit_eeprom"),
"connected_via": "Cloud",
"timezone": g("time_zone", "time_zone_identifier"),
# Termostato a parete (se presente).
"thermostats_count": len(thermos) if isinstance(thermos, list) else 0,
"thermostat_zone": zona_numero(t_zone) if t0 else None,
"thermostat_article": t0.get("article_number"),
"thermostat_temperature": decimo(t_upd.get("actual_temperature")),
"thermostat_humidity": decimo(t_upd.get("humidity")),
"thermostat_rssi": t0.get("rssi"),
"thermostat_battery_low": bool(t_upd.get("warning_low_battery_level")),
} }
# --------------------------------------------------------------------------- #
# statistiche (HeatPumpStatisticsService: sola lettura, nessun comando)
# --------------------------------------------------------------------------- #
def money_value(units, scale) -> float:
"""Money del protocollo: units / 10^scale. (2710000000, 9) -> 2.71."""
try:
return round(float(units) / (10 ** int(scale)), 2)
except (TypeError, ValueError, ZeroDivisionError):
return 0.0
def wh_to_kwh(wh):
"""Wh -> kWh con un decimale; None se il dato manca o e' zero."""
if not isinstance(wh, (int, float)) or not wh:
return None
return round(float(wh) / 1000.0, 1)
def _ym(chiave) -> str:
"""YearMonth -> '2026-09'."""
return f"{getattr(chiave, 'year', 0) or 0:04d}-{getattr(chiave, 'month', 0) or 0:02d}"
def _data(chiave) -> str:
"""LocalDateTime -> '2026-10-01'."""
return (f"{getattr(chiave, 'year', 0) or 0:04d}-"
f"{getattr(chiave, 'month', 0) or 0:02d}-"
f"{getattr(chiave, 'day', 0) or 0:02d}")
def build_stats(aira, heat_pump_id: str, household_id: str,
months: int = 6, days: int = 30) -> dict:
"""Statistiche delle stesse voci che l'app ufficiale mostra in "Dati":
calore prodotto, elettricita' usata, efficienza (COP) di acqua calda e
riscaldamento, risparmi e risparmio Smart Tariff Control.
Tutto in sola lettura: non invia nessun comando all'impianto.
"""
from datetime import datetime, timedelta
from google.protobuf.timestamp_pb2 import Timestamp
from pyairahome.device.heat_pump.statistics.v1 import service_pb2 as S
# _stats_service_stub non e' esposto dalla libreria: serve per GetCop,
# GetSavings e GetOptimisationSavings (get_insights copre solo gli insight).
stub = aira.cloud._stats_service_stub # noqa: SLF001
call = aira.cloud.call_service
now = datetime.now()
out = {
"period_days": days,
"currency": "EUR",
"month": {"label": None, "heat_kwh": None, "energy_kwh": None,
"cop_heating": None, "cop_dhw": None},
"cop_monthly": [],
"insights": [],
"savings": None,
"smart_tariff": None,
"monthly_savings": [],
}
# ---- COP mensile (l'"Efficienza energetica" dell'app ufficiale) ---------- #
req = S.GetCopRequest()
req.heat_pump_id.value = heat_pump_id
req.granularity = S.COP_GRANULARITY_MONTHLY
inizio = now - timedelta(days=31 * months)
req.start_date.year, req.start_date.month = inizio.year, inizio.month
if now.month == 12:
req.end_date_exclusive.year, req.end_date_exclusive.month = now.year + 1, 1
else:
req.end_date_exclusive.year, req.end_date_exclusive.month = now.year, now.month + 1
for e in call(stub, "GetCop", req, timeout=25, raw=True).entries:
out["cop_monthly"].append({
"month": _ym(e.start_date),
"heating_cop": round(e.room_heating_cop_value, 2) if e.room_heating_cop_value else None,
"dhw_cop": round(e.dhw_cop_value, 2) if e.dhw_cop_value else None,
"avg_outdoor": round(e.average_outdoor_temperature, 1) if e.average_outdoor_temperature else None,
})
# L'efficienza da mostrare e' quella dell'ultimo mese con un dato: se il
# riscaldamento non ha ancora dati (come nell'app ufficiale: "Nessun dato")
# resta il COP dell'acqua calda, che invece c'e'.
for voce in reversed(out["cop_monthly"]):
if voce["dhw_cop"] or voce["heating_cop"]:
out["month"]["cop_heating"] = voce["heating_cop"]
out["month"]["cop_dhw"] = voce["dhw_cop"]
break
# ---- Calore prodotto ed elettricita' usata ------------------------------ #
req = S.GetHeatPumpInsightsRequest()
req.heat_pump_id.value = heat_pump_id
req.granularity = S.GRANULARITY_DAILY
da = now - timedelta(days=days)
req.start_time.year, req.start_time.month, req.start_time.day = da.year, da.month, da.day
req.end_time_exclusive.year = now.year
req.end_time_exclusive.month = now.month
req.end_time_exclusive.day = now.day
per_mese: dict = {}
for e in call(stub, "GetHeatPumpInsights", req, timeout=25, raw=True).insights:
giorno = _data(e.start_time)
out["insights"].append({
"date": giorno,
"energy_kwh": wh_to_kwh(e.energy_consumption_wh),
"heat_kwh": wh_to_kwh(e.delivered_heat_wh),
})
voce = per_mese.setdefault(giorno[:7], {"heat": 0.0, "energy": 0.0})
voce["heat"] += float(e.delivered_heat_wh or 0)
voce["energy"] += float(e.energy_consumption_wh or 0)
if per_mese:
ultimo = max(per_mese)
out["month"]["label"] = ultimo
out["month"]["heat_kwh"] = wh_to_kwh(per_mese[ultimo]["heat"])
out["month"]["energy_kwh"] = wh_to_kwh(per_mese[ultimo]["energy"])
# ---- Risparmi del periodo ----------------------------------------------- #
req = S.GetSavingsRequest()
req.heat_pump_id.value = heat_pump_id
ts = Timestamp()
ts.FromDatetime(da)
req.start_time.CopyFrom(ts)
ts = Timestamp()
ts.FromDatetime(now)
req.end_time.CopyFrom(ts)
sav = call(stub, "GetSavings", req, timeout=25, raw=True)
out["currency"] = sav.currency_code or "EUR"
out["savings"] = {
"amount": round(sav.cost_savings, 2),
# Attenzione al nome: il campo e' co_2_savings_kg (con l'underscore),
# non "co2_savings_kg".
"co2_kg": round(sav.co_2_savings_kg, 1),
"show": bool(sav.show),
"points": int(sav.number_of_data_points or 0),
}
# ---- Risparmio Smart Tariff Control (mese mobile) ----------------------- #
if household_id:
req = S.GetOptimisationSavingsRequest()
req.household_id.value = household_id
opt = call(stub, "GetOptimisationSavings", req, timeout=25, raw=True)
rm = opt.rolling_month_optimisation
out["smart_tariff"] = {
"amount": money_value(rm.savings.units, rm.savings.scale),
"hours": int(rm.weather_optimisation_hours or 0),
}
for m in opt.monthly_savings:
out["monthly_savings"].append({
"month": _ym(m.month),
"amount": money_value(m.savings.units, m.savings.scale),
})
return out
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# comandi ammessi (whitelist: mai esporre l'intera libreria su HTTP) # comandi ammessi (whitelist: mai esporre l'intera libreria su HTTP)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -367,6 +583,9 @@ class Handler(BaseHTTPRequestHandler):
return self._send(200, {"ok": True, "summary": summarize(self.session.states())}) return self._send(200, {"ok": True, "summary": summarize(self.session.states())})
if path == "/state": if path == "/state":
return self._send(200, {"ok": True, "state": self.session.states()}) return self._send(200, {"ok": True, "state": self.session.states()})
if path == "/stats":
# Statistiche (sola lettura, con cache breve lato bridge).
return self._send(200, {"ok": True, "stats": self.session.stats()})
if path == "/commands": if path == "/commands":
return self._send(200, {"ok": True, "commands": COMMANDS}) return self._send(200, {"ok": True, "commands": COMMANDS})
return self._send(404, {"ok": False, "error": "rotta sconosciuta"}) return self._send(404, {"ok": False, "error": "rotta sconosciuta"})
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Sonda di sola lettura sui dati statistici del cloud Aira (v2).
NON invia comandi all'impianto. Obiettivo: capire cosa si puo' mostrare in una
pagina "Dati" come quella dell'app ufficiale (calore prodotto, elettricita',
efficienza/COP, risparmi).
"""
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
CFG = Path(os.environ.get("AIRA_CONFIG_DIR", str(Path.home() / ".hermes/profiles/carlo")))
sys.path.insert(0, str(CFG))
from pyairahome import AiraHome
from pyairahome.device.heat_pump.statistics.v1 import service_pb2 as S
def section(t):
print("\n" + "=" * 72)
print(t)
print("=" * 72)
section("messaggi annidati e risposte")
for name in ("Uuid", "YearMonth", "LocalDateTime", "CopEntry", "GetCopResponse",
"MonthlySavings", "GetSavingsResponse", "RollingMonthOptimisation",
"GetOptimisationSavingsResponse", "InsightEntry", "GetHeatPumpInsightsResponse"):
cls = getattr(S, name, None)
if cls is None:
print(f"{name}: assente")
continue
try:
fs = cls.DESCRIPTOR.fields
print(f"{name}: " + ", ".join(
f"{f.name}(type={f.type}, msg={f.message_type and f.message_type.name}, "
f"enum={f.enum_type and f.enum_type.name})" for f in fs))
except Exception as exc: # noqa: BLE001
print(f"{name}: introspezione fallita ({type(exc).__name__}: {exc})")
# --- login ----------------------------------------------------------------- #
tokens = json.loads((CFG / "aira_tokens.json").read_text())
state = json.loads(Path("/tmp/aira_state.json").read_text())["state"]
hp = state["heat_pump_id"]["value"]
aira = AiraHome()
aira.cloud.login_with_tokens(tokens["id_token"], tokens["access_token"], tokens["refresh_token"])
stub = aira.cloud._stats_service_stub # noqa: SLF001
call = aira.cloud.call_service
print(f"\nheat_pump_id: {hp}")
now = datetime.now()
def show(label, method, req):
section(label)
try:
out = call(stub, method, req, timeout=25, raw=True)
print(str(out).replace("\n", " | ")[:1500] or "(risposta vuota)")
except Exception as exc: # noqa: BLE001
print(f"ERRORE {type(exc).__name__}: {str(exc)[:400]}")
# COP mensile, ultimi 6 mesi
req = S.GetCopRequest()
req.heat_pump_id.value = hp
req.granularity = S.COP_GRANULARITY_MONTHLY
start = now - timedelta(days=180)
req.start_date.year, req.start_date.month = start.year, start.month
req.end_date_exclusive.year, req.end_date_exclusive.month = now.year, now.month + 1 if now.month < 12 else 1
if now.month == 12:
req.end_date_exclusive.year = now.year + 1
show("GetCop mensile (6 mesi) -> efficienza", "GetCop", req)
# Risparmi: periodo 180 giorni
from google.protobuf.timestamp_pb2 import Timestamp
req = S.GetSavingsRequest()
req.heat_pump_id.value = hp
ts = Timestamp(); ts.FromDatetime(start); req.start_time.CopyFrom(ts)
te = Timestamp(); te.FromDatetime(now); req.end_time.CopyFrom(te)
show("GetSavings (180 giorni) -> risparmi", "GetSavings", req)
# Risparmi Smart Tariff Control (per nucleo familiare)
household = None
try:
devs = aira.cloud.get_devices()
print("\nget_devices ->", str(devs)[:400])
def find_hh(o):
if isinstance(o, dict):
for k, v in o.items():
if k == "household_id" and v:
return v if isinstance(v, str) else v.get("value")
r = find_hh(v)
if r:
return r
elif isinstance(o, list):
for v in o:
r = find_hh(v)
if r:
return r
return None
household = find_hh(devs)
print("household_id:", household)
except Exception as exc: # noqa: BLE001
print("get_devices fallita:", type(exc).__name__, str(exc)[:200])
if household:
req = S.GetOptimisationSavingsRequest()
req.household_id.value = household
show("GetOptimisationSavings -> risparmio Smart Tariff Control", "GetOptimisationSavings", req)
# Insight giornalieri, ultimi 7 giorni (LocalDateTime)
req = S.GetHeatPumpInsightsRequest()
req.heat_pump_id.value = hp
req.granularity = S.GRANULARITY_DAILY
s = now - timedelta(days=7)
for m in (req.start_time, req.end_time_exclusive):
pass
req.start_time.year, req.start_time.month, req.start_time.day = s.year, s.month, s.day
req.end_time_exclusive.year, req.end_time_exclusive.month, req.end_time_exclusive.day = now.year, now.month, now.day
show("GetHeatPumpInsights giornaliero (7 giorni)", "GetHeatPumpInsights", req)
aira.cloud.close()
print("\nsonda conclusa: nessun comando inviato all'impianto.")
+49
View File
@@ -94,6 +94,55 @@ if stato_file.exists():
else: else:
print("(salto il controllo del riassunto: /tmp/state.json assente)") print("(salto il controllo del riassunto: /tmp/state.json assente)")
# --- 6) statistiche: conversioni pure ------------------------------------- #
check("money_value: 2710000000 / 10^9 = 2.71", b.money_value(2710000000, 9) == 2.71,
repr(b.money_value(2710000000, 9)))
check("money_value: valori assenti -> 0.0", b.money_value(None, None) == 0.0)
check("wh_to_kwh: 29300 Wh = 29.3 kWh", b.wh_to_kwh(29300) == 29.3, repr(b.wh_to_kwh(29300)))
check("wh_to_kwh: 70844.5 Wh = 70.8 kWh", b.wh_to_kwh(70844.53878839477) == 70.8,
repr(b.wh_to_kwh(70844.53878839477)))
check("wh_to_kwh: zero -> None (nessun dato)", b.wh_to_kwh(0) is None)
check("wh_to_kwh: valore assente -> None", b.wh_to_kwh(None) is None)
class _YM: # finto YearMonth / LocalDateTime
def __init__(self, y, m, d=0):
self.year, self.month, self.day = y, m, d
check("_ym: 2026-09", b._ym(_YM(2026, 9)) == "2026-09", b._ym(_YM(2026, 9)))
check("_data: 2026-10-01", b._data(_YM(2026, 10, 1)) == "2026-10-01", b._data(_YM(2026, 10, 1)))
# --- 7) riassunto: blocco dispositivo (stato sintetico, nessun file) ------- #
# I valori del termostato arrivano con i decimali come interi (228 -> 22.8).
finto = {
"heat_pump_id": {"value": "1f4b26c4-1800-4b21-8ea2-236925a92aeb"},
"versions": {"linux_build_id": "6.10.0", "connectivity_manager": "3.30.1",
"climate_control_villa": "3.9.12", "outdoor_unit_application": "1.30.0",
"outdoor_unit_eeprom": "0.114.0"},
"thermostats": [{"article_number": "201884", "zone": "ZONE_1", "rssi": -79,
"last_update": {"actual_temperature": 228, "humidity": 734,
"warning_low_battery_level": False}}],
"time_zone": {"time_zone_identifier": "Europe/Rome"},
"zone_setpoints_heating": {"zone_1": 19.0, "zone_2": 0.0},
"zone_temperatures": {"zone_1": 22.8, "zone_2": 0.0},
}
d = b.summarize(finto)
check("dispositivo: id breve di 6 cifre", d.get("device_id") == "1f4b26", repr(d.get("device_id")))
check("dispositivo: uuid completo", d.get("device_uuid") == "1f4b26c4-1800-4b21-8ea2-236925a92aeb")
check("dispositivo: versione piattaforma", d.get("version_platform") == "6.10.0")
check("dispositivo: versione connettività", d.get("version_connectivity") == "3.30.1")
check("dispositivo: versione unità esterna", d.get("version_outdoor_unit") == "1.30.0")
check("dispositivo: connessione", d.get("connected_via") == "Cloud")
check("dispositivo: fuso orario", d.get("timezone") == "Europe/Rome")
check("termostato: zona 1 da 'ZONE_1'", d.get("thermostat_zone") == 1, repr(d.get("thermostat_zone")))
check("termostato: 228 -> 22.8 °C", d.get("thermostat_temperature") == 22.8,
repr(d.get("thermostat_temperature")))
check("termostato: 734 -> 73.4 %", d.get("thermostat_humidity") == 73.4,
repr(d.get("thermostat_humidity")))
check("termostato: segnale riportato", d.get("thermostat_rssi") == -79)
check("termostato: batteria non scarica", d.get("thermostat_battery_low") is False)
check("senza termostato: nessun errore",
b.summarize({k: v for k, v in finto.items() if k != "thermostats"}).get("thermostat_zone") is None)
# --- esito ---------------------------------------------------------------- # # --- esito ---------------------------------------------------------------- #
falliti = [n for n, ok, _ in esiti if not ok] falliti = [n for n, ok, _ in esiti if not ok]
print(f"\n{len(esiti) - len(falliti)}/{len(esiti)} controlli superati") print(f"\n{len(esiti) - len(falliti)}/{len(esiti)} controlli superati")
+64
View File
@@ -161,3 +161,67 @@ Non verificabile qui (serve il suo PC + il telefono): build RPM, istallazione, U
nuova) oppure sottodominio HTTPS pubblico con reverse proxy. nuova) oppure sottodominio HTTPS pubblico con reverse proxy.
2. **Ambito della v0.1**: sola lettura (consigliata) oppure lettura + comandi ACS subito. 2. **Ambito della v0.1**: sola lettura (consigliata) oppure lettura + comandi ACS subito.
3. **Nome visibile dell'app**: default proposto "Aira" (pacchetto `harbour-aira`). 3. **Nome visibile dell'app**: default proposto "Aira" (pacchetto `harbour-aira`).
## 8. Riordino come l'app ufficiale — v0.5.0 (richiesta dell'utente, 10/10/2026)
L'utente ha allegato le schermate dell'app ufficiale (Home, dettaglio impianto,
Accessori, Impostazioni, Aira Intelligence, Dati) e ha chiesto di **riordinare le
informazioni allo stesso modo**. Piano scritto prima di toccare la UI, come da
convenzione.
### Cosa si può replicare (verificato con sonda di sola lettura)
Sonda `aira_stats_probe.py` (nessun comando inviato all'impianto): il cloud Aira
espone anche un **servizio statistiche** (`HeatPumpStatisticsService`) che la
libreria usava solo in parte. I numeri **coincidono con quelli dell'app ufficiale**:
| Dato dell'app ufficiale | Fonte | Valore reale letto | Coincide |
|---|---|---|---|
| Calore prodotto (questo mese) 70.8 kWh | `GetHeatPumpInsights` → `delivered_heat_wh` | 70 844 Wh | sì |
| Elettricità usata 29.3 kWh | `GetHeatPumpInsights` → `energy_consumption_wh` | 29 300 Wh | sì |
| Efficienza acqua calda 4.4 | `GetCop` mensile → `dhw_cop_value` | 4.42 (settembre) | sì |
| Efficienza riscaldamento: *Nessun dato* | `GetCop` → `room_heating_cop_value` | vuoto (solo ACS) | sì |
| Risparmio Smart Tariff Control €2.7 | `GetOptimisationSavings` → `rolling_month_optimisation` | € 2,71 | sì |
| Risparmi (ultimi 30 giorni) | `GetSavings` (180 gg) | € 95,46 + 153,9 kg CO₂ | sì |
| Dispositivo ID `1f4b26` | `heat_pump_id` (UUID) | `1f4b26c4-…` | sì |
| Versione software 3.30.1 | `versions.connectivity_manager` | 3.30.1 | sì |
| Versione piattaforma 6.10.0 | `versions.linux_build_id` | 6.10.0 | sì |
| Termostato a parete Aira · Zone 1 | `thermostats[]` | articolo 201884, zona 1, 22,8 °C, 73% UR | sì |
| Modalità Vacanza | `away_mode_enabled` | inattivo | sì |
**Resta fuori**: Wi-Fi, messaggi luminosi/LED, nucleo familiare, tariffe energia,
Aira Intelligence (comfort/risparmio) e le notifiche push. Sono funzioni che
dipendono da impostazioni dell'app ufficiale o da dati che il cloud non espone in
lettura (le tariffe servono per i *suoi* calcoli, non li leggiamo).
### Struttura nuova dell'app
Come l'app ufficiale: una **Home** con il dato esterno in alto e la scheda
dell'impianto, poi **Dati** e **Impianto** (al posto di Accessori + Riepilogo
impostazioni). La navigazione resta con il **menu pulley** di Silica e non con la
barra a tre tab in basso: la tab bar non è un pattern Sailfish e su Qt 5.6/Silica 5.1
andrebbe disegnata a mano.
1. **Home**: `esterno 16,8 °C` in alto a sinistra e stato assenza a destra; messaggio
di stato grande (derivato: errori, assenza, acqua calda in corso, riscaldamento in
corso, tutto regolare); **scheda impianto** — "Aira Pompa di calore · Connesso
tramite Cloud" con due righe (Temperatura interna con l'obiettivo, Acqua calda) e
la freccia che apre la pagina Impianto; sotto, i comandi (obiettivo casa, obiettivo
ACS e boost) che l'app ufficiale non ha e che restano il valore aggiunto nostro.
2. **Dati**: "Il tuo riscaldamento (questo mese)" con calore prodotto, elettricità
usata, efficienza (COP riscaldamento / acqua calda); "I tuoi risparmi" con il
risparmio totale del periodo, la CO₂ evitata e il risparmio Smart Tariff Control.
3. **Impianto**: stato (connessione, modalità, vacanza, messaggi luminosi, ultima
lettura), accessori (termostato: zona, temperatura, umidità, segnale, batteria),
versioni (piattaforma, connettività, controllo climatico, unità esterna, EEPROM,
dispositivo ID) ed errori.
### Lavoro
- **Bridge 0.4.0**: nuova rotta `/stats` (COP, risparmi, ottimizzazione, insight) e
blocco `device` in `/summary` (id breve, versioni, termostato, connessione);
cache breve lato bridge, perché sono chiamate al cloud più lente di `/state`.
- **App 0.5.0**: `MainPage` riordinata, nuove `DataPage.qml` e `InfoPage.qml`,
nuove stringhe nei due cataloghi, anteprime rigenerate, test del nucleo estesi al
parsing dei nuovi campi, versione e README aggiornati.
- Nessun comando nuovo all'impianto: tutto questo è **sola lettura**.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

+54
View File
@@ -0,0 +1,54 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
// Riga "titolo + sottotitolo" a sinistra e valore grande a destra: e' la forma
// delle schede dell'app ufficiale (temperatura della casa, acqua calda...).
Item {
id: row
property string title: ""
property string subtitle: ""
property string value: ""
property color valueColor: Theme.primaryColor
width: parent ? parent.width : 0
height: Math.max(testo.height, valore.height)
Column {
id: testo
anchors {
left: parent.left
right: valore.left
rightMargin: Theme.paddingMedium
verticalCenter: parent.verticalCenter
}
spacing: 0
Label {
width: parent.width
text: row.title
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
Label {
width: parent.width
visible: row.subtitle.length > 0
text: row.subtitle
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
}
Label {
id: valore
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
text: row.value
color: row.valueColor
font.pixelSize: Theme.fontSizeLarge
}
}
+251
View File
@@ -0,0 +1,251 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
// Dati: calore prodotto, elettricita' usata, efficienza (COP) e risparmi, come
// la scheda "Dati" dell'app ufficiale. Sono numeri che Aira calcola nel cloud:
// il bridge li legge in sola lettura e li tiene in cache per 10 minuti.
Page {
id: page
allowedOrientations: Orientation.All
// La pagina non ha solo la lettura: mostra anche cosa sta facendo.
property bool loading: false
property string error: ""
function stats() {
return api.stats
}
function mese() {
return page.stats()["month"] || {}
}
function risparmi() {
return page.stats()["savings"] || {}
}
function tariffa() {
return page.stats()["smart_tariff"] || {}
}
function valuta() {
return page.stats()["currency"] || "EUR"
}
function kwh(valore) {
if (valore === undefined || valore === null)
return "—"
return api.formatValue(valore) + " kWh"
}
// Il COP ha due decimali, la temperatura uno: per questo non basta fmt().
function cop(valore) {
if (valore === undefined || valore === null)
return qsTr("no data")
return api.formatNumber(valore, 2)
}
function soldi(valore) {
if (valore === undefined || valore === null)
return "—"
return api.formatMoney(valore, page.valuta())
}
function meseNome(chiave) {
return api.monthLabel(chiave)
}
Connections {
target: api
onStatsChanged: {
page.error = ""
page.loading = false
}
onStatsRefreshed: {
page.loading = false
if (!ok)
page.error = message
}
onErrorOccurred: page.error = message
}
SilicaFlickable {
anchors.fill: parent
contentHeight: col.height + Theme.paddingLarge
PullDownMenu {
MenuItem {
text: qsTr("Refresh")
onClicked: {
page.loading = true
api.refreshStats()
}
}
MenuItem {
text: qsTr("Plant")
onClicked: pageStack.push(Qt.resolvedUrl("PlantPage.qml"))
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
}
Column {
id: col
width: parent.width
spacing: Theme.paddingSmall
PageHeader { title: qsTr("Data") }
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Numbers calculated by Aira in the cloud: the app only reads them from your own bridge.")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
Label {
visible: page.error.length > 0
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.error
color: Theme.errorColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
Label {
visible: Object.keys(page.stats()).length === 0 && page.error.length === 0
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Reading the data...")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
// ---- energia e efficienza del mese ----------------------------- //
SectionHeader {
visible: Object.keys(page.stats()).length > 0
text: page.mese()["label"]
? qsTr("Month of %1").arg(page.meseNome(page.mese()["label"]))
: qsTr("This month")
}
ValueRow {
visible: Object.keys(page.stats()).length > 0
title: qsTr("Heat produced")
subtitle: qsTr("What the heat pump gave the house")
value: page.kwh(page.mese()["heat_kwh"])
}
ValueRow {
visible: Object.keys(page.stats()).length > 0
title: qsTr("Electricity used")
subtitle: qsTr("What the heat pump took from the grid")
value: page.kwh(page.mese()["energy_kwh"])
}
InfoRow {
visible: Object.keys(page.stats()).length > 0
label: qsTr("Efficiency, domestic hot water")
value: page.cop(page.mese()["cop_dhw"])
}
InfoRow {
visible: Object.keys(page.stats()).length > 0
label: qsTr("Efficiency, heating")
value: page.cop(page.mese()["cop_heating"])
}
Label {
visible: Object.keys(page.stats()).length > 0
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Efficiency (COP) is how much heat the pump delivers for each unit of electricity: higher is better.")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
// ---- risparmi --------------------------------------------------- //
SectionHeader {
visible: Object.keys(page.risparmi()).length > 0
text: qsTr("Your savings, last %1 days").arg(page.stats()["period_days"] || 30)
}
ValueRow {
visible: Object.keys(page.risparmi()).length > 0
title: qsTr("Saved on the bill")
subtitle: qsTr("Compared with a gas boiler")
value: page.soldi(page.risparmi()["amount"])
valueColor: Theme.highlightColor
}
ValueRow {
visible: Object.keys(page.risparmi()).length > 0
title: qsTr("CO₂ avoided")
subtitle: qsTr("Not released into the air")
value: api.formatValue(page.risparmi()["co2_kg"]) + " kg"
}
ValueRow {
visible: Object.keys(page.tariffa()).length > 0
title: qsTr("Smart Tariff Control")
subtitle: qsTr("Saving from heating when energy is cheaper")
value: page.soldi(page.tariffa()["amount"])
valueColor: Theme.highlightColor
}
// ---- dettaglio mese per mese ------------------------------------ //
SectionHeader {
visible: page.stats()["cop_monthly"] && page.stats()["cop_monthly"].length > 0
text: qsTr("Efficiency month by month")
}
Repeater {
model: page.stats()["cop_monthly"] || []
InfoRow {
label: page.meseNome(modelData["month"])
value: page.cop(modelData["dhw_cop"])
}
}
SectionHeader {
visible: page.stats()["monthly_savings"] && page.stats()["monthly_savings"].length > 0
text: qsTr("Savings month by month")
}
Repeater {
model: page.stats()["monthly_savings"] || []
InfoRow {
label: page.meseNome(modelData["month"])
value: page.soldi(modelData["amount"])
}
}
BusyIndicator {
visible: page.loading
anchors.horizontalCenter: parent.horizontalCenter
size: BusyIndicatorSize.Small
running: true
}
Item { width: 1; height: Theme.paddingMedium }
}
}
Component.onCompleted: {
if (api.configured) {
page.loading = true
api.refreshStats()
}
}
}
+200 -59
View File
@@ -2,8 +2,12 @@ import QtQuick 2.2
import Sailfish.Silica 1.0 import Sailfish.Silica 1.0
import "../components" import "../components"
// Dashboard dell'impianto. Legge api.summary (QVariantMap dal C++), quindi non // Dashboard dell'impianto, ordinata come la schermata iniziale dell'app
// dipende dallo scope delle property del main qml. // ufficiale Aira: in alto la temperatura esterna, poi il messaggio di stato,
// poi la scheda dell'impianto con le due letture principali (casa e acqua
// calda). I comandi restano piu' sotto, perche' qui si possono dare davvero.
// Legge api.summary (QVariantMap dal C++), quindi non dipende dallo scope
// delle property del main qml.
Page { Page {
id: page id: page
@@ -28,6 +32,14 @@ Page {
pageStack.push(Qt.resolvedUrl("CommandsPage.qml")) pageStack.push(Qt.resolvedUrl("CommandsPage.qml"))
} }
function openPlant() {
pageStack.push(Qt.resolvedUrl("PlantPage.qml"))
}
function openData() {
pageStack.push(Qt.resolvedUrl("DataPage.qml"))
}
function showNotice(msg, isError) { function showNotice(msg, isError) {
page.notice = msg page.notice = msg
page.noticeIsError = isError === true page.noticeIsError = isError === true
@@ -46,10 +58,6 @@ Page {
return api.formatValue(page.raw(key)) return api.formatValue(page.raw(key))
} }
function flag(key) {
return page.raw(key) ? qsTr("yes") : qsTr("no")
}
function count(key) { function count(key) {
var v = page.raw(key) var v = page.raw(key)
if (v === undefined || v === null) if (v === undefined || v === null)
@@ -67,6 +75,39 @@ Page {
return parts[1].substring(0, 5) return parts[1].substring(0, 5)
} }
// Messaggio grande in cima: e' la frase che l'app ufficiale mette al centro
// della schermata iniziale ("Relax, la tua pompa di calore..."). Si sceglie
// in ordine di importanza: errori, modalita' attive, pompa in funzione.
function stateMessage() {
if (!api.configured)
return ""
var errs = Number(page.raw("errors_count"))
if (errs > 0)
return (errs === 1)
? qsTr("Attention: one error on the plant")
: qsTr("Attention: %1 errors on the plant").arg(errs)
if (page.raw("away_mode") === true)
return qsTr("Away mode: the house is saving energy.")
if (page.raw("night_mode") === true)
return qsTr("Night mode is on.")
if (page.raw("force_heating") === true)
return qsTr("Boost: the house is heating at full power.")
if (page.raw("hot_water_heating") === true)
return qsTr("The heat pump is heating the hot water.")
if (String(page.raw("pump_active_state") || "").indexOf("RUNNING") >= 0)
return qsTr("The heat pump is working.")
return qsTr("Relax: the heat pump is running smoothly.")
}
// Riga piccola sotto il messaggio: modalita' e ora dell'ultima lettura.
function statusDetail() {
if (!api.configured)
return ""
return qsTr("%1 · last reading %2")
.arg(api.modeLabel(page.raw("operating_status")))
.arg(page.shortTime(page.raw("pump_updated")))
}
Timer { Timer {
id: noticeTimer id: noticeTimer
interval: 4000 interval: 4000
@@ -106,13 +147,21 @@ Page {
onClicked: api.refresh() onClicked: api.refresh()
} }
MenuItem { MenuItem {
text: qsTr("Settings") text: qsTr("Data")
onClicked: page.openSettings() onClicked: page.openData()
}
MenuItem {
text: qsTr("Plant")
onClicked: page.openPlant()
} }
MenuItem { MenuItem {
text: qsTr("Other commands") text: qsTr("Other commands")
onClicked: page.openCommands() onClicked: page.openCommands()
} }
MenuItem {
text: qsTr("Settings")
onClicked: page.openSettings()
}
} }
Column { Column {
@@ -151,39 +200,132 @@ Page {
onClicked: page.openSettings() onClicked: page.openSettings()
} }
// ---- casa: temperatura della casa (dato principale) ------------ // // ---- barra in alto: fuori casa / a casa ------------------------ //
Label { Item {
visible: api.configured visible: api.configured
anchors.horizontalCenter: parent.horizontalCenter x: Theme.horizontalPageMargin
text: qsTr("House temperature") width: parent.width - 2 * Theme.horizontalPageMargin
color: Theme.secondaryColor height: fuori.height
font.pixelSize: Theme.fontSizeSmall
}
Label { Label {
visible: api.configured id: fuori
anchors.horizontalCenter: parent.horizontalCenter anchors.left: parent.left
text: page.temp("zone1_temperature") + " °C" text: qsTr("Outside %1 °C").arg(page.temp("outdoor_temperature"))
color: Theme.primaryColor color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeHuge font.pixelSize: Theme.fontSizeSmall
} }
ProgressBar { Label {
visible: api.configured anchors {
anchors.horizontalCenter: parent.horizontalCenter right: parent.right
width: parent.width - 4 * Theme.horizontalPageMargin verticalCenter: parent.verticalCenter
minimumValue: 5 }
maximumValue: 30 text: page.raw("away_mode") === true ? qsTr("Away") : qsTr("At home")
value: { color: page.raw("away_mode") === true ? Theme.highlightColor : Theme.secondaryColor
var v = page.raw("zone1_temperature") font.pixelSize: Theme.fontSizeExtraSmall
return (v === undefined || v === null) ? 0 : Number(v)
} }
} }
// ---- messaggio di stato (il "centro" della schermata ufficiale) - //
Label {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.stateMessage()
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeMedium
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
Label {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.statusDetail()
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
horizontalAlignment: Text.AlignHCenter
}
// ---- scheda dell'impianto -------------------------------------- //
// Come la scheda dispositivo dell'app ufficiale: intestazione con
// nome e connessione, poi le due letture principali. Toccandola si
// apre la pagina "Impianto" (accessori, versioni, errori).
BackgroundItem {
id: scheda
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
height: schedaCol.height + 3 * Theme.paddingMedium
onClicked: page.openPlant()
Column {
id: schedaCol
x: Theme.paddingMedium
y: Theme.paddingMedium
width: parent.width - 2 * Theme.paddingMedium
spacing: Theme.paddingMedium
Item {
width: parent.width
height: intestazione.height
Column {
id: intestazione
anchors {
left: parent.left
right: freccia.left
rightMargin: Theme.paddingSmall
}
Label {
text: qsTr("Aira heat pump")
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
}
Label {
text: qsTr("Connected through the cloud")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
}
}
Label {
id: freccia
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
text: "›"
color: Theme.highlightColor
font.pixelSize: Theme.fontSizeLarge
}
}
ValueRow {
title: qsTr("Room temperature")
subtitle: qsTr("Target %1 °C").arg(page.temp("zone1_setpoint"))
value: page.temp("zone1_temperature") + " °C"
}
ValueRow {
title: qsTr("Domestic hot water")
subtitle: qsTr("Target %1 °C").arg(page.temp("dhw_target"))
value: page.temp("dhw_current") + " °C"
}
}
}
// ---- riscaldamento della casa ---------------------------------- //
SectionHeader { visible: api.configured; text: qsTr("Heating the house") }
Label { Label {
visible: api.configured visible: api.configured
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("target %1 °C · %2").arg(page.temp("zone1_setpoint")).arg(api.modeLabel(page.raw("zone1_mode"))) text: qsTr("Heating setpoint %1 °C · %2")
.arg(page.temp("zone1_setpoint"))
.arg(api.modeLabel(page.raw("zone1_mode")))
color: Theme.secondaryColor color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall font.pixelSize: Theme.fontSizeExtraSmall
} }
@@ -222,14 +364,6 @@ Page {
// ---- acqua calda sanitaria ------------------------------------- // // ---- acqua calda sanitaria ------------------------------------- //
SectionHeader { visible: api.configured; text: qsTr("Domestic hot water") } SectionHeader { visible: api.configured; text: qsTr("Domestic hot water") }
Label {
visible: api.configured
anchors.horizontalCenter: parent.horizontalCenter
text: page.temp("dhw_current") + " °C"
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeLarge
}
ProgressBar { ProgressBar {
visible: api.configured visible: api.configured
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
@@ -242,12 +376,6 @@ Page {
} }
} }
InfoRow {
visible: api.configured
label: qsTr("DHW target")
value: page.temp("dhw_target") + " °C"
}
Slider { Slider {
visible: api.configured visible: api.configured
width: parent.width width: parent.width
@@ -306,19 +434,32 @@ Page {
function() { api.sendCommand("hot_water_boost_off") }) function() { api.sendCommand("hot_water_boost_off") })
} }
// ---- stato impianto ------------------------------------------- // // ---- altre pagine ---------------------------------------------- //
SectionHeader { visible: api.configured; text: qsTr("Plant") } SectionHeader { visible: api.configured; text: qsTr("Details") }
InfoRow { visible: api.configured; label: qsTr("Outdoor temperature"); value: page.temp("outdoor_temperature") + " °C" } Button {
InfoRow { visible: api.configured; label: qsTr("Indoor temperature (pump)"); value: page.temp("indoor_temperature") + " °C" } visible: api.configured
InfoRow { visible: api.configured; label: qsTr("Mode"); value: api.modeLabel(page.raw("operating_status")) } anchors {
InfoRow { visible: api.configured; label: qsTr("Away mode"); value: page.flag("away_mode") } left: parent.left
InfoRow { visible: api.configured; label: qsTr("Night mode"); value: page.flag("night_mode") } right: parent.right
InfoRow { visible: api.configured; label: qsTr("Forced heating"); value: page.flag("force_heating") } leftMargin: Theme.horizontalPageMargin
InfoRow { visible: api.configured; label: qsTr("Inline heater"); value: page.flag("inline_heater") } rightMargin: Theme.horizontalPageMargin
InfoRow { visible: api.configured; label: qsTr("Zone"); value: page.count("zones") } }
InfoRow { visible: api.configured; label: qsTr("Errors"); value: page.count("errors_count") } text: qsTr("Plant and accessories")
InfoRow { visible: api.configured; label: qsTr("Last reading"); value: page.shortTime(page.raw("pump_updated")) } onClicked: page.openPlant()
}
Button {
visible: api.configured
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: qsTr("Consumption and savings")
onClicked: page.openData()
}
Button { Button {
visible: api.configured visible: api.configured
@@ -355,4 +496,4 @@ Page {
if (api.configured) if (api.configured)
api.refresh() api.refresh()
} }
} }
+187
View File
@@ -0,0 +1,187 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
// Impianto: la scheda "Impianto e accessori" dell'app ufficiale, con in piu'
// le versioni del software e gli errori. Tutto in sola lettura.
Page {
id: page
allowedOrientations: Orientation.All
function raw(key) {
return api.summary[key]
}
function temp(key) {
return api.formatValue(page.raw(key)) + " °C"
}
function flag(key) {
return page.raw(key) ? qsTr("yes") : qsTr("no")
}
function count(key) {
var v = page.raw(key)
if (v === undefined || v === null)
return "—"
return String(Math.round(Number(v)))
}
// Versioni e identificativo: possono mancare, quindi "—" invece di "null".
function testo(valore) {
if (valore === undefined || valore === null || valore === "")
return "—"
return String(valore)
}
// "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)
}
SilicaFlickable {
anchors.fill: parent
contentHeight: col.height + Theme.paddingLarge
PullDownMenu {
MenuItem {
text: qsTr("Refresh")
onClicked: api.refresh()
}
MenuItem {
text: qsTr("Data")
onClicked: pageStack.push(Qt.resolvedUrl("DataPage.qml"))
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
}
Column {
id: col
width: parent.width
spacing: Theme.paddingSmall
PageHeader { title: qsTr("Plant") }
// ---- stato ------------------------------------------------------ //
SectionHeader { text: qsTr("Status") }
InfoRow {
label: qsTr("Connection")
value: page.testo(page.raw("connected_via"))
}
InfoRow {
label: qsTr("Mode")
value: api.modeLabel(page.raw("operating_status"))
}
InfoRow { label: qsTr("Away mode"); value: page.flag("away_mode") }
InfoRow { label: qsTr("Night mode"); value: page.flag("night_mode") }
InfoRow { label: qsTr("Forced heating"); value: page.flag("force_heating") }
InfoRow { label: qsTr("Inline heater"); value: page.flag("inline_heater") }
InfoRow { label: qsTr("Outdoor temperature"); value: page.temp("outdoor_temperature") }
InfoRow { label: qsTr("Indoor temperature (pump)"); value: page.temp("indoor_temperature") }
InfoRow { label: qsTr("Zones"); value: page.count("zones") }
InfoRow { label: qsTr("Last reading"); value: page.shortTime(page.raw("pump_updated")) }
// ---- accessori -------------------------------------------------- //
SectionHeader { text: qsTr("Accessories") }
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
visible: Number(page.count("thermostats_count")) === 0
text: qsTr("No wall thermostat")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
Column {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
spacing: 0
visible: Number(page.count("thermostats_count")) > 0
Label {
text: qsTr("Aira wall thermostat")
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
}
Label {
text: qsTr("Zone %1 · Connected").arg(page.count("thermostat_zone"))
color: Theme.highlightColor
font.pixelSize: Theme.fontSizeExtraSmall
}
}
InfoRow {
visible: Number(page.count("thermostats_count")) > 0
label: qsTr("Measured temperature")
value: page.temp("thermostat_temperature")
}
InfoRow {
visible: Number(page.count("thermostats_count")) > 0
label: qsTr("Humidity")
value: api.formatNumber(page.raw("thermostat_humidity"), 1) + " %"
}
InfoRow {
visible: Number(page.count("thermostats_count")) > 0
label: qsTr("Signal")
value: page.count("thermostat_rssi") + " dBm"
}
InfoRow {
visible: Number(page.count("thermostats_count")) > 0
label: qsTr("Battery")
value: page.raw("thermostat_battery_low") ? qsTr("low") : qsTr("ok")
}
InfoRow {
visible: Number(page.count("thermostats_count")) > 0
label: qsTr("Article")
value: page.testo(page.raw("thermostat_article"))
}
// ---- versioni --------------------------------------------------- //
SectionHeader { text: qsTr("Versions") }
InfoRow { label: qsTr("Device ID"); value: page.testo(page.raw("device_id")) }
InfoRow { label: qsTr("Platform"); value: page.testo(page.raw("version_platform")) }
InfoRow { label: qsTr("Connectivity"); value: page.testo(page.raw("version_connectivity")) }
InfoRow { label: qsTr("Climate control"); value: page.testo(page.raw("version_climate_control")) }
InfoRow { label: qsTr("Outdoor unit"); value: page.testo(page.raw("version_outdoor_unit")) }
InfoRow { label: qsTr("Outdoor unit EEPROM"); value: page.testo(page.raw("version_outdoor_eeprom")) }
// ---- errori ----------------------------------------------------- //
SectionHeader { text: qsTr("Errors") }
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
visible: page.count("errors_count") === "0"
text: qsTr("No errors reported by the plant.")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
InfoRow {
visible: page.count("errors_count") !== "0"
label: qsTr("Errors reported")
value: page.count("errors_count")
}
Item { width: 1; height: Theme.paddingMedium }
}
}
Component.onCompleted: {
if (api.configured)
api.refresh()
}
}
+9 -3
View File
@@ -1,6 +1,6 @@
Name: harbour-aira Name: harbour-aira
Summary: Control your Aira heat pump through a local bridge Summary: Control your Aira heat pump through a local bridge
Version: 0.4.0 Version: 0.5.0
Release: 1 Release: 1
Group: Qt/Qt Group: Qt/Qt
License: MIT License: MIT
@@ -26,7 +26,8 @@ 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 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, 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. 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. The app shows the current state, the energy data (heat produced, electricity
used, efficiency, savings) and sends the hot-water and heating commands.
The interface is in English and Italian: by default it follows the phone The interface is in English and Italian: by default it follows the phone
language and falls back to English, and the language can also be picked in the language and falls back to English, and the language can also be picked in the
@@ -36,6 +37,11 @@ Address and token of the bridge live in the app settings (AppConfigLocation);
no personal data is stored in the binary. no personal data is stored in the binary.
%changelog %changelog
* Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.5.0-1
- schermata iniziale riordinata come l'app ufficiale: temperatura esterna in
alto, messaggio di stato, scheda dell'impianto con casa e acqua calda
- pagina Dati: calore prodotto, elettricita' usata, efficienza (COP) e risparmi
- pagina Impianto: stato, termostato a parete, versioni del software, errori
* Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.4.0-1 * Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.4.0-1
- interfaccia in inglese e italiano, scelta nelle impostazioni con ripiego - interfaccia in inglese e italiano, scelta nelle impostazioni con ripiego
sull'inglese; numeri con il separatore decimale della lingua sull'inglese; numeri con il separatore decimale della lingua
@@ -60,7 +66,7 @@ make %{?_smp_mflags}
# mancante NON fa fallire la compilazione: l'app resterebbe in inglese senza dire # mancante NON fa fallire la compilazione: l'app resterebbe in inglese senza dire
# niente. Qui lo si rigenera comunque; il file finisce nella stessa cartella dove # niente. Qui lo si rigenera comunque; il file finisce nella stessa cartella dove
# lo cerca src/translator.cpp. Il .ts si cerca con find perche' sfdk usa i # lo cerca src/translator.cpp. Il .ts si cerca con find perche' sfdk usa i
# sorgenti live (shadow build) e la cartella puo' chiamarsi harbour-aira-0.4.0 # sorgenti live (shadow build) e la cartella puo' chiamarsi harbour-aira-0.5.0
# (tarball), harbour-aira (clone) o harbour-aira-main (archivio di Gitea). # (tarball), harbour-aira (clone) o harbour-aira-main (archivio di Gitea).
TS=$(find %{_builddir}/.. -maxdepth 4 -name 'harbour-aira-it.ts' 2>/dev/null | head -1) TS=$(find %{_builddir}/.. -maxdepth 4 -name 'harbour-aira-it.ts' 2>/dev/null | head -1)
if [ -z "$TS" ]; then if [ -z "$TS" ]; then
+257 -34
View File
@@ -101,13 +101,29 @@ def slider(d: ImageDraw.ImageDraw, y: int, frac: float, value_text: str,
def button(d: ImageDraw.ImageDraw, y: int, text: str, primary: bool = False) -> int: def button(d: ImageDraw.ImageDraw, y: int, text: str, primary: bool = False) -> int:
h = 74 h = 66
d.rounded_rectangle([M, y, W - M, y + h], radius=h // 2, d.rounded_rectangle([M, y, W - M, y + h], radius=h // 2,
fill=BTN_BG, outline=ACCENT if primary else BTN_EDGE, width=2) fill=BTN_BG, outline=ACCENT if primary else BTN_EDGE, width=2)
tw = d.textlength(text, font=font(28, bold=primary)) tw = d.textlength(text, font=font(26, bold=primary))
d.text(((W - tw) / 2, y + (h - 34) / 2), text, font=font(28, bold=primary), d.text(((W - tw) / 2, y + (h - 32) / 2), text, font=font(26, bold=primary),
fill=ACCENT if primary else PRIMARY) fill=ACCENT if primary else PRIMARY)
return y + h + 16 return y + h + 14
def wrap(d: ImageDraw.ImageDraw, text: str, fnt, max_width: float) -> list:
"""Spezza il testo in righe che stanno nella larghezza data (come il QML,
che manda a capo le etichette lunghe invece di tagliarle)."""
lines, cur = [], ""
for word in text.split():
prova = (cur + " " + word).strip()
if not cur or d.textlength(prova, font=fnt) <= max_width:
cur = prova
else:
lines.append(cur)
cur = word
if cur:
lines.append(cur)
return lines
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -164,6 +180,52 @@ TEXTS = {
"night_one_hour": "Night mode for one hour", "night_one_hour": "Night mode for one hour",
"force_heating": "Force heating", "force_heating": "Force heating",
"confirm_hint": "(asks for confirmation first)", "confirm_hint": "(asks for confirmation first)",
# --- riordino come l'app ufficiale (v0.5.0) ------------------------- #
"outside": "Outside {v}",
"at_home": "At home",
"status_relax": "Relax: the heat pump is running smoothly.",
"status_detail": "Automatic · last reading 21:18",
"device_name": "Aira heat pump",
"device_link": "Connected through the cloud",
"room_temperature": "Room temperature",
"target_value": "Target {v}",
"dhw_row": "Domestic hot water",
"heating_house": "Heating the house",
"setpoint_line": "Heating setpoint 19.0 °C · Heating",
"details": "Details",
"plant_accessories": "Plant and accessories",
"consumption": "Consumption and savings",
"data_title": "Data",
"data_note": "Numbers calculated by Aira in the cloud:\nthe app only reads them from your own bridge.",
"month": "Month of October 2026",
"heat_produced": "Heat produced",
"heat_sub": "What the heat pump gave the house",
"electricity": "Electricity used",
"electricity_sub": "What the heat pump took from the grid",
"eff_dhw": "Efficiency, domestic hot water",
"eff_heat": "Efficiency, heating",
"savings_title": "Your savings, last 30 days",
"saved_bill": "Saved on the bill",
"saved_sub": "Compared with a gas boiler",
"co2": "CO₂ avoided",
"smart": "Smart Tariff Control",
"no_data": "no data",
"status": "Status",
"connection": "Connection",
"cloud": "Cloud",
"mode": "Mode",
"automatic": "Automatic",
"accessories": "Accessories",
"thermostat": "Aira wall thermostat",
"sensors": "Temperature 22.8 °C · Humidity 73.3 % · Battery ok",
"versions": "Versions",
"device_id": "Device ID",
"platform": "Platform",
"connectivity": "Connectivity",
"climate_control": "Climate control",
"outdoor_unit": "Outdoor unit",
"errors": "Errors",
"no_errors": "No errors reported by the plant.",
}, },
"it": { "it": {
"app": "Aira", "app": "Aira",
@@ -214,6 +276,52 @@ TEXTS = {
"night_one_hour": "Night mode per un'ora", "night_one_hour": "Night mode per un'ora",
"force_heating": "Forza riscaldamento", "force_heating": "Forza riscaldamento",
"confirm_hint": "(chiede conferma prima di partire)", "confirm_hint": "(chiede conferma prima di partire)",
# --- riordino come l'app ufficiale (v0.5.0) ------------------------- #
"outside": "Esterno {v}",
"at_home": "In casa",
"status_relax": "Relax: la pompa di calore funziona regolarmente.",
"status_detail": "Automatico · ultima lettura 21:18",
"device_name": "Aira Pompa di calore",
"device_link": "Connessa tramite Cloud",
"room_temperature": "Temperatura stanza",
"target_value": "Obiettivo {v}",
"dhw_row": "Acqua calda",
"heating_house": "Riscaldamento della casa",
"setpoint_line": "Obiettivo riscaldamento 19,0 °C · Riscaldamento",
"details": "Dettagli",
"plant_accessories": "Impianto e accessori",
"consumption": "Consumi e risparmi",
"data_title": "Dati",
"data_note": "Numeri calcolati da Aira nel cloud:\nl'app li legge soltanto dal tuo bridge.",
"month": "Mese di ottobre 2026",
"heat_produced": "Calore prodotto",
"heat_sub": "Quanto la pompa ha dato alla casa",
"electricity": "Elettricità usata",
"electricity_sub": "Quanta ne ha presa dalla rete",
"eff_dhw": "Efficienza, acqua calda",
"eff_heat": "Efficienza, riscaldamento",
"savings_title": "I tuoi risparmi, ultimi 30 giorni",
"saved_bill": "Risparmiati in bolletta",
"saved_sub": "Rispetto a una caldaia a gas",
"co2": "CO₂ evitata",
"smart": "Smart Tariff Control",
"no_data": "nessun dato",
"status": "Stato",
"connection": "Connessione",
"cloud": "Cloud",
"mode": "Modalità",
"automatic": "Automatico",
"accessories": "Accessori",
"thermostat": "Termostato a parete Aira",
"sensors": "Temperatura 22,8 °C · Umidità 73,3 % · Batteria ok",
"versions": "Versioni",
"device_id": "ID dispositivo",
"platform": "Piattaforma",
"connectivity": "Connettività",
"climate_control": "Controllo climatico",
"outdoor_unit": "Unità esterna",
"errors": "Errori",
"no_errors": "Nessun errore segnalato dall'impianto.",
}, },
} }
@@ -230,52 +338,90 @@ def dec(value: float) -> str:
return out.replace(".", ",") if LANG == "it" else out return out.replace(".", ",") if LANG == "it" else out
# --------------------------------------------------------------------------- #
def num(value: float, n: int = 1) -> str:
"""Numero con n decimali e il separatore della lingua, come fa l'app."""
out = f"{value:.{n}f}"
return out.replace(".", ",") if LANG == "it" else out
def card(d: ImageDraw.ImageDraw, y: int, h: int) -> None:
"""Scheda dello sfondo (BackgroundItem dell'app): angoli tondi, bordo tenue."""
d.rounded_rectangle([M, y, W - M, y + h], radius=18, fill=(26, 32, 41),
outline=BTN_EDGE, width=2)
def value_row(d: ImageDraw.ImageDraw, y: int, title: str, subtitle: str,
value: str) -> int:
"""Titolo e sottotitolo a sinistra, valore grande a destra (componente
ValueRow dell'app: e' la forma delle righe della scheda dell'impianto)."""
d.text((M + 28, y), title, font=font(26), fill=PRIMARY)
d.text((M + 28, y + 36), subtitle, font=font(22), fill=SECONDARY)
vw = d.textlength(value, font=font(42, bold=True))
d.text((W - M - 28 - vw, y + 6), value, font=font(42, bold=True), fill=PRIMARY)
return y + 96
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def main_screen() -> Image.Image: def main_screen() -> Image.Image:
"""Schermata iniziale, ordinata come quella dell'app ufficiale: temperatura
esterna in alto, messaggio di stato, scheda dell'impianto, poi i comandi."""
img = background() img = background()
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
status_bar(d) status_bar(d)
page_header(d, T("app")) page_header(d, T("app"))
y = 156 y = 150
# ---- casa: il dato principale, in cima ------------------------------- # # ---- barra in alto: fuori casa / a casa ------------------------------ #
c = T("house_temperature") d.text((M, y), T("outside").format(v=dec(18.0) + " °C"), font=font(24), fill=SECONDARY)
d.text(((W - d.textlength(c, font=font(24))) / 2, y), c, font=font(24), fill=SECONDARY) casa = T("at_home")
y += 40 d.text((W - M - d.textlength(casa, font=font(22)), y + 2), casa,
big = dec(23.0) + " °C" font=font(22), fill=SECONDARY)
d.text(((W - d.textlength(big, font=font(92, bold=True))) / 2, y), y += 58
big, font=font(92, bold=True), fill=PRIMARY)
y += 116 # ---- messaggio di stato ---------------------------------------------- #
progress(d, y, (23.0 - 5) / (30 - 5)) fnt = font(30)
y += 22 righe = wrap(d, T("status_relax"), fnt, W - 2 * M - 20)
t = f'{T("target")} {dec(19.0)} °C · {T("heating")}' for i, riga in enumerate(righe):
d.text(((W - d.textlength(t, font=font(24))) / 2, y), t, font=font(24), fill=SECONDARY) d.text(((W - d.textlength(riga, font=fnt)) / 2, y + i * 40), riga,
font=fnt, fill=PRIMARY)
y += 40 * len(righe) + 6
det = T("status_detail")
d.text(((W - d.textlength(det, font=font(22))) / 2, y), det, font=font(22), fill=SECONDARY)
y += 46 y += 46
y = slider(d, y, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target"))
# ---- scheda dell'impianto -------------------------------------------- #
card(d, y, 282)
d.text((M + 28, y + 22), T("device_name"), font=font(26), fill=PRIMARY)
d.text((M + 28, y + 58), T("device_link"), font=font(22), fill=SECONDARY)
d.text((W - M - 46, y + 24), "›", font=font(42), fill=ACCENT)
value_row(d, y + 100, T("room_temperature"),
T("target_value").format(v=dec(19.0) + " °C"), dec(22.8) + " °C")
value_row(d, y + 186, T("dhw_row"),
T("target_value").format(v=dec(55.0) + " °C"), dec(51.0) + " °C")
y += 282 + 26
# ---- riscaldamento della casa ---------------------------------------- #
y = section_header(d, y, T("heating_house"))
line = T("setpoint_line")
d.text(((W - d.textlength(line, font=font(22))) / 2, y - 8), line,
font=font(22), fill=SECONDARY)
y = slider(d, y + 28, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target"))
y = button(d, y, T("set_house").format(v=dec(19.0)), primary=True) y = button(d, y, T("set_house").format(v=dec(19.0)), primary=True)
# ---- acqua calda sanitaria ------------------------------------------- # # ---- acqua calda sanitaria ------------------------------------------- #
y = section_header(d, y + 24, T("dhw_section")) y = section_header(d, y + 6, T("dhw_section"))
acs = dec(51.0) + " °C"
d.text(((W - d.textlength(acs, font=font(56, bold=True))) / 2, y),
acs, font=font(56, bold=True), fill=PRIMARY)
y += 76
progress(d, y, (51.0 - 20) / (65 - 20)) progress(d, y, (51.0 - 20) / (65 - 20))
y += 22 y += 22
y = label_pair(d, y, T("dhw_target"), dec(55.0) + " °C") y = slider(d, y + 4, (55.0 - 45) / (60 - 45), "55 °C", T("target_label"))
y = slider(d, y + 10, (55.0 - 45) / (60 - 45), "55 °C", T("target_label"))
y = button(d, y, T("set_acs"), primary=True) y = button(d, y, T("set_acs"), primary=True)
y = button(d, y, T("start_boost")) y = button(d, y, T("start_boost"))
y = button(d, y, T("stop_boost")) y = button(d, y, T("stop_boost"))
# ---- stato impianto (scorre sotto) ----------------------------------- # # ---- pagine collegate (scorrono sotto) ------------------------------- #
y = section_header(d, y + 24, T("plant")) y = section_header(d, y + 6, T("details"))
y = label_pair(d, y, T("outdoor"), dec(18.0) + " °C") y = button(d, y, T("plant_accessories"))
y = label_pair(d, y, T("indoor_pump"), dec(22.5) + " °C") y = button(d, y, T("consumption"))
hint = T("pull_to_refresh")
d.text(((W - d.textlength(hint, font=font(22))) / 2, y + 8),
hint, font=font(22), fill=SECONDARY)
return img return img
@@ -368,6 +514,82 @@ def commands_screen() -> Image.Image:
return img return img
def data_screen() -> Image.Image:
"""Pagina "Dati": gli stessi numeri della scheda Dati dell'app ufficiale."""
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("data_title"))
y = 150
d.multiline_text((M, y), T("data_note"), font=font(22), fill=SECONDARY, spacing=8)
y += 96
# ---- energia e efficienza del mese ------------------------------------ #
y = section_header(d, y, T("month"))
d.text((M, y), T("heat_produced"), font=font(26), fill=PRIMARY)
d.text((M, y + 34), T("heat_sub"), font=font(22), fill=SECONDARY)
v = dec(70.8) + " kWh"
d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v,
font=font(40, bold=True), fill=PRIMARY)
y += 96
d.text((M, y), T("electricity"), font=font(26), fill=PRIMARY)
d.text((M, y + 34), T("electricity_sub"), font=font(22), fill=SECONDARY)
v = dec(29.3) + " kWh"
d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v,
font=font(40, bold=True), fill=PRIMARY)
y += 96
y = label_pair(d, y, T("eff_dhw"), num(4.42, 2))
y = label_pair(d, y, T("eff_heat"), T("no_data"))
# ---- risparmi ---------------------------------------------------------- #
y = section_header(d, y + 20, T("savings_title"))
d.text((M, y), T("saved_bill"), font=font(26), fill=PRIMARY)
d.text((M, y + 34), T("saved_sub"), font=font(22), fill=SECONDARY)
v = num(21.78, 2) + " €"
d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v,
font=font(40, bold=True), fill=ACCENT)
y += 96
y = label_pair(d, y, T("co2"), dec(38.1) + " kg")
y = label_pair(d, y, T("smart"), num(2.71, 2) + " €", right_color=ACCENT)
hint = T("pull_to_refresh")
d.text(((W - d.textlength(hint, font=font(22))) / 2, H - 44),
hint, font=font(22), fill=SECONDARY)
return img
def plant_screen() -> Image.Image:
"""Pagina "Impianto": stato, accessori, versioni, errori."""
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("plant"))
y = 150
y = section_header(d, y, T("status"))
for lab, val in ((T("connection"), T("cloud")), (T("mode"), T("automatic")),
(T("away"), T("off")), (T("night_mode"), T("off")),
(T("forced_heating"), T("off"))):
y = label_pair(d, y, lab, val)
y = section_header(d, y + 20, T("accessories"))
d.text((M, y), T("thermostat"), font=font(26), fill=PRIMARY)
y += 36
d.text((M, y), T("sensors"), font=font(22), fill=SECONDARY)
y += 56
y = section_header(d, y, T("versions"))
for lab, val in ((T("device_id"), "1f4b26"), (T("platform"), "6.10.0"),
(T("connectivity"), "3.30.1"), (T("climate_control"), "3.9.12"),
(T("outdoor_unit"), "1.30.0")):
y = label_pair(d, y, lab, val)
y = section_header(d, y + 20, T("errors"))
d.text((M, y), T("no_errors"), font=font(24), fill=OK)
return img
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
@@ -380,7 +602,8 @@ if __name__ == "__main__":
out = pathlib.Path("docs/anteprima") if lang == "en" else pathlib.Path("docs/anteprima") / lang out = pathlib.Path("docs/anteprima") if lang == "en" else pathlib.Path("docs/anteprima") / lang
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)
for name, fn in (("dashboard", main_screen), ("comandi", commands_screen), for name, fn in (("dashboard", main_screen), ("dati", data_screen),
("impianto", plant_screen), ("comandi", commands_screen),
("impostazioni", settings_screen), ("copertina", cover_screen)): ("impostazioni", settings_screen), ("copertina", cover_screen)):
path = out / f"{name}.png" path = out / f"{name}.png"
fn().save(path) fn().save(path)
+74 -7
View File
@@ -5,6 +5,7 @@
#include <QJsonObject> #include <QJsonObject>
#include <QJsonValue> #include <QJsonValue>
#include <QLocale> #include <QLocale>
#include <QStringList>
#include <QUrl> #include <QUrl>
namespace AiraJson { namespace AiraJson {
@@ -47,6 +48,29 @@ QVariantMap parseSummary(const QByteArray &body, bool *ok)
return summary.toVariantMap(); return summary.toVariantMap();
} }
QVariantMap parseStats(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 stats = root.value(QStringLiteral("stats")).toObject();
if (stats.isEmpty())
return QVariantMap();
if (ok)
*ok = true;
return stats.toVariantMap();
}
QString modeLabel(const QString &operatingStatus) QString modeLabel(const QString &operatingStatus)
{ {
const QString s = operatingStatus; const QString s = operatingStatus;
@@ -92,20 +116,63 @@ QString modeLabel(const QString &operatingStatus)
} }
QString formatValue(const QVariant &value) QString formatValue(const QVariant &value)
{
// Un decimale: e' il caso delle temperature ("51,0").
return formatNumber(value, 1);
}
QString formatNumber(const QVariant &value, int decimals)
{ {
if (!value.isValid() || value.isNull()) if (!value.isValid() || value.isNull())
return QStringLiteral("—"); return QStringLiteral("—");
bool isNumber = false; bool isNumber = false;
const double d = value.toDouble(&isNumber); const double d = value.toDouble(&isNumber);
if (isNumber) if (!isNumber)
// QLocale() = locale predefinito, che translator.cpp allinea alla lingua return QStringLiteral("—");
// dell'app: separatore decimale col punto in inglese, con la virgola in
// italiano (QString::number darebbe sempre il punto).
return QLocale().toString(d, 'f', 1);
const QString s = value.toString(); // QLocale() = locale predefinito, che translator.cpp allinea alla lingua
return s.isEmpty() ? QStringLiteral("—") : s; // dell'app: separatore decimale col punto in inglese, con la virgola in
// italiano (QString::number darebbe sempre il punto).
return QLocale().toString(d, 'f', decimals < 0 ? 0 : decimals);
}
QString formatMoney(const QVariant &value, const QString &currencyCode)
{
if (!value.isValid() || value.isNull())
return QStringLiteral("—");
bool isNumber = false;
const double d = value.toDouble(&isNumber);
if (!isNumber)
return QStringLiteral("—");
// Posizione del simbolo e separatori seguono la lingua dell'app, non la
// valuta: "21,78 €" in italiano, "€21.78" in inglese.
const QString code = currencyCode.trimmed().toUpper();
QString symbol;
if (code == QLatin1String("EUR"))
symbol = QString::fromUtf8("€");
else if (!code.isEmpty())
symbol = code; // valuta senza simbolo noto: codice ISO
return QLocale().toCurrencyString(d, symbol);
}
QString monthLabel(const QString &yearMonth)
{
// "2026-10" -> "ottobre 2026": il nome del mese segue la lingua dell'app.
const QStringList parti = yearMonth.split(QLatin1Char('-'));
if (parti.size() != 2)
return QString();
bool annoOk = false;
bool meseOk = false;
const int anno = parti.at(0).toInt(&annoOk);
const int mese = parti.at(1).toInt(&meseOk);
if (!annoOk || !meseOk || mese < 1 || mese > 12)
return QString();
return QStringLiteral("%1 %2").arg(QLocale().monthName(mese, QLocale::LongFormat)).arg(anno);
} }
QByteArray commandPayload(const QString &id, const QVariant &value) QByteArray commandPayload(const QString &id, const QVariant &value)
+15
View File
@@ -23,12 +23,27 @@ QString normalizeBaseUrl(const QString &raw);
// vuoto e mette *ok a false. // vuoto e mette *ok a false.
QVariantMap parseSummary(const QByteArray &body, bool *ok); QVariantMap parseSummary(const QByteArray &body, bool *ok);
// Legge il corpo di GET /stats (pagina "Dati": calore, elettricita', COP,
// risparmi) nello stesso formato. Vuoto e *ok a false se non interpretabile.
QVariantMap parseStats(const QByteArray &body, bool *ok);
// Codice di stato Aira ("OPERATING_STATUS_AUTO") in etichetta italiana. // Codice di stato Aira ("OPERATING_STATUS_AUTO") in etichetta italiana.
QString modeLabel(const QString &operatingStatus); QString modeLabel(const QString &operatingStatus);
// Temperatura leggibile: "51,0" oppure "—" se il valore manca. // Temperatura leggibile: "51,0" oppure "—" se il valore manca.
QString formatValue(const QVariant &value); QString formatValue(const QVariant &value);
// Numero con il numero di decimali voluto, col separatore della lingua
// ("4,42" in italiano, "4.42" in inglese); "—" se il valore manca.
QString formatNumber(const QVariant &value, int decimals);
// Importo con il simbolo della valuta ("21,78 €" in italiano, "€21.78" in
// inglese): posizione e separatori seguono la lingua, non la valuta.
QString formatMoney(const QVariant &value, const QString &currencyCode);
// "2026-10" -> "ottobre 2026" / "October 2026". Stringa vuota se illeggibile.
QString monthLabel(const QString &yearMonth);
// Corpo JSON per POST /command. value viene omesso se nullo (i comandi // Corpo JSON per POST /command. value viene omesso se nullo (i comandi
// semplici non hanno parametri). // semplici non hanno parametri).
QByteArray commandPayload(const QString &id, const QVariant &value); QByteArray commandPayload(const QString &id, const QVariant &value);
+58
View File
@@ -157,6 +157,64 @@ QString ApiClient::formatValue(const QVariant &value) const
return AiraJson::formatValue(value); return AiraJson::formatValue(value);
} }
QString ApiClient::formatNumber(const QVariant &value, int decimals) const
{
return AiraJson::formatNumber(value, decimals);
}
QString ApiClient::formatMoney(const QVariant &value, const QString &currencyCode) const
{
return AiraJson::formatMoney(value, currencyCode);
}
QString ApiClient::monthLabel(const QString &yearMonth) const
{
return AiraJson::monthLabel(yearMonth);
}
void ApiClient::refreshStats()
{
if (!configured()) {
emit statsRefreshed(false, tr("Set the bridge address and token"));
return;
}
// Il bridge tiene le statistiche in cache (10 minuti): la prima lettura
// interroga il cloud, quindi il tempo massimo e' piu' largo del solito.
QNetworkReply *reply = send("GET", QStringLiteral("/stats"), QByteArray(), 60);
if (!reply) {
emit statsRefreshed(false, tr("Bridge not configured"));
return;
}
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
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)
? tr("Timeout: no statistics")
: AiraJson::errorMessage(body, status);
qWarning() << "[api] statistiche non riuscite:" << status << msg;
emit statsRefreshed(false, msg);
return;
}
bool ok = false;
const QVariantMap stats = AiraJson::parseStats(body, &ok);
if (!ok) {
qWarning() << "[api] statistiche non interpretabili";
emit statsRefreshed(false, tr("Unreadable response from the bridge"));
return;
}
m_stats = stats;
emit statsChanged(stats);
emit statsRefreshed(true, QString());
});
}
void ApiClient::sendCommand(const QString &id, const QVariant &value) void ApiClient::sendCommand(const QString &id, const QVariant &value)
{ {
if (!configured()) { if (!configured()) {
+13
View File
@@ -21,6 +21,9 @@ class ApiClient : public QObject
// le property dell'ApplicationWindow NON sono visibili alle pagine dello // le property dell'ApplicationWindow NON sono visibili alle pagine dello
// pageStack, quindi la dashboard e la copertina non potrebbero condividerle. // pageStack, quindi la dashboard e la copertina non potrebbero condividerle.
Q_PROPERTY(QVariantMap summary READ summary NOTIFY summaryChanged) Q_PROPERTY(QVariantMap summary READ summary NOTIFY summaryChanged)
// Statistiche della pagina "Dati" (calore, elettricita', COP, risparmi).
// Separata dal riassunto: e' una chiamata piu' lenta, con cache lato bridge.
Q_PROPERTY(QVariantMap stats READ stats NOTIFY statsChanged)
public: public:
explicit ApiClient(Settings *settings, QObject *parent = 0); explicit ApiClient(Settings *settings, QObject *parent = 0);
@@ -28,10 +31,14 @@ public:
bool busy() const { return m_busy; } bool busy() const { return m_busy; }
bool configured() const; bool configured() const;
QVariantMap summary() const { return m_summary; } QVariantMap summary() const { return m_summary; }
QVariantMap stats() const { return m_stats; }
// Scarica il riassunto e lo emette con summaryChanged(). // Scarica il riassunto e lo emette con summaryChanged().
Q_INVOKABLE void refresh(); Q_INVOKABLE void refresh();
// Scarica le statistiche (GET /stats) e le emette con statsChanged().
Q_INVOKABLE void refreshStats();
// Prova GET /health (non richiede token) e risponde con connectionTested(). // Prova GET /health (non richiede token) e risponde con connectionTested().
Q_INVOKABLE void testConnection(); Q_INVOKABLE void testConnection();
@@ -42,11 +49,16 @@ public:
// tests/core_test.cpp), niente duplicazione di logica nel QML. // tests/core_test.cpp), niente duplicazione di logica nel QML.
Q_INVOKABLE QString modeLabel(const QString &operatingStatus) const; Q_INVOKABLE QString modeLabel(const QString &operatingStatus) const;
Q_INVOKABLE QString formatValue(const QVariant &value) const; Q_INVOKABLE QString formatValue(const QVariant &value) const;
Q_INVOKABLE QString formatNumber(const QVariant &value, int decimals) const;
Q_INVOKABLE QString formatMoney(const QVariant &value, const QString &currencyCode) const;
Q_INVOKABLE QString monthLabel(const QString &yearMonth) const;
signals: signals:
void busyChanged(); void busyChanged();
void configuredChanged(); void configuredChanged();
void summaryChanged(const QVariantMap &summary); void summaryChanged(const QVariantMap &summary);
void statsChanged(const QVariantMap &stats);
void statsRefreshed(bool ok, const QString &message);
void commandFinished(bool ok, const QString &message); void commandFinished(bool ok, const QString &message);
void connectionTested(bool ok, const QString &message); void connectionTested(bool ok, const QString &message);
void errorOccurred(const QString &message); void errorOccurred(const QString &message);
@@ -61,6 +73,7 @@ private:
QNetworkAccessManager m_nam; QNetworkAccessManager m_nam;
bool m_busy; bool m_busy;
QVariantMap m_summary; QVariantMap m_summary;
QVariantMap m_stats;
}; };
#endif // APICLIENT_H #endif // APICLIENT_H
+2 -2
View File
@@ -21,7 +21,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
app->setOrganizationName(QStringLiteral("harbour")); app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("aira")); app->setApplicationName(QStringLiteral("aira"));
app->setApplicationVersion(QStringLiteral("0.4.0")); app->setApplicationVersion(QStringLiteral("0.5.0"));
Settings settings; Settings settings;
ApiClient api(&settings); ApiClient api(&settings);
@@ -36,7 +36,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
// Prima riga del log: dice subito se il device sta girando la build nuova // Prima riga del log: dice subito se il device sta girando la build nuova
// o una stale (trappola nota del build engine). // o una stale (trappola nota del build engine).
qDebug() << "harbour-aira v0.4.0 build" << __DATE__ << __TIME__; qDebug() << "harbour-aira v0.5.0 build" << __DATE__ << __TIME__;
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings); view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
view->rootContext()->setContextProperty(QStringLiteral("api"), &api); view->rootContext()->setContextProperty(QStringLiteral("api"), &api);
+42
View File
@@ -120,6 +120,48 @@ int main(int argc, char *argv[])
check(AiraJson::errorMessage("", 502) == "HTTP error 502", "http status fallback"); check(AiraJson::errorMessage("", 502) == "HTTP error 502", "http status fallback");
check(AiraJson::errorMessage("", 0) == "server unreachable", "status 0 = unreachable"); check(AiraJson::errorMessage("", 0) == "server unreachable", "status 0 = unreachable");
// ---- parseStats (pagina "Dati") --------------------------------------- //
const QByteArray statsBody = R"({"ok":true,"stats":{"currency":"EUR","period_days":30,"month":{"label":"2026-10","heat_kwh":70.8,"energy_kwh":29.3,"cop_heating":null,"cop_dhw":4.42}}})";
bool okStats = false;
const QVariantMap st = AiraJson::parseStats(statsBody, &okStats);
check(okStats, "statistiche valide riconosciute");
check(st.value("currency").toString() == "EUR", "valuta letta");
check(st.value("period_days").toInt() == 30, "periodo letto");
check(qFuzzyCompare(st.value("month").toMap().value("heat_kwh").toDouble(), 70.8),
"calore prodotto letto");
check(st.value("month").toMap().value("cop_dhw").toDouble() == 4.42,
"COP acqua calda letto");
check(st.value("month").toMap().value("cop_heating").isNull(),
"efficienza riscaldamento assente resta nulla (nessun dato)");
check(AiraJson::parseStats(R"({"ok":false,"error":"token errato"})", &okStats).isEmpty() && !okStats,
"statistiche in errore -> vuoto");
check(AiraJson::parseStats("non sono json", &okStats).isEmpty() && !okStats,
"statistiche non JSON -> vuoto");
// ---- formatNumber / formatMoney / monthLabel -------------------------- //
QLocale::setDefault(QLocale(QLocale::Italian, QLocale::Italy));
check(AiraJson::formatNumber(QVariant(4.4213), 2) == "4,42", "due decimali (italiano)");
check(AiraJson::formatNumber(QVariant(29.3), 1) == "29,3", "un decimale (italiano)");
check(AiraJson::formatNumber(QVariant(), 2) == "—", "numero assente -> trattino");
// toCurrencyString usa lo spazio unificatore del locale: si controllano
// separatore e simbolo, non la stringa esatta.
const QString importoIt = AiraJson::formatMoney(QVariant(21.78), "EUR");
check(importoIt.contains("21,78") && importoIt.contains(QString::fromUtf8("€")),
"importo in italiano: virgola e simbolo");
check(AiraJson::formatMoney(QVariant(21.78), "eur") == importoIt,
"codice valuta minuscolo accettato");
check(AiraJson::formatMoney(QVariant(), "EUR") == "—", "importo assente -> trattino");
check(AiraJson::monthLabel("2026-10") == "ottobre 2026", "mese in italiano");
check(AiraJson::monthLabel("non-mese").isEmpty(), "chiave mese illeggibile -> vuoto");
check(AiraJson::monthLabel("2026-13").isEmpty(), "mese 13 -> vuoto");
QLocale::setDefault(QLocale(QLocale::English, QLocale::UnitedKingdom));
check(AiraJson::formatNumber(QVariant(4.4213), 2) == "4.42", "due decimali (inglese)");
const QString importoEn = AiraJson::formatMoney(QVariant(21.78), "EUR");
check(importoEn.contains("21.78") && importoEn.contains(QString::fromUtf8("€")),
"importo in inglese: punto e simbolo");
check(AiraJson::monthLabel("2026-10") == "October 2026", "mese in inglese");
QLocale::setDefault(QLocale::c());
// ---- catalogo italiano: il .qm caricato davvero ------------------------ // // ---- catalogo italiano: il .qm caricato davvero ------------------------ //
// Non basta che il .ts esista: qui si installa il .qm come fa main.cpp e si // Non basta che il .ts esista: qui si installa il .qm come fa main.cpp e si
// controlla che le stringhe sorgente inglesi diventino italiane. Il percorso // controlla che le stringhe sorgente inglesi diventino italiane. Il percorso
File diff suppressed because it is too large Load Diff