Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9527f66bb | ||
|
|
78eb283c9b | ||
|
|
1e2f5bbea3 | ||
|
|
648246b078 | ||
|
|
f4bee218bb | ||
|
|
35297a0fa0 | ||
|
|
8e13180ffb | ||
|
|
5453e4dcf7 | ||
|
|
09fadd5543 | ||
|
|
b4ac614723 | ||
|
|
f7447900f9 | ||
|
|
2b397b6c3a | ||
|
|
e8937dc1ea | ||
|
|
59894142dd | ||
|
|
d2cb9d692f | ||
|
|
6ef6f5bfd8 | ||
|
|
2136122398 | ||
|
|
1ac1617c2c | ||
|
|
92f179186a | ||
|
|
f1fdc9dfdb | ||
|
|
afb2878a8c | ||
|
|
a788255593 | ||
|
|
462ea035ca | ||
|
|
c237818e3c | ||
|
|
e3e365cbfc | ||
|
|
27f97dabef | ||
|
|
4be5caea81 | ||
|
|
a281a7e112 | ||
|
|
4050b3b5d7 | ||
|
|
70fe2299ba | ||
|
|
75c61cace2 | ||
|
|
5305fab309 | ||
|
|
36033daf3f | ||
|
|
85702880d5 | ||
|
|
d5b63b57df |
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
| Altri comandi | Impostazioni | Copertina |
|
| Impianto | Comandi impianto | Impostazioni | Copertina |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
|  |  |  |
|
|  |  |  |  |
|
||||||
|
|
||||||
## 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/)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|  |  |  |
|
|  |  |  |
|
||||||
|
|
||||||
| Other commands | Settings | Cover |
|
| Plant | Plant commands | Settings | Cover |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
|  |  |  |
|
|  |  |  |  |
|
||||||
|
|
||||||
## 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 843 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ¤cyCode);
|
|||||||
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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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))
|
||||||
@@ -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))))
|
||||||