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
@@ -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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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 |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
|  |  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
|
| Altri comandi | Impostazioni | Copertina |
|
||||||
|
|---|---|---|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
|  |  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
|
| Other commands | Settings | Cover |
|
||||||
|
|---|---|---|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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.")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 44 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ¤cyCode)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
|||||||
@@ -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 ¤cyCode);
|
||||||
|
|
||||||
|
// "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);
|
||||||
|
|||||||
@@ -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 ¤cyCode) 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()) {
|
||||||
|
|||||||
@@ -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 ¤cyCode) 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||