Compare commits

..
35 Commits
Author SHA1 Message Date
Carlo e9527f66bb 0.7.10: cambio lingua in due mosse + diagnosi lingua nel /layout
Misurato in container: la ricarica della radice ri-traduce, il catalogo si trova
anche con it_IT. La ricarica ora scarica la radice prima di ricaricarla, cosi'
non dipende da un comportamento di setSource che cambia fra versioni di Qt.
2026-10-11 19:01:07 +02:00
Carlo 78eb283c9b 0.7.9: via la barra di avanzamento dell'acqua
Partiva da 10 °C e stava sempre al 99%: non diceva niente. Tolta dal componente
ReadingCell (la usava solo la Home) e con essa la funzione waterProgress.
2026-10-11 18:52:08 +02:00
Carlo 1e2f5bbea3 0.7.8: le diciture della copertina
'T. casa', 'T. acqua', 'Temperatura esterna'. Stringhe proprie della copertina:
la copertina condivideva 'House' e 'Hot water' con la Home, quindi cambiare la
traduzione avrebbe cambiato anche la Home, che resta 'Casa' e 'Acqua calda'.
2026-10-11 18:45:46 +02:00
Carlo 648246b078 0.7.7: un solo tasto del boost, con lo stato della Home
La pagina Regolazioni aveva due tasti sempre attivi (avvia/ferma) che non
dicevano cosa sta facendo l'impianto e potevano contraddire la Home. Ora un
ActionButton solo, con testo e comando da api.boostActive — lo stesso dato della
Home — piu' il tempo che resta quando il boost corre.
2026-10-11 18:21:10 +02:00
Carlo f4bee218bb 0.7.6: via il banner del remorse dai tasti rapidi
Alla pressione Silica ri-parenta il RemorseItem dentro la fila dei tre tasti e
ci disegna sopra il banner (messaggio + conto alla rovescia) a margini fissi:
il suo riquadro non ha la larghezza del tasto, quindi copre i tasti vicini.
Il messaggio lo mostra gia' la NoticeBar, che ora ha altezza fissa a una riga e
una banda riservata sotto il contenuto in tutte e cinque le pagine.
2026-10-11 17:26:13 +02:00
Carlo 35297a0fa0 0.7.5: il corpo JSON delle misure (AiraJson::layoutPayload)
send() prende QByteArray, non la mappa: la build l'ha detto subito.
2026-10-11 17:18:40 +02:00
Carlo 8e13180ffb 0.7.5: la Home manda le sue misure al bridge
Dopo tre diagnosi dedotte e tre volte sbagliate, le coordinate arrivano dal
telefono: il timer 'misura' le manda a /layout, il bridge le scrive nel suo
registro. Nel QML arrivano versione, schermo, contenuto e y/h/testo di ogni
riga.
2026-10-11 17:16:10 +02:00
Carlo 5453e4dcf7 0.7.4: la versione dell'app a schermo
Carlo segnalava la riga che copriva i tasti mentre la correzione era nella
0.7.1: non c'era modo di sapere quale build stesse girando. In Impostazioni
ora c'e' 'Versione 0.7.4'. La disposizione l'ho anche misurata, replicandola
in QML nel container: con la scatola da 56 px la riga cade a y=240 dentro i
tasti (176..286), con quella da 152 a y=336 sotto.
2026-10-11 13:41:18 +02:00
Carlo 09fadd5543 0.7.3: copertina con la freccia e Esterno maiuscolo
'22,5/19,0 °C' era la scrittura vecchia: l'obiettivo ora segue la freccia.
La terza riga dice 'Esterno' (l'italiano era 'esterna %1 °C', minuscolo e
femminile, residuo di quando la riga era un'altra frase). Scelte di Carlo,
chieste prima di toccare.
2026-10-11 12:51:50 +02:00
Carlo b4ac614723 0.7.2: il tasto del boost diventa Stop mentre e' in corso
In Home e in copertina: un tocco ferma il boost invece di farne ripartire un
altro (dalla copertina era il caso peggiore). Il conto alla rovescia ora prende
il posto del suggerimento nella stessa riga e con lo stesso carattere: prima,
essendo piu' alto, spingeva giu' i tasti delle azioni rapide.
Glifo di stop disegnato con scripts/make_glyphs.py. Catalogo 199 voci.
2026-10-11 12:45:56 +02:00
Carlo f7447900f9 0.7.1: allineo la copia del bridge (orari dell'impianto letti come UTC) 2026-10-11 12:38:27 +02:00
Carlo 2b397b6c3a 0.7.1: i tasti delle azioni rapide dichiarano la loro altezza
In Silica il tasto piglia l'altezza dal tema (itemSizeExtraSmall, ~56 px) e non
dal contenuto: con icona e etichetta (~110) il testo usciva dalla scatola e la
riga di contorno della colonna finiva sopra le etichette. Ora height:
Theme.itemSizeMedium. Aggiunto il controllo in tests/label_fit_check.py e il
registro di posizione e altezza di ogni riga della Home.
2026-10-11 12:26:20 +02:00
Carlo e8937dc1ea 0.7.0: VERSION del bridge allineata 2026-10-11 12:07:58 +02:00
Carlo 59894142dd 0.7.0: col boost in corso la riga "tocca un valore" lascia il posto al conto alla rovescia
La Home deve restare di una schermata sola: la riga del conto alla rovescia
prende il posto del suggerimento invece di aggiungersi.
2026-10-11 12:06:27 +02:00
Carlo d2cb9d692f 0.7.0: assenza con date, conto alla rovescia del boost, catalogo rifatto
- AwayDialog.qml: popup con inizio e fine (Dialog, ValueButton,
  DatePickerDialog: componenti e proprieta' verificati nella guida Silica
  dell'SDK). Il tasto Assenza apre il popup invece di mandare il comando;
- conto alla rovescia 30:00 -> 00:00 in Home e in copertina, con i secondi
  dell'impianto (AiraJson::countdown + timer che si riallinea a ogni lettura);
- commandPayload accetta campi extra (start/end);
- catalogo: lupdate leggeva solo i C++ e marcava 151 voci QML come obsolete.
  Ora src e qml espliciti: 197 stringhe, 0 non tradotte;
- versione 0.7.0 (spec, main.cpp), PROTOCOL, README/PIANO, post per il forum.
2026-10-11 12:05:59 +02:00
Carlo 6ef6f5bfd8 0.6.9: il bridge corretto (boost con la durata) e i comandi con argomento in PROTOCOL
La copia del bridge dentro l'app va tenuta allineata: contiene la correzione
del boost, dell'assenza, di ACS off e del riscaldamento forzato, che la
libreria accetta solo con un argomento. Il pacchetto Sailfish non cambia (il
bridge non entra nell'RPM), quindi non serve ricostruire l'RPM.
2026-10-11 11:49:39 +02:00
Carlo 2136122398 0.6.9: azione sulla copertina (P8) — il boost senza aprire l'app
Sulla copertina compare l'icona del boost (CoverAction). Una sola azione: la
documentazione Silica ammette piu' di una azione ma dice che lo stile delle app
di sistema ne mostra una. CoverBackground eredita da Cover, quindi l'elenco
dichiarato li' si attacca; l'icona e' iconSource (non icon.source del Button:
sbagliarlo non da' errore, l'icona semplicemente non compare). La riga in fondo
si ancora a Cover.coverActionArea, cosi' l'icona non la copre.

Nuovo controllo statico tests/cover_action_check.py: elenco figlio diretto del
Cover, una sola azione, icona che esiste, id del comando presente nella tabella
del bridge. Il componente e' nominato nella guida Silica dell'SDK di Carlo.
2026-10-11 11:35:49 +02:00
Carlo 1ac1617c2c tests: controllo statico che le etichette dei tasti ci stiano
Le etichette si leggono da MainPage.qml e dal catalogo, la geometria dai numeri
misurati sul telefono: se un'etichetta si allunga (o l'icona si ingrandisce) il
controllo lo segnala prima della build. Su "Commands" fallisce, come deve.
2026-10-11 11:28:58 +02:00
Carlo 92f179186a 0.6.8: icone dei tasti alla misura del tema (iconSizeExtraSmall), etichette che ci stanno
Le icone della 0.6.7 erano disegnate a piena misura e il pulsante Silica le
portava a Theme.iconSizeMedium (64 px, misurato sullo screenshot del telefono):
in un tasto largo un terzo di schermo restavano 41 px di testo e le etichette
uscivano tagliate. Ora il tasto dichiara icon.width/height:
Theme.iconSizeExtraSmall e i glifi riempiono il loro riquadro.

Con la geometria vera (tasto 161 px, rientri 16, distacco 24) le etichette
Assenza/Torna/Altro e Away/Home/More ci stanno con margine; "Comandi" (8
lettere) no: il tasto dice Altro, la pagina resta Comandi impianto.

Il pulsante Silica non espone il carattere, quindi il testo non si puo'
rimpicciolire: l'unica leva e' l'icona. Prova ripetibile con i numeri misurati
in docs/icona/tasti-alla-misura-vera.png.
2026-10-11 11:27:51 +02:00
kaneda f1fdc9dfdb 0.6.7: icone sui tre tasti delle azioni rapide (fiamma, casa, manopole), disegnate in casa 2026-10-11 10:08:43 +02:00
kaneda afb2878a8c 0.6.6: dalla Home si aprono le regolazioni dell'oggetto toccato, non di tutto l'impianto 2026-10-11 09:59:25 +02:00
kaneda a788255593 0.6.5: sulla copertina l'acqua si chiama Acqua calda come in Home; attuale piu' grande, obiettivo attaccato 2026-10-11 08:24:49 +02:00
kaneda 462ea035ca piano: sezione 10 (0.6.1-0.6.4) e stato del nucleo allineato 2026-10-11 08:20:52 +02:00
kaneda c237818e3c 0.6.4: copertina con Casa e Acqua, attuale / impostata su due righe etichettate 2026-10-11 08:14:12 +02:00
kaneda e3e365cbfc 0.6.3: nuova icona (casa con l'onda calda); conteggi del README inglese allineati a 85/85 2026-10-11 08:05:39 +02:00
kaneda 27f97dabef proposte di icona: quattro direzioni disegnate a 172 e 86 px (scripts/icon_proposals.py) 2026-10-11 08:01:14 +02:00
kaneda 4be5caea81 0.6.2: documenti e conteggi allineati (85/85 verifiche del nucleo) 2026-10-10 18:28:46 +02:00
kaneda a281a7e112 0.6.2: pallino rosso solo per un allarme che ferma la macchina (avviso = ambra)
Il pallino era rosso per qualunque voce nell'elenco errori: un semplice avviso
(compressore limitato dalla temperatura esterna, caso reale del 10/10) sembrava
un guasto. Ora il ponte espone in /summary anche error_severity e
alarm_stopping, e il livello 3 (rosso) e' riservato agli allarmi che fermano la
macchina o di severita' CRITICAL/ERROR; avviso = ambra. Copertina allineata.

6 verifiche nuove nell'harness del nucleo (avviso/stop/critical/error/
precedenza sul modo attivo/gravita' ignota = ambra).
2026-10-10 18:16:04 +02:00
kaneda 4050b3b5d7 0.6.1: la Home non si apriva (doppio Component.onCompleted); controllo automatico delle assegnazioni duplicate 2026-10-10 12:22:15 +02:00
kaneda 70fe2299ba Home: riga di log che misura il contenuto contro lo schermo (per la prima build sul telefono) 2026-10-10 09:55:53 +02:00
kaneda 75c61cace2 0.6.0: copertina con pallino di stato, numeri vecchi spenti ed etichetta esterna; catalogo 191/191 2026-10-10 09:55:30 +02:00
kaneda 5305fab309 spec: nome della cartella dei sorgenti allineato alla 0.6.0 2026-10-10 09:53:30 +02:00
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
70 changed files with 5102 additions and 1154 deletions
+4 -1
View File
@@ -17,8 +17,11 @@ __pycache__/
*.pyc *.pyc
# cataloghi compilati: si rigenerano in build da translations/*.ts # cataloghi compilati: si rigenerano in build da translations/*.ts
translations/*.qm *.qm
# temporanei # temporanei
*~ *~
*.bak *.bak
# binario compilato in locale: non fa parte del sorgente
harbour-aira
+80 -29
View File
@@ -1,5 +1,7 @@
# harbour-aira — versione italiana # harbour-aira — versione italiana
<img src="icons/172x172/harbour-aira.png" width="96" alt="icona">
*English version: [README.md](README.md)* *English version: [README.md](README.md)*
--- ---
@@ -15,25 +17,43 @@ e ripiega sull'inglese; la si può scegliere anche nelle impostazioni dell'app.
## Cosa mostra ## Cosa mostra
- **Home**, ordinata come l'app ufficiale: temperatura esterna e *in casa / - **Home**, una schermata da guardare: pallino colorato con lo stato in una riga
assente* in alto, una frase di stato in chiaro ("Relax: la pompa di calore ("Tutto regolare", "Sta scaldando l'acqua"), l'ora dell'ultimo aggiornamento, le
funziona regolarmente"), poi la **scheda dell'impianto** con le due letture che due letture che contano (casa e acqua calda, ognuna con il suo obiettivo)
contano (temperatura stanza e acqua calda, ognuna con il suo obiettivo). affiancate e **toccabili**, e tre azioni rapide — boost, assenza, comandi
Toccando la scheda si apre la pagina dell'impianto. Sotto, i comandi: impianto — ognuna con la sua icona. Sta in una schermata, senza scorrere: i
obiettivo della casa con slider 5–30 °C (passi da 0,5 °C) e conferma prima comandi hanno pagine loro
dell'invio, obiettivo ACS (45–60 °C) e i pulsanti del boost - **Conto alla rovescia del boost**: mentre il boost dell'acqua calda è in corso,
in Home scorre da 30:00 a 00:00 e in copertina prende il posto dell'obiettivo
dell'acqua. I secondi vengono dalla fine del boost letta dall'impianto: il
conto è giusto anche se il boost l'ha avviato qualcun altro
- **Il tasto del boost diventa *Stop*** mentre il boost è in corso, in Home e in
copertina: un tocco lo ferma, invece di farne ripartire un altro
- **Assenza con le date**: toccando *Assenza* si apre una finestra dove si
scelgono il primo e l'ultimo giorno (calendario, un tocco ciascuno). In quel
periodo la casa resta a 15 °C e riprende il programma normale il giorno scelto
come ultimo
- **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 - **Dati**: calore prodotto, elettricità usata, efficienza (COP) di acqua calda e
riscaldamento, risparmio in bolletta, CO₂ evitata e risparmio Smart Tariff riscaldamento, risparmio in bolletta, CO₂ evitata e risparmio Smart Tariff
Control, mese per mese. Sono i numeri che Aira calcola nel cloud, gli stessi Control, mese per mese. Sono i numeri che Aira calcola nel cloud, gli stessi
della scheda *Dati* dell'app ufficiale della scheda *Dati* dell'app ufficiale
- **Impianto**: stato (connessione, modalità, assenza/notte/riscaldamento - **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 termostato a parete con temperatura, umidità, segnale, batteria), versioni del
software e numero di errori software, numero di errori e verifica impianto. Qui sta anche la frase di stato
- **Altri comandi**: assenza, night mode per un'ora, riscaldamento forzato, estesa ("Relax: la pompa di calore funziona regolarmente")
funzione riscaldamento on/off, ciclo legionella, ACS on/off e verifica - **Comandi impianto**: quattro interruttori (assenza, riscaldamento forzato,
impianto, con lo stato attuale in cima riscaldamento, acqua calda attiva) che mostrano lo stato e lo cambiano in un
- **Copertina** con la temperatura della casa 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 casa e acqua calda, ognuna con il valore che l'impianto sta
tenendo adesso e il suo obiettivo ("22,9/19,0 °C"), più il pallino di stato
come nella Home
- Indirizzo e token del bridge stanno nelle impostazioni dell'app (QSettings), - Indirizzo e token del bridge stanno nelle impostazioni dell'app (QSettings),
mai nel binario mai nel binario
@@ -43,13 +63,13 @@ 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 | 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 | | Impianto | Comandi impianto | Impostazioni | Copertina |
|---|---|---| |---|---|---|---|
| ![comandi](docs/anteprima/it/comandi.png) | ![impostazioni](docs/anteprima/it/impostazioni.png) | ![copertina](docs/anteprima/it/copertina.png) | | ![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 ## La lingua dell'interfaccia
@@ -149,26 +169,48 @@ python3 tests/ui_contract_check.py # bridge su 127.0.0.1:8790
python3 tests/ui_contract_check.py http://192.168.3.21:8790 python3 tests/ui_contract_check.py http://192.168.3.21:8790
``` ```
Altri due controlli guardano i QML stessi, visto che qui non se ne può eseguire
nessuno. Impiegano millisecondi e dicono il file di cui non sono contenti:
```sh
python3 tests/qml_check_bindings.py qml # nessuna proprietà assegnata due volte
python3 tests/qml_focus_check.py # un tocco apre solo quello che ha toccato
python3 tests/label_fit_check.py # le etichette dei tasti ci stanno
python3 tests/cover_action_check.py # l'azione di copertina c'e' ed e' collegata
```
## Stato ## Stato
- **61/61** verifiche del nucleo, compilazione senza warning - **85/85** verifiche del nucleo, compilazione senza warning (comprese le frasi
di stato, breve ed estesa, e la severita' degli allarmi: quello che la Home
dice dell'impianto è verificato senza telefono)
- **48/48** verifiche offline del bridge (`bridge/test_bridge_commands.py`), - **48/48** verifiche offline del bridge (`bridge/test_bridge_commands.py`),
comprese le conversioni Wh→kWh e degli importi delle statistiche comprese le conversioni Wh→kWh e degli importi delle statistiche
- **170/170** stringhe tradotte nel catalogo italiano: il test carica il `.qm` - **191/191** 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, importi e nomi dei mesi formattati con `QLocale`: separatore decimale e - numeri, importi e nomi dei mesi formattati con `QLocale`: separatore decimale e
posizione del simbolo di valuta seguono la lingua (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 - controlli statici dove la UI non è eseguibile: `qmllint` pulito su tutti i QML
Silica una proprietà inesistente passa in silenzio) (valida solo la sintassi: senza il modulo Silica una proprietà inesistente
passa in silenzio), le chiavi che i QML leggono sono quelle che il bridge manda
(`tests/ui_contract_check.py`), e toccare una lettura in Home apre solo le
regolazioni di quell'oggetto (`tests/qml_focus_check.py`)
- **nessuna assegnazione duplicata** nei QML (`tests/qml_check_bindings.py`,
13 file): assegnare due volte la stessa proprietà nello stesso oggetto — due
`Component.onCompleted` in una pagina, tipicamente — fa fallire la creazione
dell'oggetto in Qt 5.6, e sul telefono si vede solo `Type MainPage unavailable`
senza il nome del file. Il controllo impiega millisecondi e dice la riga
- provata dal vivo in **sola lettura** su un impianto Aira Home, statistiche - provata dal vivo in **sola lettura** su un impianto Aira Home, statistiche
comprese: coincidono con quelle mostrate dall'app ufficiale per lo stesso 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, periodo (calore prodotto 70,8 kWh, elettricità 29,3 kWh, COP acqua calda 4,42,
risparmio Smart Tariff Control 2,71 €) risparmio Smart Tariff Control 2,71 €)
- **l'app non è ancora stata compilata né eseguita su un telefono**: le immagini - **compilata ed eseguita su un telefono** (Jolla, Sailfish OS 5.2): è proprio la
in `docs/anteprima/` sono anteprime di layout costruite con i valori veri prima build ad aver rivelato il difetto della Home, corretto nella 0.6.1. Resta
dell'impianto, non screenshot **non verificato l'invio di un comando** a un impianto vero: finora solo
letture. Le immagini in `docs/anteprima/` sono anteprime di layout costruite con
i valori veri 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; le versioni del software si vedono in sola lettura. La pagina Dati dipende curve; le versioni del software si vedono in sola lettura. La pagina Dati dipende
@@ -188,16 +230,24 @@ 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 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/DataPage.qml energia, efficienza e risparmi
qml/pages/PlantPage.qml stato, accessori, versioni, errori qml/pages/PlantPage.qml stato, accessori, versioni, errori, diagnostica
qml/pages/CommandsPage.qml gli altri comandi, con lo stato attuale in cima qml/pages/CommandsPage.qml i comandi impianto, stato negli interruttori
qml/pages/SettingsPage.qml qml/pages/SettingsPage.qml
qml/components/InfoRow.qml etichetta a sinistra, valore a destra 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/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 qml/components/ActionButton.qml pulsante di comando, con conferma opzionale
qml/icons/ i tre glifi delle azioni rapide (disegnati, non dal tema)
tests/core_test.cpp verifiche del nucleo (headless) tests/core_test.cpp verifiche del nucleo (headless)
tests/ui_contract_check.py chiavi dei QML contro quelle del bridge (bridge attivo) tests/ui_contract_check.py chiavi dei QML contro quelle del bridge (bridge attivo)
tests/qml_check_bindings.py assegnazioni duplicate di proprieta' nei QML
tests/qml_focus_check.py un tocco apre solo le regolazioni di quell'oggetto
tests/label_fit_check.py le etichette dei tasti ci stanno accanto all'icona
tests/cover_action_check.py azione di copertina: figlia diretta del Cover, icona e comando esistono
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
@@ -207,8 +257,9 @@ 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
docs/PROTOCOL.md API del bridge 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 docs/ux/ schemi dello studio UX
docs/icona/ proposte di icona e confronto prima/dopo
translations/ harbour-aira-it.ts (l'inglese è la lingua sorgente) translations/ harbour-aira-it.ts (l'inglese è la lingua sorgente)
docs/anteprima/ anteprime di layout in inglese (in italiano: it/) docs/anteprima/ anteprime di layout in inglese (in italiano: it/)
``` ```
+83 -31
View File
@@ -1,5 +1,7 @@
# harbour-aira # harbour-aira
<img src="icons/172x172/harbour-aira.png" width="96" alt="icon">
Native Sailfish OS app to monitor and control an **Aira Home** heat pump: plant Native Sailfish OS app to monitor and control an **Aira Home** heat pump: plant
status, domestic hot water, and the house target temperature per zone. status, domestic hot water, and the house target temperature per zone.
@@ -9,25 +11,44 @@ status, domestic hot water, and the house target temperature per zone.
## What it does ## What it does
- **Home**, organised like the official Aira app: outdoor temperature and - **Home**, one screen to look at: a coloured dot with a one-line status
*at home / away* at the top, a plain-language status line ("Relax: the heat ("Everything is fine", "Heating the hot water"), the time of the last update,
pump is running smoothly"), then the **plant card** with the two readings that the two readings that matter (house and domestic hot water, each with its
matter (room temperature and domestic hot water, each with its target). target) side by side and **tappable**, and three quick actions — boost, away
Tapping the card opens the plant page. Below that the controls: house target mode and the plant commands, each with its own icon. It fits without scrolling
with a 5–30 °C slider (0.5 °C steps) and a confirmation before the command — the controls live in their own pages
leaves, domestic hot water target (45–60 °C) and the boost buttons - **Boost countdown**: while a hot water boost is running the home screen shows
it ticking down from 30:00 to 00:00, and the cover page shows it in place of
the hot water target. The seconds come from the plant's own boost end time, so
the count is right even if the boost was started from somewhere else
- **The boost button turns into *Stop*** while a boost is running, on the home
screen and on the cover: tapping it stops the boost instead of starting
another one
- **Away mode with dates**: tapping *Away* opens a dialog where you pick the
first and the last day (calendar, one tap each). The house is kept at 15 °C
for that period and resumes its normal programme on the day you chose as the
last one
- **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 - **Data** page: heat produced, electricity used, efficiency (COP) for hot water
and heating, savings on the bill, CO₂ avoided and the Smart Tariff Control 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, saving, month by month. These are the numbers Aira computes in the cloud,
exactly as in the official app's *Data* tab exactly as in the official app's *Data* tab
- **Plant** page: status (connection, mode, away/night/forced heating, outdoor - **Plant** page: status (connection, mode, away/night/forced heating, manual
and indoor temperature, zones, last reading), accessories (the wall thermostat mode, defrost, signature lights, pump state, allowed modes, outdoor and indoor
with temperature, humidity, signal, battery), software versions and the error temperature, zones, last reading), accessories (the wall thermostat with
count temperature, humidity, signal, battery), software versions, the error count and
- **Other commands** page: away mode, night mode for one hour, force heating, the plant check. The extended status sentence ("Relax: the heat pump is running
heating function on/off, legionella cycle, DHW heating on/off and a plant smoothly") lives here
check — with the current state at the top, so nothing is pressed blindly - **Plant commands** page: four switches (away mode, forced heating, heating, hot
- **Cover page** showing the house temperature 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 and the hot water, each with the value the
plant is keeping right now and its target ("22.9/19.0 °C"), plus a status dot
that matches the home screen
- Address and bridge token live in the app settings (QSettings), never in the - Address and bridge token live in the app settings (QSettings), never in the
binary binary
- **Languages: English and Italian.** The interface follows the phone language by - **Languages: English and Italian.** The interface follows the phone language by
@@ -39,13 +60,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 | 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 | | Plant | Plant commands | Settings | Cover |
|---|---|---| |---|---|---|---|
| ![commands](docs/anteprima/comandi.png) | ![settings](docs/anteprima/impostazioni.png) | ![cover](docs/anteprima/copertina.png) | | ![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 ## Why there is a bridge
@@ -132,31 +153,53 @@ python3 tests/ui_contract_check.py # bridge on 127.0.0.1:8790
python3 tests/ui_contract_check.py http://192.168.3.21:8790 python3 tests/ui_contract_check.py http://192.168.3.21:8790
``` ```
Two checks look at the QML itself, since none of it can be run here. Both take
milliseconds and name the file they are unhappy about:
```sh
python3 tests/qml_check_bindings.py qml # no property assigned twice in one object
python3 tests/qml_focus_check.py # a tap opens only the object it touched
python3 tests/label_fit_check.py # quick-action labels fit the button
python3 tests/cover_action_check.py # the cover action is declared and wired
```
## Status ## Status
Verified so far: Verified so far:
- **61/61** core checks (`tests/core_test.cpp`), compiled with no warnings - **85/85** core checks (`tests/core_test.cpp`), compiled with no warnings —
including the one-line and extended status sentences and the alarm severity,
so what the home says about the plant is verified without a phone
- **48/48** 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, that absurd values 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 are refused (40 °C, zone 3, text), and the Wh→kWh / money conversions of the
statistics statistics
- **170/170** strings translated in the Italian catalogue, checked by loading - **191/191** strings translated in the Italian catalogue, checked by loading
the compiled `.qm` in the test the same way `main.cpp` does: source English 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 in, Italian out, and back to English when the catalogue is removed
- numbers, amounts and month names formatted through `QLocale`, so the decimal - numbers, amounts and month names formatted through `QLocale`, so the decimal
separator and the currency position follow the language (verified for both) separator and the currency position follow the language (verified for both)
- `qmllint` clean on every QML file. Note it validates syntax only: without the - static checks where the UI cannot be run: `qmllint` clean on every QML file
Silica module an invalid Silica property passes silently (syntax only: without the Silica module an invalid Silica property passes
silently), the data keys the QML reads are the ones the bridge serves
(`tests/ui_contract_check.py`), and a tap on the home screen opens only the
settings of the object it touched (`tests/qml_focus_check.py`)
- **no duplicate property assignment** in the QML (`tests/qml_check_bindings.py`,
13 files): assigning the same property twice in one object — two
`Component.onCompleted` in a page, typically — makes Qt 5.6 fail to create the
object, and all you see on the device is `Type MainPage unavailable` with no
file named. The check runs in milliseconds and names the line
- 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 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 the same period (heat produced 70.8 kWh, electricity 29.3 kWh, hot-water COP
4.42, Smart Tariff Control saving €2.71) 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 Verified on a phone too: **built and run on a Sailfish phone** (Jolla, Sailfish
build is up to whoever has the Sailfish SDK. The images in `docs/anteprima/` are OS 5.2) — that first build is exactly what exposed the home-page bug fixed in
layout previews built from the real plant values, not screenshots. 0.6.1. Not verified: **sending a command to a real plant** (everything so far is
read-only), and the images in `docs/anteprima/` are layout previews built from
the real plant values, not screenshots.
A Qt 5.6 note on the language switch: `QQmlEngine::retranslate()` only exists A Qt 5.6 note on the language switch: `QQmlEngine::retranslate()` only exists
from Qt 5.10, so installing a translator is not enough to re-translate what is from Qt 5.10, so installing a translator is not enough to re-translate what is
@@ -202,22 +245,31 @@ 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 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/DataPage.qml energy, efficiency and savings
qml/pages/PlantPage.qml status, accessories, versions, errors qml/pages/PlantPage.qml status, accessories, versions, errors, diagnostics
qml/pages/CommandsPage.qml the other commands, current state on top qml/pages/CommandsPage.qml the plant commands, state shown by switches
qml/pages/SettingsPage.qml qml/pages/SettingsPage.qml
qml/components/InfoRow.qml label on the left, value on the right qml/components/InfoRow.qml label on the left, value on the right
qml/components/ValueRow.qml title + subtitle left, big value 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 qml/components/ActionButton.qml command button, optional confirmation
qml/icons/ the three quick-action glyphs (drawn, not from the theme)
tests/core_test.cpp core checks (headless) tests/core_test.cpp core checks (headless)
tests/ui_contract_check.py QML keys vs bridge keys (bridge must be running) tests/ui_contract_check.py QML keys vs bridge keys (bridge must be running)
tests/qml_check_bindings.py duplicate property assignments in the QML
tests/qml_focus_check.py a tap opens only that object's settings
tests/label_fit_check.py quick-action labels fit next to the icon (measured on a phone)
tests/cover_action_check.py cover action: direct child of Cover, icon exists, command exists
bridge/ local HTTP bridge -> see bridge/README.md bridge/ local HTTP bridge -> see bridge/README.md
translations/ harbour-aira-it.ts (English is the source language) translations/ harbour-aira-it.ts (English is the source language)
docs/PIANO.md project plan and decisions (Italian) docs/PIANO.md project plan and decisions (Italian)
docs/PROTOCOL.md bridge API 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/ux/ UX study wireframes
docs/icona/ icon proposals and the before/after comparison
docs/anteprima/ layout previews (English; docs/anteprima/it/ Italian) docs/anteprima/ layout previews (English; docs/anteprima/it/ Italian)
``` ```
+190 -12
View File
@@ -35,8 +35,13 @@ import sys
import threading import threading
import traceback import traceback
import warnings import warnings
from datetime import datetime, time, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from google.protobuf.duration_pb2 import Duration
from google.protobuf.timestamp_pb2 import Timestamp
warnings.filterwarnings("ignore") warnings.filterwarnings("ignore")
@@ -53,7 +58,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.4.0" VERSION = "0.7.1"
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -195,6 +200,23 @@ def summarize(st: dict) -> dict:
errs = st.get("errors") or [] errs = st.get("errors") or []
# ---- gravita' degli errori -------------------------------------------
# La severita' arriva come stringa ("SEVERITY_WARNING") e le voci possono
# essere piu' di una: si normalizza al nome nudo e si tiene la peggiore.
# "allarme che ferma" NON si deduce dalla severita': sta nei metadati
# (compreso quello del compressore).
_GRAVITA = {"WARNING": 1, "ERROR": 2, "CRITICAL": 3}
_meta = st.get("error_metadata") or {}
alarm_stopping = bool(_meta.get("hp_has_stopping_alarms")) or bool(
_meta.get("compressor_has_stopping_alarm"))
severita = ""
for _voce in errs if isinstance(errs, list) else []:
_nome = str((_voce or {}).get("severity") or "")
if _nome.startswith("SEVERITY_"):
_nome = _nome[len("SEVERITY_"):]
if _GRAVITA.get(_nome, 0) > _GRAVITA.get(severita, 0):
severita = _nome
# ---- dati del dispositivo (per la pagina "Impianto": accessori + riepilogo, # ---- dati del dispositivo (per la pagina "Impianto": accessori + riepilogo,
# come la scheda "Accessori" e il "Riepilogo impostazioni" dell'app ufficiale) # come la scheda "Accessori" e il "Riepilogo impostazioni" dell'app ufficiale)
hp_id = g("heat_pump_id", "value") or "" hp_id = g("heat_pump_id", "value") or ""
@@ -215,6 +237,8 @@ def summarize(st: dict) -> dict:
return None return None
return round(float(valore) / 10.0, 1) return round(float(valore) / 10.0, 1)
boost_restano = boost_restanti(st)
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"),
@@ -222,6 +246,8 @@ def summarize(st: dict) -> dict:
"dhw_target": g("target_hot_water_temperature"), "dhw_target": g("target_hot_water_temperature"),
"operating_status": g("operating_status"), "operating_status": g("operating_status"),
"away_mode": bool(st.get("away_mode_enabled")), "away_mode": bool(st.get("away_mode_enabled")),
"boost_active": boost_restano > 0,
"boost_remaining_seconds": boost_restano,
"night_mode": bool(st.get("night_mode_enabled")), "night_mode": bool(st.get("night_mode_enabled")),
"manual_mode": bool(st.get("manual_mode_enabled")), "manual_mode": bool(st.get("manual_mode_enabled")),
# force_heating E' un oggetto {enabled, remaining_time}: leggerlo con # force_heating E' un oggetto {enabled, remaining_time}: leggerlo con
@@ -249,6 +275,13 @@ def summarize(st: dict) -> dict:
"zone2_mode": g("current_pump_mode_state", "zone_2"), "zone2_mode": g("current_pump_mode_state", "zone_2"),
"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,
# Gravita' degli errori, per il pallino di stato dell'app: un semplice
# avviso (WARNING) non deve diventare rosso come un allarme che ferma la
# macchina. "error_metadata" dice se c'e' un allarme che ferma; le voci
# di "errors" portano la severita' di ognuna (normalizzata senza il
# prefisso SEVERITY_, cosi' la UI non dipende dal protobuf).
"alarm_stopping": alarm_stopping,
"error_severity": severita,
"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 # Identificativo e versioni: l'app ufficiale mostra le prime 6 cifre
# dell'UUID come "Dispositivo ID". # dell'UUID come "Dispositivo ID".
@@ -427,19 +460,36 @@ def build_stats(aira, heat_pump_id: str, household_id: str,
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# comandi ammessi (whitelist: mai esporre l'intera libreria su HTTP) # comandi ammessi (whitelist: mai esporre l'intera libreria su HTTP)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Quattro comandi NON si possono inviare senza un argomento: la libreria vuole
# una durata (Duration) o, per l'assenza, inizio/fine (Timestamp) e la
# temperatura da tenere in casa. Senza, solleva TypeError e il bridge risponde
# "richiesta non valida" — e' quello che e' successo al primo boost, perche'
# nemmeno l'app mandava la durata.
# I valori qui sotto sono i default del bridge: sono scelte, non misure. L'app
# puo' mandarne altri in `value` (minuti, o giorni per l'assenza).
BOOST_MIN_DEFAULT = 30 # boost ACS (richiesta dell'utente: mezz'ora)
ACS_OFF_MIN_DEFAULT = 60 # ACS spento
FORCE_MIN_DEFAULT = 60 # riscaldamento forzato
AWAY_DAYS_DEFAULT = 7 # assenza
AWAY_TARGET_TEMP = 15.0 # casa tenuta a questa temperatura in assenza, °C
# Il fuso dell'impianto, NON quello della macchina: il server puo' stare in UTC
# e nei giorni di cambio ora si sbaglia di un'ora (e' successo nei test).
# Lo stato lo dichiara in `time_zone.time_zone_identifier`.
PLANT_TZ = ZoneInfo("Europe/Rome")
COMMANDS = { COMMANDS = {
"ping": {"help": "verifica che l'impianto risponda", "value": None}, "ping": {"help": "verifica che l'impianto risponda", "value": None},
"set_hot_water_target": {"help": "temperatura ACS obiettivo in °C", "value": "45-60"}, "set_hot_water_target": {"help": "temperatura ACS obiettivo in °C", "value": "45-60"},
"set_heating_setpoint": {"help": "temperatura obiettivo della casa in °C", "value": "5-30", "zone": "1 o 2 (opzionale, default 1)"}, "set_heating_setpoint": {"help": "temperatura obiettivo della casa in °C", "value": "5-30", "zone": "1 o 2 (opzionale, default 1)"},
"set_cooling_setpoint": {"help": "temperatura obiettivo del raffrescamento in °C", "value": "5-30", "zone": "1 o 2 (opzionale, default 1)"}, "set_cooling_setpoint": {"help": "temperatura obiettivo del raffrescamento in °C", "value": "5-30", "zone": "1 o 2 (opzionale, default 1)"},
"hot_water_boost_on": {"help": "avvia il boost ACS (una tantum)", "value": None}, "hot_water_boost_on": {"help": "avvia il boost ACS (una tantum)", "value": "minuti, 15-240 (opzionale, default 30)"},
"hot_water_boost_off": {"help": "ferma il boost ACS", "value": None}, "hot_water_boost_off": {"help": "ferma il boost ACS", "value": None},
"hot_water_heating_on": {"help": "abilita il riscaldamento ACS", "value": None}, "hot_water_heating_on": {"help": "abilita il riscaldamento ACS", "value": None},
"hot_water_heating_off": {"help": "disabilita il riscaldamento ACS", "value": None}, "hot_water_heating_off": {"help": "disabilita il riscaldamento ACS", "value": "minuti, 15-1440 (opzionale, default 60)"},
"away_mode_on": {"help": "modalita' assenza", "value": None}, "away_mode_on": {"help": "assenza con date di inizio e fine", "value": "start ed end (AAAA-MM-GG); in alternativa giorni, 1-60"},
"away_mode_off": {"help": "disattiva l'assenza", "value": None}, "away_mode_off": {"help": "disattiva l'assenza", "value": None},
"night_mode_1h": {"help": "night mode per un'ora", "value": None}, "night_mode_1h": {"help": "night mode per un'ora", "value": None},
"force_heating_on": {"help": "forza il riscaldamento", "value": None}, "force_heating_on": {"help": "forza il riscaldamento", "value": "minuti, 15-240 (opzionale, default 60)"},
"force_heating_off": {"help": "disattiva il riscaldamento forzato", "value": None}, "force_heating_off": {"help": "disattiva il riscaldamento forzato", "value": None},
"heating_on": {"help": "abilita la funzione riscaldamento", "value": None}, "heating_on": {"help": "abilita la funzione riscaldamento", "value": None},
"heating_off": {"help": "disabilita la funzione riscaldamento", "value": None}, "heating_off": {"help": "disabilita la funzione riscaldamento", "value": None},
@@ -447,7 +497,7 @@ COMMANDS = {
} }
def build_command(cmd_id: str, value=None, zone=1, context=None): def build_command(cmd_id: str, value=None, zone=1, context=None, params=None):
"""Costruisce l'oggetto comando della libreria. Importata solo qui per non """Costruisce l'oggetto comando della libreria. Importata solo qui per non
pagarne il costo all'avvio del bridge. pagarne il costo all'avvio del bridge.
@@ -472,16 +522,14 @@ def build_command(cmd_id: str, value=None, zone=1, context=None):
SetZoneSetpoints, SetZoneSetpoints,
) )
# Solo i comandi che la libreria accetta senza argomenti: gli altri hanno un
# ramo loro qui sotto, perche' senza la durata sollevano TypeError.
simple = { simple = {
"ping": Ping, "ping": Ping,
"hot_water_boost_on": ActivateHotWaterBoosting,
"hot_water_boost_off": DeactivateHotWaterBoosting, "hot_water_boost_off": DeactivateHotWaterBoosting,
"hot_water_heating_on": EnableHotWaterHeating, "hot_water_heating_on": EnableHotWaterHeating,
"hot_water_heating_off": DisableHotWaterHeating,
"away_mode_on": SetAwayMode,
"away_mode_off": ClearAwayMode, "away_mode_off": ClearAwayMode,
"night_mode_1h": ActivateNightModeForOneHour, "night_mode_1h": ActivateNightModeForOneHour,
"force_heating_on": EnableForceHeating,
"force_heating_off": DisableForceHeating, "force_heating_off": DisableForceHeating,
"heating_on": EnableHeatingFunction, "heating_on": EnableHeatingFunction,
"heating_off": DisableHeatingFunction, "heating_off": DisableHeatingFunction,
@@ -533,12 +581,131 @@ def build_command(cmd_id: str, value=None, zone=1, context=None):
# Kind: 1 = riscaldamento, 2 = raffrescamento (enum del protocollo). # Kind: 1 = riscaldamento, 2 = raffrescamento (enum del protocollo).
return SetZoneSetpoints(zone_setpoints=zs, kind=1 if heating else 2) return SetZoneSetpoints(zone_setpoints=zs, kind=1 if heating else 2)
if cmd_id == "hot_water_boost_on":
return ActivateHotWaterBoosting(
hot_water_boost_duration=_durata(value, BOOST_MIN_DEFAULT, 15, 240, cmd_id))
if cmd_id == "hot_water_heating_off":
return DisableHotWaterHeating(
time=_durata(value, ACS_OFF_MIN_DEFAULT, 15, 1440, cmd_id))
if cmd_id == "force_heating_on":
return EnableForceHeating(
duration=_durata(value, FORCE_MIN_DEFAULT, 15, 240, cmd_id))
if cmd_id == "away_mode_on":
inizio, fine = _periodo_assenza(params, value)
return SetAwayMode(
current_time=inizio,
end_time=fine,
target_room_temperature=AWAY_TARGET_TEMP,
)
cls = simple.get(cmd_id) cls = simple.get(cmd_id)
if cls is None: if cls is None:
raise ValueError(f"comando non ammesso: {cmd_id}") raise ValueError(f"comando non ammesso: {cmd_id}")
return cls() return cls()
def _durata(value, default_min: int, min_min: int, max_min: int, nome: str) -> Duration:
"""Durata in minuti: `value` se l'app lo manda, altrimenti il default."""
if value in (None, ""):
minuti = default_min
else:
try:
minuti = int(float(value))
except (TypeError, ValueError):
raise ValueError(f"{nome} richiede i minuti come numero")
if not min_min <= minuti <= max_min:
raise ValueError(
f"{nome}: durata fuori intervallo ({min_min}-{max_min} minuti)")
return Duration(seconds=minuti * 60)
def _giorni(value) -> int:
"""Giorni di assenza: `value` se l'app lo manda, altrimenti il default."""
if value in (None, ""):
return AWAY_DAYS_DEFAULT
try:
g = int(float(value))
except (TypeError, ValueError):
raise ValueError("away_mode_on richiede i giorni come numero")
if not 1 <= g <= 60:
raise ValueError("away_mode_on: durata fuori intervallo (1-60 giorni)")
return g
def _ts(quando: datetime) -> Timestamp:
t = Timestamp()
t.FromDatetime(quando.astimezone(timezone.utc))
return t
def _periodo_assenza(params, value):
"""Inizio e fine dell'assenza.
Date in `start`/`end` (AAAA-MM-GG, ora locale); se mancano, `value` = giorni
da adesso. La fine e' la mezzanotte locale del giorno indicato: da li'
l'impianto riprende il programma normale e la casa risale dai 15 °C.
"""
params = params or {}
oggi = datetime.now(PLANT_TZ).date()
if not str(params.get("start") or "").strip() and not str(params.get("end") or "").strip():
# Compatibilita': senza date, solo i giorni da adesso (nessun giorno di
# calendario): e' la forma che usava il bridge prima del popup.
adesso = datetime.now(PLANT_TZ)
return _ts(adesso), _ts(adesso + timedelta(days=_giorni(value)))
def giorno(chiave, default):
testo = str(params.get(chiave) or "").strip()
if not testo:
return default
try:
return datetime.strptime(testo[:10], "%Y-%m-%d").date()
except ValueError:
raise ValueError(f"away_mode_on: data non valida in {chiave}: {testo}")
inizio = giorno("start", oggi)
fine = giorno("end", oggi + timedelta(days=_giorni(value)))
if fine <= inizio:
raise ValueError("away_mode_on: la fine deve venire dopo l'inizio")
if (fine - inizio).days > 180:
raise ValueError("away_mode_on: periodo troppo lungo (max 180 giorni)")
adesso = datetime.now(PLANT_TZ)
partenza = datetime.combine(inizio, time.min, tzinfo=PLANT_TZ)
if partenza < adesso:
partenza = adesso
return _ts(partenza), _ts(datetime.combine(fine, time.min, tzinfo=PLANT_TZ))
def boost_restanti(st: dict) -> int:
"""Secondi che mancano alla fine del boost ACS (0 se non e' in corso).
L'impianto espone solo start_time/end_time: il conto alla rovescia dell'app
si basa su end_time, non sul momento in cui l'app ha visto partire il boost.
"""
fine = str((st.get("hot_water_boost") or {}).get("end_time") or "")
if not fine:
return 0
try:
quando = datetime.fromisoformat(fine.replace("Z", "+00:00"))
except ValueError:
return 0
# L'impianto manda la fine del boost ora con il fuso, ora senza (ora
# locale): senza, va letta nel fuso dell'IMPIANTO, altrimenti la
# sottrazione con "adesso" (che il fuso ce l'ha) solleva TypeError e
# /summary risponde 502 -- l'app resta senza dati ("Nessun dato").
if quando.tzinfo is None:
# "Senza fuso" NON vuol dire ora locale: pyairahome converte i Timestamp
# con ts.ToDatetime() (utils/utils.py) che, senza argomenti, restituisce
# un datetime *naive ma in UTC*. Leggerlo come ora dell'impianto
# sbaglierebbe il conto di una o due ore.
quando = quando.replace(tzinfo=timezone.utc)
return max(0, int((quando - datetime.now(timezone.utc)).total_seconds()))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# server HTTP # server HTTP
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -596,13 +763,24 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
if not self._auth_ok(): if not self._auth_ok():
return self._send(401, {"ok": False, "error": "token mancante o errato"}) return self._send(401, {"ok": False, "error": "token mancante o errato"})
if self.path.split("?")[0] != "/command": path = self.path.split("?")[0]
# Le misure della Home che l'app manda da sola appena si apre: servono a
# correggere la disposizione senza dipendere da uno screenshot letto a
# occhio. Finiscono nel registro del bridge come "[bridge] layout {...}".
if path == "/layout":
try:
body = self._body()
except Exception as exc:
return self._send(400, {"ok": False, "error": f"richiesta non valida: {exc}"})
print("[bridge] layout " + json.dumps(body, default=str), file=sys.stderr)
return self._send(200, {"ok": True})
if path != "/command":
return self._send(404, {"ok": False, "error": "rotta sconosciuta"}) return self._send(404, {"ok": False, "error": "rotta sconosciuta"})
try: try:
body = self._body() body = self._body()
cmd_id = body.get("id", "") cmd_id = body.get("id", "")
cmd = build_command(cmd_id, body.get("value"), body.get("zone") or 1, cmd = build_command(cmd_id, body.get("value"), body.get("zone") or 1,
self.session.last_states()) self.session.last_states(), body)
except ValueError as exc: except ValueError as exc:
return self._send(400, {"ok": False, "error": str(exc)}) return self._send(400, {"ok": False, "error": str(exc)})
except Exception as exc: except Exception as exc:
+109
View File
@@ -143,6 +143,115 @@ check("termostato: batteria non scarica", d.get("thermostat_battery_low") is Fal
check("senza termostato: nessun errore", check("senza termostato: nessun errore",
b.summarize({k: v for k, v in finto.items() if k != "thermostats"}).get("thermostat_zone") is None) b.summarize({k: v for k, v in finto.items() if k != "thermostats"}).get("thermostat_zone") is None)
# --- 5) comandi che la libreria accetta solo con i loro argomenti ---------- #
# Qui sta la ragione del blocco: il boost senza durata solleva TypeError e il
# bridge risponde "richiesta non valida" all'app.
from pyairahome.commands import CommandBase # noqa: E402
boost = b.build_command("hot_water_boost_on")
check("boost: mezz'ora di default (richiesta dell'utente)",
boost._message.hot_water_boost_duration.seconds == 1800,
f"{boost._message.hot_water_boost_duration.seconds}s")
check("boost: durata scelta in minuti (90 -> 5400s)",
b.build_command("hot_water_boost_on", 90)
._message.hot_water_boost_duration.seconds == 5400)
rifiuta("boost: durata fuori intervallo", "hot_water_boost_on", 5)
rifiuta("boost: durata non numerica", "hot_water_boost_on", "presto")
acs = b.build_command("hot_water_heating_off")
check("ACS spento: un'ora di default", acs._message.time.seconds == 3600,
f"{acs._message.time.seconds}s")
forzato = b.build_command("force_heating_on")
check("forzato: un'ora di default", forzato._message.duration.seconds == 3600,
f"{forzato._message.duration.seconds}s")
assenza = b.build_command("away_mode_on")
m = assenza._message
check("assenza: da adesso a +7 giorni",
abs((m.end_time.seconds - m.current_time.seconds) - 7 * 86400) <= 1,
f"{m.end_time.seconds - m.current_time.seconds}s")
check("assenza: fine dopo l'inizio", m.end_time.seconds > m.current_time.seconds)
check("assenza: casa tenuta a 15 °C", abs(m.target_room_temperature - 15.0) < 1e-6,
f"{m.target_room_temperature}")
rifiuta("assenza: zero giorni", "away_mode_on", 0)
# Nessun comando della whitelist deve restare senza un argomento obbligatorio:
# e' il difetto che ha fermato il boost, e la whitelist e' esattamente la lista
# che l'app puo' chiamare, quindi va provata tutta.
campione = {"set_hot_water_target": 55, "set_heating_setpoint": 20,
"set_cooling_setpoint": 20, "hot_water_boost_on": 60,
"hot_water_heating_off": 60, "away_mode_on": 7,
"force_heating_on": 60}
for cid in b.COMMANDS:
try:
cmd = b.build_command(cid, campione.get(cid), 1, ctx)
# La busta e' quello che la libreria manda davvero: se il comando non
# avesse un nome di campo valido, si romperebbe qui. (I comandi senza
# parametri, come ping, hanno il messaggio interno vuoto: controllare
# quello sarebbe sbagliato.)
busta = CommandBase.build_command(cmd)
byte = len(busta.SerializeToString())
check(f"whitelist: {cid} si costruisce e si incarta",
bool(cmd.get_field()) and byte > 0,
f"campo {cmd.get_field()}, {byte} byte")
except Exception as exc:
check(f"whitelist: {cid} si costruisce e si incarta", False,
f"{type(exc).__name__}: {exc}")
# --- 6) assenza con date, e conto alla rovescia del boost ----------------- #
from datetime import datetime, timedelta, timezone # noqa: E402
from zoneinfo import ZoneInfo # noqa: E402
ROMA = ZoneInfo("Europe/Rome")
def a_roma(t):
return t.ToDatetime(tzinfo=timezone.utc).astimezone(ROMA).strftime("%Y-%m-%d %H:%M")
date_di = b.build_command("away_mode_on", None, 1, ctx,
{"start": "2026-11-01", "end": "2026-11-08"})
m3 = date_di._message
check("assenza con date: inizio = 1 novembre a mezzanotte",
a_roma(m3.current_time) == "2026-11-01 00:00", a_roma(m3.current_time))
check("assenza con date: fine = mezzanotte dell'8 (da li' riparte)",
a_roma(m3.end_time) == "2026-11-08 00:00", a_roma(m3.end_time))
check("assenza con date: casa a 15 °C", abs(m3.target_room_temperature - 15.0) < 1e-6)
rifiuta("assenza: fine prima dell'inizio", "away_mode_on", None, 1, ctx,
{"start": "2026-11-08", "end": "2026-11-01"})
rifiuta("assenza: data illeggibile", "away_mode_on", None, 1, ctx,
{"start": "domani", "end": "2026-11-08"})
rifiuta("assenza: oltre 180 giorni", "away_mode_on", None, 1, ctx,
{"start": "2026-11-01", "end": "2028-11-08"})
fra = (datetime.now(timezone.utc) + timedelta(minutes=20)).isoformat().replace("+00:00", "Z")
check("boost in corso: restano ~1200 secondi",
abs(b.boost_restanti({"hot_water_boost": {"end_time": fra}}) - 1200) <= 3)
check("boost finito: zero secondi",
b.boost_restanti({"hot_water_boost": {"end_time": "2026-01-01T00:00:00Z"}}) == 0)
check("boost mai avviato: zero secondi", b.boost_restanti({}) == 0)
# L'impianto puo' mandare la fine del boost SENZA fuso (ora locale). E' la forma
# che ha fatto rispondere 502 a /summary: la sottrazione con "adesso" (che il
# fuso ce l'ha) solleva TypeError, e l'app resta senza dati ("Nessun dato").
# Il conto alla rovescia deve reggere entrambe le forme.
_fine_naive = (datetime.now(timezone.utc) + timedelta(seconds=1200)).replace(tzinfo=None).isoformat()
check("boost con fine senza fuso (forma dell'impianto: naive UTC)",
abs(b.boost_restanti({"hot_water_boost": {"end_time": _fine_naive}}) - 1200) <= 3)
check("riassunto con fine senza fuso (la forma che dava 502)",
(b.summarize({"hot_water_boost": {"end_time": _fine_naive}}) or {}).get("boost_active") is True)
check("boost con fine fuori orario (fuso diverso)",
b.boost_restanti({"hot_water_boost": {"end_time": "2020-01-01T00:00:00"}}) == 0)
riassunto = b.summarize({"hot_water_boost": {"end_time": fra}})
check("riassunto: dice che il boost e' in corso", riassunto.get("boost_active") is True)
check("riassunto: porta i secondi del boost",
abs(riassunto.get("boost_remaining_seconds", 0) - 1200) <= 3,
str(riassunto.get("boost_remaining_seconds")))
check("riassunto senza boost: spento e zero",
b.summarize({}).get("boost_active") is False
and b.summarize({}).get("boost_remaining_seconds") == 0)
# --- 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")
+430
View File
@@ -231,3 +231,433 @@ andrebbe disegnata a mano.
parsing delle statistiche e alla formattazione di importi e mesi (61/61), versione, parsing delle statistiche e alla formattazione di importi e mesi (61/61), versione,
README e `docs/PROTOCOL.md` aggiornati. README e `docs/PROTOCOL.md` aggiornati.
- Nessun comando nuovo all'impianto: tutto questo è **sola lettura**. - 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,
numeri vecchi o avviso, rosso solo allarme che ferma la macchina), 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; **85/85** dalla 0.6.2, con le verifiche sulla
severità degli allarmi — vedi §10). Stringhe: **191/191** tradotte. Anteprime
rigenerate (nuova `regolazioni.png`) in italiano e inglese.
**Copertina** (`qml/cover/CoverPage.qml`): pallino di stato come in Home, la
temperatura grande si spegne quando i numeri sono vecchi e l'etichetta finale è
diventata "esterna 18,0 °C" (era "est.", sigla che non diceva niente), con la
riga dei numeri vecchi al posto della temperatura esterna quando serve.
**Prima build sul telefono (0.6.0): la Home non si apriva.** Il log diceva
`harbour-aira.qml:9:30: Type MainPage unavailable`, cioè l'app si avviava senza
la pagina iniziale, e non nominava nessun file dei miei. Causa: due assegnazioni
di `Component.onCompleted` nello stesso oggetto (`MainPage.qml`), una vecchia e
una aggiunta con la riga di misura dell'altezza. In Qt 5.6 non è un warning
innocuo: la pagina non si crea più. Il codice è stato unito in una sola
assegnazione e, perché non ricapiti, `tests/qml_check_bindings.py` cerca le
assegnazioni duplicate in tutti i QML (verificato: intercetta il caso
`70fe229`, il codice attuale è pulito). Da questa prima esecuzione sappiamo
anche che il resto dell'avvio funziona: configurazione letta
(`baseUrl=[192.168.3.21:8790]`, token presente), catalogo italiano caricato
(`it_IT`), versione corretta in log.
- Nessun comando nuovo all'impianto: anche questa versione resta **sola
lettura** finché non si prova l'invio dal telefono.
## 10. Dalla prima build sul telefono alla 0.6.4
Il §9 racconta il riordino delle schermate fino alla 0.6.0. Da lì sono venute
quattro versioni piccole, tutte nate da quello che si è visto usare l'app.
**0.6.1 — la Home non si apriva.** La prima build sul telefono ha rivelato il
difetto descritto al §9: due assegnazioni di `Component.onCompleted` nello stesso
oggetto, che in Qt 5.6 impediscono la creazione della pagina. Non basta
correggerlo: `tests/qml_check_bindings.py` ora cerca le assegnazioni duplicate in
tutti i QML, e gira prima della build.
**0.6.2 — il pallino non deve gridare.** Il 10/10 il pallino era rosso per un
avviso innocuo (compressore limitato dalla temperatura esterna): qualunque voce
nell'elenco errori accendeva il rosso. Ora il rosso è riservato a un allarme che
ferma la macchina o di severità CRITICAL/ERROR, l'avviso è ambra, e l'esito
ignoto resta ambra. Il bridge espone `error_severity` e `alarm_stopping` in
`/summary`, così la decisione si prende sui dati e non sul testo del messaggio.
**0.6.3 — l'icona.** Scelta fra quattro proposte disegnate con Pillow
(`scripts/icon_proposals.py`, tutte conservate in `docs/icona/proposte/`): casa
con l'onda calda dentro. È ridisegnata a 344 px e rigenerata alle quattro misure
(86/108/128/172). Disegno originale, nessun logo di terzi: il permesso per nome e
logo Aira è ancora in sospeso.
**0.6.4 — la copertina.** Due righe con l'etichetta davanti, "Casa" e "Acqua",
ognuna con temperatura attuale / impostata. Le frasi lunghe di prima ("ACS
53,4 °C · obiettivo 55 °C") sulla cover, che è stretta, venivano tagliate a metà:
il numero vero spariva proprio dove lo si guarda di sfuggita. Per stare nella
colonna stretta dell'etichetta il valore attuale è più grande e l'obiettivo più
piccolo e attaccato ("51,5/55,0 °C").
**0.6.5 — un nome solo per l'acqua.** Sulla copertina l'acqua si chiama "Acqua
calda" come in Home (era "Acqua"): era l'unico punto in cui la stessa cosa aveva
due nomi.
**0.6.6 — le regolazioni dell'oggetto toccato.** Toccando "Casa" si apriva la
pagina con casa e acqua insieme: chi tocca una lettura si aspetta le regolazioni
di quella, non di tutto l'impianto. Ora la Home dice quale oggetto è stato
toccato e la pagina mostra solo quello — l'acqua porta con sé anche il boost, che
è suo. Due lati della stessa cosa, tenuti insieme da un controllo statico
(`tests/qml_focus_check.py`), perché questa parte della UI non è eseguibile senza
il kit Silica.
**0.6.7 — le icone dei tasti.** I tre tasti delle azioni rapide in Home hanno
un'icona: fiamma per il boost, casa per l'assenza, tre manopole per i comandi.
Non vengono dal tema Sailfish: nell'elenco ufficiale delle icone non c'è nessuna
fiamma e nessuna goccia, e "boost" e "assenza" non hanno un simbolo di sistema.
Sono disegnate con Pillow (`scripts/make_glyphs.py`) in PNG bianche su
trasparente, in `qml/icons/`: il PNG si vede di sicuro dentro il sandbox, l'SVG
dipende dal supporto SVG.
**0.6.8 — le icone dei tasti, misura vera.** Le icone della 0.6.7 erano grosse:
in un tasto largo un terzo di schermo il testo veniva tagliato. Dalla
documentazione Silica risulta che `icon` è un `Image`: senza dimensione prende
quella del file, e il pulsante la riporta all'icona di tema (misurata sullo
screenshot: 64 px, `Theme.iconSizeMedium`), lasciando 41 px di testo. Ora il
tasto dichiara `Theme.iconSizeExtraSmall` e i glifi riempiono il riquadro. Con
la geometria misurata sul telefono (161 px di tasto, rientri 16, distacco 24)
le etichette **Assenza**, **Torna**, **Altro** e le inglesi **Away**, **Home**,
**More** ci stanno con margine; "Comandi" (8 lettere) no, quindi il tasto dice
"Altro" e la pagina resta "Comandi impianto". Prova ripetibile:
`tests/label_fit_check.py` (le etichette si leggono dai QML e dal catalogo: se
una si allunga il controllo lo segnala) e `docs/icona/tasti-alla-misura-vera.png`
per l'occhio. Il pulsante Silica non espone il carattere, quindi non si può
rimpicciolire il testo: l'unica leva è l'icona.
**0.6.9 — l'azione sulla copertina (P8).** Sulla copertina c'è l'icona del
boost: si avvia il boost dell'acqua calda senza aprire l'app. Una sola azione,
perché la documentazione Silica dice che due si possono avere ma che lo stile
delle app di sistema ne mostra una. Il componente esiste: `CoverActionList` e
`CoverAction` sono nominati nella guida Silica che sta nell'SDK, e
`CoverBackground` eredita da `Cover`, quindi l'elenco dichiarato lì si attacca
(non serve avvolgere la copertina in un `Cover`). L'icona si dichiara con
`iconSource`, non con `icon.source` del pulsante `Button`: sbagliarlo non dà
errore, semplicemente l'icona non compare. La riga in fondo (esterna o «non
aggiornato») si ancora a `Cover.coverActionArea`, così l'icona dell'azione non
la copre; se l'area non c'è la riga resta dov'era. Nuovo controllo statico
`tests/cover_action_check.py`: elenco figlio diretto, una sola azione, icona che
esiste, e l'id del comando confrontato con la tabella del bridge — un id
sbagliato sarebbe un 400 scoperto solo sul telefono.
**Il boost non partiva: mancava un argomento (correzione del bridge).** Il primo
tocco sul boost ha mostrato in rosso, in fondo alla Home, *«richiesta non
valida: ActivateHotWaterBoosting.__init__() missing 1 required positional
argument: 'hot_water_boost_duration'»*. Non era l'impianto a rifiutare: il
comando non veniva nemmeno costruito. La libreria pretende un argomento per
quattro comandi della whitelist — `hot_water_boost_on`, `hot_water_heating_off`
e `force_heating_on` una durata, `away_mode_on` inizio, fine e la temperatura da
tenere in casa — e il bridge li chiamava senza. Il boost è solo il primo che è
stato toccato: anche l'assenza, il tasto accanto, sarebbe fallita così. Ora
ognuno ha il suo ramo in `build_command`, con i default in cima al file (60
minuti per le tre durate, 7 giorni e 15 °C per l'assenza) e un `value`
opzionale che l'app può mandare. `test_bridge_commands.py` ora costruisce
**tutti** i comandi della whitelist e li incarta come li manda la libreria: se
uno resta senza argomento obbligatorio si vede lì (74/74). Provato dal vivo con
un `ping` (200, con la ricezione dall'impianto) e con un boost fuori intervallo,
respinto con 400 **prima** di partire.
## 11. Assenza con le date e conto alla rovescia del boost — v0.7.0
**Richieste dell'utente (11/10/2026), da applicare come dati:**
1. il **boost** deve essere di **30 minuti**;
2. l'**assenza** si imposta da un **popup con data di inizio e fine**;
3. durante l'assenza l'impianto **tiene la casa a 15 °C** e **riprende
normalmente il giorno prima del rientro**;
4. mentre il boost è in corso si deve vedere un **conto alla rovescia da 30:00 a
00:00**.
## 12. I tasti delle azioni rapide e la riga che li copriva — v0.7.1
Carlo: *"Quando premo un bottone la riga sotto si disallinea coprendo i
pulsanti"*.
Non era la riga a spostarsi: era il **tasto** a essere piu' basso del suo
contenuto. In Silica `Button` dichiara `height: implicitHeight` e
`implicitHeight: Theme.itemSizeExtraSmall` (~56 px), un'altezza pensata per
un'icona *accanto* a un'etichetta corta. I tre tasti delle azioni rapide hanno
icona **e** etichetta (ingombro visibile ~110-160 px, quello che io avevo
misurato come "tasto 161 px"): il contenuto esce dalla scatola, e la riga
successiva della colonna -- le letture di contorno -- viene messa subito sotto
la scatola, cioe' **sopra** l'etichetta che sporge. Da li' l'impressione che si
"disallinei".
**Come è stato fatto (0.7.1).**
- i tre tasti dichiarano `height: Theme.itemSizeMedium` (`Button.qml` del target
Sailfish letto dentro il motore di build, non indovinato);
- `tests/label_fit_check.py` adesso pretende l'altezza dichiarata su ogni tasto
che porta icona e testo: senza, il tema ne da' ~56 px contro ~110 di
contenuto;
- il registro dell'avvio (dopo un secondo) elenca **posizione e altezza di ogni
riga della Home**, cosi' se la disposizione non torna si legge dal registro
quale riga finisce dove invece di dedurlo da uno screenshot.
## 13. Il tasto del boost diventa Stop, e la riga che spostava i tasti — v0.7.2
Carlo: *"Il boost funziona ma la scritta sposta in basso i pulsanti facendoli
coprire. Il pulsante di avvio boost dovrebbe diventare stop boost se il boost e'
attivo"*.
Due cose distinte, e la prima va detta perche' evita confusione: la riga che
**copriva** i tasti era il difetto di 0.7.1 (il tasto piu' basso del contenuto),
che al momento della segnalazione non era ancora installata sul telefono -- il
bridge si aggiorna da solo sul server, l'app no. La riga che invece li
**spostava** era nuova e vera: il conto alla rovescia prendeva il posto del
suggerimento ma con un carattere piu' grande (`fontSizeSmall` contro
`fontSizeExtraSmall`), quindi la Home cresceva di una decina di pixel e tutto
scendeva. Ora e' una riga sola, di altezza fissa, con il carattere del
suggerimento: sostituirsi non sposta piu' niente.
**Come è stato fatto (0.7.2).**
- tasto del boost condizionale (`Stop` con il glifo di stop quando `boostActive`)
**anche in copertina**: una copertina che riavvia il boost quando vuoi fermarlo
e' peggio di un tasto che non fa niente;
- `quick-boost-stop.png` disegnato in casa con `scripts/make_glyphs.py`, come gli
altri tre glifi;
- "Stop boost" per intero non ci sta accanto all'icona (92 px contro 73): resta
`Stop`, il senso lo porta il glifo. E' `tests/label_fit_check.py` a dirlo, che
adesso legge anche le etichette condizionali;
- `tests/cover_action_check.py` legge tutti i comandi dell'azione e tutte le
icone citate: prima si aspettava un solo letterale e su un invio condizionale
segnalava un problema inesistente.
## 14. La copertina: freccia invece di barra, ed "Esterno" maiuscolo — v0.7.3
Carlo: *"La copertina ha ancora le vecchie scritte"*. Alle mie domande ha
risposto: tutte e tre le righe (Casa, Acqua calda, esterna), riscritte corte con
la freccia.
- `obiettivo()` nella copertina restituisce `" → 19,0 °C"` invece di `"/19,0 °C"`:
la barra era la scrittura vecchia, la freccia si legge di sfuggita anche su una
tessera stretta;
- l'italiano di `outside %1 °C` era `esterna %1 °C` (minuscolo e femminile,
residuo di quando la riga era un'altra frase): ora `Esterno %1 °C`, come nella
Home. Verificato leggendo il catalogo, non a memoria;
- le etichette `Casa` e `Acqua calda` restano: erano gia' quelle scelte, era il
*formato* a essere vecchio.
Due domande prima di toccare, e non e' stato tempo perso: "vecchie scritte" poteva
voler dire il formato, le parole, oppure che sul telefono c'era ancora una
versione vecchia dell'app. Chiedere ha evitato di rifare l'RPM per niente.
## 15. La versione a schermo — v0.7.4
Carlo: *"La scritta continua a sovrapporsi ai pulsanti"*, mentre la correzione
era gia' nella 0.7.1. Non c'era modo di sapere quale build stesse girando: la
versione non era scritta da nessuna parte nell'app.
- `appVersion` tra le context property di `main.cpp` (`QCoreApplication::applicationVersion()`);
- `SettingsPage.qml`: riga `qsTr("Version %1").arg(appVersion)` in fondo.
Prima di aggiungerla ho **misurato** la disposizione invece di dedurla:
riprodotta la colonna della Home in un file QML con QtQuick puro e fatta girare
con `qml` e `QT_QPA_PLATFORM=offscreen` nel container Debian (vedi la skill
`sailfish-app-development`). Con la scatola del tasto a 56 px — la 0.7.0 — la
riga di contorno cade a y=240, che sta dentro i tasti (176..286): passava sopra,
come diceva Carlo. Con la scatola a 152 px cade a y=336, sotto: nessuna
sovrapposizione. La correzione era giusta; mancava l'app aggiornata.
## 16. Le misure della Home al bridge — v0.7.5
Carlo: *"È la 0.7.4 ma il problema rimane"*. Fino a qui avevo **dedotto** la
disposizione: dal vero `Button.qml`, da uno screenshot letto a occhio, da una
replica della colonna che mi ero costruito io. Tutte e tre le volte la deduzione
ha retto in teoria e ha sbagliato sui fatti, e ogni giro costava un RPM, una
copia sul telefono e una delusione.
- `MainPage.qml`: il timer `misura` (gia' presente dalla 0.7.1) non si limita piu'
a scrivere nel registro del telefono — che nessuno puo' leggere — ma manda le
stesse misure al bridge con `api.reportLayout()`: versione, altezza dello
schermo, altezza del contenuto e `y`/`h`/testo di **ogni** riga della Home;
- `ApiClient::reportLayout()`: un POST a `/layout`, senza attendere risposta;
- `aira_bridge.py`: la rotta `/layout` (protetta da token come le altre) scrive
il corpo nel proprio registro: `[bridge] layout {...}`.
Così la diagnosi smette di passare da un essere umano: Carlo apre l'app, le
coordinate arrivano nel registro del bridge, e l'agente le legge. Verificato
prima di consegnarlo: POST con token → 200 e riga nel registro, senza token →
401.
## 17. Il banner del remorse sui tasti rapidi — v0.7.6
Carlo: *"Il problema c'e' quando premo un pulsante"*. Meta' della diagnosi stava
in quella frase, e nessuno l'aveva ancora detta: succede **alla pressione**.
In Silica `RemorseItem` eredita da `RemorseBase`, che e' uno **`SwipeItem`** e
alla `execute()` fa `parent = item.parent`: **si ri-parenta dentro il contenitore
dell'oggetto premuto**. I tasti rapidi della Home stanno in una `Row` di tre, e
il riquadro del remorse non ha la larghezza del tasto: il suo `BannerBackground`
(il messaggio piu' il conto alla rovescia) viene disegnato a margini fissi e
finisce **sopra i tasti vicini**. Ecco il "copre i pulsanti": non la fila che si
sposta, ma un banner disegnato sopra.
- `MainPage.qml`: tolti i tre `remorse.execute(...)` (boost, assenza-off). Il
messaggio lo mostra gia' la `NoticeBar` in fondo: era un doppione *e* il danno.
- `NoticeBar.qml`: altezza **fissa** a una riga (`elide`, non `wrap`) e proprieta'
`banda`, cosi' le pagine possono riservarle lo spazio;
- le cinque pagine con la barra (`MainPage`, `CommandsPage`, `DataPage`,
`PlantPage`, `SetpointsPage`): `anchors.bottomMargin: notice.banda` sulla
`SilicaFlickable` — il messaggio non copre piu' il contenuto e non lo sposta
quando compare.
Lezione di metodo: il comportamento vero dei componenti Silica sta nel loro
sorgente (`<target>/usr/lib64/qt5/qml/Sailfish/Silica/…`), non in quello che
sembra leggendo il proprio QML. `RemorseBase.qml` ha dato la risposta in un
minuto, dopo tre diagnosi dedotte e sbagliate.
## 18. Un solo tasto per il boost, con lo stato della Home — v0.7.7
Carlo: *"Unisci i pulsanti boost in modo che ce ne sia solo uno che cambia lo
stato. Lo stato deve essere coerente con la home"*.
In `SetpointsPage`, sezione *Acqua calda sanitaria*, c'erano **due** pulsanti —
«Avvia boost ACS» e «Ferma boost ACS» — entrambi sempre attivi e nessuno dei due
che dicesse cosa sta facendo l'impianto: si poteva chiedere di fermare un boost
che non c'era, e la pagina poteva contraddire la Home, che invece il boost lo
mostra e lo ferma con lo stesso tasto.
- un `ActionButton` solo, con `text` e comando scelti da `api.boostActive` — **lo
stesso dato della Home**, quindi le due schermate non possono divergere;
- riga `Boost running · %1 left` con `api.boostCountdown` quando il boost corre,
come il conto alla rovescia della Home;
- senza `confirmText`: la conferma la da' il messaggio in fondo, come in Home
(il remorse resta solo dove serve davvero, sui comandi a tutta larghezza).
## 19. Le diciture della copertina — v0.7.8
Carlo: *"Nella cover scrivi T. casa, T. acqua e Temperatura esterna"*.
La copertina usava le stesse stringhe della Home (`qsTr("House")`,
`qsTr("Hot water")`): cambiarne la traduzione avrebbe cambiato **anche** la Home,
che invece deve restare «Casa» e «Acqua calda» (sua richiesta esplicita, solo per
la copertina). Servono quindi stringhe **proprie della copertina**:
- `T. house` → italiano `T. casa`;
- `T. water` → italiano `T. acqua`;
- `Outside temperature %1 °C` (era `outside %1 °C`, usata solo qui) → italiano
`Temperatura esterna %1 °C`.
Le fonti restano in inglese, come tutto il catalogo; l'italiano sta nel `.ts`.
La Home non cambia.
## 20. Via la barra di avanzamento dell'acqua — v0.7.9
Carlo, dopo aver chiesto cosa fosse: *"Toglila pure"*.
La barra diceva quanto l'acqua è carica verso l'obiettivo **partendo da 10 °C**
(non da zero), quindi stava quasi sempre al 99% e non aggiungeva informazione:
una striscia che non cambia non dice niente, e non si spiegava da sola (servita
la domanda "cos'è la striscia bianca sotto l'acqua calda?").
- `ReadingCell.qml`: tolti `progress`, `showBar` e il `ProgressBar` (era usato
solo dalla Home, quindi sparisce dal componente);
- `MainPage.qml`: tolto `progress: page.waterProgress()` / `showBar: true` e la
funzione `waterProgress()`, che non serviva più.
Regola: un indicatore permanente che resta sempre allo stesso valore è rumore.
Se un giorno servisse, va ancorato a qualcosa che cambia davvero.
## 22. Il cambio lingua — v0.7.10
Carlo: *"Tutto perfetto. Solo il cambio lingua non sembra funzionare"*.
Misurato invece che dedotto, con un banco vero in container (Qt 5.15, non 5.6:
differenza che conta). Esiti:
1. installare il traduttore **non** ri-traduce le stringhe gia' a schermo
(conferma: Qt 5.6 non ha `retranslate()`);
2. `setSource()` con la **stessa** url **ri-traduce** (House -> Casa): la
tecnica dell'app e' quella giusta;
3. il catalogo si trova **anche** con locale `it_IT`: `load()` ripiega sul nome
accorciato `it`; e nel pacchetto sta in
`/usr/share/harbour-aira/translations/harbour-aira-it.qm`, la cartella che
l'app cerca.
Restava un'unica differenza fra banco e telefono: la versione di Qt. La
ricarica si affidava a un comportamento di `setSource(stessa url)` che cambia
fra versioni, quindi ora si fa in due mosse — radice scaricata con url vuota,
poi ricaricata: cosi' il componente **deve** essere ricreato, su qualunque Qt.
E per non indovinare piu': la Home manda al `/layout` anche `lingua`, `locale` e
`catalogo`, cosi' il registro del bridge dice cosa sta usando davvero il
telefono.
## 23. Conseguenze dell'uso reale (0.7.0)
Deciso con l'uso, non in anticipo:
- il **boost** è di **mezz'ora** (era un'ora);
- l'**assenza** non è più "N giorni": si scelgono **due date** in un popup, e la
casa resta a **15 °C**;
- mentre il boost corre, **conto alla rovescia** in Home e in copertina.
Resta aperto: il popup chiede *inizio* e *fine*, dove "fine" è il giorno in cui
l'impianto ricomincia a scaldare (nell'uso di Carlo: il giorno prima del rientro).
Se preferisce indicare il *rientro* e far togliere un giorno all'app, è una riga.
**Come è stato fatto (0.7.0).**
- Bridge: i comandi con durata hanno il loro ramo e i default in cima al file;
per l'assenza `_periodo_assenza` legge `start` ed `end` (AAAA-MM-GG) e li
traduce in `current_time`/`end_time`. La fine è la **mezzanotte del giorno
indicato nel fuso dell'impianto** (`PLANT_TZ = Europe/Rome`, dichiarato dallo
stato): da lì la pompa riprende il programma. Il fuso della *macchina* non si
usa: alle date di cambio ora sbaglia di un'ora (lo hanno visto i test).
- Bridge: `/summary` espone `boost_active` e `boost_remaining_seconds`, calcolati
dalla fine del boost letta dall'impianto (`hot_water_boost.end_time`). Il conto
alla rovescia dell'app si basa su quelli, non su quando l'app ha visto partire
il comando: se il boost l'ha avviato il telefono di casa o l'app ufficiale, il
numero è comunque giusto.
- App: `AwayDialog.qml` (Dialog + due `ValueButton` + `DatePickerDialog`, come
nell'esempio della guida Silica), il conto alla rovescia in Home sotto le
letture e in copertina al posto dell'obiettivo dell'acqua calda. La formattazione
`mm:ss` sta in `AiraJson::countdown` (C++, verificata in `tests/core_test.cpp`),
i secondi li fa scorrere un timer del client, che si riallinea a ogni rilettura.
- Catalogo: `lupdate harbour-aira.pro` leggeva solo i C++ e marcava 151 voci QML
come obsolete (l'italiano sarebbe rimasto a metà senza accorgersene). Ora:
`lupdate src qml -ts translations/harbour-aira-it.ts`. 197 stringhe, 0 non
tradotte.
+6 -4
View File
@@ -37,6 +37,8 @@ Valori pronti per la dashboard.
"manual_mode": false, "manual_mode": false,
"force_heating": false, "force_heating": false,
"force_heating_remaining": "0s", "force_heating_remaining": "0s",
"boost_active": false,
"boost_remaining_seconds": 0,
"inline_heater": false, "inline_heater": false,
"hot_water_heating": true, "hot_water_heating": true,
"configured_modes": "PUMP_MODE_STATE_HEATING", "configured_modes": "PUMP_MODE_STATE_HEATING",
@@ -153,11 +155,11 @@ Risposta: `{"ok": true, "id": "...", "result": {...}}`.
| `set_hot_water_target` | 45–60 | temperatura ACS obiettivo | | `set_hot_water_target` | 45–60 | temperatura ACS obiettivo |
| `set_heating_setpoint` | 5–30 | temperatura obiettivo della casa (zona, default 1) | | `set_heating_setpoint` | 5–30 | temperatura obiettivo della casa (zona, default 1) |
| `set_cooling_setpoint` | 5–30 | temperatura obiettivo del raffrescamento (zona, default 1) | | `set_cooling_setpoint` | 5–30 | temperatura obiettivo del raffrescamento (zona, default 1) |
| `hot_water_boost_on` / `hot_water_boost_off` | — | boost ACS una tantum | | `hot_water_boost_on` / `hot_water_boost_off` | minuti, 15–240 (default 30) | boost ACS una tantum |
| `hot_water_heating_on` / `hot_water_heating_off` | — | riscaldamento ACS | | `hot_water_heating_on` / `hot_water_heating_off` | minuti, 15–1440 (default 60) | riscaldamento ACS |
| `away_mode_on` / `away_mode_off` | — | modalità assenza | | `away_mode_on` / `away_mode_off` | `start` ed `end`, AAAA-MM-GG | assenza con date; la casa resta a 15 °C |
| `night_mode_1h` | — | night mode per un'ora | | `night_mode_1h` | — | night mode per un'ora |
| `force_heating_on` / `force_heating_off` | — | riscaldamento forzato | | `force_heating_on` / `force_heating_off` | minuti, 15–240 (default 60) | riscaldamento forzato |
| `heating_on` / `heating_off` | — | funzione riscaldamento | | `heating_on` / `heating_off` | — | funzione riscaldamento |
| `legionella_cycle` | — | ciclo antilegionella | | `legionella_cycle` | — | ciclo antilegionella |
+47 -8
View File
@@ -8,6 +8,41 @@ Immagini di questo studio (schemi, non anteprime): `docs/ux/`.
--- ---
## 0. Cosa è stato deciso e cosa è stato fatto (0.6.0)
**Prima build sul telefono:** la Home non si apriva — `Type MainPage unavailable`.
Causa: due assegnazioni di `Component.onCompleted` nello stesso oggetto, che in
Qt 5.6 impediscono la creazione della pagina. Corretto nella 0.6.1 e coperto da
`tests/qml_check_bindings.py`. Tutto il resto dell'avvio funziona (configurazione
letta, catalogo italiano caricato). Il primo giro sul device ha quindi già dato
la sua risposta più importante: la riga `harbour-aira home: contenuto … schermo …`
arriva nei log e si può leggere.
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: **fatte** in 0.6.9 (il boost). Il componente c'è: la guida Silica dell'SDK nomina `CoverActionList` e `CoverAction`.
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.
Fatto in 0.6.9: P8 (azione boost sulla copertina), con `tests/cover_action_check.py`.
Verifiche fatte qui: **85 verifiche del nucleo** (erano 79), `qmllint` pulito su
tutti i file QML, 191/191 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 (la riga `harbour-aira home: contenuto … schermo …` è già nel codice,
basta leggerla nei log) e l'uso con una mano sola.
---
## 1. Metodo ## 1. Metodo
Tre riferimenti, in quest'ordine: Tre riferimenti, in quest'ordine:
@@ -134,8 +169,11 @@ I richiami numerati dell'immagine:
Solo navigazione fra pagine, una voce per pagina: i pulsanti in fondo alla Solo navigazione fra pagine, una voce per pagina: i pulsanti in fondo alla
home non ci sono più (risolve P4). home non ci sono più (risolve P4).
2. **Stato in una riga**: pallino colorato + "Tutto regolare · Automatico" + 2. **Stato in una riga**: pallino colorato + "Tutto regolare · Automatico" +
ora dell'ultimo aggiornamento. Verde regolare, ambra attenzione, rosso ora dell'ultimo aggiornamento. Verde regolare, ambra attenzione (modo
errore o non raggiungibile (risolve P6). La frase estesa dell'app ufficiale attivo, numeri vecchi o avviso della pompa), rosso solo un allarme che
ferma la macchina o non raggiungibile (risolve P6 — la distinzione fra
avviso e allarme è arrivata il 10/10: prima qualunque voce nell'elenco
errori accendeva il rosso). La frase estesa dell'app ufficiale
("Relax: la pompa funziona regolarmente") è bella ma occupa 2 righe in cima: ("Relax: la pompa funziona regolarmente") è bella ma occupa 2 righe in cima:
sta meglio nella pagina Impianto (decisione 1). sta meglio nella pagina Impianto (decisione 1).
3. **Due letture grandi, affiancate**: Casa 22,8 °C (obiettivo 19,0) e Acqua 3. **Due letture grandi, affiancate**: Casa 22,8 °C (obiettivo 19,0) e Acqua
@@ -204,9 +242,10 @@ Resta com'è nelle letture (casa grande, ACS, esterna), con due migliorie basse:
- Dicitura chiara: "esterna" invece di "est." (P7). - Dicitura chiara: "esterna" invece di "est." (P7).
- **Azioni sulla copertina**: Silica ha `CoverAction`/`CoverActionList` - **Azioni sulla copertina**: Silica ha `CoverAction`/`CoverActionList`
documentati ("un'azione per una copertina"). Una o due icone — boost e documentati ("un'azione per una copertina"). Fatto in 0.6.9 con **una** icona —
assenza — permettono di agire senza aprire l'app (P8). È rifinitura: da il boost — perché la documentazione dice che due si possono avere ma che lo
provare sul kit prima di contarla, come ultimo passo. stile delle app di sistema ne mostra una. Si può aggiungere l'assenza quando
serve. Attenzione: qui l'icona è `iconSource`, non `icon.source` del `Button`.
### 4.6 Feedback e stato non aggiornato (P2, P6) ### 4.6 Feedback e stato non aggiornato (P2, P6)
@@ -298,7 +337,7 @@ davvero: **quanti tocchi per T2 e T3**, prima e dopo.
| Rischio | Come si governa | | Rischio | Come si governa |
|---|---| |---|---|
| `TextSwitch` documentato ma non istanziabile sul kit 5.1 | Provarlo in una pagina di prova alla prima build; ripiego: si resta sui pulsanti (già funzionanti) | | `TextSwitch` documentato ma non istanziabile sul kit 5.1 | Provarlo in una pagina di prova alla prima build; ripiego: si resta sui pulsanti (già funzionanti) |
| `CoverAction` idem | È l'ultimo passo; se non c'è, la copertina resta com'è | | `CoverAction` | C'è: nominato nella guida Silica dell'SDK. Usato in 0.6.9, con `tests/cover_action_check.py` |
| Le anteprime non rappresentano lo scroll reale | La riga di log con `contentHeight` dà la misura vera | | Le anteprime non rappresentano lo scroll reale | La riga di log con `contentHeight` dà la misura vera |
| Un interruttore che anticipa lo stato (bug classico) | `checked` legato ai dati del bridge, non al tocco (§4.4) | | Un interruttore che anticipa lo stato (bug classico) | `checked` legato ai dati del bridge, non al tocco (§4.4) |
| Il cambio lingua ricarica la radice (Qt 5.6) | Nessun impatto: già così | | Il cambio lingua ricarica la radice (Qt 5.6) | Nessun impatto: già così |
@@ -325,5 +364,5 @@ una build e un controllo tuo sul telefono.
2. **"Altri comandi" → "Comandi impianto"**: rinominare? 2. **"Altri comandi" → "Comandi impianto"**: rinominare?
3. **Azioni rapide**: pulsanti visibili sulla Home (proposta) o menù dal basso 3. **Azioni rapide**: pulsanti visibili sulla Home (proposta) o menù dal basso
all'altezza del pollice? all'altezza del pollice?
4. **Copertina con le azioni**: la facciamo (dopo aver verificato `CoverAction` 4. **Copertina con le azioni**: fatto in 0.6.9 — il boost sulla copertina. Se
sul kit) o resta di sola lettura? serve, si aggiunge l'assenza (una sola per lo stile di sistema).
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: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 48 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: 30 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 48 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+47
View File
@@ -0,0 +1,47 @@
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
}
// Altezza FISSA, una riga sola: le pagine riservano questo spazio sotto la
// zona che scorre (`anchors.bottomMargin: notice.banda`), cosi' il messaggio
// non copre mai il contenuto e non sposta niente quando compare.
readonly property real banda: Theme.fontSizeSmall * 2.2 + 2 * Theme.paddingMedium
height: banda
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
maximumLineCount: 1
elide: Text.ElideRight
}
}
+55
View File
@@ -0,0 +1,55 @@
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 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
}
}
}
+143 -20
View File
@@ -3,6 +3,12 @@ import Sailfish.Silica 1.0
// Copertina: legge api.summary (context property C++), quindi non dipende // Copertina: legge api.summary (context property C++), quindi non dipende
// dallo scope delle property della pagina principale. // dallo scope delle property della pagina principale.
//
// Due righe con l'etichetta davanti — "Casa" e "Acqua calda" — ognuna con il
// valore attuale e l'obiettivo dopo la freccia ("51,5 → 55,0 °C"): l'etichetta
// dell'acqua e' lunga e la cover e' stretta, percio' il valore attuale sta piu'
// grande e l'obiettivo piu' piccolo, invece di due numeri della stessa taglia
// che costringerebbero a tagliare.
CoverBackground { CoverBackground {
id: cover id: cover
@@ -11,54 +17,171 @@ CoverBackground {
return api.formatValue(v) return api.formatValue(v)
} }
Label { // "\u2192 19,0 °C": l'obiettivo dopo la freccia, attaccato al valore attuale.
id: titleLabel // Scelto da Carlo: "22,5/19,0 °C" era la scrittura vecchia, e la freccia si
// legge a colpo d'occhio anche di sfuggita.
function obiettivo(key) {
return " \u2192 " + cover.t(key) + " °C"
}
// Colore del pallino: gli stessi criteri della Home (vedi MainPage.qml).
// Un dato vecchio e' ambra come un modo attivo: un dato vecchio non e' un
// dato buono, e la copertina e' il posto dove si guarda di sfuggita.
// Rosso solo per un allarme che ferma la macchina (livello 3): un avviso
// (livello 2) e' ambra.
function statusColor() {
if (!api.hasData || api.stale)
return "orange"
var livello = api.statusLevel()
if (livello === 3)
return Theme.errorColor
if (livello === 2)
return "#ffb300"
if (livello === 1)
return Theme.highlightColor
return "#4caf50"
}
Row {
id: intestazione
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: Theme.paddingLarge anchors.topMargin: Theme.paddingLarge
spacing: Theme.paddingSmall
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: Theme.paddingSmall
height: width
radius: width / 2
color: cover.statusColor()
}
Label {
text: "Aira" text: "Aira"
color: Theme.primaryColor color: Theme.primaryColor
font.pixelSize: Theme.fontSizeLarge font.pixelSize: Theme.fontSizeLarge
} }
}
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - 2 * Theme.paddingLarge width: parent.width - 2 * Theme.paddingMedium
spacing: Theme.paddingMedium
// ---- casa ---------------------------------------------------------- //
Row {
id: rigaCasa
width: parent.width
spacing: Theme.paddingSmall spacing: Theme.paddingSmall
Label { Label {
width: parent.width text: qsTr("T. house")
horizontalAlignment: Text.AlignHCenter color: Theme.secondaryColor
text: cover.t("zone1_temperature") + " °C" font.pixelSize: Theme.fontSizeExtraSmall
color: Theme.primaryColor width: rigaCasa.width * 0.42
font.pixelSize: Theme.fontSizeHuge anchors.verticalCenter: parent.verticalCenter
}
Row {
width: rigaCasa.width * 0.58 - rigaCasa.spacing
spacing: 0
anchors.verticalCenter: parent.verticalCenter
Label {
id: casaOra
// Spento quando i numeri sono vecchi: non deve sembrare fresco.
text: cover.t("zone1_temperature")
color: api.stale ? Theme.secondaryColor : Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
font.bold: true
} }
Label { Label {
width: parent.width text: cover.obiettivo("zone1_setpoint")
horizontalAlignment: Text.AlignHCenter
text: qsTr("house · target %1 °C").arg(cover.t("zone1_setpoint"))
color: Theme.secondaryColor color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall font.pixelSize: Theme.fontSizeExtraSmall
truncationMode: TruncationMode.Fade width: parent.width - casaOra.width
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
}
}
// ---- acqua calda --------------------------------------------------- //
Row {
id: rigaAcqua
width: parent.width
spacing: Theme.paddingSmall
Label {
text: qsTr("T. water")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
width: rigaAcqua.width * 0.42
anchors.verticalCenter: parent.verticalCenter
}
Row {
width: rigaAcqua.width * 0.58 - rigaAcqua.spacing
spacing: 0
anchors.verticalCenter: parent.verticalCenter
Label {
id: acquaOra
text: cover.t("dhw_current")
color: api.stale ? Theme.secondaryColor : Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
font.bold: true
} }
Label { Label {
width: parent.width // Col boost in corso questo posto lo prende il conto alla
horizontalAlignment: Text.AlignHCenter // rovescia: piu' utile del valore obiettivo.
text: qsTr("DHW %1 °C · target %2 °C").arg(cover.t("dhw_current")).arg(cover.t("dhw_target")) text: api.boostActive
color: Theme.secondaryColor ? qsTr("boost %1").arg(api.boostCountdown)
: cover.obiettivo("dhw_target")
color: api.boostActive ? Theme.highlightColor : Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall font.pixelSize: Theme.fontSizeExtraSmall
truncationMode: TruncationMode.Fade width: parent.width - acquaOra.width
elide: Text.ElideRight
anchors.verticalCenter: parent.verticalCenter
}
}
} }
} }
Label { Label {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom // Sopra l'area delle azioni: quando l'azione c'e', Silica disegna li'
// l'icona del boost, che non deve coprire questa riga. Se l'area non
// c'e' la riga resta attaccata al fondo, come prima.
anchors.bottom: cover.coverActionArea ? cover.coverActionArea.top : parent.bottom
anchors.bottomMargin: Theme.paddingMedium anchors.bottomMargin: Theme.paddingMedium
text: qsTr("outdoor %1 °C").arg(cover.t("outdoor_temperature")) // Qui c'e' posto per dire che i numeri non sono freschi.
color: Theme.secondaryColor text: api.stale
? qsTr("Not updated · last %1").arg(api.lastUpdate)
: qsTr("Outside temperature %1 °C").arg(cover.t("outdoor_temperature"))
color: api.stale ? "orange" : Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall font.pixelSize: Theme.fontSizeExtraSmall
truncationMode: TruncationMode.Fade
width: parent.width - 2 * Theme.paddingMedium
}
// ---- azione sulla copertina (P8) --------------------------------------- //
// Agire senza aprire l'app: il boost dell'acqua calda. Una sola, perche' la
// documentazione Silica dice che due azioni si possono avere ma che lo stile
// delle app di sistema ne mostra una. CoverBackground eredita da Cover,
// quindi l'elenco si attacca: e' il posto documentato dove dichiararlo.
// Il componente e' in Sailfish.Silica 1.0 (verificato nella guida dell'SDK).
CoverActionList {
CoverAction {
// Anche dalla copertina il tasto ferma quando il boost e' in corso:
// altrimenti un tocco per fermarlo ne farebbe ripartire un altro.
iconSource: api.boostActive ? "../icons/quick-boost-stop.png"
: "../icons/quick-boost.png"
onTriggered: api.sendCommand(api.boostActive ? "hot_water_boost_off"
: "hot_water_boost_on")
}
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+55
View File
@@ -0,0 +1,55 @@
import QtQuick 2.6
import Sailfish.Silica 1.0
// Assenza con le date (richiesta dell'utente, 11/10/2026): si scelgono inizio e
// fine e il comando parte solo quando si accetta. La "fine" e' il giorno in cui
// l'impianto ricomincia a scaldare: nell'uso di Carlo, il giorno prima del
// rientro. Durante l'assenza la casa resta a 15 °C.
Dialog {
id: away
property date inizio: new Date()
property date fine: new Date(Date.now() + 7 * 24 * 3600000)
// Non si accetta una fine che non viene dopo l'inizio: il bridge la
// respingerebbe comunque, ed e' meglio non far partire il comando.
canAccept: away.fine > away.inizio
Column {
width: parent.width
DialogHeader {
title: qsTr("Away mode")
acceptText: qsTr("Activate")
}
ValueButton {
label: qsTr("From")
value: Qt.formatDate(away.inizio, Qt.SystemLocaleShortDate)
onClicked: {
var scelta = pageStack.push("Sailfish.Silica.DatePickerDialog",
{ "date": away.inizio })
scelta.accepted.connect(function() { away.inizio = scelta.date })
}
}
ValueButton {
label: qsTr("To")
value: Qt.formatDate(away.fine, Qt.SystemLocaleShortDate)
onClicked: {
var scelta = pageStack.push("Sailfish.Silica.DatePickerDialog",
{ "date": away.fine })
scelta.accepted.connect(function() { away.fine = scelta.date })
}
}
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("The house stays at 15 °C and starts heating again on the day chosen as \"To\".")
wrapMode: Text.WordWrap
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
}
}
}
+116 -109
View File
@@ -2,90 +2,144 @@ import QtQuick 2.2
import Sailfish.Silica 1.0 import Sailfish.Silica 1.0
import "../components" import "../components"
// Comandi secondari: quelli che non stanno nella home. In cima lo stato // Comandi impianto: i comandi che non stanno nella Home.
// attuale, cosi' non si preme alla cieca. //
// 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 { Page {
id: page id: page
allowedOrientations: Orientation.All allowedOrientations: Orientation.All
property string notice: ""
property bool noticeIsError: false
function raw(key) { function raw(key) {
return api.summary[key] return api.summary[key]
} }
function flag(key) { // La funzione riscaldamento e' abilitata quando i modi configurati della
return page.raw(key) ? qsTr("yes") : qsTr("no") // pompa la comprendono ("PUMP_MODE_STATE_HEATING").
function heatingEnabled() {
return String(page.raw("configured_modes") || "").indexOf("HEATING") >= 0
} }
function stato(key) { Component.onCompleted: {
return api.modeLabel(page.raw(key)) if (api.configured)
api.refresh()
} }
function showNotice(msg, isError) { // Fuori dai pulsanti/interruttori: dentro finirebbero nel loro contentItem.
page.notice = msg RemorseItem { id: remorse }
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)
}
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
// Banda riservata al messaggio in fondo: cosi' non copre il contenuto.
anchors.bottomMargin: notice.banda
contentHeight: col.height + Theme.paddingLarge 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 { Column {
id: col id: col
width: parent.width width: parent.width
spacing: Theme.paddingSmall spacing: Theme.paddingSmall
PageHeader { title: qsTr("Other commands") } PageHeader { title: qsTr("Plant commands") }
Label { // ---- modalita' -------------------------------------------------- //
visible: page.notice.length > 0 SectionHeader { text: qsTr("Modes") }
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin TextSwitch {
text: page.notice id: awaySwitch
color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor text: qsTr("Away mode")
font.pixelSize: Theme.fontSizeSmall description: qsTr("Lowers the house and stops the hot water")
wrapMode: Text.Wrap automaticCheck: false
checked: page.raw("away_mode") === true
onClicked: {
if (checked)
api.sendCommand("away_mode_off")
else
api.sendCommand("away_mode_on")
}
} }
// ---- stato attuale -------------------------------------------- // TextSwitch {
SectionHeader { text: qsTr("Current state") } 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") } TextSwitch {
InfoRow { label: qsTr("Night mode"); value: page.flag("night_mode") } id: heatingSwitch
InfoRow { label: qsTr("Forced heating"); value: page.flag("force_heating") } text: qsTr("Heating")
InfoRow { label: qsTr("Hot water active"); value: page.flag("hot_water_heating") } description: qsTr("The heating can turn on")
InfoRow { label: qsTr("Pump"); value: page.stato("pump_active_state") } automaticCheck: false
InfoRow { label: qsTr("Allowed modes"); value: page.stato("configured_modes") } 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 --------------------------------------------------- // // ---- acqua calda ------------------------------------------------ //
SectionHeader { text: qsTr("Away mode") } 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 { ActionButton {
text: qsTr("Turn away mode on") text: qsTr("Legionella cycle")
onTriggered: api.sendCommand("away_mode_on") confirmText: qsTr("Starting the legionella cycle: it takes a while")
} onTriggered: api.sendCommand("legionella_cycle")
ActionButton {
text: qsTr("Turn away mode off")
onTriggered: api.sendCommand("away_mode_off")
} }
// ---- rumore ---------------------------------------------------- // // ---- rumore ----------------------------------------------------- //
SectionHeader { text: qsTr("Noise") } SectionHeader { text: qsTr("Noise") }
ActionButton { ActionButton {
@@ -93,59 +147,13 @@ Page {
onTriggered: api.sendCommand("night_mode_1h") onTriggered: api.sendCommand("night_mode_1h")
} }
// ---- riscaldamento --------------------------------------------- // Label {
SectionHeader { text: qsTr("Heating") } x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
ActionButton { text: qsTr("The switches show the current state of the plant: the Status section is not needed any more.")
text: qsTr("Force heating") color: Theme.secondaryColor
confirmText: qsTr("Starting forced heating") font.pixelSize: Theme.fontSizeExtraSmall
onTriggered: api.sendCommand("force_heating_on") wrapMode: Text.Wrap
}
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 { Label {
@@ -156,11 +164,10 @@ Page {
font.pixelSize: Theme.fontSizeExtraSmall font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
Item { width: 1; height: Theme.paddingMedium }
} }
} }
Component.onCompleted: { NoticeBar { id: notice }
if (api.configured)
api.refresh()
}
} }
+6 -9
View File
@@ -73,6 +73,8 @@ Page {
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
// Banda riservata al messaggio in fondo: cosi' non copre il contenuto.
anchors.bottomMargin: notice.banda
contentHeight: col.height + Theme.paddingLarge contentHeight: col.height + Theme.paddingLarge
PullDownMenu { PullDownMenu {
@@ -109,15 +111,8 @@ Page {
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
Label { // Gli errori non si mostrano piu' qui dentro: il messaggio arriva
visible: page.error.length > 0 // ancorato in fondo alla pagina (NoticeBar), dove si vede sempre.
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.error
color: Theme.errorColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
}
Label { Label {
visible: Object.keys(page.stats()).length === 0 && page.error.length === 0 visible: Object.keys(page.stats()).length === 0 && page.error.length === 0
@@ -242,6 +237,8 @@ Page {
} }
} }
NoticeBar { id: notice }
Component.onCompleted: { Component.onCompleted: {
if (api.configured) { if (api.configured) {
page.loading = true page.loading = true
+300 -387
View File
@@ -2,27 +2,61 @@ import QtQuick 2.2
import Sailfish.Silica 1.0 import Sailfish.Silica 1.0
import "../components" import "../components"
// Dashboard dell'impianto, ordinata come la schermata iniziale dell'app // Home: una schermata da GUARDARE, non un modulo da compilare.
// 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 // Stato in una riga, le due letture che contano (casa e acqua calda), tre
// calda). I comandi restano piu' sotto, perche' qui si possono dare davvero. // azioni rapide. Le regolazioni stanno in SetpointsPage, gli altri comandi in
// Legge api.summary (QVariantMap dal C++), quindi non dipende dallo scope // CommandsPage, i numeri in DataPage, i dettagli in PlantPage: qui solo quello
// delle property del main qml. // che si guarda molte volte al giorno, senza scorrere (docs/UX-STUDIO.md).
Page { Page {
id: page id: page
allowedOrientations: Orientation.All allowedOrientations: Orientation.All
// obiettivo ACS scelto dall'utente (non sovrascritto dal refresh finche' function raw(key) {
// non lo tocca, altrimenti lo slider "salta" a ogni lettura) return api.summary[key]
property int targetAcs: 55 }
property bool targetAcsTouched: false
// obiettivo della casa (zona 1); parte dalla temperatura attuale e non // I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il
// viene sovrascritto dal refresh finche' l'utente non lo tocca // separatore decimale segue la lingua dell'app, non il toFixed di JS.
property real targetCasa: 20 function fmt(value) { return api.formatValue(value) }
property bool targetCasaTouched: false
property string notice: "" function count(key) {
property bool noticeIsError: false 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 avviso, 3 allarme serio) 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. Rosso solo quando e' la macchina a essere ferma o guasta: un
// avviso (per esempio il compressore limitato dalla temperatura esterna)
// e' ambra, come nell'app ufficiale.
function statusColor() {
if (!api.hasData || api.stale)
return "orange"
var livello = api.statusLevel()
if (livello === 3)
return Theme.errorColor
if (livello === 2)
return "#ffb300"
if (livello === 1)
return Theme.highlightColor
return "#4caf50"
}
// 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() { function openSettings() {
pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
@@ -40,103 +74,26 @@ Page {
pageStack.push(Qt.resolvedUrl("DataPage.qml")) pageStack.push(Qt.resolvedUrl("DataPage.qml"))
} }
function showNotice(msg, isError) { // UNA sola assegnazione: in Qt 5.6 assegnare Component.onCompleted due volte
page.notice = msg // e' un errore di compilazione del file, e la pagina non si crea piu'
page.noticeIsError = isError === true // ("Type MainPage unavailable" e app senza Home).
noticeTimer.restart() Component.onCompleted: {
if (api.configured)
api.refresh()
misura.start()
} }
function raw(key) { // Niente RemorseItem sui tasti rapidi: Silica se lo ri-parenta dentro la
return api.summary[key] // fila dei tre tasti e ci disegna sopra il suo banner (il riquadro del
} // remorse non ha la larghezza del tasto), coprendo i tasti vicini. Il
// messaggio lo dice la NoticeBar in fondo: la stessa cosa, senza rompere
// I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il // la fila. Segnalato da Carlo: "il problema c'e' quando premo un pulsante".
// 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 = ""
}
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 { SilicaFlickable {
id: flick id: flick
anchors.fill: parent anchors.fill: parent
// Banda riservata al messaggio in fondo: cosi' non copre il contenuto.
anchors.bottomMargin: notice.banda
contentHeight: col.height + Theme.paddingLarge contentHeight: col.height + Theme.paddingLarge
// I menu pulley stanno DENTRO la vista: come figli diretti della Page // I menu pulley stanno DENTRO la vista: come figli diretti della Page
@@ -154,10 +111,6 @@ Page {
text: qsTr("Plant") text: qsTr("Plant")
onClicked: page.openPlant() onClicked: page.openPlant()
} }
MenuItem {
text: qsTr("Other commands")
onClicked: page.openCommands()
}
MenuItem { MenuItem {
text: qsTr("Settings") text: qsTr("Settings")
onClicked: page.openSettings() onClicked: page.openSettings()
@@ -169,17 +122,9 @@ Page {
width: parent.width width: parent.width
spacing: Theme.paddingSmall spacing: Theme.paddingSmall
PageHeader { title: "Aira" } PageHeader {
title: "Aira"
// ---- avvisi ---------------------------------------------------- // description: page.updateText()
Label {
visible: page.notice.length > 0
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: page.notice
color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
} }
// ---- bridge non configurato ------------------------------------ // // ---- bridge non configurato ------------------------------------ //
@@ -200,300 +145,268 @@ Page {
onClicked: page.openSettings() onClicked: page.openSettings()
} }
// ---- barra in alto: fuori casa / a casa ------------------------ // // ---- stato in una riga ----------------------------------------- //
Item { Item {
visible: api.configured visible: api.configured
x: Theme.horizontalPageMargin x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin width: parent.width - 2 * Theme.horizontalPageMargin
height: fuori.height height: Math.max(stato.height, pallino.height, attesa.height)
Label { Rectangle {
id: fuori id: pallino
anchors.left: parent.left width: Theme.fontSizeSmall * 0.6
text: qsTr("Outside %1 °C").arg(page.temp("outdoor_temperature")) height: width
color: Theme.secondaryColor radius: width / 2
font.pixelSize: Theme.fontSizeSmall color: page.statusColor()
anchors.verticalCenter: parent.verticalCenter
} }
Label { Label {
id: stato
anchors { anchors {
right: parent.right left: pallino.right
leftMargin: Theme.paddingMedium
right: attesa.left
rightMargin: Theme.paddingMedium
verticalCenter: parent.verticalCenter verticalCenter: parent.verticalCenter
} }
text: page.raw("away_mode") === true ? qsTr("Away") : qsTr("At home") text: api.stateShort()
color: page.raw("away_mode") === true ? Theme.highlightColor : Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
}
}
// ---- 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 color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
} elide: Text.ElideRight
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 {
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 { BusyIndicator {
visible: api.busy id: attesa
anchors.horizontalCenter: parent.horizontalCenter anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
size: BusyIndicatorSize.Small size: BusyIndicatorSize.Small
running: true running: api.busy
visible: api.busy
}
} }
Label { // ---- le due letture che contano -------------------------------- //
Row {
id: letture
visible: api.configured visible: api.configured
x: Theme.horizontalPageMargin x: Theme.horizontalPageMargin
width: parent.width - 2 * 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.") 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"),
{ "section": "house" })
}
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")))
valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor
onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml"),
{ "section": "water" })
}
}
// Una sola riga, sempre della stessa altezza: mentre il boost corre
// il conto alla rovescia prende il posto del suggerimento senza
// spostare niente sotto. Il carattere e' quello del suggerimento
// (fontSizeExtraSmall): se fosse piu' grande, la riga piu' alta
// spingerebbe giu' i pulsanti e la riga di contorno finirebbe loro
// addosso (segnalato da Carlo).
Item {
visible: api.configured
width: parent.width
height: Math.max(contoRovescia.implicitHeight,
suggerimento.implicitHeight)
Label {
id: contoRovescia
visible: api.boostActive
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Boost · %1").arg(api.boostCountdown)
color: Theme.highlightColor
font.pixelSize: Theme.fontSizeExtraSmall
font.bold: true
}
Label {
id: suggerimento
visible: !api.boostActive
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Tap a value to adjust it")
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
}
}
// ---- 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
// In Silica il tasto piglia l'altezza dal tema
// (itemSizeExtraSmall ~56 px), ma icona E etichetta ne
// occupano ~110: senza questa riga l'etichetta esce dalla
// scatola e la riga successiva della colonna le finisce
// sopra (segnalato da Carlo: "la riga sotto copre i
// pulsanti"). Vedi tests/label_fit_check.py.
height: Theme.itemSizeMedium
// Mentre il boost e' in corso il tasto ferma invece di
// avviare: dice cosa succede premendolo, come il tasto
// dell'assenza. "Stop boost" per intero non ci sta accanto
// all'icona (92 px contro 73 disponibili): resta "Stop",
// che il glifo di stop rende inequivocabile.
text: api.boostActive ? qsTr("Stop") : qsTr("Boost")
// La casella dell'icona la decide il tema: senza, l'Image
// prende la misura del file e si mangia il testo.
icon.source: api.boostActive ? "../icons/quick-boost-stop.png"
: "../icons/quick-boost.png"
icon.width: Theme.iconSizeExtraSmall
icon.height: Theme.iconSizeExtraSmall
// Comando diretto: il messaggio lo mostra la NoticeBar in
// fondo, senza il banner del remorse sulla fila dei tasti.
onClicked: api.sendCommand(api.boostActive ? "hot_water_boost_off"
: "hot_water_boost_on")
}
Button {
id: assenzaBtn
width: (parent.width - 2 * parent.spacing) / 3
height: Theme.itemSizeMedium
// Il pulsante dice cosa succede premendolo, non lo stato.
// Etichette corte: nel tasto, largo un terzo di schermo,
// icona e testo devono starci insieme.
text: page.raw("away_mode") ? qsTr("Home") : qsTr("Away")
icon.source: "../icons/quick-away.png"
icon.width: Theme.iconSizeExtraSmall
icon.height: Theme.iconSizeExtraSmall
onClicked: {
if (page.raw("away_mode")) {
api.sendCommand("away_mode_off")
} else {
// L'assenza si imposta da un popup con le date
// (richiesta dell'utente): si apre qui e il comando
// parte solo se si accetta.
var scelta = pageStack.push(Qt.resolvedUrl("AwayDialog.qml"))
scelta.accepted.connect(function() {
api.sendCommand("away_mode_on", null, {
"start": Qt.formatDate(scelta.inizio, "yyyy-MM-dd"),
"end": Qt.formatDate(scelta.fine, "yyyy-MM-dd")
})
})
}
}
}
Button {
width: (parent.width - 2 * parent.spacing) / 3
height: Theme.itemSizeMedium
// "Comandi" (8 lettere) non ci sta accanto a un'icona in un
// terzo di schermo: la pagina resta "Comandi impianto".
text: qsTr("More")
icon.source: "../icons/quick-commands.png"
icon.width: Theme.iconSizeExtraSmall
icon.height: Theme.iconSizeExtraSmall
onClicked: page.openCommands()
}
}
// ---- letture di contorno (si toccano: portano a Impianto) ------ //
BackgroundItem {
visible: api.configured
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
height: contornoTxt.height + Theme.paddingMedium
onClicked: page.openPlant()
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 color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall font.pixelSize: Theme.fontSizeExtraSmall
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
} }
Item { width: 1; height: Theme.paddingMedium }
}
} }
Component.onCompleted: { NoticeBar { id: notice }
// La disposizione non e' definitiva subito: si misura un secondo dopo.
// Questa e' la riga da leggere alla prima build sul telefono: la Home deve
// stare in una schermata sola, se "contenuto" supera "schermo" c'e' ancora
// qualcosa da togliere (o da accettare, ma sapendolo).
Timer {
id: misura
interval: 1000
onTriggered: {
console.log("harbour-aira home: contenuto", flick.contentHeight,
"schermo", height)
// Ogni riga della Home con la sua posizione: se una riga finisce
// addosso a un'altra, qui si legge quale e di quanto. E' la riga da
// mandare al primo avvio se la disposizione non torna.
var righe = []
for (var i = 0; i < col.children.length; ++i) {
var r = col.children[i]
if (r.height === undefined)
continue
console.log("harbour-aira riga", i, "y", Math.round(r.y),
"h", Math.round(r.height),
r.text !== undefined ? JSON.stringify(r.text) : "")
righe.push({
"i": i,
"y": Math.round(r.y),
"h": Math.round(r.height),
"t": r.text !== undefined ? String(r.text) : ""
})
}
// Le stesse misure vanno anche al bridge, che le scrive nel
// registro: cosi' l'agente le legge da li' invece di chiedere il
// terminale o di decifrare uno screenshot.
if (api.configured) if (api.configured)
api.refresh() api.reportLayout({
"versione": appVersion,
"schermo": Math.round(height),
"contenuto": Math.round(flick.contentHeight),
"righe": righe,
// Diagnostica della lingua: con che scelta, che locale e che
// catalogo sta girando l'app in questo momento.
"lingua": translator.language === "" ? "sistema" : translator.language,
"locale": translator.localeName,
"catalogo": translator.catalogLoaded
})
}
} }
} }
+30
View File
@@ -47,6 +47,8 @@ Page {
SilicaFlickable { SilicaFlickable {
anchors.fill: parent anchors.fill: parent
// Banda riservata al messaggio in fondo: cosi' non copre il contenuto.
anchors.bottomMargin: notice.banda
contentHeight: col.height + Theme.paddingLarge contentHeight: col.height + Theme.paddingLarge
PullDownMenu { PullDownMenu {
@@ -71,6 +73,19 @@ Page {
PageHeader { title: qsTr("Plant") } 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 ------------------------------------------------------ // // ---- stato ------------------------------------------------------ //
SectionHeader { text: qsTr("Status") } SectionHeader { text: qsTr("Status") }
@@ -86,6 +101,11 @@ Page {
InfoRow { label: qsTr("Night mode"); value: page.flag("night_mode") } InfoRow { label: qsTr("Night mode"); value: page.flag("night_mode") }
InfoRow { label: qsTr("Forced heating"); value: page.flag("force_heating") } InfoRow { label: qsTr("Forced heating"); value: page.flag("force_heating") }
InfoRow { label: qsTr("Inline heater"); value: page.flag("inline_heater") } 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("Outdoor temperature"); value: page.temp("outdoor_temperature") }
InfoRow { label: qsTr("Indoor temperature (pump)"); value: page.temp("indoor_temperature") } InfoRow { label: qsTr("Indoor temperature (pump)"); value: page.temp("indoor_temperature") }
InfoRow { label: qsTr("Zones"); value: page.count("zones") } InfoRow { label: qsTr("Zones"); value: page.count("zones") }
@@ -176,10 +196,20 @@ Page {
value: page.count("errors_count") 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 } Item { width: 1; height: Theme.paddingMedium }
} }
} }
NoticeBar { id: notice }
Component.onCompleted: { Component.onCompleted: {
if (api.configured) if (api.configured)
api.refresh() api.refresh()
+274
View File
@@ -0,0 +1,274 @@
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".
//
// La Home passa l'oggetto toccato ("house" o "water") e si vedono solo le sue
// regolazioni: toccando "Acqua calda" non ha senso trovarsi davanti anche la
// casa. Senza parametro si vedono tutte, come in origine.
Page {
id: page
allowedOrientations: Orientation.All
property string section: ""
property bool mostraCasa: page.section !== "water"
property bool mostraAcqua: page.section !== "house"
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 }
// UNA sola assegnazione: in Qt 5.6 assegnare Component.onCompleted due volte
// nello stesso oggetto e' un errore di compilazione del file.
Component.onCompleted: {
page.syncFromPlant()
if (api.configured)
api.refresh()
}
Connections {
target: api
onSummaryChanged: page.syncFromPlant()
}
SilicaFlickable {
anchors.fill: parent
// Banda riservata al messaggio in fondo: cosi' non copre il contenuto.
anchors.bottomMargin: notice.banda
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 {
visible: page.mostraCasa
text: qsTr("Heating the house")
}
ValueRow {
visible: page.mostraCasa
title: qsTr("Current target")
subtitle: qsTr("What the plant is keeping now")
value: page.fmt(page.houseTarget()) + " °C"
}
Slider {
id: casaSlider
visible: page.mostraCasa
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 {
visible: page.mostraCasa
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 {
visible: page.mostraCasa
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 {
visible: page.mostraAcqua
text: qsTr("Domestic hot water")
}
ValueRow {
visible: page.mostraAcqua
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
visible: page.mostraAcqua
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 {
visible: page.mostraAcqua
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 {
visible: page.mostraAcqua
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 ------------------------------------------------------ //
// Il boost e' dell'acqua calda: si vede con lei.
//
// UN tasto solo, che cambia con lo stato come quello della Home:
// due tasti sempre attivi (avvia / ferma) non dicevano cosa sta
// facendo l'impianto e potevano contraddire la Home. Niente
// confirmText: la conferma la da' il messaggio in fondo, come in
// Home, e il remorse non copre niente.
SectionHeader {
visible: page.mostraAcqua
text: qsTr("Hot water boost")
}
Label {
visible: page.mostraAcqua && api.boostActive
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Boost running · %1 left").arg(api.boostCountdown)
color: Theme.highlightColor
font.pixelSize: Theme.fontSizeExtraSmall
}
ActionButton {
visible: page.mostraAcqua
text: api.boostActive ? qsTr("Stop DHW boost") : qsTr("Start DHW boost")
onTriggered: api.sendCommand(api.boostActive ? "hot_water_boost_off"
: "hot_water_boost_on")
}
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 { id: notice }
}
+15 -11
View File
@@ -128,19 +128,11 @@ Page {
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
// Un pulsante solo: salvare e provare erano due gesti per la stessa
// cosa (il secondo salvava comunque).
Button { Button {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Save") text: qsTr("Save and test")
onClicked: {
page.save()
page.testResult = qsTr("settings saved")
page.testOk = true
}
}
Button {
anchors.horizontalCenter: parent.horizontalCenter
text: qsTr("Test connection")
onClicked: page.testAndReport() onClicked: page.testAndReport()
} }
@@ -153,6 +145,18 @@ Page {
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
// Quale build sta girando, a schermo: senza, per sapere se un
// difetto e' davvero un difetto o e' l'app vecchia bisogna andare a
// leggere il registro (e' successo: la riga che copriva i tasti era
// la 0.7.0, gia' corretta da tre versioni).
Label {
x: Theme.horizontalPageMargin
width: parent.width - 2 * Theme.horizontalPageMargin
text: qsTr("Version %1").arg(appVersion)
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeExtraSmall
}
} }
} }
} }
+127 -2
View File
@@ -1,6 +1,6 @@
Name: harbour-aira Name: harbour-aira
Summary: Control your Aira heat pump through a local bridge Summary: Control your Aira heat pump through a local bridge
Version: 0.5.0 Version: 0.7.10
Release: 1 Release: 1
Group: Qt/Qt Group: Qt/Qt
License: MIT License: MIT
@@ -37,6 +37,131 @@ 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
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.10-1
- cambio lingua: la ricarica della radice QML ora e' in due mosse (url vuota,
poi ricarica), cosi' il componente viene ricreato su qualunque versione di Qt
invece di affidarsi a un comportamento che cambia fra versioni.
- diagnosi: la Home manda al /layout anche lingua, locale e catalogo, perche'
un difetto di lingua sul telefono si possa leggere invece che indovinare.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.9-1
- via la barra di avanzamento sotto l'acqua calda: partiva da 10 °C e stava
sempre al 99%, quindi non diceva niente. Tolta dal componente ReadingCell
(era usata solo dalla Home) e la funzione waterProgress dalla Home.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.8-1
- copertina: le tre diciture diventano "T. casa", "T. acqua" e "Temperatura
esterna". Sono stringhe proprie della copertina, cosi' la Home resta "Casa" e
"Acqua calda".
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.7-1
- Regolazioni, acqua calda: un solo tasto del boost che cambia con lo stato
(avvia / ferma) invece di due sempre attivi, con il tempo che resta. Lo stato
e' quello della Home (api.boostActive): stesso dato, stessa verita'.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.6-1
- via il RemorseItem dai tasti rapidi: Silica se lo ri-parenta dentro la fila
dei tre tasti e ci disegna sopra il banner, coprendoli (succedeva premendo);
- la barra messaggi ha altezza fissa e una banda riservata sotto il contenuto,
cosi' non copre piu' niente e non sposta niente quando compare.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.5-1
- La Home manda le sue misure al bridge (rotta /layout): cosi' la disposizione
si corregge guardando i numeri veri del telefono, non deducendoli.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.4-1
- Impostazioni: la versione dell'app scritta a schermo. Servita subito: un
difetto della disposizione era l'app vecchia installata, non un bug.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.3-1
- copertina: l'obiettivo dopo una freccia ("22,5 -> 19,0 °C") invece della barra
vecchia; la riga esterna dice "Esterno" (era "esterna", minuscolo).
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.2-1
- il tasto del boost diventa "Stop" (con glifo di stop) mentre il boost e' in
corso, in Home e in copertina: prima un tocco dalla copertina ne faceva
ripartire un altro invece di fermarlo.
- il conto alla rovescia prende il posto del suggerimento nella stessa riga e
con la stessa misura del carattere: essendo piu' alto spingeva giu' i tasti
delle azioni rapide.
- tests/cover_action_check.py controlla tutti i comandi dell'azione (anche
condizionali) e tutte le icone citate.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.1-1
- i tre tasti delle azioni rapide dichiarano la loro altezza: in Silica il tasto
la piglia dal tema (Theme.itemSizeExtraSmall, ~56 px) e non dal contenuto,
quindi con icona E etichetta il testo (~110 px) usciva dalla scatola e la riga
di contorno finiva sopra le etichette (segnalato: "la riga sotto si disallinea
coprendo i pulsanti").
- il registro dell'avvio elenca posizione e altezza di ogni riga della Home.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.7.0-1
- assenza con le date: popup con inizio e fine (DatePickerDialog, componenti
verificati nella guida Silica dell'SDK). Il bridge manda inizio e fine come
orari veri, l'impianto tiene la casa a 15 °C e riprende il giorno scelto
come fine (per Carlo: il giorno prima del rientro).
- conto alla rovescia del boost da 30:00 a 00:00, in Home e in copertina: i
secondi vengono dalla fine del boost letta dall'impianto, l'app li fa scorrere.
- boost a mezz'ora (era un'ora): richiesta dell'utente.
- catalogo italiano rifatto: lupdate leggeva solo i C++ e marcava 151 voci dei
QML come obsolete. Ora si passano src e qml espliciti: 197 stringhe tradotte.
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.6.9-1
- copertina: azione rapida (P8 del piano). Sulla copertina compare l'icona del
boost: si avvia il boost dell'acqua calda senza aprire l'app. Una sola azione,
perche' la documentazione Silica dice che due si possono avere ma che lo stile
delle app di sistema ne mostra una. La riga dei dati non freschi si sposta
sopra l'area delle azioni (Cover.coverActionArea), cosi' l'icona non la copre.
Componente e nomi verificati nella guida dell'SDK (CoverActionList, CoverAction,
iconSource, onTriggered: attenzione, e' iconSource, non icon.source del Button)
- nuovo controllo statico tests/cover_action_check.py: elenco figlio diretto del
Cover, una sola azione, icona che esiste, comando presente nella tabella del
bridge (un id sbagliato sarebbe un 400 scoperto solo sul telefono)
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.6.8-1
- Home: le icone dei tasti rimpicciolite. Il pulsante Silica, se non si dice
altro, disegna l'icona a Theme.iconSizeMedium (64 px): in un tasto largo un
terzo di schermo restavano 41 px di testo e le etichette uscivano tagliate
("Boos", "Asse", "Altro" no). Ora l'icona e' Theme.iconSizeExtraSmall e i
glifi riempiono il loro riquadro. Etichette piu' corte dove non ci stavano
(Assenza/Torna/Altro, Away/Home/More): con i numeri veri misurati sullo
screenshot ognuna ci sta con margine. La pagina resta "Comandi impianto"
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.6.7-1
- Home: i tre tasti delle azioni rapide hanno un'icona (fiamma = boost, casa =
assenza, tre manopole = comandi). Il tema Sailfish non ha icone per questi
concetti (nell'elenco ufficiale non c'e' nessuna fiamma): sono disegnate con
Pillow in scripts/make_glyphs.py, PNG bianche su trasparente in qml/icons/
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.6.6-1
- Home: toccare "Casa" o "Acqua calda" apre le regolazioni di quell'oggetto e
non piu' casa e acqua insieme; l'acqua porta con se' il boost, che e' suo.
Nuovo controllo statico (tests/qml_focus_check.py) su chi apre e chi mostra
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.6.5-1
- copertina: l'acqua si chiama "Acqua calda" come in Home (era "Acqua"). Con
l'etichetta piu' lunga il valore attuale sta piu' grande e l'obiettivo piu'
piccolo e attaccato ("51,5/55,0 °C"): la riga resta dentro la cover, che e'
stretta, e il numero attuale non si taglia
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.6.4-1
- copertina: due righe con l'etichetta davanti, "Casa" e "Acqua", ognuna con
temperatura attuale / impostata ("22,9 / 19,0 °C"). Prima erano frasi lunghe
("casa · obiettivo 19,0 °C", "ACS 53,4 °C · obiettivo 55 °C") che sulla cover,
stretta, venivano tagliate a meta' e il numero vero non si leggeva
* Sun Oct 11 2026 Carlo <carlo@carlobaratto.it> - 0.6.3-1
- nuova icona: casa con l'onda calda dentro, scelta fra le quattro proposte in
scripts/icon_proposals.py. Ridisegnata a 344 px e rigenerata alle quattro
misure (86/108/128/172); disegno originale, nessun logo di terzi
* Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.6.2-1
- pallino di stato: rosso solo per un allarme che ferma la macchina (o di
severita' CRITICAL/ERROR); un avviso ora e' ambra. Prima qualunque voce
nell'elenco errori accendeva il rosso, e un semplice avviso (compressore
limitato dalla temperatura esterna, caso reale del 10/10) sembrava un guasto.
Il bridge espone error_severity e alarm_stopping in /summary; l'esito ignoto
resta ambra, il rosso si accende solo su un guasto che sappiamo essere serio
* Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.6.1-1
- correzione: la Home non si apriva ("Type MainPage unavailable", app senza
pagina iniziale). Causa: due assegnazioni di Component.onCompleted nello
stesso oggetto; in Qt 5.6 la pagina non si crea piu' e il messaggio non dice
quale sia il file. Ora un controllo automatico le intercetta in pochi
millisecondi (tests/qml_check_bindings.py), prima della build
* 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 * Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.5.0-1
- schermata iniziale riordinata come l'app ufficiale: temperatura esterna in - schermata iniziale riordinata come l'app ufficiale: temperatura esterna in
alto, messaggio di stato, scheda dell'impianto con casa e acqua calda alto, messaggio di stato, scheda dell'impianto con casa e acqua calda
@@ -66,7 +191,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.5.0 # sorgenti live (shadow build) e la cartella puo' chiamarsi harbour-aira-0.6.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
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Quattro proposte di icona per harbour-aira, alla misura vera (172 e 86 px).
Si disegna a 344 px e si ridimensiona: bordi puliti a ogni taglia, come fa
scripts/make_icons.py. Nessun logo di terzi (il permesso per nome e logo Aira
non e' ancora arrivato), nessuna lettera generata.
Uso: python3 scripts/icon_proposals.py
Esito: docs/icona/proposte/*.png (172 e 86 px) + contatto.png con tutte e quattro
"""
import math
import pathlib
from PIL import Image, ImageDraw, ImageFont
BASE = 344 # si disegna grande, si ridimensiona dopo
OUT = pathlib.Path("docs/icona/proposte")
TOP = (12, 62, 130) # blu profondo (identita' attuale)
BOTTOM = (24, 148, 190) # azzurro
WARM = (255, 138, 46) # arancione
WHITE = (255, 255, 255, 240)
BLUE = (14, 78, 148) # blu del fondo, per i ritagli (negative space)
def rounded_mask(size, radius):
m = Image.new("L", (size, size), 0)
ImageDraw.Draw(m).rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=255)
return m
def gradient(size):
img = Image.new("RGB", (1, size))
d = ImageDraw.Draw(img)
for y in range(size):
t = y / max(1, size - 1)
d.point((0, y), tuple(int(TOP[i] + (BOTTOM[i] - TOP[i]) * t) for i in range(3)))
return img.resize((size, size))
def onda(d, x0, x1, y, ampiezza, spessore, fill, creste=2):
"""Una linea ondulata: la stessa forma in tutti i disegni che la usano."""
punti = []
passi = 120
for i in range(passi + 1):
t = i / passi
x = x0 + (x1 - x0) * t
yy = y + ampiezza * math.sin(2 * math.pi * creste * t)
punti.append((x, yy))
d.line(punti, fill=fill, width=int(spessore), joint="curve")
def base_icon():
img = gradient(BASE).convert("RGBA")
return img, ImageDraw.Draw(img)
def goccia(d, cx, cy, r, fill, fattore_punta=2.35):
"""Goccia con la punta smussata: la punta aguzza, a 86 px, sparisce."""
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=fill)
d.polygon([(cx - r * 0.11, cy - fattore_punta * r), (cx + r * 0.11, cy - fattore_punta * r),
(cx + r * 0.99, cy + r * 0.10), (cx - r * 0.99, cy + r * 0.10)], fill=fill)
# --------------------------------------------------------------------------- #
# A. Casa con l'onda calda dentro: "la casa che viene scaldata"
# --------------------------------------------------------------------------- #
def casa_onda():
img, d = base_icon()
tetto = [(BASE * 0.16, BASE * 0.50), (BASE * 0.50, BASE * 0.24), (BASE * 0.84, BASE * 0.50)]
corpo = [BASE * 0.24, BASE * 0.50, BASE * 0.76, BASE * 0.78]
d.polygon(tetto, fill=WHITE)
d.rectangle(corpo, fill=WHITE)
# onda dentro il corpo: non deve toccare il tetto (si fondeva a 86 px)
onda(d, BASE * 0.26, BASE * 0.74, BASE * 0.665, BASE * 0.040, BASE * 0.078, WARM + (255,), creste=2)
return img
# --------------------------------------------------------------------------- #
# B. Manopola: "il controllo"
# --------------------------------------------------------------------------- #
def manopola():
img, d = base_icon()
m = BASE * 0.125 # margine piu' largo: il pomello non deve
spessore = int(BASE * 0.115) # sfiorare l'angolo arrotondato
riquadro = [m, m, BASE - m, BASE - m]
# anello aperto in alto (la finestra della scala) e pomello in alto a destra
d.arc(riquadro, start=295, end=245, fill=WHITE, width=spessore)
cx = cy = BASE / 2
raggio = (BASE - 2 * m) / 2
ang = math.radians(315)
px, py = cx + raggio * math.cos(ang), cy + raggio * math.sin(ang)
r = BASE * 0.095
d.ellipse([px - r, py - r, px + r, py + r], fill=WARM + (255,))
# tacca al centro: il valore
d.rounded_rectangle([cx - BASE * 0.032, cy - BASE * 0.14,
cx + BASE * 0.032, cy + BASE * 0.14],
radius=BASE * 0.032, fill=WHITE)
return img
# --------------------------------------------------------------------------- #
# C. Goccia con la fiamma: "acqua calda" (evoluzione dell'icona attuale)
# --------------------------------------------------------------------------- #
def goccia_fiamma():
img, d = base_icon()
cx, cy = BASE * 0.50, BASE * 0.615
r = BASE * 0.225
goccia(d, cx, cy, r, WHITE)
img = img.transform(img.size, Image.AFFINE, (1, 0, 0, 0, 1, -BASE * 0.055))
d = ImageDraw.Draw(img)
# fiamma piu' piccola: il bianco attorno deve restare spesso a 86 px
cy2 = cy + r * 0.18 - BASE * 0.055
fr = r * 0.42
d.ellipse([cx - fr, cy2 - fr, cx + fr, cy2 + fr], fill=WARM + (255,))
d.polygon([(cx - fr * 0.13, cy2 - fr * 2.15), (cx + fr * 0.13, cy2 - fr * 2.15),
(cx + fr * 0.96, cy2 + fr * 0.20)], fill=WARM + (255,))
return img
# --------------------------------------------------------------------------- #
# D. Casa e goccia insieme: "riscaldamento e acqua calda"
# --------------------------------------------------------------------------- #
def casa_goccia():
img, d = base_icon()
tetto = [(BASE * 0.16, BASE * 0.50), (BASE * 0.50, BASE * 0.24), (BASE * 0.84, BASE * 0.50)]
corpo = [BASE * 0.24, BASE * 0.50, BASE * 0.76, BASE * 0.78]
d.polygon(tetto, fill=WHITE)
d.rectangle(corpo, fill=WHITE)
# goccia nel corpo della casa, in arancione: a 86 px il blu si perderebbe
goccia(d, BASE * 0.50, BASE * 0.665, BASE * 0.092, WARM + (255,), fattore_punta=2.10)
return img
PROPOSTE = [
("a-casa-onda", casa_onda, "A. Casa + onda calda"),
("b-manopola", manopola, "B. Manopola"),
("c-goccia-fiamma", goccia_fiamma, "C. Goccia + fiamma"),
("d-casa-goccia", casa_goccia, "D. Casa + goccia"),
]
def salva(img, nome, size):
fuori = img.resize((size, size), Image.LANCZOS)
fuori = fuori.convert("RGB").convert("RGBA")
fuori.putalpha(rounded_mask(size, int(size * 0.22)))
OUT.mkdir(parents=True, exist_ok=True)
percorso = OUT / f"{nome}-{size}.png"
fuori.save(percorso)
return percorso
def contatto():
"""Un'immagine sola: le quattro proposte a 172 px e, sotto, a 86 px."""
larghezza = 4 * 200
altezza = 200 + 130 + 30
tela = Image.new("RGB", (larghezza, altezza), (16, 20, 26))
d = ImageDraw.Draw(tela)
try:
piccolo = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
except OSError:
piccolo = ImageFont.load_default()
for i, (nome, disegna, descrizione) in enumerate(PROPOSTE):
img = disegna()
grande = Image.open(salva(img, nome, 172)).convert("RGBA")
piccola = Image.open(salva(img, nome, 86)).convert("RGBA")
x = i * 200 + 14
tela.paste(grande, (x, 14), grande.split()[3])
tela.paste(piccola, (x + 43, 196), piccola.split()[3])
d.text((x, 296), descrizione, font=piccolo, fill=(200, 208, 216))
percorso = OUT / "contatto.png"
tela.save(percorso)
return percorso
def main():
for nome, disegna, _ in PROPOSTE:
img = disegna()
for size in (172, 86):
print("scritto", salva(img, nome, size))
print("scritto", contatto())
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Disegna le icone dei tre tasti delle opzioni rapide in Home.
Stanno in qml/icons/ perche' il pulsante Silica vuole un'immagine, non un disegno
QML. PNG e non SVG di proposito: il PNG si vede di sicuro, l'SVG dipende dal
supporto SVG dentro il sandbox dell'app.
Bianche su trasparente: sul tema Sailfish i tasti sono scuri e le icone di
sistema sono cosi'. Tre concetti che il tema Sailfish non ha (nell'elenco
ufficiale non c'e' nessuna fiamma, nessuna goccia): boost = fiamma, assenza =
casa (la stessa casa dell'icona dell'app), comandi = tre righe con le manopole.
Uso: python3 scripts/make_glyphs.py
"""
import math
import pathlib
from PIL import Image, ImageDraw
BASE = 688 # si disegna grande e si riduce: bordi puliti a ogni taglia
OUT = 64 # il riquadro vero lo decide il tema (iconSizeExtraSmall)
DEST = pathlib.Path(__file__).resolve().parent.parent / "qml" / "icons"
BIANCO = (255, 255, 255, 255)
VUOTO = (0, 0, 0, 0)
def nuova():
return Image.new("RGBA", (BASE, BASE), VUOTO)
def fiamma(d, cx, cy, w, h):
"""Boost: fiamma — stretta, punta piegata a sinistra, fondo tondo."""
r = 0.26 * w
alto = h - 0.55 * r
fondo = cy + h / 2
destra, sinistra = [], []
passi = 160
for i in range(passi + 1):
t = i / passi
mezza = (w / 2) * math.sin(math.pi * (t ** 0.7))
piega = (w * 0.18) * (t ** 1.7) # la punta pende a sinistra
y = fondo - alto * t
destra.append((cx + mezza - piega, y))
sinistra.append((cx - mezza - piega, y))
d.polygon(destra + list(reversed(sinistra)), fill=BIANCO)
# fondo arrotondato: copre la punta stretta in basso
d.ellipse([cx - r, fondo - 0.55 * r, cx + r, fondo + 0.55 * r], fill=BIANCO)
def casa(d, cx, cy, w, h):
"""Assenza: casa con la porta (senza la porta si legge come una freccia)."""
cima = cy - h / 2
base = cy + h / 2
spalle = cima + h * 0.36 # dove il tetto incontra il muro
d.polygon([(cx - w * 0.52, spalle), (cx, cima), (cx + w * 0.52, spalle)], fill=BIANCO)
d.rectangle([cx - w * 0.33, spalle - 1, cx + w * 0.33, base], fill=BIANCO)
# la porta: e' quello che la distingue da una freccia
d.rectangle([cx - w * 0.20 / 2, base - h * 0.30, cx + w * 0.20 / 2, base], fill=VUOTO)
def comandi(d, cx, cy, w, h):
"""Comandi: tre righe con la manopola in posizioni diverse."""
manopole = (-0.18, 0.20, -0.06)
for i, posizione in enumerate(manopole):
y = cy - h / 2 + h * (0.16 + 0.34 * i)
spessore = h * 0.115
d.rounded_rectangle([cx - w / 2, y - spessore / 2, cx + w / 2, y + spessore / 2],
radius=spessore / 2, fill=BIANCO)
kx = cx + w * posizione
r = h * 0.13
d.ellipse([kx - r * 1.5, y - r * 1.5, kx + r * 1.5, y + r * 1.5], fill=VUOTO)
d.ellipse([kx - r, y - r, kx + r, y + r], fill=BIANCO)
def stop(d, cx, cy, w, h):
"""Ferma il boost: quadrato pieno dentro un anello (il segno universale)."""
r = min(w, h) / 2
d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=BIANCO,
width=max(2, int(h * 0.12)))
q = r * 0.46
d.rectangle([cx - q, cy - q, cx + q, cy + q], fill=BIANCO)
def main():
DEST.mkdir(parents=True, exist_ok=True)
disegni = (
("quick-boost.png", fiamma, 0.62),
("quick-away.png", casa, 0.80),
("quick-commands.png", comandi, 0.90),
("quick-boost-stop.png", stop, 0.90),
)
# il glifo riempie il suo riquadro (un filo di margine): la misura sul
# telefono la decide Theme.iconSizeExtraSmall nel pulsante, non il file
lato = BASE * 0.80
for nome, disegno, strettezza in disegni:
img = nuova()
d = ImageDraw.Draw(img)
disegno(d, BASE / 2, BASE / 2, lato * strettezza, lato)
img = img.resize((OUT, OUT), Image.LANCZOS)
img.save(DEST / nome)
print("scritto", (DEST / nome).relative_to(DEST.parent.parent))
if __name__ == "__main__":
main()
+28 -23
View File
@@ -1,20 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Genera le icone di harbour-aira (86/108/128/172 px) in icons/<size>/. """Genera le icone di harbour-aira (86/108/128/172 px) in icons/<size>/.
Disegno originale (nessun logo di terzi): sfondo blu sfumato, goccia bianca e Disegno scelto: **casa con l'onda calda dentro** — la proposta A di
un punto caldo arancione — acqua calda + pompa di calore. scripts/icon_proposals.py, dove restano le altre direzioni provate. E' un
disegno originale: nessun logo di terzi, visto che il permesso per nome e logo
Aira non e' ancora arrivato.
Uso: python3 scripts/make_icons.py Uso: python3 scripts/make_icons.py
""" """
from PIL import Image, ImageDraw import math
import pathlib import pathlib
from PIL import Image, ImageDraw
SIZES = [86, 108, 128, 172] SIZES = [86, 108, 128, 172]
BASE = 172 # si disegna grande e si ridimensiona: bordi puliti a ogni taglia BASE = 344 # si disegna grande e si ridimensiona: bordi puliti a ogni taglia
TOP = (12, 62, 130) # blu profondo TOP = (12, 62, 130) # blu profondo
BOTTOM = (24, 148, 190) # azzurro BOTTOM = (24, 148, 190) # azzurro
WARM = (255, 138, 46) # arancione WARM = (255, 138, 46) # arancione
WHITE = (255, 255, 255, 240)
def rounded_mask(size, radius): def rounded_mask(size, radius):
@@ -32,29 +37,29 @@ def gradient(size):
return img.resize((size, size)) return img.resize((size, size))
def onda(d, x0, x1, y, ampiezza, spessore, fill, creste=2):
"""L'onda calda: due creste dentro il corpo della casa."""
punti = []
passi = 120
for i in range(passi + 1):
t = i / passi
x = x0 + (x1 - x0) * t
punti.append((x, y + ampiezza * math.sin(2 * math.pi * creste * t)))
d.line(punti, fill=fill, width=int(spessore), joint="curve")
def draw_icon(size): def draw_icon(size):
img = gradient(size).convert("RGBA") img = gradient(size).convert("RGBA")
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
# goccia: cerchio + triangolo verso l'alto # tetto e corpo della casa
cx = size * 0.5 d.polygon([(size * 0.16, size * 0.50), (size * 0.50, size * 0.24),
cy = size * 0.60 (size * 0.84, size * 0.50)], fill=WHITE)
r = size * 0.19 d.rectangle([size * 0.24, size * 0.50, size * 0.76, size * 0.78], fill=WHITE)
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(255, 255, 255, 235))
d.polygon([(cx, cy - 2.35 * r), (cx - r * 0.98, cy + r * 0.10), (cx + r * 0.98, cy + r * 0.10)],
fill=(255, 255, 255, 235))
# ricentra la goccia: il triangolo allunga verso l'alto
shift = -size * 0.05
img = img.transform(img.size, Image.AFFINE, (1, 0, 0, 0, 1, shift))
d = ImageDraw.Draw(img)
# punto caldo in basso a destra, dentro la goccia
wr = size * 0.085
wx, wy = cx + r * 0.10, cy + r * 0.30
for i in range(int(wr), 0, -1):
a = int(255 * (1 - i / wr) + 90)
d.ellipse([wx - i, wy - i, wx + i, wy + i], fill=WARM + (a,))
# onda calda dentro il corpo: sta sotto il tetto, altrimenti a 86 px si fonde
onda(d, size * 0.26, size * 0.74, size * 0.665, size * 0.040, size * 0.078,
WARM + (255,), creste=2)
return img return img
+260 -85
View File
@@ -327,6 +327,96 @@ TEXTS = {
LANG = "en" 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",
"back_home": "Home",
"commands": "More",
"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",
"cover_house": "House",
"cover_hot_water": "Hot water",
})
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",
"commands": "Altro",
"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",
"cover_house": "Casa",
"cover_hot_water": "Acqua calda",
})
def T(key: str) -> str: def T(key: str) -> str:
return TEXTS[LANG][key] return TEXTS[LANG][key]
@@ -363,65 +453,77 @@ 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: def main_screen() -> Image.Image:
"""Schermata iniziale, ordinata come quella dell'app ufficiale: temperatura """Home della 0.6.0: una schermata da guardare, non un modulo. Stato in una
esterna in alto, messaggio di stato, scheda dell'impianto, poi i comandi.""" riga, le due letture che contano, tre azioni rapide. Niente scorrimento."""
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"))
d.text((M, 128), T("updated_at"), font=font(22), fill=SECONDARY)
y = 150 y = 176
# ---- barra in alto: fuori casa / a casa ------------------------------ # # ---- stato in una riga ------------------------------------------------ #
d.text((M, y), T("outside").format(v=dec(18.0) + " °C"), font=font(24), fill=SECONDARY) d.ellipse([M, y + 7, M + 18, y + 25], fill=OK)
casa = T("at_home") d.text((M + 34, y), T("status_water"), font=font(26), fill=PRIMARY)
d.text((W - M - d.textlength(casa, font=font(22)), y + 2), casa, 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
# icona a sinistra e testo di fianco, come fa il pulsante Silica
glifi = ("quick-boost.png", "quick-away.png", "quick-commands.png")
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)
icona = Image.open(OUT.parent.parent / "qml" / "icons" / glifi[i]).convert("RGBA")
# stessa proporzione del telefono: icona piccola del tema (32 su 540)
# riportata alla larghezza di questa anteprima
lato = 42
icona = icona.resize((lato, lato), Image.LANCZOS)
tw = d.textlength(testo, font=font(26))
blocco = lato + 20 + tw
x0 = x + (bw - blocco) / 2
img.paste(icona, (int(x0), int(y + (74 - lato) / 2)), icona)
d.text((x0 + lato + 20, y + 22), testo, font=font(26), 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) 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 return img
@@ -449,10 +551,9 @@ def settings_screen() -> Image.Image:
d.multiline_text((M, y), T("settings_note"), font=font(22), fill=SECONDARY, spacing=10) d.multiline_text((M, y), T("settings_note"), font=font(22), fill=SECONDARY, spacing=10)
y += 104 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) d.text((M, y), T("connection_ok"), font=font(24), fill=OK)
y += 58
y = button(d, y, T("save"))
return img return img
@@ -470,50 +571,113 @@ def cover_screen() -> Image.Image:
card = [96, 430, W - 96, 430 + 470] card = [96, 430, W - 96, 430 + 470]
d.rounded_rectangle(card, radius=34, fill=(14, 18, 24), outline=ACCENT, width=2) d.rounded_rectangle(card, radius=34, fill=(14, 18, 24), outline=ACCENT, width=2)
d.text((card[0] + 34, card[1] + 30), T("app"), font=font(40, bold=True), fill=PRIMARY) d.text((card[0] + 34, card[1] + 30), T("app"), font=font(40, bold=True), fill=PRIMARY)
# pallino di stato, come nella copertina vera (verde = tutto regolare)
d.ellipse([card[2] - 56, card[1] + 48, card[2] - 32, card[1] + 72], fill=OK)
d.line([(card[0] + 34, card[1] + 100), (card[2] - 34, card[1] + 100)], d.line([(card[0] + 34, card[1] + 100), (card[2] - 34, card[1] + 100)],
fill=BTN_EDGE, width=2) fill=BTN_EDGE, width=2)
y = card[1] + 132 y = card[1] + 150
big = dec(23.0) + " °C" # due righe: etichetta a sinistra, poi l'attuale piu' grande e l'obiettivo
d.text(((W - d.textlength(big, font=font(76, bold=True))) / 2, y), # piu' piccolo attaccato — come nella copertina vera
big, font=font(76, bold=True), fill=PRIMARY) colonna = 34 + int((card[2] - card[0] - 68) * 0.42)
y += 108 for lab, ora, obiettivo in ((T("cover_house"), dec(22.9), dec(19.0)),
for lab, val in ((T("cover_target"), dec(19.0) + " °C"), (T("cover_hot_water"), dec(51.5), dec(55.0))):
(T("cover_acs"), dec(51.0) + " °C"), d.text((card[0] + 34, y + 10), lab, font=font(22), fill=SECONDARY)
(T("cover_outdoor"), dec(18.0) + " °C")): d.text((card[0] + colonna, y), ora, font=font(30, bold=True), fill=PRIMARY)
d.text((card[0] + 34, y), lab, font=font(24), fill=SECONDARY) d.text((card[0] + colonna + d.textlength(ora, font=font(30, bold=True)), y + 4),
vw = d.textlength(val, font=font(24)) "/" + obiettivo + " °C", font=font(24), fill=SECONDARY)
d.text((card[2] - 34 - vw, y), val, font=font(24), fill=PRIMARY) y += 72
y += 52 d.text((card[0] + 34, card[3] - 118),
d.text((card[0] + 34, card[3] - 60), T("cover_footer"), font=font(22), fill=SECONDARY) T("cover_outdoor") + " " + dec(18.0) + " °C", font=font(22), fill=SECONDARY)
# l'azione sulla copertina (P8): il boost, nell'area in basso. Dove la
# disegni Silica lo decide lei; qui si mostra che c'e'.
icona = Image.open(OUT.parent.parent / "qml" / "icons" / "quick-boost.png").convert("RGBA")
lato = 64
icona = icona.resize((lato, lato), Image.LANCZOS)
img.paste(icona, (int(card[0] + (card[2] - card[0] - lato) / 2), card[3] - 96), icona)
return img 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: 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() img = background()
d = ImageDraw.Draw(img) d = ImageDraw.Draw(img)
status_bar(d) status_bar(d)
page_header(d, T("other_commands")) page_header(d, T("plant_commands"))
y = section_header(d, 156, T("current_state")) y = section_header(d, 150, T("modes"))
for lab, val in ((T("away"), T("off")), (T("night_mode"), T("off")), y = switch_row(d, y, T("away"), T("sw_away_desc"), False)
(T("forced_heating"), T("off")), (T("hot_water_active"), T("on")), y = switch_row(d, y, T("forced_heating"), T("sw_forced_desc"), False)
(T("pump"), T("stopped")), (T("allowed_modes"), T("heating"))): y = switch_row(d, y, T("heating"), T("sw_heating_desc"), True)
y = label_pair(d, y, lab, val)
y = section_header(d, y + 24, T("away")) y = section_header(d, y + 10, T("dhw_section"))
y = button(d, y, T("turn_away_on")) y = switch_row(d, y, T("hot_water_active"), T("sw_water_desc"), True)
y = button(d, y, T("turn_away_off")) 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 = button(d, y, T("night_one_hour"))
y = section_header(d, y + 24, T("heating")) d.text((M, y + 12), T("switches_note"), font=font(22), fill=SECONDARY)
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)
return img 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: def data_screen() -> Image.Image:
"""Pagina "Dati": gli stessi numeri della scheda Dati dell'app ufficiale.""" """Pagina "Dati": gli stessi numeri della scheda Dati dell'app ufficiale."""
img = background() img = background()
@@ -566,11 +730,18 @@ def plant_screen() -> Image.Image:
status_bar(d) status_bar(d)
page_header(d, T("plant")) 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")) y = section_header(d, y, T("status"))
for lab, val in ((T("connection"), T("cloud")), (T("mode"), T("automatic")), for lab, val in ((T("connection"), T("cloud")), (T("mode"), T("automatic")),
(T("away"), T("off")), (T("night_mode"), T("off")), (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 = label_pair(d, y, lab, val)
y = section_header(d, y + 20, T("accessories")) y = section_header(d, y + 20, T("accessories"))
@@ -587,6 +758,9 @@ def plant_screen() -> Image.Image:
y = section_header(d, y + 20, T("errors")) y = section_header(d, y + 20, T("errors"))
d.text((M, y), T("no_errors"), font=font(24), fill=OK) 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 return img
@@ -602,7 +776,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), ("dati", data_screen), for name, fn in (("dashboard", main_screen), ("regolazioni", setpoints_screen),
("dati", data_screen),
("impianto", plant_screen), ("comandi", commands_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"
+114 -1
View File
@@ -175,15 +175,31 @@ QString monthLabel(const QString &yearMonth)
return QStringLiteral("%1 %2").arg(QLocale().monthName(mese, QLocale::LongFormat)).arg(anno); 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, const QVariantMap &extra)
{ {
QJsonObject o; QJsonObject o;
o.insert(QStringLiteral("id"), id); o.insert(QStringLiteral("id"), id);
if (value.isValid() && !value.isNull()) if (value.isValid() && !value.isNull())
o.insert(QStringLiteral("value"), QJsonValue::fromVariant(value)); o.insert(QStringLiteral("value"), QJsonValue::fromVariant(value));
// Campi in piu' (per esempio start ed end dell'assenza): li passa al bridge
// cosi' come sono, saltando quelli vuoti.
for (QVariantMap::const_iterator it = extra.constBegin(); it != extra.constEnd(); ++it) {
const QVariant v = it.value();
if (v.isValid() && !v.isNull() && !v.toString().isEmpty())
o.insert(it.key(), QJsonValue::fromVariant(v));
}
return QJsonDocument(o).toJson(QJsonDocument::Compact); return QJsonDocument(o).toJson(QJsonDocument::Compact);
} }
QString countdown(int seconds)
{
if (seconds < 0)
seconds = 0;
return QStringLiteral("%1:%2")
.arg(seconds / 60, 2, 10, QLatin1Char('0'))
.arg(seconds % 60, 2, 10, QLatin1Char('0'));
}
QString errorMessage(const QByteArray &body, int httpStatus) QString errorMessage(const QByteArray &body, int httpStatus)
{ {
QJsonParseError err; QJsonParseError err;
@@ -198,4 +214,101 @@ QString errorMessage(const QByteArray &body, int httpStatus)
return QCoreApplication::translate("AiraJson", "HTTP error %1").arg(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();
}
// Allarme "serio": quello che ferma la macchina (flag del bridge, dai metadati
// della pompa) oppure una voce di severita' CRITICAL/ERROR. Un semplice avviso
// (WARNING: per esempio il compressore limitato dalla temperatura esterna) NON
// e' serio, e non deve accendere il pallino rosso. Senza i campi (bridge piu'
// vecchio) si resta prudenti sul colore: ambra, non rosso.
bool alarmSerious(const QVariantMap &summary)
{
if (flagValue(summary, QStringLiteral("alarm_stopping")))
return true;
const QString severita = summary.value(QStringLiteral("error_severity")).toString();
return severita == QStringLiteral("CRITICAL") || severita == QStringLiteral("ERROR");
}
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.");
}
QByteArray layoutPayload(const QVariantMap &info)
{
return QJsonDocument(QJsonObject::fromVariantMap(info))
.toJson(QJsonDocument::Compact);
}
int statusLevel(const QVariantMap &summary)
{
if (summary.isEmpty())
return 1;
if (errorCount(summary) > 0)
return alarmSerious(summary) ? 3 : 2;
if (flagValue(summary, QStringLiteral("away_mode"))
|| flagValue(summary, QStringLiteral("night_mode"))
|| flagValue(summary, QStringLiteral("force_heating")))
return 1;
return 0;
}
} // namespace AiraJson } // namespace AiraJson
+27 -2
View File
@@ -30,6 +30,20 @@ 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);
// 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 del pallino in Home:
// 0 regolare, 1 modo attivo (assenza, notte, riscaldamento forzato),
// 2 avviso (errori di severita' WARNING: la macchina lavora),
// 3 allarme serio (allarme che ferma la macchina, o severita' CRITICAL/ERROR).
int statusLevel(const QVariantMap &summary);
// 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);
@@ -45,8 +59,19 @@ QString formatMoney(const QVariant &value, const QString &currencyCode);
QString monthLabel(const QString &yearMonth); 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). `extra` aggiunge campi in piu' (per esempio
QByteArray commandPayload(const QString &id, const QVariant &value); // start/end dell'assenza): passano al bridge saltando i vuoti.
QByteArray commandPayload(const QString &id, const QVariant &value,
const QVariantMap &extra = QVariantMap());
// Corpo JSON per POST /layout: le misure della Home come le manda il QML
// (versione, altezza dello schermo, del contenuto, e y/h/testo di ogni riga).
// Serve a correggere la disposizione leggendo i numeri veri del telefono.
QByteArray layoutPayload(const QVariantMap &info);
// Conto alla rovescia leggibile da secondi: 1800 -> "30:00", 65 -> "01:05",
// 0 -> "00:00". Oltre l'ora i minuti continuano (240 min -> "240:00").
QString countdown(int seconds);
// Messaggio d'errore leggibile da una risposta del bridge // Messaggio d'errore leggibile da una risposta del bridge
// ({"ok":false,"error":"..."}), con fallback sullo stato HTTP. // ({"ok":false,"error":"..."}), con fallback sullo stato HTTP.
+143 -2
View File
@@ -3,8 +3,10 @@
#include "settings.h" #include "settings.h"
#include <QDebug> #include <QDebug>
#include <QLocale>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QNetworkReply> #include <QNetworkReply>
#include <QTime>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
@@ -12,7 +14,53 @@ ApiClient::ApiClient(Settings *settings, QObject *parent)
: QObject(parent) : QObject(parent)
, m_settings(settings) , m_settings(settings)
, m_busy(false) , 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();
});
// Un secondo alla volta mentre il boost e' in corso: i secondi veri li da'
// il bridge a ogni rilettura (ogni 30 s), qui si scorre in mezzo.
m_boostActive = false;
m_boostRemaining = 0;
m_boostTimer = new QTimer(this);
m_boostTimer->setInterval(1000);
connect(m_boostTimer, &QTimer::timeout, this, &ApiClient::tickBoost);
if (m_settings) { if (m_settings) {
// La UI deve riflettere subito il passaggio da "non configurato" a // La UI deve riflettere subito il passaggio da "non configurato" a
// "configurato" quando si salvano indirizzo o token. // "configurato" quando si salvano indirizzo o token.
@@ -29,6 +77,30 @@ bool ApiClient::configured() const
&& !m_settings->token().trimmed().isEmpty(); && !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) void ApiClient::setBusy(bool value)
{ {
if (value == m_busy) if (value == m_busy)
@@ -102,6 +174,10 @@ void ApiClient::refresh()
? tr("Server unreachable (timeout)") ? tr("Server unreachable (timeout)")
: AiraJson::errorMessage(body, status); : AiraJson::errorMessage(body, status);
qWarning() << "[api] summary non riuscito:" << status << msg; qWarning() << "[api] summary non riuscito:" << status << msg;
if (!m_stale) {
m_stale = true;
emit staleChanged();
}
emit errorOccurred(msg); emit errorOccurred(msg);
return; return;
} }
@@ -110,10 +186,23 @@ void ApiClient::refresh()
const QVariantMap summary = AiraJson::parseSummary(body, &ok); const QVariantMap summary = AiraJson::parseSummary(body, &ok);
if (!ok) { if (!ok) {
qWarning() << "[api] riassunto non interpretabile"; qWarning() << "[api] riassunto non interpretabile";
if (!m_stale) {
m_stale = true;
emit staleChanged();
}
emit errorOccurred(tr("Unreadable response from the bridge")); emit errorOccurred(tr("Unreadable response from the bridge"));
return; return;
} }
m_summary = summary; m_summary = summary;
setBoostFromSummary(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); emit summaryChanged(summary);
}); });
} }
@@ -215,14 +304,26 @@ void ApiClient::refreshStats()
}); });
} }
void ApiClient::sendCommand(const QString &id, const QVariant &value) // Le misure della Home vanno al bridge, che le scrive nel suo registro: cosi'
// per correggere la disposizione non servono ne' terminale sul telefono ne'
// screenshot da interpretare a occhio.
void ApiClient::reportLayout(const QVariantMap &info)
{
QNetworkReply *reply = send("POST", QStringLiteral("/layout"),
AiraJson::layoutPayload(info), 5);
if (!reply)
return;
connect(reply, &QNetworkReply::finished, reply, &QObject::deleteLater);
}
void ApiClient::sendCommand(const QString &id, const QVariant &value, const QVariantMap &extra)
{ {
if (!configured()) { if (!configured()) {
emit commandFinished(false, tr("Set the bridge address and token")); emit commandFinished(false, tr("Set the bridge address and token"));
return; return;
} }
const QByteArray payload = AiraJson::commandPayload(id, value); const QByteArray payload = AiraJson::commandPayload(id, value, extra);
qDebug() << "[api] comando:" << id; // mai i valori: qui potrebbe passare altro qDebug() << "[api] comando:" << id; // mai i valori: qui potrebbe passare altro
QNetworkReply *reply = send("POST", QStringLiteral("/command"), payload, 30); QNetworkReply *reply = send("POST", QStringLiteral("/command"), payload, 30);
@@ -252,3 +353,43 @@ void ApiClient::sendCommand(const QString &id, const QVariant &value)
refresh(); // i valori letti cambiano: rileggo refresh(); // i valori letti cambiano: rileggo
}); });
} }
QString ApiClient::boostCountdown() const
{
return AiraJson::countdown(m_boostRemaining);
}
// I secondi veri arrivano dal riassunto: il bridge li ricava dalla FINE del
// boost letta dall'impianto. Il timer li fa solo scorrere fra una lettura e
// l'altra, cosi' il conto non riparte da capo quando si riapre la app (e non
// sbaglia se il boost e' stato avviato da un'altra parte).
void ApiClient::setBoostFromSummary(const QVariantMap &summary)
{
const int restanti = summary.value(QStringLiteral("boost_remaining_seconds")).toInt();
const bool attivo = restanti > 0;
if (!attivo && !m_boostActive && m_boostRemaining == 0) {
m_boostTimer->stop(); // niente da mostrare: non disturbo
return;
}
m_boostRemaining = restanti;
m_boostActive = attivo;
if (attivo)
m_boostTimer->start();
else
m_boostTimer->stop();
emit boostChanged();
}
void ApiClient::tickBoost()
{
if (m_boostRemaining > 0)
--m_boostRemaining;
if (m_boostRemaining <= 0) {
m_boostRemaining = 0;
m_boostActive = false;
m_boostTimer->stop(); // arrivato a 00:00
}
emit boostChanged();
}
+54 -1
View File
@@ -7,6 +7,7 @@
#include <QVariantMap> #include <QVariantMap>
class QNetworkReply; class QNetworkReply;
class QTimer;
class Settings; class Settings;
// Client HTTP verso il bridge sul server (~/workspace/Carlo/Aira/aira_bridge.py). // Client HTTP verso il bridge sul server (~/workspace/Carlo/Aira/aira_bridge.py).
@@ -24,6 +25,23 @@ class ApiClient : public QObject
// Statistiche della pagina "Dati" (calore, elettricita', COP, risparmi). // Statistiche della pagina "Dati" (calore, elettricita', COP, risparmi).
// Separata dal riassunto: e' una chiamata piu' lenta, con cache lato bridge. // Separata dal riassunto: e' una chiamata piu' lenta, con cache lato bridge.
Q_PROPERTY(QVariantMap stats READ stats NOTIFY statsChanged) 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)
// Conto alla rovescia del boost ACS (richiesta dell'utente: da 30:00 a
// 00:00). I secondi arrivano dal bridge, che li calcola dalla FINE del
// boost letta dall'impianto: cosi' il numero non dipende dal momento in cui
// l'app ha visto partire il comando. Qui scorrono di secondo in secondo.
Q_PROPERTY(bool boostActive READ boostActive NOTIFY boostChanged)
Q_PROPERTY(int boostRemaining READ boostRemaining NOTIFY boostChanged)
Q_PROPERTY(QString boostCountdown READ boostCountdown NOTIFY boostChanged)
public: public:
explicit ApiClient(Settings *settings, QObject *parent = 0); explicit ApiClient(Settings *settings, QObject *parent = 0);
@@ -32,6 +50,21 @@ public:
bool configured() const; bool configured() const;
QVariantMap summary() const { return m_summary; } QVariantMap summary() const { return m_summary; }
QVariantMap stats() const { return m_stats; } 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; }
bool boostActive() const { return m_boostActive; }
int boostRemaining() const { return m_boostRemaining; }
QString boostCountdown() const;
// 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(). // Scarica il riassunto e lo emette con summaryChanged().
Q_INVOKABLE void refresh(); Q_INVOKABLE void refresh();
@@ -43,10 +76,13 @@ public:
Q_INVOKABLE void testConnection(); Q_INVOKABLE void testConnection();
// Invia un comando: sendCommand("set_hot_water_target", 55). // Invia un comando: sendCommand("set_hot_water_target", 55).
Q_INVOKABLE void sendCommand(const QString &id, const QVariant &value = QVariant()); // `extra` aggiunge campi al corpo JSON (start/end dell'assenza).
Q_INVOKABLE void sendCommand(const QString &id, const QVariant &value = QVariant(),
const QVariantMap &extra = QVariantMap());
// Formattazione per la UI: unica fonte di verita' in C++ (testata in // Formattazione per la UI: unica fonte di verita' in C++ (testata in
// tests/core_test.cpp), niente duplicazione di logica nel QML. // tests/core_test.cpp), niente duplicazione di logica nel QML.
Q_INVOKABLE void reportLayout(const QVariantMap &info);
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 formatNumber(const QVariant &value, int decimals) const;
@@ -56,6 +92,10 @@ public:
signals: signals:
void busyChanged(); void busyChanged();
void configuredChanged(); void configuredChanged();
void staleChanged();
void lastUpdateChanged();
void noticeChanged();
void boostChanged();
void summaryChanged(const QVariantMap &summary); void summaryChanged(const QVariantMap &summary);
void statsChanged(const QVariantMap &stats); void statsChanged(const QVariantMap &stats);
void statsRefreshed(bool ok, const QString &message); void statsRefreshed(bool ok, const QString &message);
@@ -68,12 +108,25 @@ private:
const QByteArray &body, int timeoutSec); const QByteArray &body, int timeoutSec);
void armTimeout(QNetworkReply *reply, int seconds); void armTimeout(QNetworkReply *reply, int seconds);
void setBusy(bool value); void setBusy(bool value);
// Mostra un messaggio in fondo alla pagina per qualche secondo.
void setNotice(const QString &message, bool isError);
void setBoostFromSummary(const QVariantMap &summary);
void tickBoost();
Settings *m_settings; Settings *m_settings;
QNetworkAccessManager m_nam; QNetworkAccessManager m_nam;
bool m_busy; 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_summary;
QVariantMap m_stats; QVariantMap m_stats;
bool m_boostActive;
int m_boostRemaining;
QTimer *m_boostTimer;
}; };
#endif // APICLIENT_H #endif // APICLIENT_H
+14 -7
View File
@@ -21,7 +21,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
app->setOrganizationName(QStringLiteral("harbour")); app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("aira")); app->setApplicationName(QStringLiteral("aira"));
app->setApplicationVersion(QStringLiteral("0.5.0")); app->setApplicationVersion(QStringLiteral("0.7.10"));
Settings settings; Settings settings;
ApiClient api(&settings); ApiClient api(&settings);
@@ -36,23 +36,30 @@ 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.5.0 build" << __DATE__ << __TIME__; qDebug() << "harbour-aira v0.7.10 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);
view->rootContext()->setContextProperty(QStringLiteral("translator"), &translator); view->rootContext()->setContextProperty(QStringLiteral("translator"), &translator);
// La versione anche a schermo: senza, per sapere quale build sta girando
// bisogna andare a leggere il registro, e una app vecchia sembra un difetto
// (successo davvero: la riga che copriva i tasti era la 0.7.0).
view->rootContext()->setContextProperty(QStringLiteral("appVersion"),
QCoreApplication::applicationVersion());
// Qt 5.6 non ha QQmlEngine::retranslate() (arriva con Qt 5.10): installare un // Qt 5.6 non ha QQmlEngine::retranslate() (arriva con Qt 5.10): installare un
// traduttore nuovo NON ri-traduce le stringhe gia' mostrate, quindi la lingua // traduttore nuovo NON ri-traduce le stringhe gia' mostrate, quindi la lingua
// scelta nelle impostazioni si applica ricaricando la radice QML. Conseguenza // scelta nelle impostazioni si applica ricaricando la radice QML. Conseguenza
// voluta e attesa: si torna alla home, nella lingua nuova. // voluta e attesa: si torna alla home, nella lingua nuova.
// //
// setSource() con la STESSA url ricrea davvero il componente: in Qt 5.6 // La ricarica si fa in DUE mosse: prima si scarica la radice (url vuota), poi
// QQuickViewPrivate::execute() cancella root e component e ne costruisce uno // si ricarica. Una sola setSource con la STESSA url e' un comportamento che
// nuovo (documentato: "the QML component being instantiated, even if the URL // cambia fra le versioni di Qt (misurato su Qt 5.15: ricrea; su 5.6 non e'
// is unchanged"), quindi ogni qsTr() viene rivalutato. Non serve // garantito) e il telefono non e' la macchina su cui si prova. Con l'url vuota
// clearComponentCache(): i valori delle stringhe non stanno nella cache. // il componente e' per forza ricreato, quindi ogni qsTr() viene rivalutato.
// Non serve clearComponentCache(): le stringhe non stanno nella cache.
QObject::connect(&translator, &Translator::languageChanged, view, [view]() { QObject::connect(&translator, &Translator::languageChanged, view, [view]() {
view->setSource(QUrl());
view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-aira.qml"))); view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-aira.qml")));
}); });
+13
View File
@@ -40,6 +40,16 @@ void Translator::applySaved()
apply(language()); apply(language());
} }
bool Translator::catalogLoaded() const
{
return m_catalogLoaded;
}
QString Translator::localeName() const
{
return m_localeName;
}
void Translator::apply(const QString &value) void Translator::apply(const QString &value)
{ {
if (m_translator) { if (m_translator) {
@@ -67,6 +77,9 @@ void Translator::apply(const QString &value)
delete translator; delete translator;
} }
m_localeName = locale.name();
m_catalogLoaded = !m_translator.isNull();
qDebug() << "Translator: richiesta [" qDebug() << "Translator: richiesta ["
<< (value.isEmpty() ? QStringLiteral("sistema") : value) << (value.isEmpty() ? QStringLiteral("sistema") : value)
<< "] locale" << locale.name() << "] locale" << locale.name()
+10
View File
@@ -27,6 +27,11 @@ class Translator : public QObject
Q_OBJECT Q_OBJECT
Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged) Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged)
// Diagnostica: quali valori sono in vigore davvero. Finiscono nel /layout che
// la Home manda al bridge, cosi' un problema di lingua sul telefono si legge
// invece di indovinarlo.
Q_PROPERTY(bool catalogLoaded READ catalogLoaded)
Q_PROPERTY(QString localeName READ localeName)
public: public:
explicit Translator(Settings *settings, QObject *parent = 0); explicit Translator(Settings *settings, QObject *parent = 0);
@@ -35,6 +40,9 @@ public:
void setLanguage(const QString &value); // salva nelle impostazioni e applica void setLanguage(const QString &value); // salva nelle impostazioni e applica
void applySaved(); // all'avvio, PRIMA di creare la view void applySaved(); // all'avvio, PRIMA di creare la view
bool catalogLoaded() const; // catalogo installato?
QString localeName() const; // locale in vigore (es. it_IT)
signals: signals:
void languageChanged(); void languageChanged();
@@ -43,6 +51,8 @@ private:
Settings *m_settings; Settings *m_settings;
QPointer<QTranslator> m_translator; QPointer<QTranslator> m_translator;
QString m_localeName;
bool m_catalogLoaded = false;
}; };
#endif // TRANSLATOR_H #endif // TRANSLATOR_H
+117
View File
@@ -114,6 +114,26 @@ int main(int argc, char *argv[])
AiraJson::commandPayload("hot_water_boost_on", QVariant())).object(); AiraJson::commandPayload("hot_water_boost_on", QVariant())).object();
check(c2.contains("value") == false, "comando senza parametri: nessun campo value"); check(c2.contains("value") == false, "comando senza parametri: nessun campo value");
// Il popup dell'assenza manda start ed end: devono arrivare nel corpo, e i
// campi vuoti non devono entrarci.
const QJsonObject c3 = QJsonDocument::fromJson(AiraJson::commandPayload(
"away_mode_on", QVariant(), QVariantMap{
{ QStringLiteral("start"), QStringLiteral("2026-11-01") },
{ QStringLiteral("end"), QStringLiteral("2026-11-08") },
{ QStringLiteral("zone"), QString() } })).object();
check(c3.value("start").toString() == "2026-11-01", "campo start nel corpo");
check(c3.value("end").toString() == "2026-11-08", "campo end nel corpo");
check(c3.contains("value") == false, "assenza con date: nessun campo value");
check(c3.contains("zone") == false, "campo vuoto: non entra nel corpo");
// ---- countdown (conto alla rovescia del boost) ------------------------ //
check(AiraJson::countdown(1800) == "30:00", "mezz'ora -> 30:00");
check(AiraJson::countdown(65) == "01:05", "65 secondi -> 01:05");
check(AiraJson::countdown(59) == "00:59", "59 secondi -> 00:59");
check(AiraJson::countdown(0) == "00:00", "zero -> 00:00");
check(AiraJson::countdown(-5) == "00:00", "negativo -> 00:00");
check(AiraJson::countdown(240 * 60) == "240:00", "quattro ore -> 240:00");
// ---- errorMessage ----------------------------------------------------- // // ---- errorMessage ----------------------------------------------------- //
check(AiraJson::errorMessage(R"({"ok":false,"error":"token mancante o errato"})", 401) check(AiraJson::errorMessage(R"({"ok":false,"error":"token mancante o errato"})", 401)
== "token mancante o errato", "messaggio d'errore dal corpo"); == "token mancante o errato", "messaggio d'errore dal corpo");
@@ -190,6 +210,103 @@ 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");
// Un avviso NON e' un allarme: pallino ambra (livello 2), non rosso.
// Caso reale 10/10/2026: compressore limitato dalla temperatura esterna
// (SEVERITY_WARNING, nessun allarme che ferma la macchina) -> l'app
// accendeva il rosso per un semplice avviso.
QVariantMap avviso = calmo;
avviso.insert("errors_count", 1);
avviso.insert("alarm_stopping", false);
avviso.insert("error_severity", "WARNING");
check(AiraJson::statusLevel(avviso) == 2,
"livello: avviso (WARNING) = ambra, non rosso");
// Allarme che ferma la macchina: rosso anche con severita' di avviso.
QVariantMap fermo = avviso;
fermo.insert("alarm_stopping", true);
check(AiraJson::statusLevel(fermo) == 3, "livello: allarme che ferma = rosso");
// Severita' alta: rosso anche senza il flag di stop.
QVariantMap critico = avviso;
critico.insert("error_severity", "CRITICAL");
check(AiraJson::statusLevel(critico) == 3, "livello: CRITICAL = rosso");
QVariantMap guasto = avviso;
guasto.insert("error_severity", "ERROR");
check(AiraJson::statusLevel(guasto) == 3, "livello: ERROR = rosso");
// Il rosso vince sul modo attivo.
QVariantMap fermoAssente = assente;
fermoAssente.insert("errors_count", 1);
fermoAssente.insert("alarm_stopping", true);
check(AiraJson::statusLevel(fermoAssente) == 3,
"livello: allarme che ferma prima del modo attivo");
// Senza i campi di gravita' (bridge piu' vecchio) si resta ambra:
// il rosso si accende solo su un guasto che sappiamo essere serio.
check(AiraJson::statusLevel(uno) == 2,
"livello: gravita' ignota = ambra");
check(AiraJson::stateShort(QVariantMap()) == "No data",
"stato breve: nessun dato");
check(AiraJson::stateLong(QVariantMap()).isEmpty(),
"stato esteso: nessun dato = nessuna frase");
}
qInfo() << "----"; qInfo() << "----";
qInfo() << checks - failures << "/" << checks << "verifiche superate"; qInfo() << checks - failures << "/" << checks << "verifiche superate";
return failures == 0 ? 0 : 1; return failures == 0 ? 0 : 1;
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Controllo statico dell'azione sulla copertina (P8 del piano).
Verifica le regole che la documentazione Silica pone sulle azioni di copertina,
e che il componente sia usato come va usato:
- il `CoverActionList` sta come figlio diretto della copertina (qui la radice
e' `CoverBackground`, che eredita da `Cover`: e' li' che va dichiarato).
Un elenco annidato dentro un altro elemento non si attaccherebbe;
- al massimo una azione: la documentazione dice che due si possono avere, ma
che lo stile delle app di sistema ne mostra una;
- l'icona si dichiara con `iconSource` (non `icon.source`, che e' del
pulsante Button: sbagliarlo qui non da' errore, l'icona semplicemente non
compare);
- l'icona esiste davvero, risolta rispetto al file QML che definisce la
copertina;
- il comando che l'azione invia esiste nella tabella dei comandi del bridge
(un id sbagliato lo scoprirebbe solo il telefono, con un 400).
Non serve il kit Silica: legge i file. Uscita 0 = tutto a posto.
"""
import pathlib
import re
import sys
RADICE = pathlib.Path(__file__).resolve().parent.parent
COPERTINA = RADICE / "qml" / "cover" / "CoverPage.qml"
BRIDGE = RADICE / "bridge" / "aira_bridge.py"
def main():
if not COPERTINA.exists():
print("ESITO: manca %s" % COPERTINA)
return 1
testo = COPERTINA.read_text(encoding="utf-8")
righe = testo.splitlines()
problemi = []
radice = next((r.strip() for r in righe
if re.match(r"^[A-Z][A-Za-z]*\s*\{", r)), "")
if not re.match(r"CoverBackground|Cover\s*\{", radice):
problemi.append("la radice della copertina e' %r: le azioni vanno dentro "
"un Cover (CoverBackground eredita da Cover)" % radice[:40])
# il CoverActionList deve stare al primo livello dentro la radice
elenchi = [(i, r) for i, r in enumerate(righe) if re.match(r"^\s*CoverActionList\s*\{", r)]
if not elenchi:
print("nessuna azione sulla copertina: niente da controllare (0.6.8 e prima)")
return 0
for i, r in elenchi:
rientro = len(r) - len(r.lstrip())
if rientro != 4:
problemi.append("riga %d: il CoverActionList e' rientrato di %d spazi, "
"deve essere figlio diretto della copertina" % (i + 1, rientro))
azioni = [r for r in righe if re.match(r"^\s*CoverAction\s*\{", r)]
if len(azioni) > 1:
problemi.append("%d azioni: la documentazione Silica ne ammette piu' di una "
"ma lo stile delle app di sistema ne mostra una" % len(azioni))
if re.search(r"^\s*icon\.source\s*:", testo, re.M):
problemi.append("usa `iconSource`, non `icon.source`: `icon.source` e' del "
"pulsante Button e qui non darebbe errore, solo nessuna icona")
for m in re.finditer(r'"(\.\./icons/[^"]+\.png)"', testo):
icona = (COPERTINA.parent / m.group(1)).resolve()
if not icona.exists():
problemi.append("l'icona %s non esiste (risolta da %s)"
% (m.group(1), COPERTINA.name))
else:
print("icona: %s -> %s" % (m.group(1), icona.relative_to(RADICE)))
# L'invio puo' essere condizionale (il tasto del boost avvia o ferma) e
# scritto su piu' righe: si prende tutto il testo fra sendCommand( e la
# parentesi di chiusura, poi tutti i letterali che sembrano un id.
comandi = []
for m in re.finditer(r"sendCommand\(([^)]*)\)", testo, re.S):
comandi += [s for s in re.findall(r'"([^"]+)"', m.group(1))
if re.match(r"^[a-z][a-z_]*$", s)]
if not comandi:
problemi.append("l'azione non invia nessun comando: a che serve?")
else:
print("comandi inviati: %s" % ", ".join(comandi))
for c in comandi:
if not BRIDGE.exists():
print("salto il controllo del comando %r: manca %s" % (c, BRIDGE))
continue
ponte = BRIDGE.read_text(encoding="utf-8")
if not re.search(r'"%s"\s*:' % re.escape(c), ponte):
problemi.append("il comando %r non e' nella tabella del bridge: "
"il telefono riceverebbe un 400" % c)
else:
print("comando: %s -> presente nel bridge" % c)
if problemi:
for p in problemi:
print("PROBLEMA: %s" % p)
print("ESITO: %d problemi sull'azione di copertina" % len(problemi))
return 1
print("ESITO: azione di copertina a posto")
return 0
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Controllo statico: le etichette dei tre tasti delle azioni rapide ci stanno?
Sul pulsante Silica il carattere non si puo' rimpicciolire (il tipo non espone
proprieta' `font`), quindi lo spazio per il testo dipende solo dall'icona. I
numeri qui sotto sono MISURATI sullo screenshot di un telefono vero (0.6.7):
tasto 161 px logici, rientri 16, distacco icona-testo 24
icona di Silica senza dichiarazione = 64 px (Theme.iconSizeMedium)
-> restavano 41 px di testo: le etichette uscivano tagliate
Con Theme.iconSizeExtraSmall (32 px) restano 73 px. Il carattere di Sailfish e'
piu' stretto di quello usato qui per misurare (DejaVu): dal confronto sul
telefono risulta circa 0,78 volte, quindi si misura col fattore di sicurezza.
Il test legge i testi veri da qml/pages/MainPage.qml e le traduzioni da
translations/harbour-aira-it.ts: non ci sono etichette scritte a mano qui.
Se cambia la geometria della riga (margini, spazi, altezza) questi numeri vanno
rimisurati su uno screenshot nuovo.
"""
import pathlib
import re
import sys
RADICE = pathlib.Path(__file__).resolve().parent.parent
TASTO, RIENTRO, DISTACCO = 161.0, 16.0, 24.0
ICONA = 32.0 # Theme.iconSizeExtraSmall
CARATTERE = 22 # carattere dei pulsanti Silica
FATTORE = 0.78 # Sailfish (Nokia Pure) rispetto a DejaVu
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
def testi_dei_tasti(qml: str):
"""I tasti con icona: (testo, altezza dichiarata o None), in ordine."""
fuori = []
for blocco in re.finditer(r"Button\s*\{(.*?)\n\s*\}", qml, re.S):
corpo = blocco.group(1)
if "../icons/quick-" not in corpo:
continue
riga = re.search(r"text:\s*(.+)", corpo)
if not riga:
continue
testo = re.findall(r'qsTr\("([^"]+)"\)', riga.group(1))
if not testo:
continue
alt = re.search(r"^\s*height:\s*(.+)$", corpo, re.M)
fuori.append((testo[0], alt.group(1).strip() if alt else None))
return fuori
def traduzioni(ts: str):
coppie = {}
for blocco in re.finditer(r"<source>(.*?)</source>\s*<translation[^>]*>(.*?)</translation>",
ts, re.S):
coppie[blocco.group(1)] = blocco.group(2)
return coppie
def main():
try:
from PIL import ImageFont
except ImportError:
print("salto: manca Pillow (serve solo per misurare il testo)")
return 0
qml = (RADICE / "qml" / "pages" / "MainPage.qml").read_text(encoding="utf-8")
ts = (RADICE / "translations" / "harbour-aira-it.ts").read_text(encoding="utf-8")
it = traduzioni(ts)
origine = testi_dei_tasti(qml)
if len(origine) < 3:
print("ESITO: trovati %d testi con icona, ne servono 3" % len(origine))
return 1
sorgenti = [t for t, _ in origine]
problema = 0
# 1. L'altezza. In Silica il tasto la piglia dal tema
# (Theme.itemSizeExtraSmall, ~56 px) e non dal contenuto: con icona E
# etichetta il contenuto ne occupa ~110, esce dalla scatola, e la riga
# successiva della colonna viene messa sopra la scatola -- cioe' sopra
# l'etichetta che sporge. Chi ha entrambi deve dichiarare l'altezza.
for testo, alt in origine:
if not alt:
print(" %-9s nessuna altezza dichiarata: il tema ne da' ~56 px, il contenuto ~110" % testo)
problema += 1
disponibile = TASTO - 2 * RIENTRO - ICONA - DISTACCO
f = ImageFont.truetype(FONT, CARATTERE)
def largo(testo):
# Pillow >= 9.2 ha getlength; prima si misurava con getsize
if hasattr(f, "getlength"):
return f.getlength(testo)
from PIL import Image, ImageDraw
return ImageDraw.Draw(Image.new("L", (1, 1))).textlength(testo, font=f)
prova = [("it", [it.get(s, s) for s in sorgenti]), ("en", sorgenti)]
print("spazio per il testo: %.0f px (tasto %.0f, icona %.0f)" % (disponibile, TASTO, ICONA))
for lingua, etichette in prova:
for t in etichette:
serve = largo(t) * FATTORE
esito = "ok" if serve <= disponibile else "NON CI STA"
if serve > disponibile:
problema += 1
print(" %s %-9s %5.0f px %s" % (lingua, t, serve, esito))
if problema:
print("ESITO: %d etichette non ci stanno: accorciarle o ridurre l'icona" % problema)
return 1
print("ESITO: tutte le etichette ci stanno")
return 0
if __name__ == "__main__":
sys.exit(main())
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Assegnazioni duplicate di proprieta' negli stessi oggetti QML.
Perche' esiste: in Qt 5.6 assegnare due volte la stessa proprieta' di un oggetto
QML (tipico: due `Component.onCompleted` nella stessa pagina) non e' un errore
innocuo: il file compila con un warning "Property value set multiple times" e
l'oggetto NON si crea piu'. Dall'esterno si vede solo
harbour-aira.qml:9:30: Type MainPage unavailable
cioe' l'app che si apre senza la pagina iniziale, senza dire il perche'.
Il kit Silica non vede niente di tutto questo (qmllint conosce il QML, non il
significato di questi binding), quindi il controllo sta qui e gira in pochi
millisecondi.
Uso:
python3 tests/qml_check_bindings.py [cartella_qml]
python3 tests/qml_check_bindings.py --dettagli # elenca anche l'ok
Esce 1 se trova duplicati, 0 se e' pulito.
"""
import pathlib
import re
import sys
# Riga che apre un oggetto QML: un componente (maiuscola) oppure un gruppo di
# proprieta' noto. Il corpo di un gestore (onClicked:) o di una funzione e' JS,
# e li' dentro i "duplicati" sono normali (ternari, oggetti letterali, rami).
APRE_OGGETTO = re.compile(r"^\s*([A-Z][A-Za-z0-9_]*)\s*\{")
APRE_GRUPPO = re.compile(r"^\s*(anchors|font|Layout\.[A-Za-z]+)\s*\{")
APRE_JS = re.compile(r"^\s*(function\s+\w+|if\b|else\b|for\b|while\b|switch\b|do\b|try\b|catch\b)\s*[\({]")
APRE_GESTORE = re.compile(r"^\s*on[A-Z][A-Za-z0-9_]*\s*:")
PROPRIETA = re.compile(r"^\s*((?:readonly\s+)?property\s+\S+\s+([A-Za-z_]\w*)|signal\s+(\w+)|function\s+(\w+)|([A-Za-z_][\w.]*))\s*:")
SENZA_COMMENTO = re.compile(r"//.*$")
def chiave(riga: str):
"""Nome della proprieta' assegnata dalla riga, o None."""
m = PROPRIETA.match(SENZA_COMMENTO.sub("", riga))
if not m:
return None
return m.group(2) or m.group(3) or m.group(4) or m.group(5)
def controlla(testo: str):
"""Ritorna la lista di (riga, chiave) duplicate nello stesso oggetto."""
pila = [("qml", {})] # (tipo ambiente, chiavi viste)
problemi = []
for numero, riga in enumerate(testo.splitlines(), 1):
pulita = SENZA_COMMENTO.sub("", riga)
if not pulita.strip():
continue
# Chiusura: si esce dall'ambiente corrente.
while pulita.strip().startswith("}") and len(pila) > 1:
pila.pop()
pulita = pulita.strip()[1:]
if not pulita.strip():
break
if not pulita.strip():
continue
ambiente, chiavi = pila[-1]
if ambiente == "qml":
k = chiave(pulita)
if k:
if k in chiavi:
problemi.append((numero, k))
else:
chiavi[k] = numero
# Apertura di un nuovo ambiente: il contenuto e' QML solo se la riga
# apre un oggetto o un gruppo; gestori e funzioni aprono JS.
apre_js = bool(APRE_JS.match(pulita)) or bool(APRE_GESTORE.match(pulita))
apre_qml = (bool(APRE_OGGETTO.match(pulita)) or bool(APRE_GRUPPO.match(pulita))) \
and not apre_js
# Una proprieta' il cui valore e' un oggetto letterale JS non e' QML.
if ":" in pulita and "{" in pulita and not apre_qml and not apre_js:
apre_js = True
if pulita.count("{") > pulita.count("}"):
pila.append(("qml" if apre_qml else "js", {}))
return problemi
def main(argv):
argomenti = [a for a in argv[1:] if not a.startswith("--")]
radice = pathlib.Path(argomenti[0] if argomenti else "qml")
dettagli = "--dettagli" in argv
file_qml = sorted(radice.rglob("*.qml"))
if not file_qml:
print("nessun file QML in", radice)
return 1
totale = 0
for percorso in file_qml:
problemi = controlla(percorso.read_text(encoding="utf-8"))
relative = percorso.relative_to(radice.parent if radice.name == "qml" else radice)
if problemi:
totale += len(problemi)
print("%s: %d assegnazioni duplicate" % (relative, len(problemi)))
for numero, k in problemi:
print(" riga %d: '%s' gia' assegnata in questo oggetto" % (numero, k))
elif dettagli:
print("%s: pulito" % relative)
print("---")
if totale:
print("ESITO: %d assegnazioni duplicate (l'oggetto non si crea: "
"vedi Qt 5.6, 'Property value set multiple times')" % totale)
return 1
print("ESITO: nessuna assegnazione duplicata in %d file" % len(file_qml))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Controlla che la Home apra le regolazioni dell'oggetto toccato.
La UI non e' eseguibile senza il kit Silica, quindi il controllo e' statico:
1. ogni push verso SetpointsPage.qml dichiara una sezione (chi tocca "Casa"
non deve trovarsi davanti le regolazioni di tutto l'impianto);
2. la sezione dichiarata e' fra quelle che la pagina conosce;
3. ogni sezione della pagina e' legata a una visibilita' (mostraCasa /
mostraAcqua), altrimenti "solo quelle dell'oggetto" resta una promessa.
Esce 0 se tutto regge, 1 con l'elenco dei problemi.
"""
import pathlib
import re
import sys
QML = pathlib.Path(__file__).resolve().parent.parent / "qml"
HOME = QML / "pages" / "MainPage.qml"
PAGINA = QML / "pages" / "SetpointsPage.qml"
problemi = []
home = HOME.read_text(encoding="utf-8")
pagina = PAGINA.read_text(encoding="utf-8")
# 1. le chiamate dalla Home, con l'eventuale sezione subito dopo
chiamate = re.findall(
r'SetpointsPage\.qml"\)\s*(?:,\s*\{\s*"section"\s*:\s*"([^"]+)"\s*\})?',
home,
)
if not chiamate:
problemi.append("MainPage: nessun push verso SetpointsPage.qml (rinominato?)")
senza_sezione = chiamate.count("")
if senza_sezione:
problemi.append(
"%d push verso SetpointsPage.qml senza sezione: la pagina si apre intera"
% senza_sezione
)
# 2. le sezioni che la pagina conosce, lette dai confronti su page.section
conosciute = set(re.findall(r'page\.section\s*!==\s*"([^"]+)"', pagina))
if not conosciute:
problemi.append("SetpointsPage: nessuna sezione dichiarata (page.section)")
for sezione in chiamate:
if sezione and sezione not in conosciute:
problemi.append(
"MainPage chiede la sezione %r, che SetpointsPage non conosce (%s)"
% (sezione, ", ".join(sorted(conosciute)) or "nessuna")
)
# 3. ogni sezione deve nascondere l'altra: entrambe le visibilita' usate
for proprieta in ("mostraCasa", "mostraAcqua"):
if ("visible: page.%s" % proprieta) not in pagina:
problemi.append(
"SetpointsPage: nessun blocco legato a %s: la sezione non si nasconde"
% proprieta
)
if problemi:
print("ESITO: problemi trovati")
for p in problemi:
print(" -", p)
sys.exit(1)
print("ESITO: sezioni OK (%d chiamate dalla Home, sezioni %s)"
% (len(chiamate), ", ".join(sorted(conosciute))))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff