Compare commits

...
3 Commits
Author SHA1 Message Date
kaneda 36033daf3f 0.6.0 blocco 3: copertina, versione, documenti e anteprime nelle due lingue 2026-10-10 09:52:32 +02:00
kaneda 85702880d5 0.6.0 blocco 2: Comandi impianto con interruttori, diagnostica in Impianto
- CommandsPage: 4 interruttori (TextSwitch) al posto di 8 pulsanti in coppie
  accendi/spegni, piu' 3 pulsanti per le azioni senza stato. Gli interruttori
  seguono il dato vero (automaticCheck: false), non anticipano l'impianto, e
  la sezione 'Stato attuale' non serve piu'.
- La pagina si chiama 'Comandi impianto'.
- PlantPage: frase estesa dello stato, diagnostica (verifica impianto) e le
  letture che non avevano posto (pompa, modi consentiti, manuale, sbrinamento,
  luci di firma).
- SettingsPage: 'Salva' e 'Prova connessione' diventano 'Salva e prova'.
- ApiClient: dopo un comando riuscito lo stato si rilegge da solo (prima non
  si aggiornava nulla: l'aggiornamento automatico non esisteva).
- NoticeBar anche su Dati, Impianto e Comandi; via i messaggi dentro il
  contenuto che scorre.
2026-10-10 09:48:41 +02:00
kaneda d5b63b57df 0.6.0 blocco 1: Home di consultazione e pagina Regolazioni
- Home: stato in una riga col pallino, due letture grandi toccabili, tre
  azioni rapide; via i due slider, i sette pulsanti e la scheda separata.
- Nuova pagina Regolazioni: un blocco per grandezza, valore attuale sempre
  visibile, preimpostazioni per le dita fredde, conferma esplicita; il
  pulsante e' attivo solo se c'e' davvero qualcosa da cambiare.
- Messaggi dei comandi spostati in ApiClient (api.notice) con NoticeBar
  ancorata in fondo: prima nascevano fuori dallo schermo.
- Stato breve/esteso/livello in C++ (testabile senza SDK), 18 verifiche
  nuove: 79/79.
- stale + lastUpdate: la Home dice 'aggiornato alle HH:MM' e spegne i
  numeri vecchi invece di farli passare per freschi.
- 192/192 stringhe tradotte (54 nuove).
2026-10-10 09:47:16 +02:00
32 changed files with 1732 additions and 1009 deletions
+33 -23
View File
@@ -15,24 +15,29 @@ 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
- **Home**, una schermata da guardare: pallino colorato con lo stato in una riga
("Tutto regolare", "Sta scaldando l'acqua"), l'ora dell'ultimo aggiornamento, le
due letture che contano (casa e acqua calda, ognuna con il suo obiettivo)
affiancate e **toccabili**, e tre azioni rapide: boost, assenza, comandi
impianto. Sta in una schermata, senza scorrere: i comandi hanno pagine loro
- **Regolazioni**: obiettivo casa e obiettivo acqua calda, ognuno con il valore
che l'impianto sta tenendo adesso, lo slider (la proposta: il valore vero non si
muove), una fila di valori rapidi per le dita fredde e il pulsante di conferma,
che si accende solo se c'è davvero qualcosa da cambiare. Il boost sta qui
- **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
forzato, modalità manuale, sbrinamento, luci di firma, stato pompa, modi
consentiti, 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
software, numero di errori e verifica impianto. Qui sta anche la frase di stato
estesa ("Relax: la pompa di calore funziona regolarmente")
- **Comandi impianto**: quattro interruttori (assenza, riscaldamento forzato,
riscaldamento, acqua calda attiva) che mostrano lo stato e lo cambiano in un
tocco, più i pulsanti per quello che uno stato non ce l'ha: ciclo legionella e
night mode per un'ora. Gli interruttori seguono l'impianto, non lo anticipano
- **Copertina** con la temperatura della casa
- Indirizzo e token del bridge stanno nelle impostazioni dell'app (QSettings),
mai nel binario
@@ -43,13 +48,13 @@ Anteprime del layout (valori veri dell'impianto, **non** screenshot — vedi
*Stato*). Queste sono in italiano; quelle in inglese stanno in
`docs/anteprima/`:
| Home | Dati | Impianto |
| Home | Regolazioni | Dati |
|---|---|---|
| ![home](docs/anteprima/it/dashboard.png) | ![dati](docs/anteprima/it/dati.png) | ![impianto](docs/anteprima/it/impianto.png) |
| ![home](docs/anteprima/it/dashboard.png) | ![regolazioni](docs/anteprima/it/regolazioni.png) | ![dati](docs/anteprima/it/dati.png) |
| Altri comandi | Impostazioni | Copertina |
|---|---|---|
| ![comandi](docs/anteprima/it/comandi.png) | ![impostazioni](docs/anteprima/it/impostazioni.png) | ![copertina](docs/anteprima/it/copertina.png) |
| Impianto | Comandi impianto | Impostazioni | Copertina |
|---|---|---|---|
| ![impianto](docs/anteprima/it/impianto.png) | ![comandi](docs/anteprima/it/comandi.png) | ![impostazioni](docs/anteprima/it/impostazioni.png) | ![copertina](docs/anteprima/it/copertina.png) |
## La lingua dell'interfaccia
@@ -151,10 +156,12 @@ python3 tests/ui_contract_check.py http://192.168.3.21:8790
## Stato
- **61/61** verifiche del nucleo, compilazione senza warning
- **79/79** verifiche del nucleo, compilazione senza warning (comprese le frasi
di stato, breve ed estesa: quello che la Home dice dell'impianto è verificato
senza telefono)
- **48/48** verifiche offline del bridge (`bridge/test_bridge_commands.py`),
comprese le conversioni Wh→kWh e degli importi delle statistiche
- **170/170** stringhe tradotte nel catalogo italiano: il test carica il `.qm`
- **190/190** 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
la sorgente e in italiano quella tradotta, e che togliendo il catalogo si torni
all'inglese
@@ -188,13 +195,16 @@ src/translator.{h,cpp} lingua dell'interfaccia: locale di sistema, ripiego
sull'inglese, ricarica della UI al cambio
src/main.cpp context properties: appSettings, api, translator
qml/harbour-aira.qml ApplicationWindow + copertina
qml/pages/MainPage.qml home: stato, scheda impianto, riscaldamento e acqua
qml/pages/MainPage.qml home: stato in una riga, due letture, azioni rapide
qml/pages/SetpointsPage.qml obiettivi casa e acqua, valori rapidi, boost
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/PlantPage.qml stato, accessori, versioni, errori, diagnostica
qml/pages/CommandsPage.qml i comandi impianto, stato negli interruttori
qml/pages/SettingsPage.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/ReadingCell.qml lettura toccabile: titolo, valore grande, barra
qml/components/NoticeBar.qml messaggi dei comandi, ancorati in fondo
qml/components/ActionButton.qml pulsante di comando, con conferma opzionale
tests/core_test.cpp verifiche del nucleo (headless)
tests/ui_contract_check.py chiavi dei QML contro quelle del bridge (bridge attivo)
@@ -207,7 +217,7 @@ bridge/harbour-aira-bridge.service unit systemd utente
bridge/README.md istruzioni di installazione del bridge (inglese)
docs/PIANO.md piano di progetto e scelte
docs/PROTOCOL.md API del bridge
docs/UX-STUDIO.md studio UX e proposta per la 0.6.0
docs/UX-STUDIO.md studio UX e cosa è cambiato nella 0.6.0
docs/ux/ schemi dello studio UX
translations/ harbour-aira-it.ts (l'inglese è la lingua sorgente)
docs/anteprima/ anteprime di layout in inglese (in italiano: it/)
+36 -25
View File
@@ -9,24 +9,30 @@ status, domestic hot water, and the house target temperature per zone.
## What it does
- **Home**, organised like the official Aira app: outdoor temperature and
*at home / away* at the top, a plain-language status line ("Relax: the heat
pump is running smoothly"), then the **plant card** with the two readings that
matter (room temperature and domestic hot water, each with its target).
Tapping the card opens the plant page. Below that the controls: house target
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
- **Home**, one screen to look at: a coloured dot with a one-line status
("Everything is fine", "Heating the hot water"), the time of the last update,
the two readings that matter (house and domestic hot water, each with its
target) side by side and **tappable**, and three quick actions: boost, away
mode and the plant commands. It fits without scrolling — the controls live in
their own pages
- **Setpoints** page: house target and hot water target, each with the value the
plant is keeping right now, a slider (the proposal, the plant value does not
move), a row of quick values for cold fingers and a confirmation button that
only lights up when there is something to change
- **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,
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
- **Plant** page: status (connection, mode, away/night/forced heating, manual
mode, defrost, signature lights, pump state, allowed modes, outdoor and indoor
temperature, zones, last reading), accessories (the wall thermostat with
temperature, humidity, signal, battery), software versions, the error count and
the plant check. The extended status sentence ("Relax: the heat pump is running
smoothly") lives here
- **Plant commands** page: four switches (away mode, forced heating, heating, hot
water active) that show the state of the plant and change it in one tap, plus
the buttons for what has no state: legionella cycle and night mode for one
hour. The switches follow the plant — they never anticipate it
- **Cover page** showing the house temperature
- Address and bridge token live in the app settings (QSettings), never in the
binary
@@ -39,13 +45,13 @@ status, domestic hot water, and the house target temperature per zone.
Layout previews (rendered with the real values the plant returned, **not**
screenshots — see *Status*):
| Home | Data | Plant |
| Home | Setpoints | Data |
|---|---|---|
| ![home](docs/anteprima/dashboard.png) | ![data](docs/anteprima/dati.png) | ![plant](docs/anteprima/impianto.png) |
| ![home](docs/anteprima/dashboard.png) | ![setpoints](docs/anteprima/regolazioni.png) | ![data](docs/anteprima/dati.png) |
| Other commands | Settings | Cover |
|---|---|---|
| ![commands](docs/anteprima/comandi.png) | ![settings](docs/anteprima/impostazioni.png) | ![cover](docs/anteprima/copertina.png) |
| Plant | Plant commands | Settings | Cover |
|---|---|---|---|
| ![plant](docs/anteprima/impianto.png) | ![commands](docs/anteprima/comandi.png) | ![settings](docs/anteprima/impostazioni.png) | ![cover](docs/anteprima/copertina.png) |
## Why there is a bridge
@@ -136,12 +142,14 @@ python3 tests/ui_contract_check.py http://192.168.3.21:8790
Verified so far:
- **61/61** core checks (`tests/core_test.cpp`), compiled with no warnings
- **79/79** core checks (`tests/core_test.cpp`), compiled with no warnings —
including the one-line and extended status sentences, so what the home says
about the plant is verified without a phone
- **48/48** bridge checks, offline (`bridge/test_bridge_commands.py`) —
including that a zone command always carries every zone, that absurd values
are refused (40 °C, zone 3, text), and the Wh→kWh / money conversions of the
statistics
- **170/170** strings translated in the Italian catalogue, checked by loading
- **190/190** strings translated in the Italian catalogue, checked by loading
the compiled `.qm` in the test the same way `main.cpp` does: source English
in, Italian out, and back to English when the catalogue is removed
- numbers, amounts and month names formatted through `QLocale`, so the decimal
@@ -202,13 +210,16 @@ src/settings.{h,cpp} QSettings in AppConfigLocation (SailJail sandbox)
src/translator.{h,cpp} UI language: system locale, English fallback, reload
src/main.cpp context properties: appSettings, api, translator
qml/harbour-aira.qml ApplicationWindow + cover
qml/pages/MainPage.qml home: status, plant card, heating and water
qml/pages/MainPage.qml home: one-line status, two readings, quick actions
qml/pages/SetpointsPage.qml house and water targets, quick values, boost
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/PlantPage.qml status, accessories, versions, errors, diagnostics
qml/pages/CommandsPage.qml the plant commands, state shown by switches
qml/pages/SettingsPage.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/ReadingCell.qml tappable reading: title, big value, charge bar
qml/components/NoticeBar.qml command messages, anchored to the bottom edge
qml/components/ActionButton.qml command button, optional confirmation
tests/core_test.cpp core checks (headless)
tests/ui_contract_check.py QML keys vs bridge keys (bridge must be running)
@@ -216,7 +227,7 @@ bridge/ local HTTP bridge -> see bridge/README.md
translations/ harbour-aira-it.ts (English is the source language)
docs/PIANO.md project plan and decisions (Italian)
docs/PROTOCOL.md bridge API
docs/UX-STUDIO.md UX review + proposal for 0.6.0 (Italian)
docs/UX-STUDIO.md UX review and what changed in 0.6.0 (Italian)
docs/ux/ UX study wireframes
docs/anteprima/ layout previews (English; docs/anteprima/it/ Italian)
```
+49
View File
@@ -231,3 +231,52 @@ andrebbe disegnata a mano.
parsing delle statistiche e alla formattazione di importi e mesi (61/61), versione,
README e `docs/PROTOCOL.md` aggiornati.
- Nessun comando nuovo all'impianto: tutto questo è **sola lettura**.
---
## 9. Studio UX/UI e riordino delle schermate — v0.6.0
Prima di scrivere codice è stato fatto lo studio in `docs/UX-STUDIO.md`: dieci
problemi rilevati nelle schermate della 0.5.0, la proposta, i wireframe
(`docs/ux/`, generati da `scripts/render_wireframes.py`) e il piano di verifica.
Le quattro scelte di design sono state prese dall'autore: la frase di stato
lunga va in Impianto, "Altri comandi" diventa "Comandi impianto", le azioni
rapide sono pulsanti visibili, le azioni sulla copertina restano per dopo la
prova di `CoverAction` sul kit.
**Home** (`MainPage.qml`, riscritta): una schermata da guardare, non un modulo.
Stato in una riga con il pallino colorato (verde regolare, ambra modo attivo o
numeri vecchi, rosso errore), le due letture che contano — casa e acqua calda,
con obiettivo e barra di carica — affiancate e **toccabili**, e tre azioni
rapide: boost, assenza, comandi. Escono dalla Home i due slider, i sette
pulsanti e i tre pulsanti di navigazione ridondanti col menù a tendina. Sotto,
in una riga toccabile che porta a Impianto: esterna, termostato, zone.
**Regolazioni** (`SetpointsPage.qml`, nuova): un blocco per grandezza, con il
valore vero dell'impianto sempre visibile, slider (la proposta), fila di valori
rapidi per le dita fredde e pulsante di conferma che si attiva solo se c'è
davvero qualcosa da cambiare. Il boost ha qui i suoi due pulsanti.
**Comandi impianto** (`CommandsPage.qml`): da 12 pulsanti in quattro sezioni
(otto in coppie acceso/spento) a **4 interruttori `TextSwitch` + 3 pulsanti**.
Gli interruttori non anticipano l'impianto: `automaticCheck: false`, `checked`
sull'ultimo dato vero, e se il comando non riesce restano dov'erano. La
diagnostica (verifica impianto) si sposta in Impianto, insieme alle letture che
non avevano posto: pompa, modi consentiti, modalità manuale, sbrinamento, luci
di firma.
**Feedback e dati vecchi**: i messaggi dei comandi passano in `ApiClient`
(`api.notice`) e si mostrano nel nuovo `NoticeBar`, **ancorato al bordo
inferiore della pagina** invece che dentro il contenuto che scorre: prima,
premendo un pulsante in fondo alla pagina, il messaggio nasceva fuori schermo.
In più `stale` e `lastUpdate`: la Home scrive "aggiornato alle HH:MM" e spegne i
numeri quando l'ultima lettura non è recente. Dopo un comando riuscito lo stato
si rilegge da solo (2 s): l'aggiornamento automatico, prima, non esisteva.
**Stato breve ed esteso in C++** (`AiraJson::stateShort/stateLong/statusLevel`):
logica pura, verificabile senza SDK e non duplicata fra Home e Impianto. Test
del nucleo: **79/79** (erano 61). Stringhe: **190/190** tradotte. Anteprime
rigenerate (nuova `regolazioni.png`) in italiano e inglese.
- Nessun comando nuovo all'impianto: anche questa versione resta **sola
lettura** finché non si prova l'invio dal telefono.
+27
View File
@@ -8,6 +8,33 @@ Immagini di questo studio (schemi, non anteprime): `docs/ux/`.
---
## 0. Cosa è stato deciso e cosa è stato fatto (0.6.0)
Le quattro scelte, applicate:
1. La frase di stato lunga sta nella pagina **Impianto**; in Home resta la riga
compatta con il pallino colorato.
2. "Altri comandi" è diventata **"Comandi impianto"**.
3. Le azioni rapide sono **pulsanti visibili** in Home.
4. Azioni sulla copertina: **rimandate** a dopo la prova di `CoverAction` sul kit.
Problemi risolti: P1 (Home di consultazione), P2 (feedback ancorato), P3
(interruttori), P4 (un solo percorso per pagina), P5 (un blocco per
grandezza, valori rapidi), P6 (numeri vecchi spenti + ora), P7 ("esterna"),
P9, P10 (diagnostica in Impianto), P11.
Rimandato: P8 (azioni sulla copertina), in attesa della prova sul telefono.
Verifiche fatte qui: **79 verifiche del nucleo** (erano 61), `qmllint` pulito su
tutti i file QML, 190/190 stringhe tradotte, anteprime rigenerate in inglese e
italiano, contratto dei dati fra UI e bridge rispettato
(`tests/ui_contract_check.py`).
Da provare sul telefono: che `TextSwitch` si istanzi davvero, l'altezza reale
della Home (riga di log con `contentHeight` contro l'altezza dello schermo) e
l'uso con una mano sola.
---
## 1. Metodo
Tre riferimenti, in quest'ordine:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

+42
View File
@@ -0,0 +1,42 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
// Messaggio dell'ultimo comando, ancorato al bordo inferiore della pagina.
//
// Sta FUORI dal contenuto che scorre: prima il messaggio era la prima riga
// della colonna, quindi premendo un pulsante in fondo alla pagina nasceva
// fuori dallo schermo e non si capiva se il comando fosse partito.
//
// Lo stato sta in ApiClient (api.notice): qui si mostra e basta, cosi' il
// messaggio e' lo stesso su qualsiasi pagina.
Rectangle {
id: root
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
}
height: message.height + 2 * Theme.paddingMedium
color: Qt.rgba(0, 0, 0, 0.78)
opacity: api.notice.length > 0 ? 1 : 0
visible: opacity > 0
z: 100
Behavior on opacity { NumberAnimation { duration: 200 } }
Label {
id: message
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
verticalCenter: parent.verticalCenter
}
text: api.notice
color: api.noticeIsError ? Theme.errorColor : Theme.highlightColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
}
+62
View File
@@ -0,0 +1,62 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
// Riquadro di una lettura grande nella Home: titolo, valore, riga sotto e la
// barra di avanzamento (facoltativa). Toccabile: porta alle regolazioni.
//
// La barra c'e' sempre, anche quando non serve, e resta trasparente: cosi' i
// due riquadri affiancati hanno la stessa altezza senza vincoli circolari.
BackgroundItem {
id: root
property string title: ""
property string value: ""
property string subtitle: ""
property real progress: 0
property bool showBar: false
property color valueColor: Theme.primaryColor
signal tapped()
height: content.height + 2 * Theme.paddingMedium
onClicked: root.tapped()
Column {
id: content
x: Theme.paddingMedium
y: Theme.paddingMedium
width: parent.width - 2 * Theme.paddingMedium
spacing: Theme.paddingSmall
Label {
width: parent.width
text: root.title
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Label {
width: parent.width
text: root.value
color: root.valueColor
font.pixelSize: Theme.fontSizeHuge
elide: Text.ElideRight
}
Label {
width: parent.width
text: root.subtitle
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
ProgressBar {
width: parent.width
value: Math.max(0, Math.min(1, root.progress))
opacity: root.showBar ? 1 : 0
}
}
}
+114 -109
View File
@@ -2,90 +2,142 @@ import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
// Comandi secondari: quelli che non stanno nella home. In cima lo stato
// attuale, cosi' non si preme alla cieca.
// Comandi impianto: i comandi che non stanno nella Home.
//
// Prima erano 12 pulsanti in quattro sezioni, otto dei quali in coppie
// accendi/spegni, con lo stato da leggere in sei righe piu' in alto. Ora ogni
// cosa che ha uno stato e' un interruttore: lo stato si vede e si cambia in un
// tocco solo (docs/UX-STUDIO.md).
//
// Gli interruttori NON anticipano l'impianto: `checked` segue l'ultimo dato
// vero (automaticCheck: false) e al tocco parte il comando. Se il comando non
// riesce, l'interruttore non si muove; se riesce, lo stato arriva da solo
// (ApiClient rilegge il riassunto due secondi dopo).
Page {
id: page
allowedOrientations: Orientation.All
property string notice: ""
property bool noticeIsError: false
function raw(key) {
return api.summary[key]
}
function flag(key) {
return page.raw(key) ? qsTr("yes") : qsTr("no")
// La funzione riscaldamento e' abilitata quando i modi configurati della
// pompa la comprendono ("PUMP_MODE_STATE_HEATING").
function heatingEnabled() {
return String(page.raw("configured_modes") || "").indexOf("HEATING") >= 0
}
function stato(key) {
return api.modeLabel(page.raw(key))
Component.onCompleted: {
if (api.configured)
api.refresh()
}
function showNotice(msg, isError) {
page.notice = msg
page.noticeIsError = isError === true
noticeTimer.restart()
}
Timer {
id: noticeTimer
interval: 4000
onTriggered: page.notice = ""
}
Connections {
target: api
onErrorOccurred: page.showNotice(message, true)
onCommandFinished: page.showNotice(message, !ok)
}
// Fuori dai pulsanti/interruttori: dentro finirebbero nel loro contentItem.
RemorseItem { id: remorse }
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("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("Other commands") }
PageHeader { title: qsTr("Plant commands") }
Label {
visible: page.notice.length > 0
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.notice
color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
// ---- modalita' -------------------------------------------------- //
SectionHeader { text: qsTr("Modes") }
TextSwitch {
id: awaySwitch
text: qsTr("Away mode")
description: qsTr("Lowers the house and stops the hot water")
automaticCheck: false
checked: page.raw("away_mode") === true
onClicked: {
if (checked)
api.sendCommand("away_mode_off")
else
api.sendCommand("away_mode_on")
}
}
// ---- stato attuale -------------------------------------------- //
SectionHeader { text: qsTr("Current state") }
TextSwitch {
text: qsTr("Forced heating")
description: qsTr("Heats at full power until you stop it")
automaticCheck: false
checked: page.raw("force_heating") === true
onClicked: {
if (checked)
api.sendCommand("force_heating_off")
else
api.sendCommand("force_heating_on")
}
}
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("Hot water active"); value: page.flag("hot_water_heating") }
InfoRow { label: qsTr("Pump"); value: page.stato("pump_active_state") }
InfoRow { label: qsTr("Allowed modes"); value: page.stato("configured_modes") }
TextSwitch {
id: heatingSwitch
text: qsTr("Heating")
description: qsTr("The heating can turn on")
automaticCheck: false
checked: page.heatingEnabled()
onClicked: {
// Spegnere il riscaldamento ha conseguenze: si conferma.
if (checked)
remorse.execute(heatingSwitch, qsTr("Heating will be disabled"),
function() { api.sendCommand("heating_off") })
else
api.sendCommand("heating_on")
}
}
// ---- assenza --------------------------------------------------- //
SectionHeader { text: qsTr("Away mode") }
// ---- acqua calda ------------------------------------------------ //
SectionHeader { text: qsTr("Domestic hot water") }
TextSwitch {
id: waterSwitch
text: qsTr("Hot water active")
description: qsTr("The heat pump keeps the water in temperature")
automaticCheck: false
checked: page.raw("hot_water_heating") === true
onClicked: {
if (checked)
remorse.execute(waterSwitch,
qsTr("Domestic hot water will stop being heated"),
function() { api.sendCommand("hot_water_heating_off") })
else
api.sendCommand("hot_water_heating_on")
}
}
ActionButton {
text: qsTr("Turn away mode on")
onTriggered: api.sendCommand("away_mode_on")
}
ActionButton {
text: qsTr("Turn away mode off")
onTriggered: api.sendCommand("away_mode_off")
text: qsTr("Legionella cycle")
confirmText: qsTr("Starting the legionella cycle: it takes a while")
onTriggered: api.sendCommand("legionella_cycle")
}
// ---- rumore ---------------------------------------------------- //
// ---- rumore ----------------------------------------------------- //
SectionHeader { text: qsTr("Noise") }
ActionButton {
@@ -93,59 +145,13 @@ Page {
onTriggered: api.sendCommand("night_mode_1h")
}
// ---- riscaldamento --------------------------------------------- //
SectionHeader { text: qsTr("Heating") }
ActionButton {
text: qsTr("Force heating")
confirmText: qsTr("Starting forced heating")
onTriggered: api.sendCommand("force_heating_on")
}
ActionButton {
text: qsTr("Stop forced heating")
onTriggered: api.sendCommand("force_heating_off")
}
ActionButton {
text: qsTr("Enable heating function")
onTriggered: api.sendCommand("heating_on")
}
ActionButton {
text: qsTr("Disable heating function")
confirmText: qsTr("Heating will be disabled")
onTriggered: api.sendCommand("heating_off")
}
// ---- acqua calda ----------------------------------------------- //
SectionHeader { text: qsTr("Domestic hot water") }
ActionButton {
text: qsTr("Legionella cycle")
confirmText: qsTr("Starting the legionella cycle: it takes a while")
onTriggered: api.sendCommand("legionella_cycle")
}
ActionButton {
text: qsTr("Enable DHW heating")
onTriggered: api.sendCommand("hot_water_heating_on")
}
ActionButton {
text: qsTr("Disable DHW heating")
confirmText: qsTr("Domestic hot water will stop being heated")
onTriggered: api.sendCommand("hot_water_heating_off")
}
// ---- diagnostica ----------------------------------------------- //
SectionHeader { text: qsTr("Diagnostics") }
ActionButton {
text: qsTr("Plant check")
onTriggered: api.sendCommand("ping")
}
BusyIndicator {
visible: api.busy
anchors.horizontalCenter: parent.horizontalCenter
size: BusyIndicatorSize.Small
running: true
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("The switches show the current state of the plant: the Status section is not needed any more.")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
Label {
@@ -156,11 +162,10 @@ Page {
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
Item { width: 1; height: Theme.paddingMedium }
}
}
Component.onCompleted: {
if (api.configured)
api.refresh()
}
NoticeBar { }
}
+4 -9
View File
@@ -109,15 +109,8 @@ Page {
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
}
// Gli errori non si mostrano piu' qui dentro: il messaggio arriva
// ancorato in fondo alla pagina (NoticeBar), dove si vede sempre.
Label {
visible: Object.keys(page.stats()).length === 0 && page.error.length === 0
@@ -242,6 +235,8 @@ Page {
}
}
NoticeBar { }
Component.onCompleted: {
if (api.configured) {
page.loading = true
+174 -376
View File
@@ -2,27 +2,65 @@ import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
// Dashboard dell'impianto, ordinata come la schermata iniziale dell'app
// 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.
// Home: una schermata da GUARDARE, non un modulo da compilare.
//
// Stato in una riga, le due letture che contano (casa e acqua calda), tre
// azioni rapide. Le regolazioni stanno in SetpointsPage, gli altri comandi in
// CommandsPage, i numeri in DataPage, i dettagli in PlantPage: qui solo quello
// che si guarda molte volte al giorno, senza scorrere (docs/UX-STUDIO.md).
Page {
id: page
allowedOrientations: Orientation.All
// obiettivo ACS scelto dall'utente (non sovrascritto dal refresh finche'
// non lo tocca, altrimenti lo slider "salta" a ogni lettura)
property int targetAcs: 55
property bool targetAcsTouched: false
// obiettivo della casa (zona 1); parte dalla temperatura attuale e non
// viene sovrascritto dal refresh finche' l'utente non lo tocca
property real targetCasa: 20
property bool targetCasaTouched: false
property string notice: ""
property bool noticeIsError: false
function raw(key) {
return api.summary[key]
}
// I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il
// separatore decimale segue la lingua dell'app, non il toFixed di JS.
function fmt(value) { return api.formatValue(value) }
function count(key) {
var v = page.raw(key)
if (v === undefined || v === null)
return "—"
return String(Math.round(Number(v)))
}
// Colore del pallino di stato. Il livello (0 regolare, 1 modo attivo,
// 2 errore) lo calcola il C++; qui si sceglie il colore, e l'ambra quando
// i numeri sono vecchi: un dato vecchio non e' un dato buono.
function statusColor() {
if (!api.hasData || api.stale)
return "orange"
var livello = api.statusLevel()
if (livello === 2)
return Theme.errorColor
if (livello === 1)
return Theme.highlightColor
return "#4caf50"
}
// Quanto e' carica l'acqua calda verso l'obiettivo (da 10 °C in su).
function waterProgress() {
var ora = Number(page.raw("dhw_current"))
var obiettivo = Number(page.raw("dhw_target"))
if (!(obiettivo > 10) || isNaN(ora))
return 0
return (ora - 10) / (obiettivo - 10)
}
// Riga sotto il titolo: dice anche quando i numeri sono vecchi.
function updateText() {
if (!api.hasData)
return qsTr("No data yet")
if (api.stale)
return qsTr("Not updated · last %1").arg(api.lastUpdate)
if (api.lastUpdate.length === 0)
return ""
return qsTr("Updated at %1").arg(api.lastUpdate)
}
function openSettings() {
pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
@@ -40,100 +78,14 @@ Page {
pageStack.push(Qt.resolvedUrl("DataPage.qml"))
}
function showNotice(msg, isError) {
page.notice = msg
page.noticeIsError = isError === true
noticeTimer.restart()
}
function raw(key) {
return api.summary[key]
}
// I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il
// separatore decimale segue la lingua dell'app, non il toFixed di JS.
function fmt(value) { return api.formatValue(value) }
function temp(key) {
return api.formatValue(page.raw(key))
}
function count(key) {
var v = page.raw(key)
if (v === undefined || v === null)
return "—"
return String(Math.round(Number(v)))
}
// "2026-10-09 18:52:17.199340" -> "18:52"
function shortTime(s) {
if (!s)
return "—"
var parts = String(s).split(" ")
if (parts.length < 2)
return String(s)
return parts[1].substring(0, 5)
}
// 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 {
id: noticeTimer
interval: 4000
onTriggered: page.notice = ""
Component.onCompleted: {
if (api.configured)
api.refresh()
}
// Fuori dal pulsante: dentro il pulsante i figli finiscono nel contentItem.
RemorseItem { id: remorse }
Connections {
target: api
onErrorOccurred: page.showNotice(message, true)
onCommandFinished: page.showNotice(message, !ok)
onSummaryChanged: {
if (!page.targetAcsTouched) {
var v = page.raw("dhw_target")
if (v !== undefined && v !== null)
page.targetAcs = Math.round(Number(v))
}
if (!page.targetCasaTouched) {
var c = page.raw("zone1_setpoint")
if (c !== undefined && c !== null && Number(c) > 0)
page.targetCasa = Math.round(Number(c) * 2) / 2
}
}
}
SilicaFlickable {
id: flick
anchors.fill: parent
@@ -154,10 +106,6 @@ Page {
text: qsTr("Plant")
onClicked: page.openPlant()
}
MenuItem {
text: qsTr("Other commands")
onClicked: page.openCommands()
}
MenuItem {
text: qsTr("Settings")
onClicked: page.openSettings()
@@ -169,17 +117,9 @@ Page {
width: parent.width
spacing: Theme.paddingSmall
PageHeader { title: "Aira" }
// ---- avvisi ---------------------------------------------------- //
Label {
visible: page.notice.length > 0
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.notice
color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
PageHeader {
title: "Aira"
description: page.updateText()
}
// ---- bridge non configurato ------------------------------------ //
@@ -200,300 +140,158 @@ Page {
onClicked: page.openSettings()
}
// ---- barra in alto: fuori casa / a casa ------------------------ //
// ---- stato in una riga ----------------------------------------- //
Item {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
height: fuori.height
height: Math.max(stato.height, pallino.height, attesa.height)
Label {
id: fuori
anchors.left: parent.left
text: qsTr("Outside %1 °C").arg(page.temp("outdoor_temperature"))
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
Rectangle {
id: pallino
width: Theme.fontSizeSmall * 0.6
height: width
radius: width / 2
color: page.statusColor()
anchors.verticalCenter: parent.verticalCenter
}
Label {
id: stato
anchors {
left: pallino.right
leftMargin: Theme.paddingMedium
right: attesa.left
rightMargin: Theme.paddingMedium
verticalCenter: parent.verticalCenter
}
text: api.stateShort()
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
BusyIndicator {
id: attesa
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
text: page.raw("away_mode") === true ? qsTr("Away") : qsTr("At home")
color: page.raw("away_mode") === true ? Theme.highlightColor : Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
size: BusyIndicatorSize.Small
running: api.busy
visible: api.busy
}
}
// ---- messaggio di stato (il "centro" della schermata ufficiale) - //
Label {
// ---- le due letture che contano -------------------------------- //
Row {
id: letture
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
spacing: Theme.paddingSmall
ReadingCell {
width: (letture.width - letture.spacing) / 2
title: qsTr("House")
value: page.fmt(page.raw("zone1_temperature")) + " °C"
subtitle: qsTr("target %1 °C").arg(page.fmt(page.raw("zone1_setpoint")))
valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor
onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml"))
}
ReadingCell {
width: (letture.width - letture.spacing) / 2
title: qsTr("Hot water")
value: page.fmt(page.raw("dhw_current")) + " °C"
subtitle: qsTr("target %1 °C").arg(page.fmt(page.raw("dhw_target")))
progress: page.waterProgress()
showBar: true
valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor
onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml"))
}
}
Label {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.statusDetail()
text: qsTr("Tap a value to adjust it")
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).
// ---- azioni rapide --------------------------------------------- //
SectionHeader {
visible: api.configured
text: qsTr("Quick actions")
}
Row {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
spacing: Theme.paddingSmall
Button {
id: boostBtn
width: (parent.width - 2 * parent.spacing) / 3
text: qsTr("Boost")
onClicked: remorse.execute(boostBtn, qsTr("Starting DHW boost"),
function() { api.sendCommand("hot_water_boost_on") })
}
Button {
id: assenzaBtn
width: (parent.width - 2 * parent.spacing) / 3
// Il pulsante dice cosa succede premendolo, non lo stato.
text: page.raw("away_mode") ? qsTr("Back home") : qsTr("Away mode")
onClicked: {
if (page.raw("away_mode")) {
remorse.execute(assenzaBtn, qsTr("Away mode off"),
function() { api.sendCommand("away_mode_off") })
} else {
remorse.execute(assenzaBtn, qsTr("Away mode on"),
function() { api.sendCommand("away_mode_on") })
}
}
}
Button {
width: (parent.width - 2 * parent.spacing) / 3
text: qsTr("Commands")
onClicked: page.openCommands()
}
}
// ---- letture di contorno (si toccano: portano a Impianto) ------ //
BackgroundItem {
id: scheda
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
height: schedaCol.height + 3 * Theme.paddingMedium
height: contornoTxt.height + 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"
Label {
id: contornoTxt
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
}
text: qsTr("Outside %1 °C · Thermostat %2 °C · Zones %3")
.arg(page.fmt(page.raw("outdoor_temperature")))
.arg(page.fmt(page.raw("thermostat_temperature")))
.arg(page.count("zones"))
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
}
// ---- riscaldamento della casa ---------------------------------- //
SectionHeader { visible: api.configured; text: qsTr("Heating the house") }
Label {
visible: api.configured
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Heating setpoint %1 °C · %2")
.arg(page.temp("zone1_setpoint"))
.arg(api.modeLabel(page.raw("zone1_mode")))
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
}
Slider {
id: casaSlider
visible: api.configured
width: parent.width
label: qsTr("New house target")
minimumValue: 5
maximumValue: 30
stepSize: 0.5
value: page.targetCasa
valueText: page.fmt(value) + " °C"
onValueChanged: {
page.targetCasaTouched = true
page.targetCasa = Math.round(value * 2) / 2
}
}
Button {
id: setCasaButton
visible: api.configured
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: qsTr("Set house to %1 °C").arg(page.fmt(page.targetCasa))
onClicked: remorse.execute(setCasaButton,
qsTr("House temperature to %1 °C").arg(page.fmt(page.targetCasa)),
function() { api.sendCommand("set_heating_setpoint", page.targetCasa) })
}
// ---- acqua calda sanitaria ------------------------------------- //
SectionHeader { visible: api.configured; text: qsTr("Domestic hot water") }
ProgressBar {
visible: api.configured
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 4 * Theme.horizontalPageMargin
minimumValue: 20
maximumValue: 65
value: {
var v = page.raw("dhw_current")
return (v === undefined || v === null) ? 0 : Number(v)
}
}
Slider {
visible: api.configured
width: parent.width
label: qsTr("New target")
minimumValue: 45
maximumValue: 60
stepSize: 1
value: page.targetAcs
valueText: page.fmt(value) + " °C"
onValueChanged: {
page.targetAcsTouched = true
page.targetAcs = Math.round(value)
}
}
Button {
id: setAcsButton
visible: api.configured
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: qsTr("Set target %1 °C").arg(page.targetAcs)
onClicked: remorse.execute(setAcsButton,
qsTr("DHW target to %1 °C").arg(page.targetAcs),
function() { api.sendCommand("set_hot_water_target", page.targetAcs) })
}
Button {
id: boostOnButton
visible: api.configured
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: qsTr("Start DHW boost")
onClicked: remorse.execute(boostOnButton, qsTr("Starting DHW boost"),
function() { api.sendCommand("hot_water_boost_on") })
}
Button {
id: boostOffButton
visible: api.configured
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: qsTr("Stop DHW boost")
onClicked: remorse.execute(boostOffButton, qsTr("Stopping DHW boost"),
function() { api.sendCommand("hot_water_boost_off") })
}
// ---- altre pagine ---------------------------------------------- //
SectionHeader { visible: api.configured; text: qsTr("Details") }
Button {
visible: api.configured
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: qsTr("Plant and accessories")
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 {
visible: api.configured
anchors {
left: parent.left
right: parent.right
leftMargin: Theme.horizontalPageMargin
rightMargin: Theme.horizontalPageMargin
}
text: qsTr("Other commands")
onClicked: page.openCommands()
}
BusyIndicator {
visible: api.busy
anchors.horizontalCenter: parent.horizontalCenter
size: BusyIndicatorSize.Small
running: true
}
Label {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Commands are sent to the bridge and from there to the plant: they may take a few seconds.")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
Item { width: 1; height: Theme.paddingMedium }
}
}
Component.onCompleted: {
if (api.configured)
api.refresh()
}
NoticeBar { }
}
+28
View File
@@ -71,6 +71,19 @@ Page {
PageHeader { title: qsTr("Plant") }
// La frase estesa dell'app ufficiale ("Relax: la pompa di calore
// funziona regolarmente"): sta qui e non in Home, dove serve
// spazio per le due letture. La calcola il C++ (AiraJson::stateLong).
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: api.stateLong()
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeMedium
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
// ---- stato ------------------------------------------------------ //
SectionHeader { text: qsTr("Status") }
@@ -86,6 +99,11 @@ Page {
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("Manual mode"); value: page.flag("manual_mode") }
InfoRow { label: qsTr("Defrost"); value: page.flag("outdoor_defrost") }
InfoRow { label: qsTr("Signature lights"); value: page.flag("signature_lights") }
InfoRow { label: qsTr("Pump"); value: api.modeLabel(page.raw("pump_active_state")) }
InfoRow { label: qsTr("Allowed modes"); value: api.modeLabel(page.raw("configured_modes")) }
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") }
@@ -176,10 +194,20 @@ Page {
value: page.count("errors_count")
}
// ---- diagnostica ------------------------------------------------ //
SectionHeader { text: qsTr("Diagnostics") }
ActionButton {
text: qsTr("Plant check")
onTriggered: api.sendCommand("ping")
}
Item { width: 1; height: Theme.paddingMedium }
}
}
NoticeBar { }
Component.onCompleted: {
if (api.configured)
api.refresh()
+234
View File
@@ -0,0 +1,234 @@
import QtQuick 2.2
import Sailfish.Silica 1.0
import "../components"
// Regolazioni: le grandezze che si cambiano (obiettivo casa, obiettivo acqua
// calda) e il boost, ognuna con il valore attuale sempre visibile, le
// preimpostazioni per le dita fredde e la conferma esplicita.
//
// Pagina a parte perche' la Home deve restare una schermata da guardare: due
// slider dentro la pagina che scorre si contendevano anche il trascinamento.
// Qui lo slider e' la PROPOSTA, il numero grande e' quello vero dell'impianto:
// i due coincidono solo dopo "Imposta".
Page {
id: page
allowedOrientations: Orientation.All
property real pendingHouse: 0
property bool houseTouched: false
property real pendingWater: 0
property bool waterTouched: false
function raw(key) {
return api.summary[key]
}
function fmt(value) { return api.formatValue(value) }
// Obiettivo vero dell'impianto (zona 1), a passi di mezzo grado.
function houseTarget() {
var v = page.raw("zone1_setpoint")
if (v === undefined || v === null || Number(v) <= 0)
return 0
return Math.round(Number(v) * 2) / 2
}
function waterTarget() {
var v = page.raw("dhw_target")
if (v === undefined || v === null)
return 0
return Math.round(Number(v))
}
// Allinea le proposte ai valori dell'impianto, ma non sovrascrive quello
// che l'utente sta scegliendo.
function syncFromPlant() {
if (!page.houseTouched)
page.pendingHouse = page.houseTarget()
if (!page.waterTouched)
page.pendingWater = page.waterTarget()
}
function houseChanged() { return Math.abs(page.pendingHouse - page.houseTarget()) > 0.001 }
function waterChanged() { return Math.abs(page.pendingWater - page.waterTarget()) > 0.001 }
Component.onCompleted: {
page.syncFromPlant()
if (api.configured)
api.refresh()
}
Connections {
target: api
onSummaryChanged: page.syncFromPlant()
}
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("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("Setpoints") }
// ---- riscaldamento casa ---------------------------------------- //
SectionHeader { text: qsTr("Heating the house") }
ValueRow {
title: qsTr("Current target")
subtitle: qsTr("What the plant is keeping now")
value: page.fmt(page.houseTarget()) + " °C"
}
Slider {
id: casaSlider
width: parent.width
label: qsTr("New house target")
minimumValue: 5
maximumValue: 30
stepSize: 0.5
value: page.pendingHouse
valueText: page.fmt(value) + " °C"
onValueChanged: {
// Solo un movimento vero dell'utente segna "toccato": il
// valore che arriva dall'impianto non deve bloccarlo.
if (Math.abs(page.pendingHouse - value) > 0.001) {
page.houseTouched = true
page.pendingHouse = Math.round(value * 2) / 2
}
}
}
Row {
x: Theme.horizontalPageMargin
width: col.width - 2 * Theme.horizontalPageMargin
spacing: Theme.paddingSmall
Repeater {
model: [16, 17, 18, 19, 20, 21]
Button {
width: (col.width - 2 * Theme.horizontalPageMargin
- 5 * Theme.paddingSmall) / 6
text: modelData + "°"
onClicked: {
page.houseTouched = true
page.pendingHouse = modelData
}
}
}
}
ActionButton {
text: qsTr("Set house to %1 °C").arg(page.fmt(page.pendingHouse))
confirmText: qsTr("House temperature to %1 °C").arg(page.fmt(page.pendingHouse))
enabled: page.houseChanged()
onTriggered: api.sendCommand("set_heating_setpoint", page.pendingHouse)
}
// ---- acqua calda sanitaria ------------------------------------- //
SectionHeader { text: qsTr("Domestic hot water") }
ValueRow {
title: qsTr("DHW target")
subtitle: qsTr("Water now %1 °C").arg(page.fmt(page.raw("dhw_current")))
value: page.fmt(page.waterTarget()) + " °C"
}
Slider {
id: acquaSlider
width: parent.width
label: qsTr("New target")
minimumValue: 45
maximumValue: 60
stepSize: 1
value: page.pendingWater
valueText: page.fmt(value) + " °C"
onValueChanged: {
if (Math.abs(page.pendingWater - value) > 0.001) {
page.waterTouched = true
page.pendingWater = Math.round(value)
}
}
}
Row {
x: Theme.horizontalPageMargin
width: col.width - 2 * Theme.horizontalPageMargin
spacing: Theme.paddingSmall
Repeater {
model: [45, 47, 50, 55, 60]
Button {
width: (col.width - 2 * Theme.horizontalPageMargin
- 4 * Theme.paddingSmall) / 5
text: modelData + "°"
onClicked: {
page.waterTouched = true
page.pendingWater = modelData
}
}
}
}
ActionButton {
text: qsTr("Set target %1 °C").arg(page.fmt(page.pendingWater))
confirmText: qsTr("DHW target to %1 °C").arg(page.fmt(page.pendingWater))
enabled: page.waterChanged()
onTriggered: api.sendCommand("set_hot_water_target", page.pendingWater)
}
// ---- boost ------------------------------------------------------ //
SectionHeader { text: qsTr("Hot water boost") }
ActionButton {
text: qsTr("Start DHW boost")
confirmText: qsTr("Starting DHW boost")
onTriggered: api.sendCommand("hot_water_boost_on")
}
ActionButton {
text: qsTr("Stop DHW boost")
confirmText: qsTr("Stopping DHW boost")
onTriggered: api.sendCommand("hot_water_boost_off")
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Commands go through the bridge and may take a few seconds. The status refreshes by itself afterwards.")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap
}
Item { width: 1; height: Theme.paddingMedium }
}
}
NoticeBar { }
}
+3 -11
View File
@@ -128,19 +128,11 @@ Page {
wrapMode: Text.Wrap
}
// Un pulsante solo: salvare e provare erano due gesti per la stessa
// cosa (il secondo salvava comunque).
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Save")
onClicked: {
page.save()
page.testResult = qsTr("settings saved")
page.testOk = true
}
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Test connection")
text: qsTr("Save and test")
onClicked: page.testAndReport()
}
+13 -1
View File
@@ -1,6 +1,6 @@
Name: harbour-aira
Summary: Control your Aira heat pump through a local bridge
Version: 0.5.0
Version: 0.6.0
Release: 1
Group: Qt/Qt
License: MIT
@@ -37,6 +37,18 @@ Address and token of the bridge live in the app settings (AppConfigLocation);
no personal data is stored in the binary.
%changelog
* Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.6.0-1
- Home di sola consultazione: stato in una riga con pallino colorato, le due
letture (casa e acqua calda) grandi e toccabili, tre azioni rapide; le due
regolazioni e i pulsanti di navigazione escono dalla Home
- pagina Regolazioni: obiettivo casa e obiettivo acqua calda, ognuno con il
valore attuale sempre visibile, slider, valori rapidi e conferma; boost ACS
- Comandi impianto: quattro interruttori al posto di otto pulsanti acceso/
spento, piu' tre pulsanti per le azioni senza stato; diagnostica spostata
nella pagina Impianto
- messaggi dei comandi ancorati in fondo alla pagina, visibili sempre
- numeri vecchi mostrati spenti, con l'ora dell'ultimo aggiornamento
- lo stato si rilegge da solo due secondi dopo un comando
* 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
+225 -72
View File
@@ -327,6 +327,92 @@ TEXTS = {
LANG = "en"
# --------------------------------------------------------------------------- #
# Testi aggiunti nella 0.6.0 (Home di consultazione, pagina Regolazioni,
# Comandi con interruttori). Le due lingue in un colpo solo: le anteprime
# restano bilingui come l'app.
TEXTS["en"].update({
"outside": "Outside {v}",
"house_cell": "House",
"hot_water_cell": "Hot water",
"cell_target": "target {v}",
"status_fine": "Everything is fine",
"status_water": "Heating the hot water",
"updated_at": "Updated at 21:18",
"pull_hint": "Pull down: Aggiorna · Dati · Impianto · Impostazioni",
"tap_adjust": "Tap a value to adjust it",
"quick_actions": "Quick actions",
"boost": "Boost",
"away_btn": "Away mode",
"back_home": "Back home",
"commands": "Commands",
"contorno": "Outside {a} · Thermostat {b} · Zones 1",
"setpoints": "Setpoints",
"current_target": "Current target",
"keep_now": "What the plant is keeping now",
"water_now": "Water now {v}",
"hot_water_boost": "Hot water boost",
"set_house_v": "Set house to {v} °C",
"set_water_v": "Set target {v} °C",
"plant_commands": "Plant commands",
"modes": "Modes",
"sw_away_desc": "Lowers the house and stops the hot water",
"sw_forced_desc": "Heats at full power until you stop it",
"sw_heating_desc": "The heating can turn on",
"sw_water_desc": "The heat pump keeps the water in temperature",
"legionella": "Legionella cycle",
"switches_note": "The switches show the current state of the plant.",
"commands_note": "Commands go through the bridge and may take a few seconds.",
"diagnostics": "Diagnostics",
"plant_check": "Plant check",
"manual_mode": "Manual mode",
"defrost": "Defrost",
"signature_lights": "Signature lights",
"save_and_test": "Save and test",
"cover_outdoor": "outside",
})
TEXTS["it"].update({
"house_cell": "Casa",
"hot_water_cell": "Acqua calda",
"cell_target": "obiettivo {v}",
"status_fine": "Tutto regolare",
"status_water": "Sta scaldando l'acqua",
"updated_at": "Aggiornato alle 21:18",
"pull_hint": "Trascina in giù: Aggiorna · Dati · Impianto · Impostazioni",
"tap_adjust": "Tocca un valore per regolarlo",
"quick_actions": "Azioni rapide",
"boost": "Boost",
"away_btn": "Assenza",
"back_home": "Torna a casa",
"commands": "Comandi",
"contorno": "Esterno {a} · Termostato {b} · Zone 1",
"setpoints": "Regolazioni",
"current_target": "Obiettivo attuale",
"keep_now": "Quello che l'impianto mantiene adesso",
"water_now": "Acqua ora {v}",
"hot_water_boost": "Boost acqua calda",
"set_house_v": "Imposta casa a {v} °C",
"set_water_v": "Imposta obiettivo {v} °C",
"plant_commands": "Comandi impianto",
"modes": "Modalità",
"sw_away_desc": "Abbassa la casa e spegne l'acqua calda",
"sw_forced_desc": "Scalda a pieno regime finché non lo fermi",
"sw_heating_desc": "Il riscaldamento può accendersi",
"sw_water_desc": "Mantiene l'acqua in temperatura",
"legionella": "Ciclo legionella",
"switches_note": "Gli interruttori mostrano lo stato attuale dell'impianto.",
"commands_note": "I comandi passano dal bridge e possono richiedere qualche secondo.",
"diagnostics": "Diagnostica",
"plant_check": "Verifica impianto",
"manual_mode": "Modalità manuale",
"defrost": "Sbrinamento",
"signature_lights": "Luci di firma",
"save_and_test": "Salva e prova",
"cover_outdoor": "esterna",
"night_one_hour": "Modalità notte per un'ora",
})
def T(key: str) -> str:
return TEXTS[LANG][key]
@@ -363,65 +449,67 @@ def value_row(d: ImageDraw.ImageDraw, y: int, title: str, subtitle: str,
# --------------------------------------------------------------------------- #
def reading_cell(d: ImageDraw.ImageDraw, x: int, y: int, w: int, h: int,
title: str, value: str, subtitle: str,
frac: float = None) -> None:
"""Riquadro di una lettura grande (componente ReadingCell dell'app): titolo,
valore, riga sotto e barra di avanzamento facoltativa."""
d.rounded_rectangle([x, y, x + w, y + h], radius=18, fill=(26, 32, 41),
outline=BTN_EDGE, width=2)
d.text((x + 26, y + 22), title, font=font(24), fill=SECONDARY)
d.text((x + 26, y + 56), value, font=font(56, bold=True), fill=PRIMARY)
d.text((x + 26, y + 132), subtitle, font=font(20), fill=SECONDARY)
if frac is not None:
bx0, bx1 = x + 26, x + w - 26
by = y + h - 30
d.rounded_rectangle([bx0, by, bx1, by + 10], radius=5, fill=(46, 54, 66))
d.rounded_rectangle([bx0, by, bx0 + (bx1 - bx0) * max(0.0, min(1.0, frac)), by + 10],
radius=5, fill=ACCENT)
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."""
"""Home della 0.6.0: una schermata da guardare, non un modulo. Stato in una
riga, le due letture che contano, tre azioni rapide. Niente scorrimento."""
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("app"))
d.text((M, 128), T("updated_at"), font=font(22), fill=SECONDARY)
y = 150
# ---- barra in alto: fuori casa / a casa ------------------------------ #
d.text((M, y), T("outside").format(v=dec(18.0) + " °C"), font=font(24), fill=SECONDARY)
casa = T("at_home")
d.text((W - M - d.textlength(casa, font=font(22)), y + 2), casa,
y = 176
# ---- stato in una riga ------------------------------------------------ #
d.ellipse([M, y + 7, M + 18, y + 25], fill=OK)
d.text((M + 34, y), T("status_water"), font=font(26), fill=PRIMARY)
y += 54
# ---- le due letture che contano --------------------------------------- #
gap = 20
cw = (W - 2 * M - gap) // 2
ch = 200
reading_cell(d, M, y, cw, ch, T("house_cell"), dec(23.0) + " °C",
T("cell_target").format(v=dec(19.0) + " °C"))
reading_cell(d, M + cw + gap, y, cw, ch, T("hot_water_cell"), dec(50.6) + " °C",
T("cell_target").format(v=dec(55.0) + " °C"),
(50.6 - 10) / (55.0 - 10))
y += ch + 18
d.text((M, y), T("tap_adjust"), font=font(22), fill=SECONDARY)
y += 54
# ---- azioni rapide ----------------------------------------------------- #
y = section_header(d, y, T("quick_actions"))
bw = (W - 2 * M - 2 * gap) // 3
for i, testo in enumerate((T("boost"), T("away_btn"), T("commands"))):
x = M + i * (bw + gap)
d.rounded_rectangle([x, y, x + bw, y + 74], radius=18, fill=BTN_BG,
outline=BTN_EDGE, width=2)
tw = d.textlength(testo, font=font(24))
d.text((x + (bw - tw) / 2, y + 23), testo, font=font(24), fill=PRIMARY)
y += 104
# ---- letture di contorno ----------------------------------------------- #
d.text((M, y), T("contorno").format(a=dec(17.8) + " °C", b=dec(22.8) + " °C"),
font=font(22), fill=SECONDARY)
y += 58
# ---- messaggio di stato ---------------------------------------------- #
fnt = font(30)
righe = wrap(d, T("status_relax"), fnt, W - 2 * M - 20)
for i, riga in enumerate(righe):
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
# ---- 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)
# ---- acqua calda sanitaria ------------------------------------------- #
y = section_header(d, y + 6, T("dhw_section"))
progress(d, y, (51.0 - 20) / (65 - 20))
y += 22
y = slider(d, y + 4, (55.0 - 45) / (60 - 45), "55 °C", T("target_label"))
y = button(d, y, T("set_acs"), primary=True)
y = button(d, y, T("start_boost"))
y = button(d, y, T("stop_boost"))
# ---- pagine collegate (scorrono sotto) ------------------------------- #
y = section_header(d, y + 6, T("details"))
y = button(d, y, T("plant_accessories"))
y = button(d, y, T("consumption"))
return img
@@ -449,10 +537,9 @@ def settings_screen() -> Image.Image:
d.multiline_text((M, y), T("settings_note"), font=font(22), fill=SECONDARY, spacing=10)
y += 104
y = button(d, y, T("test_connection"), primary=True)
# Un pulsante solo: salvare e provare erano due gesti per la stessa cosa.
y = button(d, y, T("save_and_test"), primary=True)
d.text((M, y), T("connection_ok"), font=font(24), fill=OK)
y += 58
y = button(d, y, T("save"))
return img
@@ -488,32 +575,87 @@ def cover_screen() -> Image.Image:
return img
def switch_row(d: ImageDraw.ImageDraw, y: int, text: str, description: str,
checked: bool) -> int:
"""Riga con interruttore (componente TextSwitch dell'app): etichetta e
spiegazione a sinistra, interruttore a destra con lo stato dell'impianto."""
d.text((M, y), text, font=font(28), fill=PRIMARY)
d.text((M, y + 38), description, font=font(22), fill=SECONDARY)
pw, ph = 96, 44
x0 = W - M - pw
y0 = y + 8
d.rounded_rectangle([x0, y0, x0 + pw, y0 + ph], radius=ph / 2,
fill=ACCENT if checked else (48, 56, 68))
cx = x0 + pw - ph / 2 if checked else x0 + ph / 2
d.ellipse([cx - ph / 2 + 5, y0 + 5, cx + ph / 2 - 5, y0 + ph - 5],
fill=(238, 242, 246))
return y + 98
def commands_screen() -> Image.Image:
"""Comandi impianto della 0.6.0: gli interruttori dicono lo stato e si
cambiano in un tocco; i pulsanti restano per le azioni senza stato."""
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("other_commands"))
page_header(d, T("plant_commands"))
y = section_header(d, 156, T("current_state"))
for lab, val in ((T("away"), T("off")), (T("night_mode"), T("off")),
(T("forced_heating"), T("off")), (T("hot_water_active"), T("on")),
(T("pump"), T("stopped")), (T("allowed_modes"), T("heating"))):
y = label_pair(d, y, lab, val)
y = section_header(d, 150, T("modes"))
y = switch_row(d, y, T("away"), T("sw_away_desc"), False)
y = switch_row(d, y, T("forced_heating"), T("sw_forced_desc"), False)
y = switch_row(d, y, T("heating"), T("sw_heating_desc"), True)
y = section_header(d, y + 24, T("away"))
y = button(d, y, T("turn_away_on"))
y = button(d, y, T("turn_away_off"))
y = section_header(d, y + 10, T("dhw_section"))
y = switch_row(d, y, T("hot_water_active"), T("sw_water_desc"), True)
y = button(d, y, T("legionella"))
y = section_header(d, y + 24, T("noise"))
y = section_header(d, y + 10, T("noise"))
y = button(d, y, T("night_one_hour"))
y = section_header(d, y + 24, T("heating"))
y = button(d, y, T("force_heating"), primary=True)
hint = T("confirm_hint")
d.text(((W - d.textlength(hint, font=font(22))) / 2, y - 4), hint, font=font(22), fill=SECONDARY)
d.text((M, y + 12), T("switches_note"), font=font(22), fill=SECONDARY)
return img
def setpoints_screen() -> Image.Image:
"""Regolazioni: un blocco per grandezza, con il valore attuale sempre
visibile, le preimpostazioni e la conferma."""
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("setpoints"))
y = section_header(d, 150, T("heating_house"))
y = value_row(d, y, T("current_target"), T("keep_now"), dec(19.0) + " °C")
y = slider(d, y, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target"))
y = preset_row(d, y, ["16°", "17°", "18°", "19°", "20°", "21°"])
y = button(d, y, T("set_house_v").format(v=dec(19.0)), primary=True)
y = section_header(d, y + 6, T("dhw_section"))
y = value_row(d, y, T("dhw_target"), T("water_now").format(v=dec(50.6) + " °C"),
dec(55.0) + " °C")
y = slider(d, y, (55.0 - 45) / (60 - 45), dec(55.0) + " °C", T("target_label"))
y = preset_row(d, y, ["45°", "47°", "50°", "55°", "60°"])
y = button(d, y, T("set_water_v").format(v=dec(55.0)), primary=True)
y = section_header(d, y + 6, T("hot_water_boost"))
y = button(d, y, T("start_boost"))
y = button(d, y, T("stop_boost"))
return img
def preset_row(d: ImageDraw.ImageDraw, y: int, valori: list) -> int:
"""Fila di valori rapidi: un tocco invece del cursore, per le dita fredde."""
gap = 12
bw = (W - 2 * M - (len(valori) - 1) * gap) // len(valori)
for i, testo in enumerate(valori):
x = M + i * (bw + gap)
d.rounded_rectangle([x, y, x + bw, y + 62], radius=16, fill=BTN_BG,
outline=BTN_EDGE, width=2)
tw = d.textlength(testo, font=font(24))
d.text((x + (bw - tw) / 2, y + 17), testo, font=font(24), fill=PRIMARY)
return y + 86
def data_screen() -> Image.Image:
"""Pagina "Dati": gli stessi numeri della scheda Dati dell'app ufficiale."""
img = background()
@@ -566,11 +708,18 @@ def plant_screen() -> Image.Image:
status_bar(d)
page_header(d, T("plant"))
y = 150
# La frase estesa dell'app ufficiale: sta qui, non in Home.
righe = wrap(d, T("status_relax"), font(24), W - 2 * M)
for i, riga in enumerate(righe):
d.text((M, 132 + i * 32), riga, font=font(24), fill=PRIMARY)
y = 150 + 32 * len(righe)
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"))):
(T("forced_heating"), T("off")), (T("manual_mode"), T("off")),
(T("defrost"), T("off")), (T("signature_lights"), T("on")),
(T("pump"), T("stopped")), (T("allowed_modes"), T("heating"))):
y = label_pair(d, y, lab, val)
y = section_header(d, y + 20, T("accessories"))
@@ -587,6 +736,9 @@ def plant_screen() -> Image.Image:
y = section_header(d, y + 20, T("errors"))
d.text((M, y), T("no_errors"), font=font(24), fill=OK)
y = section_header(d, y + 40, T("diagnostics"))
y = button(d, y, T("plant_check"))
return img
@@ -602,7 +754,8 @@ if __name__ == "__main__":
out = pathlib.Path("docs/anteprima") if lang == "en" else pathlib.Path("docs/anteprima") / lang
out.mkdir(parents=True, exist_ok=True)
for name, fn in (("dashboard", main_screen), ("dati", data_screen),
for name, fn in (("dashboard", main_screen), ("regolazioni", setpoints_screen),
("dati", data_screen),
("impianto", plant_screen), ("comandi", commands_screen),
("impostazioni", settings_screen), ("copertina", cover_screen)):
path = out / f"{name}.png"
+78
View File
@@ -198,4 +198,82 @@ QString errorMessage(const QByteArray &body, int httpStatus)
return QCoreApplication::translate("AiraJson", "HTTP error %1").arg(httpStatus);
}
namespace {
// Flag booleano del riassunto, con falso come ripiego.
bool flagValue(const QVariantMap &summary, const QString &key)
{
return summary.value(key).toBool();
}
int errorCount(const QVariantMap &summary)
{
return summary.value(QStringLiteral("errors_count")).toInt();
}
bool pumpRunning(const QVariantMap &summary)
{
return summary.value(QStringLiteral("pump_active_state")).toString()
.contains(QStringLiteral("RUNNING"));
}
} // namespace
QString stateShort(const QVariantMap &summary)
{
if (summary.isEmpty())
return QCoreApplication::translate("AiraJson", "No data");
const int errs = errorCount(summary);
if (errs > 0)
return (errs == 1)
? QCoreApplication::translate("AiraJson", "Attention: one error")
: QCoreApplication::translate("AiraJson", "Attention: %1 errors").arg(errs);
if (flagValue(summary, QStringLiteral("away_mode")))
return QCoreApplication::translate("AiraJson", "Away mode");
if (flagValue(summary, QStringLiteral("night_mode")))
return QCoreApplication::translate("AiraJson", "Night mode");
if (flagValue(summary, QStringLiteral("force_heating")))
return QCoreApplication::translate("AiraJson", "Heating at full power");
if (flagValue(summary, QStringLiteral("hot_water_heating")))
return QCoreApplication::translate("AiraJson", "Heating the hot water");
if (pumpRunning(summary))
return QCoreApplication::translate("AiraJson", "Working");
return QCoreApplication::translate("AiraJson", "Everything is fine");
}
QString stateLong(const QVariantMap &summary)
{
if (summary.isEmpty())
return QString();
const int errs = errorCount(summary);
if (errs > 0)
return (errs == 1)
? QCoreApplication::translate("AiraJson", "Attention: one error on the plant")
: QCoreApplication::translate("AiraJson", "Attention: %1 errors on the plant").arg(errs);
if (flagValue(summary, QStringLiteral("away_mode")))
return QCoreApplication::translate("AiraJson", "Away mode: the house is saving energy.");
if (flagValue(summary, QStringLiteral("night_mode")))
return QCoreApplication::translate("AiraJson", "Night mode is on.");
if (flagValue(summary, QStringLiteral("force_heating")))
return QCoreApplication::translate("AiraJson", "Boost: the house is heating at full power.");
if (flagValue(summary, QStringLiteral("hot_water_heating")))
return QCoreApplication::translate("AiraJson", "The heat pump is heating the hot water.");
if (pumpRunning(summary))
return QCoreApplication::translate("AiraJson", "The heat pump is working.");
return QCoreApplication::translate("AiraJson", "Relax: the heat pump is running smoothly.");
}
int statusLevel(const QVariantMap &summary)
{
if (summary.isEmpty())
return 1;
if (errorCount(summary) > 0)
return 2;
if (flagValue(summary, QStringLiteral("away_mode"))
|| flagValue(summary, QStringLiteral("night_mode"))
|| flagValue(summary, QStringLiteral("force_heating")))
return 1;
return 0;
}
} // namespace AiraJson
+12
View File
@@ -30,6 +30,18 @@ QVariantMap parseStats(const QByteArray &body, bool *ok);
// Codice di stato Aira ("OPERATING_STATUS_AUTO") in etichetta italiana.
QString modeLabel(const QString &operatingStatus);
// Riga di stato per la Home: breve, per stare in una riga sola
// ("Tutto regolare", "In assenza", "Attenzione: un errore").
QString stateShort(const QVariantMap &summary);
// Frase estesa per la pagina Impianto (quella dell'app ufficiale:
// "Relax: la pompa di calore funziona regolarmente").
QString stateLong(const QVariantMap &summary);
// Livello dello stato, per il colore della riga: 0 regolare, 1 modo attivo
// (assenza, notte, riscaldamento forzato), 2 errore sull'impianto.
int statusLevel(const QVariantMap &summary);
// Temperatura leggibile: "51,0" oppure "—" se il valore manca.
QString formatValue(const QVariant &value);
+80
View File
@@ -3,8 +3,10 @@
#include "settings.h"
#include <QDebug>
#include <QLocale>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QTime>
#include <QTimer>
#include <QUrl>
@@ -12,7 +14,45 @@ ApiClient::ApiClient(Settings *settings, QObject *parent)
: QObject(parent)
, m_settings(settings)
, m_busy(false)
, m_stale(false)
, m_noticeIsError(false)
, m_noticeTimer(0)
, m_refreshTimer(0)
{
// Il messaggio dell'ultimo comando (o dell'ultimo errore) vale per ogni
// pagina: lo stato sta qui, si spegne da solo e la UI lo mostra ancorato
// in fondo alla schermata (NoticeBar), fuori dal contenuto che scorre.
m_noticeTimer = new QTimer(this);
m_noticeTimer->setSingleShot(true);
m_noticeTimer->setInterval(7000);
connect(m_noticeTimer, &QTimer::timeout, this, [this]() {
if (m_notice.isEmpty())
return;
m_notice.clear();
emit noticeChanged();
});
connect(this, &ApiClient::errorOccurred, this, [this](const QString &message) {
setNotice(message, true);
});
connect(this, &ApiClient::commandFinished, this, [this](bool ok, const QString &message) {
setNotice(message, !ok);
});
connect(this, &ApiClient::statsRefreshed, this, [this](bool ok, const QString &message) {
setNotice(message, !ok);
});
// Dopo un comando riuscito lo stato si rilegge da solo: l'impianto applica
// in qualche istante, e senza questa rilettura gli interruttori della
// pagina dei comandi resterebbero indietro rispetto alla pompa.
m_refreshTimer = new QTimer(this);
m_refreshTimer->setSingleShot(true);
m_refreshTimer->setInterval(2000);
connect(m_refreshTimer, &QTimer::timeout, this, &ApiClient::refresh);
connect(this, &ApiClient::commandFinished, this, [this](bool ok, const QString &) {
if (ok)
m_refreshTimer->start();
});
if (m_settings) {
// La UI deve riflettere subito il passaggio da "non configurato" a
// "configurato" quando si salvano indirizzo o token.
@@ -29,6 +69,30 @@ bool ApiClient::configured() const
&& !m_settings->token().trimmed().isEmpty();
}
QString ApiClient::stateShort() const
{
return AiraJson::stateShort(m_summary);
}
QString ApiClient::stateLong() const
{
return AiraJson::stateLong(m_summary);
}
int ApiClient::statusLevel() const
{
return AiraJson::statusLevel(m_summary);
}
void ApiClient::setNotice(const QString &message, bool isError)
{
m_notice = message;
m_noticeIsError = isError;
emit noticeChanged();
if (!message.isEmpty())
m_noticeTimer->start();
}
void ApiClient::setBusy(bool value)
{
if (value == m_busy)
@@ -102,6 +166,10 @@ void ApiClient::refresh()
? tr("Server unreachable (timeout)")
: AiraJson::errorMessage(body, status);
qWarning() << "[api] summary non riuscito:" << status << msg;
if (!m_stale) {
m_stale = true;
emit staleChanged();
}
emit errorOccurred(msg);
return;
}
@@ -110,10 +178,22 @@ void ApiClient::refresh()
const QVariantMap summary = AiraJson::parseSummary(body, &ok);
if (!ok) {
qWarning() << "[api] riassunto non interpretabile";
if (!m_stale) {
m_stale = true;
emit staleChanged();
}
emit errorOccurred(tr("Unreadable response from the bridge"));
return;
}
m_summary = summary;
if (m_stale) {
m_stale = false;
emit staleChanged();
}
// Ora dell'ultimo aggiornamento riuscito: la Home la mostra, cosi' un
// numero vecchio non passa per un numero fresco.
m_lastUpdate = QLocale::system().toString(QTime::currentTime(), QLocale::ShortFormat);
emit lastUpdateChanged();
emit summaryChanged(summary);
});
}
+34
View File
@@ -7,6 +7,7 @@
#include <QVariantMap>
class QNetworkReply;
class QTimer;
class Settings;
// Client HTTP verso il bridge sul server (~/workspace/Carlo/Aira/aira_bridge.py).
@@ -24,6 +25,16 @@ class ApiClient : public QObject
// 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)
// Salute dell'aggiornamento: "stale" = l'ultimo tentativo non e' riuscito,
// quindi i numeri a schermo sono vecchi e vanno mostrati spenti.
Q_PROPERTY(bool stale READ stale NOTIFY staleChanged)
Q_PROPERTY(QString lastUpdate READ lastUpdate NOTIFY lastUpdateChanged)
Q_PROPERTY(bool hasData READ hasData NOTIFY summaryChanged)
// Messaggio dell'ultimo comando (o errore), mostrato in fondo alla pagina:
// sta qui e non nel QML perche' vale per OGNI pagina, e perche' cosi' il
// messaggio e' lo stesso ovunque si stia.
Q_PROPERTY(QString notice READ notice NOTIFY noticeChanged)
Q_PROPERTY(bool noticeIsError READ noticeIsError NOTIFY noticeChanged)
public:
explicit ApiClient(Settings *settings, QObject *parent = 0);
@@ -32,6 +43,18 @@ public:
bool configured() const;
QVariantMap summary() const { return m_summary; }
QVariantMap stats() const { return m_stats; }
bool stale() const { return m_stale; }
QString lastUpdate() const { return m_lastUpdate; }
bool hasData() const { return !m_summary.isEmpty(); }
QString notice() const { return m_notice; }
bool noticeIsError() const { return m_noticeIsError; }
// Stato leggibile in una riga (stateShort) e per esteso (stateLong), piu'
// il livello da cui la UI prende il colore. Stanno in C++ perche' sono
// logica pura: verificate in tests/core_test.cpp e non duplicate fra pagine.
Q_INVOKABLE QString stateShort() const;
Q_INVOKABLE QString stateLong() const;
Q_INVOKABLE int statusLevel() const;
// Scarica il riassunto e lo emette con summaryChanged().
Q_INVOKABLE void refresh();
@@ -56,6 +79,9 @@ public:
signals:
void busyChanged();
void configuredChanged();
void staleChanged();
void lastUpdateChanged();
void noticeChanged();
void summaryChanged(const QVariantMap &summary);
void statsChanged(const QVariantMap &stats);
void statsRefreshed(bool ok, const QString &message);
@@ -68,10 +94,18 @@ private:
const QByteArray &body, int timeoutSec);
void armTimeout(QNetworkReply *reply, int seconds);
void setBusy(bool value);
// Mostra un messaggio in fondo alla pagina per qualche secondo.
void setNotice(const QString &message, bool isError);
Settings *m_settings;
QNetworkAccessManager m_nam;
bool m_busy;
bool m_stale;
QString m_lastUpdate;
QString m_notice;
bool m_noticeIsError;
QTimer *m_noticeTimer;
QTimer *m_refreshTimer;
QVariantMap m_summary;
QVariantMap m_stats;
};
+2 -2
View File
@@ -21,7 +21,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("aira"));
app->setApplicationVersion(QStringLiteral("0.5.0"));
app->setApplicationVersion(QStringLiteral("0.6.0"));
Settings 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
// o una stale (trappola nota del build engine).
qDebug() << "harbour-aira v0.5.0 build" << __DATE__ << __TIME__;
qDebug() << "harbour-aira v0.6.0 build" << __DATE__ << __TIME__;
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
view->rootContext()->setContextProperty(QStringLiteral("api"), &api);
+61
View File
@@ -190,6 +190,67 @@ int main(int argc, char *argv[])
}
}
// --- stato in una riga e per esteso (Home e pagina Impianto) ----------- //
{
QVariantMap calmo;
calmo.insert("errors_count", 0);
calmo.insert("away_mode", false);
calmo.insert("night_mode", false);
calmo.insert("force_heating", false);
calmo.insert("hot_water_heating", false);
calmo.insert("pump_active_state", "PUMP_ACTIVE_STATE_IDLE");
check(AiraJson::stateShort(calmo) == "Everything is fine",
"stato breve: nessuna attivita'");
check(AiraJson::stateLong(calmo) == "Relax: the heat pump is running smoothly.",
"stato esteso: frase dell'app ufficiale");
check(AiraJson::statusLevel(calmo) == 0, "livello: regolare");
QVariantMap lavora = calmo;
lavora.insert("pump_active_state", "PUMP_ACTIVE_STATE_RUNNING");
check(AiraJson::stateShort(lavora) == "Working", "stato breve: pompa in funzione");
check(AiraJson::statusLevel(lavora) == 0,
"livello: pompa in funzione e' comunque regolare");
QVariantMap acqua = calmo;
acqua.insert("hot_water_heating", true);
check(AiraJson::stateShort(acqua) == "Heating the hot water",
"stato breve: sta scaldando l'acqua");
check(AiraJson::stateLong(acqua) == "The heat pump is heating the hot water.",
"stato esteso: acqua calda in carica");
QVariantMap assente = calmo;
assente.insert("away_mode", true);
check(AiraJson::stateShort(assente) == "Away mode", "stato breve: assenza");
check(AiraJson::statusLevel(assente) == 1, "livello: modo attivo, non regolare");
QVariantMap uno = calmo;
uno.insert("errors_count", 1);
check(AiraJson::stateShort(uno) == "Attention: one error",
"stato breve: un errore (singolare)");
check(AiraJson::stateLong(uno) == "Attention: one error on the plant",
"stato esteso: un errore");
check(AiraJson::statusLevel(uno) == 2, "livello: errore");
QVariantMap due = calmo;
due.insert("errors_count", 2);
check(AiraJson::stateShort(due) == "Attention: 2 errors",
"stato breve: due errori (plurale)");
check(AiraJson::stateLong(due) == "Attention: 2 errors on the plant",
"stato esteso: due errori");
// L'errore ha la precedenza su tutto: e' quello che va visto per primo.
QVariantMap misto = assente;
misto.insert("errors_count", 3);
check(AiraJson::stateShort(misto) == "Attention: 3 errors",
"stato breve: l'errore vince sul modo attivo");
check(AiraJson::statusLevel(misto) == 2, "livello: errore prima di tutto");
check(AiraJson::stateShort(QVariantMap()) == "No data",
"stato breve: nessun dato");
check(AiraJson::stateLong(QVariantMap()).isEmpty(),
"stato esteso: nessun dato = nessuna frase");
}
qInfo() << "----";
qInfo() << checks - failures << "/" << checks << "verifiche superate";
return failures == 0 ? 0 : 1;
File diff suppressed because it is too large Load Diff