Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4050b3b5d7 | ||
|
|
70fe2299ba | ||
|
|
75c61cace2 | ||
|
|
5305fab309 | ||
|
|
36033daf3f | ||
|
|
85702880d5 | ||
|
|
d5b63b57df |
@@ -15,24 +15,29 @@ e ripiega sull'inglese; la si può scegliere anche nelle impostazioni dell'app.
|
||||
|
||||
## Cosa mostra
|
||||
|
||||
- **Home**, ordinata come l'app ufficiale: temperatura esterna e *in casa /
|
||||
assente* in alto, una frase di stato in chiaro ("Relax: la pompa di calore
|
||||
funziona regolarmente"), poi la **scheda dell'impianto** con le due letture che
|
||||
contano (temperatura stanza e acqua calda, ognuna con il suo obiettivo).
|
||||
Toccando la scheda si apre la pagina dell'impianto. Sotto, i comandi:
|
||||
obiettivo della casa con slider 5–30 °C (passi da 0,5 °C) e conferma prima
|
||||
dell'invio, obiettivo ACS (45–60 °C) e i pulsanti del boost
|
||||
- **Home**, una schermata da guardare: pallino colorato con lo stato in una riga
|
||||
("Tutto regolare", "Sta scaldando l'acqua"), l'ora dell'ultimo aggiornamento, le
|
||||
due letture che contano (casa e acqua calda, ognuna con il suo obiettivo)
|
||||
affiancate e **toccabili**, e tre azioni rapide: boost, assenza, comandi
|
||||
impianto. Sta in una schermata, senza scorrere: i comandi hanno pagine loro
|
||||
- **Regolazioni**: obiettivo casa e obiettivo acqua calda, ognuno con il valore
|
||||
che l'impianto sta tenendo adesso, lo slider (la proposta: il valore vero non si
|
||||
muove), una fila di valori rapidi per le dita fredde e il pulsante di conferma,
|
||||
che si accende solo se c'è davvero qualcosa da cambiare. Il boost sta qui
|
||||
- **Dati**: calore prodotto, elettricità usata, efficienza (COP) di acqua calda e
|
||||
riscaldamento, risparmio in bolletta, CO₂ evitata e risparmio Smart Tariff
|
||||
Control, mese per mese. Sono i numeri che Aira calcola nel cloud, gli stessi
|
||||
della scheda *Dati* dell'app ufficiale
|
||||
- **Impianto**: stato (connessione, modalità, assenza/notte/riscaldamento
|
||||
forzato, temperature esterna e interna, zone, ultima lettura), accessori (il
|
||||
forzato, modalità manuale, sbrinamento, luci di firma, stato pompa, modi
|
||||
consentiti, temperature esterna e interna, zone, ultima lettura), accessori (il
|
||||
termostato a parete con temperatura, umidità, segnale, batteria), versioni del
|
||||
software e numero di errori
|
||||
- **Altri comandi**: assenza, night mode per un'ora, riscaldamento forzato,
|
||||
funzione riscaldamento on/off, ciclo legionella, ACS on/off e verifica
|
||||
impianto, con lo stato attuale in cima
|
||||
software, numero di errori e verifica impianto. Qui sta anche la frase di stato
|
||||
estesa ("Relax: la pompa di calore funziona regolarmente")
|
||||
- **Comandi impianto**: quattro interruttori (assenza, riscaldamento forzato,
|
||||
riscaldamento, acqua calda attiva) che mostrano lo stato e lo cambiano in un
|
||||
tocco, più i pulsanti per quello che uno stato non ce l'ha: ciclo legionella e
|
||||
night mode per un'ora. Gli interruttori seguono l'impianto, non lo anticipano
|
||||
- **Copertina** con la temperatura della casa
|
||||
- Indirizzo e token del bridge stanno nelle impostazioni dell'app (QSettings),
|
||||
mai nel binario
|
||||
@@ -43,13 +48,13 @@ Anteprime del layout (valori veri dell'impianto, **non** screenshot — vedi
|
||||
*Stato*). Queste sono in italiano; quelle in inglese stanno in
|
||||
`docs/anteprima/`:
|
||||
|
||||
| Home | Dati | Impianto |
|
||||
| Home | Regolazioni | Dati |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
| Altri comandi | Impostazioni | Copertina |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
| Impianto | Comandi impianto | Impostazioni | Copertina |
|
||||
|---|---|---|---|
|
||||
|  |  |  |  |
|
||||
|
||||
## La lingua dell'interfaccia
|
||||
|
||||
@@ -151,10 +156,12 @@ python3 tests/ui_contract_check.py http://192.168.3.21:8790
|
||||
|
||||
## Stato
|
||||
|
||||
- **61/61** verifiche del nucleo, compilazione senza warning
|
||||
- **79/79** verifiche del nucleo, compilazione senza warning (comprese le frasi
|
||||
di stato, breve ed estesa: quello che la Home dice dell'impianto è verificato
|
||||
senza telefono)
|
||||
- **48/48** verifiche offline del bridge (`bridge/test_bridge_commands.py`),
|
||||
comprese le conversioni Wh→kWh e degli importi delle statistiche
|
||||
- **170/170** stringhe tradotte nel catalogo italiano: il test carica il `.qm`
|
||||
- **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
|
||||
la sorgente e in italiano quella tradotta, e che togliendo il catalogo si torni
|
||||
all'inglese
|
||||
@@ -162,6 +169,11 @@ python3 tests/ui_contract_check.py http://192.168.3.21:8790
|
||||
posizione del simbolo di valuta seguono la lingua (verificato per entrambe)
|
||||
- `qmllint` pulito su tutti i QML (valida solo la sintassi: senza il modulo
|
||||
Silica una proprietà inesistente passa in silenzio)
|
||||
- **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
|
||||
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,
|
||||
@@ -188,16 +200,20 @@ src/translator.{h,cpp} lingua dell'interfaccia: locale di sistema, ripiego
|
||||
sull'inglese, ricarica della UI al cambio
|
||||
src/main.cpp context properties: appSettings, api, translator
|
||||
qml/harbour-aira.qml ApplicationWindow + copertina
|
||||
qml/pages/MainPage.qml home: stato, scheda impianto, riscaldamento e acqua
|
||||
qml/pages/MainPage.qml home: stato in una riga, due letture, azioni rapide
|
||||
qml/pages/SetpointsPage.qml obiettivi casa e acqua, valori rapidi, boost
|
||||
qml/pages/DataPage.qml energia, efficienza e risparmi
|
||||
qml/pages/PlantPage.qml stato, accessori, versioni, errori
|
||||
qml/pages/CommandsPage.qml gli altri comandi, con lo stato attuale in cima
|
||||
qml/pages/PlantPage.qml stato, accessori, versioni, errori, diagnostica
|
||||
qml/pages/CommandsPage.qml i comandi impianto, stato negli interruttori
|
||||
qml/pages/SettingsPage.qml
|
||||
qml/components/InfoRow.qml etichetta a sinistra, valore a destra
|
||||
qml/components/ValueRow.qml titolo e sottotitolo a sinistra, valore grande a destra
|
||||
qml/components/ReadingCell.qml lettura toccabile: titolo, valore grande, barra
|
||||
qml/components/NoticeBar.qml messaggi dei comandi, ancorati in fondo
|
||||
qml/components/ActionButton.qml pulsante di comando, con conferma opzionale
|
||||
tests/core_test.cpp verifiche del nucleo (headless)
|
||||
tests/ui_contract_check.py chiavi dei QML contro quelle del bridge (bridge attivo)
|
||||
tests/qml_check_bindings.py assegnazioni duplicate di proprieta' nei QML
|
||||
bridge/aira_bridge.py servizio HTTP/1.1 + JSON verso il cloud (gRPC)
|
||||
bridge/setup_creds.py salva le credenziali Aira (file 600)
|
||||
bridge/aira_probe.py sonda di sola lettura: login + stato impianto
|
||||
@@ -207,7 +223,7 @@ bridge/harbour-aira-bridge.service unit systemd utente
|
||||
bridge/README.md istruzioni di installazione del bridge (inglese)
|
||||
docs/PIANO.md piano di progetto e scelte
|
||||
docs/PROTOCOL.md API del bridge
|
||||
docs/UX-STUDIO.md studio UX e proposta per la 0.6.0
|
||||
docs/UX-STUDIO.md studio UX e cosa è cambiato nella 0.6.0
|
||||
docs/ux/ schemi dello studio UX
|
||||
translations/ harbour-aira-it.ts (l'inglese è la lingua sorgente)
|
||||
docs/anteprima/ anteprime di layout in inglese (in italiano: it/)
|
||||
|
||||
@@ -9,24 +9,30 @@ status, domestic hot water, and the house target temperature per zone.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Home**, organised like the official Aira app: outdoor temperature and
|
||||
*at home / away* at the top, a plain-language status line ("Relax: the heat
|
||||
pump is running smoothly"), then the **plant card** with the two readings that
|
||||
matter (room temperature and domestic hot water, each with its target).
|
||||
Tapping the card opens the plant page. Below that the controls: house target
|
||||
with a 5–30 °C slider (0.5 °C steps) and a confirmation before the command
|
||||
leaves, domestic hot water target (45–60 °C) and the boost buttons
|
||||
- **Home**, one screen to look at: a coloured dot with a one-line status
|
||||
("Everything is fine", "Heating the hot water"), the time of the last update,
|
||||
the two readings that matter (house and domestic hot water, each with its
|
||||
target) side by side and **tappable**, and three quick actions: boost, away
|
||||
mode and the plant commands. It fits without scrolling — the controls live in
|
||||
their own pages
|
||||
- **Setpoints** page: house target and hot water target, each with the value the
|
||||
plant is keeping right now, a slider (the proposal, the plant value does not
|
||||
move), a row of quick values for cold fingers and a confirmation button that
|
||||
only lights up when there is something to change
|
||||
- **Data** page: heat produced, electricity used, efficiency (COP) for hot water
|
||||
and heating, savings on the bill, CO₂ avoided and the Smart Tariff Control
|
||||
saving, month by month. These are the numbers Aira computes in the cloud,
|
||||
exactly as in the official app's *Data* tab
|
||||
- **Plant** page: status (connection, mode, away/night/forced heating, outdoor
|
||||
and indoor temperature, zones, last reading), accessories (the wall thermostat
|
||||
with temperature, humidity, signal, battery), software versions and the error
|
||||
count
|
||||
- **Other commands** page: away mode, night mode for one hour, force heating,
|
||||
heating function on/off, legionella cycle, DHW heating on/off and a plant
|
||||
check — with the current state at the top, so nothing is pressed blindly
|
||||
- **Plant** page: status (connection, mode, away/night/forced heating, manual
|
||||
mode, defrost, signature lights, pump state, allowed modes, outdoor and indoor
|
||||
temperature, zones, last reading), accessories (the wall thermostat with
|
||||
temperature, humidity, signal, battery), software versions, the error count and
|
||||
the plant check. The extended status sentence ("Relax: the heat pump is running
|
||||
smoothly") lives here
|
||||
- **Plant commands** page: four switches (away mode, forced heating, heating, hot
|
||||
water active) that show the state of the plant and change it in one tap, plus
|
||||
the buttons for what has no state: legionella cycle and night mode for one
|
||||
hour. The switches follow the plant — they never anticipate it
|
||||
- **Cover page** showing the house temperature
|
||||
- Address and bridge token live in the app settings (QSettings), never in the
|
||||
binary
|
||||
@@ -39,13 +45,13 @@ status, domestic hot water, and the house target temperature per zone.
|
||||
Layout previews (rendered with the real values the plant returned, **not**
|
||||
screenshots — see *Status*):
|
||||
|
||||
| Home | Data | Plant |
|
||||
| Home | Setpoints | Data |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
| Other commands | Settings | Cover |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
| Plant | Plant commands | Settings | Cover |
|
||||
|---|---|---|---|
|
||||
|  |  |  |  |
|
||||
|
||||
## Why there is a bridge
|
||||
|
||||
@@ -136,18 +142,25 @@ python3 tests/ui_contract_check.py http://192.168.3.21:8790
|
||||
|
||||
Verified so far:
|
||||
|
||||
- **61/61** core checks (`tests/core_test.cpp`), compiled with no warnings
|
||||
- **79/79** core checks (`tests/core_test.cpp`), compiled with no warnings —
|
||||
including the one-line and extended status sentences, so what the home says
|
||||
about the plant is verified without a phone
|
||||
- **48/48** bridge checks, offline (`bridge/test_bridge_commands.py`) —
|
||||
including that a zone command always carries every zone, that absurd values
|
||||
are refused (40 °C, zone 3, text), and the Wh→kWh / money conversions of the
|
||||
statistics
|
||||
- **170/170** strings translated in the Italian catalogue, checked by loading
|
||||
- **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
|
||||
in, Italian out, and back to English when the catalogue is removed
|
||||
- numbers, amounts and month names formatted through `QLocale`, so the decimal
|
||||
separator and the currency position follow the language (verified for both)
|
||||
- `qmllint` clean on every QML file. Note it validates syntax only: without the
|
||||
Silica module an invalid Silica property passes silently
|
||||
- **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 —
|
||||
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
|
||||
@@ -202,21 +215,25 @@ src/settings.{h,cpp} QSettings in AppConfigLocation (SailJail sandbox)
|
||||
src/translator.{h,cpp} UI language: system locale, English fallback, reload
|
||||
src/main.cpp context properties: appSettings, api, translator
|
||||
qml/harbour-aira.qml ApplicationWindow + cover
|
||||
qml/pages/MainPage.qml home: status, plant card, heating and water
|
||||
qml/pages/MainPage.qml home: one-line status, two readings, quick actions
|
||||
qml/pages/SetpointsPage.qml house and water targets, quick values, boost
|
||||
qml/pages/DataPage.qml energy, efficiency and savings
|
||||
qml/pages/PlantPage.qml status, accessories, versions, errors
|
||||
qml/pages/CommandsPage.qml the other commands, current state on top
|
||||
qml/pages/PlantPage.qml status, accessories, versions, errors, diagnostics
|
||||
qml/pages/CommandsPage.qml the plant commands, state shown by switches
|
||||
qml/pages/SettingsPage.qml
|
||||
qml/components/InfoRow.qml label on the left, value on the right
|
||||
qml/components/ValueRow.qml title + subtitle left, big value right
|
||||
qml/components/ReadingCell.qml tappable reading: title, big value, charge bar
|
||||
qml/components/NoticeBar.qml command messages, anchored to the bottom edge
|
||||
qml/components/ActionButton.qml command button, optional confirmation
|
||||
tests/core_test.cpp core checks (headless)
|
||||
tests/ui_contract_check.py QML keys vs bridge keys (bridge must be running)
|
||||
tests/qml_check_bindings.py duplicate property assignments in the QML
|
||||
bridge/ local HTTP bridge -> see bridge/README.md
|
||||
translations/ harbour-aira-it.ts (English is the source language)
|
||||
docs/PIANO.md project plan and decisions (Italian)
|
||||
docs/PROTOCOL.md bridge API
|
||||
docs/UX-STUDIO.md UX review + proposal for 0.6.0 (Italian)
|
||||
docs/UX-STUDIO.md UX review and what changed in 0.6.0 (Italian)
|
||||
docs/ux/ UX study wireframes
|
||||
docs/anteprima/ layout previews (English; docs/anteprima/it/ Italian)
|
||||
```
|
||||
|
||||
@@ -231,3 +231,70 @@ andrebbe disegnata a mano.
|
||||
parsing delle statistiche e alla formattazione di importi e mesi (61/61), versione,
|
||||
README e `docs/PROTOCOL.md` aggiornati.
|
||||
- Nessun comando nuovo all'impianto: tutto questo è **sola lettura**.
|
||||
|
||||
---
|
||||
|
||||
## 9. Studio UX/UI e riordino delle schermate — v0.6.0
|
||||
|
||||
Prima di scrivere codice è stato fatto lo studio in `docs/UX-STUDIO.md`: dieci
|
||||
problemi rilevati nelle schermate della 0.5.0, la proposta, i wireframe
|
||||
(`docs/ux/`, generati da `scripts/render_wireframes.py`) e il piano di verifica.
|
||||
Le quattro scelte di design sono state prese dall'autore: la frase di stato
|
||||
lunga va in Impianto, "Altri comandi" diventa "Comandi impianto", le azioni
|
||||
rapide sono pulsanti visibili, le azioni sulla copertina restano per dopo la
|
||||
prova di `CoverAction` sul kit.
|
||||
|
||||
**Home** (`MainPage.qml`, riscritta): una schermata da guardare, non un modulo.
|
||||
Stato in una riga con il pallino colorato (verde regolare, ambra modo attivo o
|
||||
numeri vecchi, rosso errore), le due letture che contano — casa e acqua calda,
|
||||
con obiettivo e barra di carica — affiancate e **toccabili**, e tre azioni
|
||||
rapide: boost, assenza, comandi. Escono dalla Home i due slider, i sette
|
||||
pulsanti e i tre pulsanti di navigazione ridondanti col menù a tendina. Sotto,
|
||||
in una riga toccabile che porta a Impianto: esterna, termostato, zone.
|
||||
|
||||
**Regolazioni** (`SetpointsPage.qml`, nuova): un blocco per grandezza, con il
|
||||
valore vero dell'impianto sempre visibile, slider (la proposta), fila di valori
|
||||
rapidi per le dita fredde e pulsante di conferma che si attiva solo se c'è
|
||||
davvero qualcosa da cambiare. Il boost ha qui i suoi due pulsanti.
|
||||
|
||||
**Comandi impianto** (`CommandsPage.qml`): da 12 pulsanti in quattro sezioni
|
||||
(otto in coppie acceso/spento) a **4 interruttori `TextSwitch` + 3 pulsanti**.
|
||||
Gli interruttori non anticipano l'impianto: `automaticCheck: false`, `checked`
|
||||
sull'ultimo dato vero, e se il comando non riesce restano dov'erano. La
|
||||
diagnostica (verifica impianto) si sposta in Impianto, insieme alle letture che
|
||||
non avevano posto: pompa, modi consentiti, modalità manuale, sbrinamento, luci
|
||||
di firma.
|
||||
|
||||
**Feedback e dati vecchi**: i messaggi dei comandi passano in `ApiClient`
|
||||
(`api.notice`) e si mostrano nel nuovo `NoticeBar`, **ancorato al bordo
|
||||
inferiore della pagina** invece che dentro il contenuto che scorre: prima,
|
||||
premendo un pulsante in fondo alla pagina, il messaggio nasceva fuori schermo.
|
||||
In più `stale` e `lastUpdate`: la Home scrive "aggiornato alle HH:MM" e spegne i
|
||||
numeri quando l'ultima lettura non è recente. Dopo un comando riuscito lo stato
|
||||
si rilegge da solo (2 s): l'aggiornamento automatico, prima, non esisteva.
|
||||
|
||||
**Stato breve ed esteso in C++** (`AiraJson::stateShort/stateLong/statusLevel`):
|
||||
logica pura, verificabile senza SDK e non duplicata fra Home e Impianto. Test
|
||||
del nucleo: **79/79** (erano 61). Stringhe: **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.
|
||||
|
||||
@@ -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: **rimandate** a dopo la prova di `CoverAction` sul kit.
|
||||
|
||||
Problemi risolti: P1 (Home di consultazione), P2 (feedback ancorato), P3
|
||||
(interruttori), P4 (un solo percorso per pagina), P5 (un blocco per
|
||||
grandezza, valori rapidi), P6 (numeri vecchi spenti + ora), P7 ("esterna"),
|
||||
P9, P10 (diagnostica in Impianto), P11.
|
||||
|
||||
Rimandato: P8 (azioni sulla copertina), in attesa della prova sul telefono.
|
||||
|
||||
Verifiche fatte qui: **79 verifiche del nucleo** (erano 61), `qmllint` pulito su
|
||||
tutti i file QML, 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
|
||||
|
||||
Tre riferimenti, in quest'ordine:
|
||||
|
||||
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 44 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: 31 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 44 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 |
@@ -0,0 +1,42 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
// Messaggio dell'ultimo comando, ancorato al bordo inferiore della pagina.
|
||||
//
|
||||
// Sta FUORI dal contenuto che scorre: prima il messaggio era la prima riga
|
||||
// della colonna, quindi premendo un pulsante in fondo alla pagina nasceva
|
||||
// fuori dallo schermo e non si capiva se il comando fosse partito.
|
||||
//
|
||||
// Lo stato sta in ApiClient (api.notice): qui si mostra e basta, cosi' il
|
||||
// messaggio e' lo stesso su qualsiasi pagina.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
bottom: parent.bottom
|
||||
}
|
||||
height: message.height + 2 * Theme.paddingMedium
|
||||
color: Qt.rgba(0, 0, 0, 0.78)
|
||||
opacity: api.notice.length > 0 ? 1 : 0
|
||||
visible: opacity > 0
|
||||
z: 100
|
||||
|
||||
Behavior on opacity { NumberAnimation { duration: 200 } }
|
||||
|
||||
Label {
|
||||
id: message
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
leftMargin: Theme.horizontalPageMargin
|
||||
rightMargin: Theme.horizontalPageMargin
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
text: api.notice
|
||||
color: api.noticeIsError ? Theme.errorColor : Theme.highlightColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
|
||||
// Riquadro di una lettura grande nella Home: titolo, valore, riga sotto e la
|
||||
// barra di avanzamento (facoltativa). Toccabile: porta alle regolazioni.
|
||||
//
|
||||
// La barra c'e' sempre, anche quando non serve, e resta trasparente: cosi' i
|
||||
// due riquadri affiancati hanno la stessa altezza senza vincoli circolari.
|
||||
BackgroundItem {
|
||||
id: root
|
||||
|
||||
property string title: ""
|
||||
property string value: ""
|
||||
property string subtitle: ""
|
||||
property real progress: 0
|
||||
property bool showBar: false
|
||||
property color valueColor: Theme.primaryColor
|
||||
|
||||
signal tapped()
|
||||
|
||||
height: content.height + 2 * Theme.paddingMedium
|
||||
|
||||
onClicked: root.tapped()
|
||||
|
||||
Column {
|
||||
id: content
|
||||
x: Theme.paddingMedium
|
||||
y: Theme.paddingMedium
|
||||
width: parent.width - 2 * Theme.paddingMedium
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
Label {
|
||||
width: parent.width
|
||||
text: root.title
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Label {
|
||||
width: parent.width
|
||||
text: root.value
|
||||
color: root.valueColor
|
||||
font.pixelSize: Theme.fontSizeHuge
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Label {
|
||||
width: parent.width
|
||||
text: root.subtitle
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
ProgressBar {
|
||||
width: parent.width
|
||||
value: Math.max(0, Math.min(1, root.progress))
|
||||
opacity: root.showBar ? 1 : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,40 @@ CoverBackground {
|
||||
return api.formatValue(v)
|
||||
}
|
||||
|
||||
Label {
|
||||
id: titleLabel
|
||||
// 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.
|
||||
function statusColor() {
|
||||
if (!api.hasData || api.stale)
|
||||
return "orange"
|
||||
var livello = api.statusLevel()
|
||||
if (livello === 2)
|
||||
return Theme.errorColor
|
||||
if (livello === 1)
|
||||
return Theme.highlightColor
|
||||
return "#4caf50"
|
||||
}
|
||||
|
||||
Row {
|
||||
id: intestazione
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: Theme.paddingLarge
|
||||
text: "Aira"
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Theme.paddingSmall
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: cover.statusColor()
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Aira"
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
@@ -29,8 +55,9 @@ CoverBackground {
|
||||
Label {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
// Spenta quando i numeri sono vecchi: non deve sembrare fresca.
|
||||
color: api.stale ? Theme.secondaryColor : Theme.primaryColor
|
||||
text: cover.t("zone1_temperature") + " °C"
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeHuge
|
||||
}
|
||||
|
||||
@@ -57,8 +84,13 @@ CoverBackground {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: Theme.paddingMedium
|
||||
text: qsTr("outdoor %1 °C").arg(cover.t("outdoor_temperature"))
|
||||
color: Theme.secondaryColor
|
||||
// Qui c'e' posto per dire che i numeri non sono freschi.
|
||||
text: api.stale
|
||||
? qsTr("Not updated · last %1").arg(api.lastUpdate)
|
||||
: qsTr("outside %1 °C").arg(cover.t("outdoor_temperature"))
|
||||
color: api.stale ? "orange" : Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
truncationMode: TruncationMode.Fade
|
||||
width: parent.width - 2 * Theme.paddingMedium
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,90 +2,142 @@ import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
import "../components"
|
||||
|
||||
// Comandi secondari: quelli che non stanno nella home. In cima lo stato
|
||||
// attuale, cosi' non si preme alla cieca.
|
||||
// Comandi impianto: i comandi che non stanno nella Home.
|
||||
//
|
||||
// Prima erano 12 pulsanti in quattro sezioni, otto dei quali in coppie
|
||||
// accendi/spegni, con lo stato da leggere in sei righe piu' in alto. Ora ogni
|
||||
// cosa che ha uno stato e' un interruttore: lo stato si vede e si cambia in un
|
||||
// tocco solo (docs/UX-STUDIO.md).
|
||||
//
|
||||
// Gli interruttori NON anticipano l'impianto: `checked` segue l'ultimo dato
|
||||
// vero (automaticCheck: false) e al tocco parte il comando. Se il comando non
|
||||
// riesce, l'interruttore non si muove; se riesce, lo stato arriva da solo
|
||||
// (ApiClient rilegge il riassunto due secondi dopo).
|
||||
Page {
|
||||
id: page
|
||||
|
||||
allowedOrientations: Orientation.All
|
||||
|
||||
property string notice: ""
|
||||
property bool noticeIsError: false
|
||||
|
||||
function raw(key) {
|
||||
return api.summary[key]
|
||||
}
|
||||
|
||||
function flag(key) {
|
||||
return page.raw(key) ? qsTr("yes") : qsTr("no")
|
||||
// La funzione riscaldamento e' abilitata quando i modi configurati della
|
||||
// pompa la comprendono ("PUMP_MODE_STATE_HEATING").
|
||||
function heatingEnabled() {
|
||||
return String(page.raw("configured_modes") || "").indexOf("HEATING") >= 0
|
||||
}
|
||||
|
||||
function stato(key) {
|
||||
return api.modeLabel(page.raw(key))
|
||||
Component.onCompleted: {
|
||||
if (api.configured)
|
||||
api.refresh()
|
||||
}
|
||||
|
||||
function showNotice(msg, isError) {
|
||||
page.notice = msg
|
||||
page.noticeIsError = isError === true
|
||||
noticeTimer.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: noticeTimer
|
||||
interval: 4000
|
||||
onTriggered: page.notice = ""
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: api
|
||||
onErrorOccurred: page.showNotice(message, true)
|
||||
onCommandFinished: page.showNotice(message, !ok)
|
||||
}
|
||||
// Fuori dai pulsanti/interruttori: dentro finirebbero nel loro contentItem.
|
||||
RemorseItem { id: remorse }
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: col.height + Theme.paddingLarge
|
||||
|
||||
PullDownMenu {
|
||||
MenuItem {
|
||||
text: qsTr("Refresh")
|
||||
onClicked: api.refresh()
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Data")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("DataPage.qml"))
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Plant")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("PlantPage.qml"))
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Settings")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: col
|
||||
width: parent.width
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
PageHeader { title: qsTr("Other commands") }
|
||||
PageHeader { title: qsTr("Plant commands") }
|
||||
|
||||
Label {
|
||||
visible: page.notice.length > 0
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.notice
|
||||
color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.Wrap
|
||||
// ---- modalita' -------------------------------------------------- //
|
||||
SectionHeader { text: qsTr("Modes") }
|
||||
|
||||
TextSwitch {
|
||||
id: awaySwitch
|
||||
text: qsTr("Away mode")
|
||||
description: qsTr("Lowers the house and stops the hot water")
|
||||
automaticCheck: false
|
||||
checked: page.raw("away_mode") === true
|
||||
onClicked: {
|
||||
if (checked)
|
||||
api.sendCommand("away_mode_off")
|
||||
else
|
||||
api.sendCommand("away_mode_on")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- stato attuale -------------------------------------------- //
|
||||
SectionHeader { text: qsTr("Current state") }
|
||||
TextSwitch {
|
||||
text: qsTr("Forced heating")
|
||||
description: qsTr("Heats at full power until you stop it")
|
||||
automaticCheck: false
|
||||
checked: page.raw("force_heating") === true
|
||||
onClicked: {
|
||||
if (checked)
|
||||
api.sendCommand("force_heating_off")
|
||||
else
|
||||
api.sendCommand("force_heating_on")
|
||||
}
|
||||
}
|
||||
|
||||
InfoRow { label: qsTr("Away mode"); value: page.flag("away_mode") }
|
||||
InfoRow { label: qsTr("Night mode"); value: page.flag("night_mode") }
|
||||
InfoRow { label: qsTr("Forced heating"); value: page.flag("force_heating") }
|
||||
InfoRow { label: qsTr("Hot water active"); value: page.flag("hot_water_heating") }
|
||||
InfoRow { label: qsTr("Pump"); value: page.stato("pump_active_state") }
|
||||
InfoRow { label: qsTr("Allowed modes"); value: page.stato("configured_modes") }
|
||||
TextSwitch {
|
||||
id: heatingSwitch
|
||||
text: qsTr("Heating")
|
||||
description: qsTr("The heating can turn on")
|
||||
automaticCheck: false
|
||||
checked: page.heatingEnabled()
|
||||
onClicked: {
|
||||
// Spegnere il riscaldamento ha conseguenze: si conferma.
|
||||
if (checked)
|
||||
remorse.execute(heatingSwitch, qsTr("Heating will be disabled"),
|
||||
function() { api.sendCommand("heating_off") })
|
||||
else
|
||||
api.sendCommand("heating_on")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- assenza --------------------------------------------------- //
|
||||
SectionHeader { text: qsTr("Away mode") }
|
||||
// ---- acqua calda ------------------------------------------------ //
|
||||
SectionHeader { text: qsTr("Domestic hot water") }
|
||||
|
||||
TextSwitch {
|
||||
id: waterSwitch
|
||||
text: qsTr("Hot water active")
|
||||
description: qsTr("The heat pump keeps the water in temperature")
|
||||
automaticCheck: false
|
||||
checked: page.raw("hot_water_heating") === true
|
||||
onClicked: {
|
||||
if (checked)
|
||||
remorse.execute(waterSwitch,
|
||||
qsTr("Domestic hot water will stop being heated"),
|
||||
function() { api.sendCommand("hot_water_heating_off") })
|
||||
else
|
||||
api.sendCommand("hot_water_heating_on")
|
||||
}
|
||||
}
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Turn away mode on")
|
||||
onTriggered: api.sendCommand("away_mode_on")
|
||||
}
|
||||
ActionButton {
|
||||
text: qsTr("Turn away mode off")
|
||||
onTriggered: api.sendCommand("away_mode_off")
|
||||
text: qsTr("Legionella cycle")
|
||||
confirmText: qsTr("Starting the legionella cycle: it takes a while")
|
||||
onTriggered: api.sendCommand("legionella_cycle")
|
||||
}
|
||||
|
||||
// ---- rumore ---------------------------------------------------- //
|
||||
// ---- rumore ----------------------------------------------------- //
|
||||
SectionHeader { text: qsTr("Noise") }
|
||||
|
||||
ActionButton {
|
||||
@@ -93,59 +145,13 @@ Page {
|
||||
onTriggered: api.sendCommand("night_mode_1h")
|
||||
}
|
||||
|
||||
// ---- riscaldamento --------------------------------------------- //
|
||||
SectionHeader { text: qsTr("Heating") }
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Force heating")
|
||||
confirmText: qsTr("Starting forced heating")
|
||||
onTriggered: api.sendCommand("force_heating_on")
|
||||
}
|
||||
ActionButton {
|
||||
text: qsTr("Stop forced heating")
|
||||
onTriggered: api.sendCommand("force_heating_off")
|
||||
}
|
||||
ActionButton {
|
||||
text: qsTr("Enable heating function")
|
||||
onTriggered: api.sendCommand("heating_on")
|
||||
}
|
||||
ActionButton {
|
||||
text: qsTr("Disable heating function")
|
||||
confirmText: qsTr("Heating will be disabled")
|
||||
onTriggered: api.sendCommand("heating_off")
|
||||
}
|
||||
|
||||
// ---- acqua calda ----------------------------------------------- //
|
||||
SectionHeader { text: qsTr("Domestic hot water") }
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Legionella cycle")
|
||||
confirmText: qsTr("Starting the legionella cycle: it takes a while")
|
||||
onTriggered: api.sendCommand("legionella_cycle")
|
||||
}
|
||||
ActionButton {
|
||||
text: qsTr("Enable DHW heating")
|
||||
onTriggered: api.sendCommand("hot_water_heating_on")
|
||||
}
|
||||
ActionButton {
|
||||
text: qsTr("Disable DHW heating")
|
||||
confirmText: qsTr("Domestic hot water will stop being heated")
|
||||
onTriggered: api.sendCommand("hot_water_heating_off")
|
||||
}
|
||||
|
||||
// ---- diagnostica ----------------------------------------------- //
|
||||
SectionHeader { text: qsTr("Diagnostics") }
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Plant check")
|
||||
onTriggered: api.sendCommand("ping")
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
visible: api.busy
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
size: BusyIndicatorSize.Small
|
||||
running: true
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: qsTr("The switches show the current state of the plant: the Status section is not needed any more.")
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Label {
|
||||
@@ -156,11 +162,10 @@ Page {
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Item { width: 1; height: Theme.paddingMedium }
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (api.configured)
|
||||
api.refresh()
|
||||
}
|
||||
}
|
||||
NoticeBar { }
|
||||
}
|
||||
|
||||
@@ -109,15 +109,8 @@ Page {
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: page.error.length > 0
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.error
|
||||
color: Theme.errorColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
// Gli errori non si mostrano piu' qui dentro: il messaggio arriva
|
||||
// ancorato in fondo alla pagina (NoticeBar), dove si vede sempre.
|
||||
|
||||
Label {
|
||||
visible: Object.keys(page.stats()).length === 0 && page.error.length === 0
|
||||
@@ -242,6 +235,8 @@ Page {
|
||||
}
|
||||
}
|
||||
|
||||
NoticeBar { }
|
||||
|
||||
Component.onCompleted: {
|
||||
if (api.configured) {
|
||||
page.loading = true
|
||||
|
||||
@@ -2,27 +2,65 @@ import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
import "../components"
|
||||
|
||||
// Dashboard dell'impianto, ordinata come la schermata iniziale dell'app
|
||||
// ufficiale Aira: in alto la temperatura esterna, poi il messaggio di stato,
|
||||
// poi la scheda dell'impianto con le due letture principali (casa e acqua
|
||||
// calda). I comandi restano piu' sotto, perche' qui si possono dare davvero.
|
||||
// Legge api.summary (QVariantMap dal C++), quindi non dipende dallo scope
|
||||
// delle property del main qml.
|
||||
// Home: una schermata da GUARDARE, non un modulo da compilare.
|
||||
//
|
||||
// Stato in una riga, le due letture che contano (casa e acqua calda), tre
|
||||
// azioni rapide. Le regolazioni stanno in SetpointsPage, gli altri comandi in
|
||||
// CommandsPage, i numeri in DataPage, i dettagli in PlantPage: qui solo quello
|
||||
// che si guarda molte volte al giorno, senza scorrere (docs/UX-STUDIO.md).
|
||||
Page {
|
||||
id: page
|
||||
|
||||
allowedOrientations: Orientation.All
|
||||
|
||||
// obiettivo ACS scelto dall'utente (non sovrascritto dal refresh finche'
|
||||
// non lo tocca, altrimenti lo slider "salta" a ogni lettura)
|
||||
property int targetAcs: 55
|
||||
property bool targetAcsTouched: false
|
||||
// obiettivo della casa (zona 1); parte dalla temperatura attuale e non
|
||||
// viene sovrascritto dal refresh finche' l'utente non lo tocca
|
||||
property real targetCasa: 20
|
||||
property bool targetCasaTouched: false
|
||||
property string notice: ""
|
||||
property bool noticeIsError: false
|
||||
function raw(key) {
|
||||
return api.summary[key]
|
||||
}
|
||||
|
||||
// I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il
|
||||
// separatore decimale segue la lingua dell'app, non il toFixed di JS.
|
||||
function fmt(value) { return api.formatValue(value) }
|
||||
|
||||
function count(key) {
|
||||
var v = page.raw(key)
|
||||
if (v === undefined || v === null)
|
||||
return "—"
|
||||
return String(Math.round(Number(v)))
|
||||
}
|
||||
|
||||
// Colore del pallino di stato. Il livello (0 regolare, 1 modo attivo,
|
||||
// 2 errore) lo calcola il C++; qui si sceglie il colore, e l'ambra quando
|
||||
// i numeri sono vecchi: un dato vecchio non e' un dato buono.
|
||||
function statusColor() {
|
||||
if (!api.hasData || api.stale)
|
||||
return "orange"
|
||||
var livello = api.statusLevel()
|
||||
if (livello === 2)
|
||||
return Theme.errorColor
|
||||
if (livello === 1)
|
||||
return Theme.highlightColor
|
||||
return "#4caf50"
|
||||
}
|
||||
|
||||
// Quanto e' carica l'acqua calda verso l'obiettivo (da 10 °C in su).
|
||||
function waterProgress() {
|
||||
var ora = Number(page.raw("dhw_current"))
|
||||
var obiettivo = Number(page.raw("dhw_target"))
|
||||
if (!(obiettivo > 10) || isNaN(ora))
|
||||
return 0
|
||||
return (ora - 10) / (obiettivo - 10)
|
||||
}
|
||||
|
||||
// Riga sotto il titolo: dice anche quando i numeri sono vecchi.
|
||||
function updateText() {
|
||||
if (!api.hasData)
|
||||
return qsTr("No data yet")
|
||||
if (api.stale)
|
||||
return qsTr("Not updated · last %1").arg(api.lastUpdate)
|
||||
if (api.lastUpdate.length === 0)
|
||||
return ""
|
||||
return qsTr("Updated at %1").arg(api.lastUpdate)
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
|
||||
@@ -40,100 +78,18 @@ Page {
|
||||
pageStack.push(Qt.resolvedUrl("DataPage.qml"))
|
||||
}
|
||||
|
||||
function showNotice(msg, isError) {
|
||||
page.notice = msg
|
||||
page.noticeIsError = isError === true
|
||||
noticeTimer.restart()
|
||||
}
|
||||
|
||||
function raw(key) {
|
||||
return api.summary[key]
|
||||
}
|
||||
|
||||
// I numeri si formattano nel C++ (ApiClient::formatValue): cosi' il
|
||||
// separatore decimale segue la lingua dell'app, non il toFixed di JS.
|
||||
function fmt(value) { return api.formatValue(value) }
|
||||
|
||||
function temp(key) {
|
||||
return api.formatValue(page.raw(key))
|
||||
}
|
||||
|
||||
function count(key) {
|
||||
var v = page.raw(key)
|
||||
if (v === undefined || v === null)
|
||||
return "—"
|
||||
return String(Math.round(Number(v)))
|
||||
}
|
||||
|
||||
// "2026-10-09 18:52:17.199340" -> "18:52"
|
||||
function shortTime(s) {
|
||||
if (!s)
|
||||
return "—"
|
||||
var parts = String(s).split(" ")
|
||||
if (parts.length < 2)
|
||||
return String(s)
|
||||
return parts[1].substring(0, 5)
|
||||
}
|
||||
|
||||
// Messaggio grande in cima: e' la frase che l'app ufficiale mette al centro
|
||||
// della schermata iniziale ("Relax, la tua pompa di calore..."). Si sceglie
|
||||
// in ordine di importanza: errori, modalita' attive, pompa in funzione.
|
||||
function stateMessage() {
|
||||
if (!api.configured)
|
||||
return ""
|
||||
var errs = Number(page.raw("errors_count"))
|
||||
if (errs > 0)
|
||||
return (errs === 1)
|
||||
? qsTr("Attention: one error on the plant")
|
||||
: qsTr("Attention: %1 errors on the plant").arg(errs)
|
||||
if (page.raw("away_mode") === true)
|
||||
return qsTr("Away mode: the house is saving energy.")
|
||||
if (page.raw("night_mode") === true)
|
||||
return qsTr("Night mode is on.")
|
||||
if (page.raw("force_heating") === true)
|
||||
return qsTr("Boost: the house is heating at full power.")
|
||||
if (page.raw("hot_water_heating") === true)
|
||||
return qsTr("The heat pump is heating the hot water.")
|
||||
if (String(page.raw("pump_active_state") || "").indexOf("RUNNING") >= 0)
|
||||
return qsTr("The heat pump is working.")
|
||||
return qsTr("Relax: the heat pump is running smoothly.")
|
||||
}
|
||||
|
||||
// Riga piccola sotto il messaggio: modalita' e ora dell'ultima lettura.
|
||||
function statusDetail() {
|
||||
if (!api.configured)
|
||||
return ""
|
||||
return qsTr("%1 · last reading %2")
|
||||
.arg(api.modeLabel(page.raw("operating_status")))
|
||||
.arg(page.shortTime(page.raw("pump_updated")))
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: noticeTimer
|
||||
interval: 4000
|
||||
onTriggered: page.notice = ""
|
||||
// UNA sola assegnazione: in Qt 5.6 assegnare Component.onCompleted due volte
|
||||
// e' un errore di compilazione del file, e la pagina non si crea piu'
|
||||
// ("Type MainPage unavailable" e app senza Home).
|
||||
Component.onCompleted: {
|
||||
if (api.configured)
|
||||
api.refresh()
|
||||
misura.start()
|
||||
}
|
||||
|
||||
// Fuori dal pulsante: dentro il pulsante i figli finiscono nel contentItem.
|
||||
RemorseItem { id: remorse }
|
||||
|
||||
Connections {
|
||||
target: api
|
||||
onErrorOccurred: page.showNotice(message, true)
|
||||
onCommandFinished: page.showNotice(message, !ok)
|
||||
onSummaryChanged: {
|
||||
if (!page.targetAcsTouched) {
|
||||
var v = page.raw("dhw_target")
|
||||
if (v !== undefined && v !== null)
|
||||
page.targetAcs = Math.round(Number(v))
|
||||
}
|
||||
if (!page.targetCasaTouched) {
|
||||
var c = page.raw("zone1_setpoint")
|
||||
if (c !== undefined && c !== null && Number(c) > 0)
|
||||
page.targetCasa = Math.round(Number(c) * 2) / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SilicaFlickable {
|
||||
id: flick
|
||||
anchors.fill: parent
|
||||
@@ -154,10 +110,6 @@ Page {
|
||||
text: qsTr("Plant")
|
||||
onClicked: page.openPlant()
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Other commands")
|
||||
onClicked: page.openCommands()
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Settings")
|
||||
onClicked: page.openSettings()
|
||||
@@ -169,17 +121,9 @@ Page {
|
||||
width: parent.width
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
PageHeader { title: "Aira" }
|
||||
|
||||
// ---- avvisi ---------------------------------------------------- //
|
||||
Label {
|
||||
visible: page.notice.length > 0
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.notice
|
||||
color: page.noticeIsError ? Theme.errorColor : Theme.highlightColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.Wrap
|
||||
PageHeader {
|
||||
title: "Aira"
|
||||
description: page.updateText()
|
||||
}
|
||||
|
||||
// ---- bridge non configurato ------------------------------------ //
|
||||
@@ -200,300 +144,169 @@ Page {
|
||||
onClicked: page.openSettings()
|
||||
}
|
||||
|
||||
// ---- barra in alto: fuori casa / a casa ------------------------ //
|
||||
// ---- stato in una riga ----------------------------------------- //
|
||||
Item {
|
||||
visible: api.configured
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
height: fuori.height
|
||||
height: Math.max(stato.height, pallino.height, attesa.height)
|
||||
|
||||
Label {
|
||||
id: fuori
|
||||
anchors.left: parent.left
|
||||
text: qsTr("Outside %1 °C").arg(page.temp("outdoor_temperature"))
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
Rectangle {
|
||||
id: pallino
|
||||
width: Theme.fontSizeSmall * 0.6
|
||||
height: width
|
||||
radius: width / 2
|
||||
color: page.statusColor()
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Label {
|
||||
id: stato
|
||||
anchors {
|
||||
left: pallino.right
|
||||
leftMargin: Theme.paddingMedium
|
||||
right: attesa.left
|
||||
rightMargin: Theme.paddingMedium
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
text: api.stateShort()
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
id: attesa
|
||||
anchors {
|
||||
right: parent.right
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
text: page.raw("away_mode") === true ? qsTr("Away") : qsTr("At home")
|
||||
color: page.raw("away_mode") === true ? Theme.highlightColor : Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
size: BusyIndicatorSize.Small
|
||||
running: api.busy
|
||||
visible: api.busy
|
||||
}
|
||||
}
|
||||
|
||||
// ---- messaggio di stato (il "centro" della schermata ufficiale) - //
|
||||
Label {
|
||||
// ---- le due letture che contano -------------------------------- //
|
||||
Row {
|
||||
id: letture
|
||||
visible: api.configured
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.stateMessage()
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
ReadingCell {
|
||||
width: (letture.width - letture.spacing) / 2
|
||||
title: qsTr("House")
|
||||
value: page.fmt(page.raw("zone1_temperature")) + " °C"
|
||||
subtitle: qsTr("target %1 °C").arg(page.fmt(page.raw("zone1_setpoint")))
|
||||
valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor
|
||||
onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml"))
|
||||
}
|
||||
|
||||
ReadingCell {
|
||||
width: (letture.width - letture.spacing) / 2
|
||||
title: qsTr("Hot water")
|
||||
value: page.fmt(page.raw("dhw_current")) + " °C"
|
||||
subtitle: qsTr("target %1 °C").arg(page.fmt(page.raw("dhw_target")))
|
||||
progress: page.waterProgress()
|
||||
showBar: true
|
||||
valueColor: api.stale ? Theme.secondaryColor : Theme.primaryColor
|
||||
onTapped: pageStack.push(Qt.resolvedUrl("SetpointsPage.qml"))
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: api.configured
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: page.statusDetail()
|
||||
text: qsTr("Tap a value to adjust it")
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
// ---- scheda dell'impianto -------------------------------------- //
|
||||
// Come la scheda dispositivo dell'app ufficiale: intestazione con
|
||||
// nome e connessione, poi le due letture principali. Toccandola si
|
||||
// apre la pagina "Impianto" (accessori, versioni, errori).
|
||||
// ---- azioni rapide --------------------------------------------- //
|
||||
SectionHeader {
|
||||
visible: api.configured
|
||||
text: qsTr("Quick actions")
|
||||
}
|
||||
|
||||
Row {
|
||||
visible: api.configured
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
Button {
|
||||
id: boostBtn
|
||||
width: (parent.width - 2 * parent.spacing) / 3
|
||||
text: qsTr("Boost")
|
||||
onClicked: remorse.execute(boostBtn, qsTr("Starting DHW boost"),
|
||||
function() { api.sendCommand("hot_water_boost_on") })
|
||||
}
|
||||
|
||||
Button {
|
||||
id: assenzaBtn
|
||||
width: (parent.width - 2 * parent.spacing) / 3
|
||||
// Il pulsante dice cosa succede premendolo, non lo stato.
|
||||
text: page.raw("away_mode") ? qsTr("Back home") : qsTr("Away mode")
|
||||
onClicked: {
|
||||
if (page.raw("away_mode")) {
|
||||
remorse.execute(assenzaBtn, qsTr("Away mode off"),
|
||||
function() { api.sendCommand("away_mode_off") })
|
||||
} else {
|
||||
remorse.execute(assenzaBtn, qsTr("Away mode on"),
|
||||
function() { api.sendCommand("away_mode_on") })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
width: (parent.width - 2 * parent.spacing) / 3
|
||||
text: qsTr("Commands")
|
||||
onClicked: page.openCommands()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- letture di contorno (si toccano: portano a Impianto) ------ //
|
||||
BackgroundItem {
|
||||
id: scheda
|
||||
visible: api.configured
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
height: schedaCol.height + 3 * Theme.paddingMedium
|
||||
height: contornoTxt.height + Theme.paddingMedium
|
||||
onClicked: page.openPlant()
|
||||
|
||||
Column {
|
||||
id: schedaCol
|
||||
x: Theme.paddingMedium
|
||||
y: Theme.paddingMedium
|
||||
width: parent.width - 2 * Theme.paddingMedium
|
||||
spacing: Theme.paddingMedium
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: intestazione.height
|
||||
|
||||
Column {
|
||||
id: intestazione
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: freccia.left
|
||||
rightMargin: Theme.paddingSmall
|
||||
}
|
||||
|
||||
Label {
|
||||
text: qsTr("Aira heat pump")
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
Label {
|
||||
text: qsTr("Connected through the cloud")
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
id: freccia
|
||||
anchors {
|
||||
right: parent.right
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
text: "›"
|
||||
color: Theme.highlightColor
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
}
|
||||
}
|
||||
|
||||
ValueRow {
|
||||
title: qsTr("Room temperature")
|
||||
subtitle: qsTr("Target %1 °C").arg(page.temp("zone1_setpoint"))
|
||||
value: page.temp("zone1_temperature") + " °C"
|
||||
}
|
||||
|
||||
ValueRow {
|
||||
title: qsTr("Domestic hot water")
|
||||
subtitle: qsTr("Target %1 °C").arg(page.temp("dhw_target"))
|
||||
value: page.temp("dhw_current") + " °C"
|
||||
Label {
|
||||
id: contornoTxt
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
text: qsTr("Outside %1 °C · Thermostat %2 °C · Zones %3")
|
||||
.arg(page.fmt(page.raw("outdoor_temperature")))
|
||||
.arg(page.fmt(page.raw("thermostat_temperature")))
|
||||
.arg(page.count("zones"))
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// ---- riscaldamento della casa ---------------------------------- //
|
||||
SectionHeader { visible: api.configured; text: qsTr("Heating the house") }
|
||||
|
||||
Label {
|
||||
visible: api.configured
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Heating setpoint %1 °C · %2")
|
||||
.arg(page.temp("zone1_setpoint"))
|
||||
.arg(api.modeLabel(page.raw("zone1_mode")))
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: casaSlider
|
||||
visible: api.configured
|
||||
width: parent.width
|
||||
label: qsTr("New house target")
|
||||
minimumValue: 5
|
||||
maximumValue: 30
|
||||
stepSize: 0.5
|
||||
value: page.targetCasa
|
||||
valueText: page.fmt(value) + " °C"
|
||||
onValueChanged: {
|
||||
page.targetCasaTouched = true
|
||||
page.targetCasa = Math.round(value * 2) / 2
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: setCasaButton
|
||||
visible: api.configured
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
leftMargin: Theme.horizontalPageMargin
|
||||
rightMargin: Theme.horizontalPageMargin
|
||||
}
|
||||
text: qsTr("Set house to %1 °C").arg(page.fmt(page.targetCasa))
|
||||
onClicked: remorse.execute(setCasaButton,
|
||||
qsTr("House temperature to %1 °C").arg(page.fmt(page.targetCasa)),
|
||||
function() { api.sendCommand("set_heating_setpoint", page.targetCasa) })
|
||||
}
|
||||
|
||||
// ---- acqua calda sanitaria ------------------------------------- //
|
||||
SectionHeader { visible: api.configured; text: qsTr("Domestic hot water") }
|
||||
|
||||
ProgressBar {
|
||||
visible: api.configured
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: parent.width - 4 * Theme.horizontalPageMargin
|
||||
minimumValue: 20
|
||||
maximumValue: 65
|
||||
value: {
|
||||
var v = page.raw("dhw_current")
|
||||
return (v === undefined || v === null) ? 0 : Number(v)
|
||||
}
|
||||
}
|
||||
|
||||
Slider {
|
||||
visible: api.configured
|
||||
width: parent.width
|
||||
label: qsTr("New target")
|
||||
minimumValue: 45
|
||||
maximumValue: 60
|
||||
stepSize: 1
|
||||
value: page.targetAcs
|
||||
valueText: page.fmt(value) + " °C"
|
||||
onValueChanged: {
|
||||
page.targetAcsTouched = true
|
||||
page.targetAcs = Math.round(value)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: setAcsButton
|
||||
visible: api.configured
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
leftMargin: Theme.horizontalPageMargin
|
||||
rightMargin: Theme.horizontalPageMargin
|
||||
}
|
||||
text: qsTr("Set target %1 °C").arg(page.targetAcs)
|
||||
onClicked: remorse.execute(setAcsButton,
|
||||
qsTr("DHW target to %1 °C").arg(page.targetAcs),
|
||||
function() { api.sendCommand("set_hot_water_target", page.targetAcs) })
|
||||
}
|
||||
|
||||
Button {
|
||||
id: boostOnButton
|
||||
visible: api.configured
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
leftMargin: Theme.horizontalPageMargin
|
||||
rightMargin: Theme.horizontalPageMargin
|
||||
}
|
||||
text: qsTr("Start DHW boost")
|
||||
onClicked: remorse.execute(boostOnButton, qsTr("Starting DHW boost"),
|
||||
function() { api.sendCommand("hot_water_boost_on") })
|
||||
}
|
||||
|
||||
Button {
|
||||
id: boostOffButton
|
||||
visible: api.configured
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
leftMargin: Theme.horizontalPageMargin
|
||||
rightMargin: Theme.horizontalPageMargin
|
||||
}
|
||||
text: qsTr("Stop DHW boost")
|
||||
onClicked: remorse.execute(boostOffButton, qsTr("Stopping DHW boost"),
|
||||
function() { api.sendCommand("hot_water_boost_off") })
|
||||
}
|
||||
|
||||
// ---- altre pagine ---------------------------------------------- //
|
||||
SectionHeader { visible: api.configured; text: qsTr("Details") }
|
||||
|
||||
Button {
|
||||
visible: api.configured
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
leftMargin: Theme.horizontalPageMargin
|
||||
rightMargin: Theme.horizontalPageMargin
|
||||
}
|
||||
text: qsTr("Plant and accessories")
|
||||
onClicked: page.openPlant()
|
||||
}
|
||||
|
||||
Button {
|
||||
visible: api.configured
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
leftMargin: Theme.horizontalPageMargin
|
||||
rightMargin: Theme.horizontalPageMargin
|
||||
}
|
||||
text: qsTr("Consumption and savings")
|
||||
onClicked: page.openData()
|
||||
}
|
||||
|
||||
Button {
|
||||
visible: api.configured
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
leftMargin: Theme.horizontalPageMargin
|
||||
rightMargin: Theme.horizontalPageMargin
|
||||
}
|
||||
text: qsTr("Other commands")
|
||||
onClicked: page.openCommands()
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
visible: api.busy
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
size: BusyIndicatorSize.Small
|
||||
running: true
|
||||
}
|
||||
|
||||
Label {
|
||||
visible: api.configured
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: qsTr("Commands are sent to the bridge and from there to the plant: they may take a few seconds.")
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Item { width: 1; height: Theme.paddingMedium }
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (api.configured)
|
||||
api.refresh()
|
||||
NoticeBar { }
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,19 @@ Page {
|
||||
|
||||
PageHeader { title: qsTr("Plant") }
|
||||
|
||||
// La frase estesa dell'app ufficiale ("Relax: la pompa di calore
|
||||
// funziona regolarmente"): sta qui e non in Home, dove serve
|
||||
// spazio per le due letture. La calcola il C++ (AiraJson::stateLong).
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: api.stateLong()
|
||||
color: Theme.primaryColor
|
||||
font.pixelSize: Theme.fontSizeMedium
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
// ---- stato ------------------------------------------------------ //
|
||||
SectionHeader { text: qsTr("Status") }
|
||||
|
||||
@@ -86,6 +99,11 @@ Page {
|
||||
InfoRow { label: qsTr("Night mode"); value: page.flag("night_mode") }
|
||||
InfoRow { label: qsTr("Forced heating"); value: page.flag("force_heating") }
|
||||
InfoRow { label: qsTr("Inline heater"); value: page.flag("inline_heater") }
|
||||
InfoRow { label: qsTr("Manual mode"); value: page.flag("manual_mode") }
|
||||
InfoRow { label: qsTr("Defrost"); value: page.flag("outdoor_defrost") }
|
||||
InfoRow { label: qsTr("Signature lights"); value: page.flag("signature_lights") }
|
||||
InfoRow { label: qsTr("Pump"); value: api.modeLabel(page.raw("pump_active_state")) }
|
||||
InfoRow { label: qsTr("Allowed modes"); value: api.modeLabel(page.raw("configured_modes")) }
|
||||
InfoRow { label: qsTr("Outdoor temperature"); value: page.temp("outdoor_temperature") }
|
||||
InfoRow { label: qsTr("Indoor temperature (pump)"); value: page.temp("indoor_temperature") }
|
||||
InfoRow { label: qsTr("Zones"); value: page.count("zones") }
|
||||
@@ -176,10 +194,20 @@ Page {
|
||||
value: page.count("errors_count")
|
||||
}
|
||||
|
||||
// ---- diagnostica ------------------------------------------------ //
|
||||
SectionHeader { text: qsTr("Diagnostics") }
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Plant check")
|
||||
onTriggered: api.sendCommand("ping")
|
||||
}
|
||||
|
||||
Item { width: 1; height: Theme.paddingMedium }
|
||||
}
|
||||
}
|
||||
|
||||
NoticeBar { }
|
||||
|
||||
Component.onCompleted: {
|
||||
if (api.configured)
|
||||
api.refresh()
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import QtQuick 2.2
|
||||
import Sailfish.Silica 1.0
|
||||
import "../components"
|
||||
|
||||
// Regolazioni: le grandezze che si cambiano (obiettivo casa, obiettivo acqua
|
||||
// calda) e il boost, ognuna con il valore attuale sempre visibile, le
|
||||
// preimpostazioni per le dita fredde e la conferma esplicita.
|
||||
//
|
||||
// Pagina a parte perche' la Home deve restare una schermata da guardare: due
|
||||
// slider dentro la pagina che scorre si contendevano anche il trascinamento.
|
||||
// Qui lo slider e' la PROPOSTA, il numero grande e' quello vero dell'impianto:
|
||||
// i due coincidono solo dopo "Imposta".
|
||||
Page {
|
||||
id: page
|
||||
|
||||
allowedOrientations: Orientation.All
|
||||
|
||||
property real pendingHouse: 0
|
||||
property bool houseTouched: false
|
||||
property real pendingWater: 0
|
||||
property bool waterTouched: false
|
||||
|
||||
function raw(key) {
|
||||
return api.summary[key]
|
||||
}
|
||||
|
||||
function fmt(value) { return api.formatValue(value) }
|
||||
|
||||
// Obiettivo vero dell'impianto (zona 1), a passi di mezzo grado.
|
||||
function houseTarget() {
|
||||
var v = page.raw("zone1_setpoint")
|
||||
if (v === undefined || v === null || Number(v) <= 0)
|
||||
return 0
|
||||
return Math.round(Number(v) * 2) / 2
|
||||
}
|
||||
|
||||
function waterTarget() {
|
||||
var v = page.raw("dhw_target")
|
||||
if (v === undefined || v === null)
|
||||
return 0
|
||||
return Math.round(Number(v))
|
||||
}
|
||||
|
||||
// Allinea le proposte ai valori dell'impianto, ma non sovrascrive quello
|
||||
// che l'utente sta scegliendo.
|
||||
function syncFromPlant() {
|
||||
if (!page.houseTouched)
|
||||
page.pendingHouse = page.houseTarget()
|
||||
if (!page.waterTouched)
|
||||
page.pendingWater = page.waterTarget()
|
||||
}
|
||||
|
||||
function houseChanged() { return Math.abs(page.pendingHouse - page.houseTarget()) > 0.001 }
|
||||
function waterChanged() { return Math.abs(page.pendingWater - page.waterTarget()) > 0.001 }
|
||||
|
||||
Component.onCompleted: {
|
||||
page.syncFromPlant()
|
||||
if (api.configured)
|
||||
api.refresh()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: api
|
||||
onSummaryChanged: page.syncFromPlant()
|
||||
}
|
||||
|
||||
SilicaFlickable {
|
||||
anchors.fill: parent
|
||||
contentHeight: col.height + Theme.paddingLarge
|
||||
|
||||
PullDownMenu {
|
||||
MenuItem {
|
||||
text: qsTr("Refresh")
|
||||
onClicked: api.refresh()
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Data")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("DataPage.qml"))
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Plant")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("PlantPage.qml"))
|
||||
}
|
||||
MenuItem {
|
||||
text: qsTr("Settings")
|
||||
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: col
|
||||
width: parent.width
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
PageHeader { title: qsTr("Setpoints") }
|
||||
|
||||
// ---- riscaldamento casa ---------------------------------------- //
|
||||
SectionHeader { text: qsTr("Heating the house") }
|
||||
|
||||
ValueRow {
|
||||
title: qsTr("Current target")
|
||||
subtitle: qsTr("What the plant is keeping now")
|
||||
value: page.fmt(page.houseTarget()) + " °C"
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: casaSlider
|
||||
width: parent.width
|
||||
label: qsTr("New house target")
|
||||
minimumValue: 5
|
||||
maximumValue: 30
|
||||
stepSize: 0.5
|
||||
value: page.pendingHouse
|
||||
valueText: page.fmt(value) + " °C"
|
||||
onValueChanged: {
|
||||
// Solo un movimento vero dell'utente segna "toccato": il
|
||||
// valore che arriva dall'impianto non deve bloccarlo.
|
||||
if (Math.abs(page.pendingHouse - value) > 0.001) {
|
||||
page.houseTouched = true
|
||||
page.pendingHouse = Math.round(value * 2) / 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: col.width - 2 * Theme.horizontalPageMargin
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
Repeater {
|
||||
model: [16, 17, 18, 19, 20, 21]
|
||||
|
||||
Button {
|
||||
width: (col.width - 2 * Theme.horizontalPageMargin
|
||||
- 5 * Theme.paddingSmall) / 6
|
||||
text: modelData + "°"
|
||||
onClicked: {
|
||||
page.houseTouched = true
|
||||
page.pendingHouse = modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Set house to %1 °C").arg(page.fmt(page.pendingHouse))
|
||||
confirmText: qsTr("House temperature to %1 °C").arg(page.fmt(page.pendingHouse))
|
||||
enabled: page.houseChanged()
|
||||
onTriggered: api.sendCommand("set_heating_setpoint", page.pendingHouse)
|
||||
}
|
||||
|
||||
// ---- acqua calda sanitaria ------------------------------------- //
|
||||
SectionHeader { text: qsTr("Domestic hot water") }
|
||||
|
||||
ValueRow {
|
||||
title: qsTr("DHW target")
|
||||
subtitle: qsTr("Water now %1 °C").arg(page.fmt(page.raw("dhw_current")))
|
||||
value: page.fmt(page.waterTarget()) + " °C"
|
||||
}
|
||||
|
||||
Slider {
|
||||
id: acquaSlider
|
||||
width: parent.width
|
||||
label: qsTr("New target")
|
||||
minimumValue: 45
|
||||
maximumValue: 60
|
||||
stepSize: 1
|
||||
value: page.pendingWater
|
||||
valueText: page.fmt(value) + " °C"
|
||||
onValueChanged: {
|
||||
if (Math.abs(page.pendingWater - value) > 0.001) {
|
||||
page.waterTouched = true
|
||||
page.pendingWater = Math.round(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: col.width - 2 * Theme.horizontalPageMargin
|
||||
spacing: Theme.paddingSmall
|
||||
|
||||
Repeater {
|
||||
model: [45, 47, 50, 55, 60]
|
||||
|
||||
Button {
|
||||
width: (col.width - 2 * Theme.horizontalPageMargin
|
||||
- 4 * Theme.paddingSmall) / 5
|
||||
text: modelData + "°"
|
||||
onClicked: {
|
||||
page.waterTouched = true
|
||||
page.pendingWater = modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Set target %1 °C").arg(page.fmt(page.pendingWater))
|
||||
confirmText: qsTr("DHW target to %1 °C").arg(page.fmt(page.pendingWater))
|
||||
enabled: page.waterChanged()
|
||||
onTriggered: api.sendCommand("set_hot_water_target", page.pendingWater)
|
||||
}
|
||||
|
||||
// ---- boost ------------------------------------------------------ //
|
||||
SectionHeader { text: qsTr("Hot water boost") }
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Start DHW boost")
|
||||
confirmText: qsTr("Starting DHW boost")
|
||||
onTriggered: api.sendCommand("hot_water_boost_on")
|
||||
}
|
||||
|
||||
ActionButton {
|
||||
text: qsTr("Stop DHW boost")
|
||||
confirmText: qsTr("Stopping DHW boost")
|
||||
onTriggered: api.sendCommand("hot_water_boost_off")
|
||||
}
|
||||
|
||||
Label {
|
||||
x: Theme.horizontalPageMargin
|
||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||
text: qsTr("Commands go through the bridge and may take a few seconds. The status refreshes by itself afterwards.")
|
||||
color: Theme.secondaryColor
|
||||
font.pixelSize: Theme.fontSizeExtraSmall
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Item { width: 1; height: Theme.paddingMedium }
|
||||
}
|
||||
}
|
||||
|
||||
NoticeBar { }
|
||||
}
|
||||
@@ -128,19 +128,11 @@ Page {
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
// Un pulsante solo: salvare e provare erano due gesti per la stessa
|
||||
// cosa (il secondo salvava comunque).
|
||||
Button {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Save")
|
||||
onClicked: {
|
||||
page.save()
|
||||
page.testResult = qsTr("settings saved")
|
||||
page.testOk = true
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: qsTr("Test connection")
|
||||
text: qsTr("Save and test")
|
||||
onClicked: page.testAndReport()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Name: harbour-aira
|
||||
Summary: Control your Aira heat pump through a local bridge
|
||||
Version: 0.5.0
|
||||
Version: 0.6.1
|
||||
Release: 1
|
||||
Group: Qt/Qt
|
||||
License: MIT
|
||||
@@ -37,6 +37,24 @@ Address and token of the bridge live in the app settings (AppConfigLocation);
|
||||
no personal data is stored in the binary.
|
||||
|
||||
%changelog
|
||||
* Sat Oct 10 2026 Carlo <carlo@carlobaratto.it> - 0.6.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
|
||||
- schermata iniziale riordinata come l'app ufficiale: temperatura esterna in
|
||||
alto, messaggio di stato, scheda dell'impianto con casa e acqua calda
|
||||
@@ -66,7 +84,7 @@ make %{?_smp_mflags}
|
||||
# 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
|
||||
# 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).
|
||||
TS=$(find %{_builddir}/.. -maxdepth 4 -name 'harbour-aira-it.ts' 2>/dev/null | head -1)
|
||||
if [ -z "$TS" ]; then
|
||||
|
||||
@@ -327,6 +327,92 @@ TEXTS = {
|
||||
|
||||
LANG = "en"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Testi aggiunti nella 0.6.0 (Home di consultazione, pagina Regolazioni,
|
||||
# Comandi con interruttori). Le due lingue in un colpo solo: le anteprime
|
||||
# restano bilingui come l'app.
|
||||
TEXTS["en"].update({
|
||||
"outside": "Outside {v}",
|
||||
"house_cell": "House",
|
||||
"hot_water_cell": "Hot water",
|
||||
"cell_target": "target {v}",
|
||||
"status_fine": "Everything is fine",
|
||||
"status_water": "Heating the hot water",
|
||||
"updated_at": "Updated at 21:18",
|
||||
"pull_hint": "Pull down: Aggiorna · Dati · Impianto · Impostazioni",
|
||||
"tap_adjust": "Tap a value to adjust it",
|
||||
"quick_actions": "Quick actions",
|
||||
"boost": "Boost",
|
||||
"away_btn": "Away mode",
|
||||
"back_home": "Back home",
|
||||
"commands": "Commands",
|
||||
"contorno": "Outside {a} · Thermostat {b} · Zones 1",
|
||||
"setpoints": "Setpoints",
|
||||
"current_target": "Current target",
|
||||
"keep_now": "What the plant is keeping now",
|
||||
"water_now": "Water now {v}",
|
||||
"hot_water_boost": "Hot water boost",
|
||||
"set_house_v": "Set house to {v} °C",
|
||||
"set_water_v": "Set target {v} °C",
|
||||
"plant_commands": "Plant commands",
|
||||
"modes": "Modes",
|
||||
"sw_away_desc": "Lowers the house and stops the hot water",
|
||||
"sw_forced_desc": "Heats at full power until you stop it",
|
||||
"sw_heating_desc": "The heating can turn on",
|
||||
"sw_water_desc": "The heat pump keeps the water in temperature",
|
||||
"legionella": "Legionella cycle",
|
||||
"switches_note": "The switches show the current state of the plant.",
|
||||
"commands_note": "Commands go through the bridge and may take a few seconds.",
|
||||
"diagnostics": "Diagnostics",
|
||||
"plant_check": "Plant check",
|
||||
"manual_mode": "Manual mode",
|
||||
"defrost": "Defrost",
|
||||
"signature_lights": "Signature lights",
|
||||
"save_and_test": "Save and test",
|
||||
"cover_outdoor": "outside",
|
||||
})
|
||||
|
||||
TEXTS["it"].update({
|
||||
"house_cell": "Casa",
|
||||
"hot_water_cell": "Acqua calda",
|
||||
"cell_target": "obiettivo {v}",
|
||||
"status_fine": "Tutto regolare",
|
||||
"status_water": "Sta scaldando l'acqua",
|
||||
"updated_at": "Aggiornato alle 21:18",
|
||||
"pull_hint": "Trascina in giù: Aggiorna · Dati · Impianto · Impostazioni",
|
||||
"tap_adjust": "Tocca un valore per regolarlo",
|
||||
"quick_actions": "Azioni rapide",
|
||||
"boost": "Boost",
|
||||
"away_btn": "Assenza",
|
||||
"back_home": "Torna a casa",
|
||||
"commands": "Comandi",
|
||||
"contorno": "Esterno {a} · Termostato {b} · Zone 1",
|
||||
"setpoints": "Regolazioni",
|
||||
"current_target": "Obiettivo attuale",
|
||||
"keep_now": "Quello che l'impianto mantiene adesso",
|
||||
"water_now": "Acqua ora {v}",
|
||||
"hot_water_boost": "Boost acqua calda",
|
||||
"set_house_v": "Imposta casa a {v} °C",
|
||||
"set_water_v": "Imposta obiettivo {v} °C",
|
||||
"plant_commands": "Comandi impianto",
|
||||
"modes": "Modalità",
|
||||
"sw_away_desc": "Abbassa la casa e spegne l'acqua calda",
|
||||
"sw_forced_desc": "Scalda a pieno regime finché non lo fermi",
|
||||
"sw_heating_desc": "Il riscaldamento può accendersi",
|
||||
"sw_water_desc": "Mantiene l'acqua in temperatura",
|
||||
"legionella": "Ciclo legionella",
|
||||
"switches_note": "Gli interruttori mostrano lo stato attuale dell'impianto.",
|
||||
"commands_note": "I comandi passano dal bridge e possono richiedere qualche secondo.",
|
||||
"diagnostics": "Diagnostica",
|
||||
"plant_check": "Verifica impianto",
|
||||
"manual_mode": "Modalità manuale",
|
||||
"defrost": "Sbrinamento",
|
||||
"signature_lights": "Luci di firma",
|
||||
"save_and_test": "Salva e prova",
|
||||
"cover_outdoor": "esterna",
|
||||
"night_one_hour": "Modalità notte per un'ora",
|
||||
})
|
||||
|
||||
|
||||
def T(key: str) -> str:
|
||||
return TEXTS[LANG][key]
|
||||
@@ -363,65 +449,67 @@ def value_row(d: ImageDraw.ImageDraw, y: int, title: str, subtitle: str,
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
def reading_cell(d: ImageDraw.ImageDraw, x: int, y: int, w: int, h: int,
|
||||
title: str, value: str, subtitle: str,
|
||||
frac: float = None) -> None:
|
||||
"""Riquadro di una lettura grande (componente ReadingCell dell'app): titolo,
|
||||
valore, riga sotto e barra di avanzamento facoltativa."""
|
||||
d.rounded_rectangle([x, y, x + w, y + h], radius=18, fill=(26, 32, 41),
|
||||
outline=BTN_EDGE, width=2)
|
||||
d.text((x + 26, y + 22), title, font=font(24), fill=SECONDARY)
|
||||
d.text((x + 26, y + 56), value, font=font(56, bold=True), fill=PRIMARY)
|
||||
d.text((x + 26, y + 132), subtitle, font=font(20), fill=SECONDARY)
|
||||
if frac is not None:
|
||||
bx0, bx1 = x + 26, x + w - 26
|
||||
by = y + h - 30
|
||||
d.rounded_rectangle([bx0, by, bx1, by + 10], radius=5, fill=(46, 54, 66))
|
||||
d.rounded_rectangle([bx0, by, bx0 + (bx1 - bx0) * max(0.0, min(1.0, frac)), by + 10],
|
||||
radius=5, fill=ACCENT)
|
||||
|
||||
|
||||
def main_screen() -> Image.Image:
|
||||
"""Schermata iniziale, ordinata come quella dell'app ufficiale: temperatura
|
||||
esterna in alto, messaggio di stato, scheda dell'impianto, poi i comandi."""
|
||||
"""Home della 0.6.0: una schermata da guardare, non un modulo. Stato in una
|
||||
riga, le due letture che contano, tre azioni rapide. Niente scorrimento."""
|
||||
img = background()
|
||||
d = ImageDraw.Draw(img)
|
||||
status_bar(d)
|
||||
page_header(d, T("app"))
|
||||
d.text((M, 128), T("updated_at"), font=font(22), fill=SECONDARY)
|
||||
|
||||
y = 150
|
||||
# ---- barra in alto: fuori casa / a casa ------------------------------ #
|
||||
d.text((M, y), T("outside").format(v=dec(18.0) + " °C"), font=font(24), fill=SECONDARY)
|
||||
casa = T("at_home")
|
||||
d.text((W - M - d.textlength(casa, font=font(22)), y + 2), casa,
|
||||
y = 176
|
||||
# ---- stato in una riga ------------------------------------------------ #
|
||||
d.ellipse([M, y + 7, M + 18, y + 25], fill=OK)
|
||||
d.text((M + 34, y), T("status_water"), font=font(26), fill=PRIMARY)
|
||||
y += 54
|
||||
|
||||
# ---- le due letture che contano --------------------------------------- #
|
||||
gap = 20
|
||||
cw = (W - 2 * M - gap) // 2
|
||||
ch = 200
|
||||
reading_cell(d, M, y, cw, ch, T("house_cell"), dec(23.0) + " °C",
|
||||
T("cell_target").format(v=dec(19.0) + " °C"))
|
||||
reading_cell(d, M + cw + gap, y, cw, ch, T("hot_water_cell"), dec(50.6) + " °C",
|
||||
T("cell_target").format(v=dec(55.0) + " °C"),
|
||||
(50.6 - 10) / (55.0 - 10))
|
||||
y += ch + 18
|
||||
|
||||
d.text((M, y), T("tap_adjust"), font=font(22), fill=SECONDARY)
|
||||
y += 54
|
||||
|
||||
# ---- azioni rapide ----------------------------------------------------- #
|
||||
y = section_header(d, y, T("quick_actions"))
|
||||
bw = (W - 2 * M - 2 * gap) // 3
|
||||
for i, testo in enumerate((T("boost"), T("away_btn"), T("commands"))):
|
||||
x = M + i * (bw + gap)
|
||||
d.rounded_rectangle([x, y, x + bw, y + 74], radius=18, fill=BTN_BG,
|
||||
outline=BTN_EDGE, width=2)
|
||||
tw = d.textlength(testo, font=font(24))
|
||||
d.text((x + (bw - tw) / 2, y + 23), testo, font=font(24), fill=PRIMARY)
|
||||
y += 104
|
||||
|
||||
# ---- letture di contorno ----------------------------------------------- #
|
||||
d.text((M, y), T("contorno").format(a=dec(17.8) + " °C", b=dec(22.8) + " °C"),
|
||||
font=font(22), fill=SECONDARY)
|
||||
y += 58
|
||||
|
||||
# ---- messaggio di stato ---------------------------------------------- #
|
||||
fnt = font(30)
|
||||
righe = wrap(d, T("status_relax"), fnt, W - 2 * M - 20)
|
||||
for i, riga in enumerate(righe):
|
||||
d.text(((W - d.textlength(riga, font=fnt)) / 2, y + i * 40), riga,
|
||||
font=fnt, fill=PRIMARY)
|
||||
y += 40 * len(righe) + 6
|
||||
det = T("status_detail")
|
||||
d.text(((W - d.textlength(det, font=font(22))) / 2, y), det, font=font(22), fill=SECONDARY)
|
||||
y += 46
|
||||
|
||||
# ---- scheda dell'impianto -------------------------------------------- #
|
||||
card(d, y, 282)
|
||||
d.text((M + 28, y + 22), T("device_name"), font=font(26), fill=PRIMARY)
|
||||
d.text((M + 28, y + 58), T("device_link"), font=font(22), fill=SECONDARY)
|
||||
d.text((W - M - 46, y + 24), "›", font=font(42), fill=ACCENT)
|
||||
value_row(d, y + 100, T("room_temperature"),
|
||||
T("target_value").format(v=dec(19.0) + " °C"), dec(22.8) + " °C")
|
||||
value_row(d, y + 186, T("dhw_row"),
|
||||
T("target_value").format(v=dec(55.0) + " °C"), dec(51.0) + " °C")
|
||||
y += 282 + 26
|
||||
|
||||
# ---- riscaldamento della casa ---------------------------------------- #
|
||||
y = section_header(d, y, T("heating_house"))
|
||||
line = T("setpoint_line")
|
||||
d.text(((W - d.textlength(line, font=font(22))) / 2, y - 8), line,
|
||||
font=font(22), fill=SECONDARY)
|
||||
y = slider(d, y + 28, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target"))
|
||||
y = button(d, y, T("set_house").format(v=dec(19.0)), primary=True)
|
||||
|
||||
# ---- acqua calda sanitaria ------------------------------------------- #
|
||||
y = section_header(d, y + 6, T("dhw_section"))
|
||||
progress(d, y, (51.0 - 20) / (65 - 20))
|
||||
y += 22
|
||||
y = slider(d, y + 4, (55.0 - 45) / (60 - 45), "55 °C", T("target_label"))
|
||||
y = button(d, y, T("set_acs"), primary=True)
|
||||
y = button(d, y, T("start_boost"))
|
||||
y = button(d, y, T("stop_boost"))
|
||||
|
||||
# ---- pagine collegate (scorrono sotto) ------------------------------- #
|
||||
y = section_header(d, y + 6, T("details"))
|
||||
y = button(d, y, T("plant_accessories"))
|
||||
y = button(d, y, T("consumption"))
|
||||
return img
|
||||
|
||||
|
||||
@@ -449,10 +537,9 @@ def settings_screen() -> Image.Image:
|
||||
|
||||
d.multiline_text((M, y), T("settings_note"), font=font(22), fill=SECONDARY, spacing=10)
|
||||
y += 104
|
||||
y = button(d, y, T("test_connection"), primary=True)
|
||||
# Un pulsante solo: salvare e provare erano due gesti per la stessa cosa.
|
||||
y = button(d, y, T("save_and_test"), primary=True)
|
||||
d.text((M, y), T("connection_ok"), font=font(24), fill=OK)
|
||||
y += 58
|
||||
y = button(d, y, T("save"))
|
||||
return img
|
||||
|
||||
|
||||
@@ -470,6 +557,8 @@ def cover_screen() -> Image.Image:
|
||||
card = [96, 430, W - 96, 430 + 470]
|
||||
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)
|
||||
# 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)],
|
||||
fill=BTN_EDGE, width=2)
|
||||
y = card[1] + 132
|
||||
@@ -488,32 +577,87 @@ def cover_screen() -> Image.Image:
|
||||
return img
|
||||
|
||||
|
||||
def switch_row(d: ImageDraw.ImageDraw, y: int, text: str, description: str,
|
||||
checked: bool) -> int:
|
||||
"""Riga con interruttore (componente TextSwitch dell'app): etichetta e
|
||||
spiegazione a sinistra, interruttore a destra con lo stato dell'impianto."""
|
||||
d.text((M, y), text, font=font(28), fill=PRIMARY)
|
||||
d.text((M, y + 38), description, font=font(22), fill=SECONDARY)
|
||||
pw, ph = 96, 44
|
||||
x0 = W - M - pw
|
||||
y0 = y + 8
|
||||
d.rounded_rectangle([x0, y0, x0 + pw, y0 + ph], radius=ph / 2,
|
||||
fill=ACCENT if checked else (48, 56, 68))
|
||||
cx = x0 + pw - ph / 2 if checked else x0 + ph / 2
|
||||
d.ellipse([cx - ph / 2 + 5, y0 + 5, cx + ph / 2 - 5, y0 + ph - 5],
|
||||
fill=(238, 242, 246))
|
||||
return y + 98
|
||||
|
||||
|
||||
def commands_screen() -> Image.Image:
|
||||
"""Comandi impianto della 0.6.0: gli interruttori dicono lo stato e si
|
||||
cambiano in un tocco; i pulsanti restano per le azioni senza stato."""
|
||||
img = background()
|
||||
d = ImageDraw.Draw(img)
|
||||
status_bar(d)
|
||||
page_header(d, T("other_commands"))
|
||||
page_header(d, T("plant_commands"))
|
||||
|
||||
y = section_header(d, 156, T("current_state"))
|
||||
for lab, val in ((T("away"), T("off")), (T("night_mode"), T("off")),
|
||||
(T("forced_heating"), T("off")), (T("hot_water_active"), T("on")),
|
||||
(T("pump"), T("stopped")), (T("allowed_modes"), T("heating"))):
|
||||
y = label_pair(d, y, lab, val)
|
||||
y = section_header(d, 150, T("modes"))
|
||||
y = switch_row(d, y, T("away"), T("sw_away_desc"), False)
|
||||
y = switch_row(d, y, T("forced_heating"), T("sw_forced_desc"), False)
|
||||
y = switch_row(d, y, T("heating"), T("sw_heating_desc"), True)
|
||||
|
||||
y = section_header(d, y + 24, T("away"))
|
||||
y = button(d, y, T("turn_away_on"))
|
||||
y = button(d, y, T("turn_away_off"))
|
||||
y = section_header(d, y + 10, T("dhw_section"))
|
||||
y = switch_row(d, y, T("hot_water_active"), T("sw_water_desc"), True)
|
||||
y = button(d, y, T("legionella"))
|
||||
|
||||
y = section_header(d, y + 24, T("noise"))
|
||||
y = section_header(d, y + 10, T("noise"))
|
||||
y = button(d, y, T("night_one_hour"))
|
||||
|
||||
y = section_header(d, y + 24, T("heating"))
|
||||
y = button(d, y, T("force_heating"), primary=True)
|
||||
hint = T("confirm_hint")
|
||||
d.text(((W - d.textlength(hint, font=font(22))) / 2, y - 4), hint, font=font(22), fill=SECONDARY)
|
||||
d.text((M, y + 12), T("switches_note"), font=font(22), fill=SECONDARY)
|
||||
return img
|
||||
|
||||
|
||||
def setpoints_screen() -> Image.Image:
|
||||
"""Regolazioni: un blocco per grandezza, con il valore attuale sempre
|
||||
visibile, le preimpostazioni e la conferma."""
|
||||
img = background()
|
||||
d = ImageDraw.Draw(img)
|
||||
status_bar(d)
|
||||
page_header(d, T("setpoints"))
|
||||
|
||||
y = section_header(d, 150, T("heating_house"))
|
||||
y = value_row(d, y, T("current_target"), T("keep_now"), dec(19.0) + " °C")
|
||||
y = slider(d, y, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target"))
|
||||
y = preset_row(d, y, ["16°", "17°", "18°", "19°", "20°", "21°"])
|
||||
y = button(d, y, T("set_house_v").format(v=dec(19.0)), primary=True)
|
||||
|
||||
y = section_header(d, y + 6, T("dhw_section"))
|
||||
y = value_row(d, y, T("dhw_target"), T("water_now").format(v=dec(50.6) + " °C"),
|
||||
dec(55.0) + " °C")
|
||||
y = slider(d, y, (55.0 - 45) / (60 - 45), dec(55.0) + " °C", T("target_label"))
|
||||
y = preset_row(d, y, ["45°", "47°", "50°", "55°", "60°"])
|
||||
y = button(d, y, T("set_water_v").format(v=dec(55.0)), primary=True)
|
||||
|
||||
y = section_header(d, y + 6, T("hot_water_boost"))
|
||||
y = button(d, y, T("start_boost"))
|
||||
y = button(d, y, T("stop_boost"))
|
||||
return img
|
||||
|
||||
|
||||
def preset_row(d: ImageDraw.ImageDraw, y: int, valori: list) -> int:
|
||||
"""Fila di valori rapidi: un tocco invece del cursore, per le dita fredde."""
|
||||
gap = 12
|
||||
bw = (W - 2 * M - (len(valori) - 1) * gap) // len(valori)
|
||||
for i, testo in enumerate(valori):
|
||||
x = M + i * (bw + gap)
|
||||
d.rounded_rectangle([x, y, x + bw, y + 62], radius=16, fill=BTN_BG,
|
||||
outline=BTN_EDGE, width=2)
|
||||
tw = d.textlength(testo, font=font(24))
|
||||
d.text((x + (bw - tw) / 2, y + 17), testo, font=font(24), fill=PRIMARY)
|
||||
return y + 86
|
||||
|
||||
|
||||
def data_screen() -> Image.Image:
|
||||
"""Pagina "Dati": gli stessi numeri della scheda Dati dell'app ufficiale."""
|
||||
img = background()
|
||||
@@ -566,11 +710,18 @@ def plant_screen() -> Image.Image:
|
||||
status_bar(d)
|
||||
page_header(d, T("plant"))
|
||||
|
||||
y = 150
|
||||
# La frase estesa dell'app ufficiale: sta qui, non in Home.
|
||||
righe = wrap(d, T("status_relax"), font(24), W - 2 * M)
|
||||
for i, riga in enumerate(righe):
|
||||
d.text((M, 132 + i * 32), riga, font=font(24), fill=PRIMARY)
|
||||
|
||||
y = 150 + 32 * len(righe)
|
||||
y = section_header(d, y, T("status"))
|
||||
for lab, val in ((T("connection"), T("cloud")), (T("mode"), T("automatic")),
|
||||
(T("away"), T("off")), (T("night_mode"), T("off")),
|
||||
(T("forced_heating"), T("off"))):
|
||||
(T("forced_heating"), T("off")), (T("manual_mode"), T("off")),
|
||||
(T("defrost"), T("off")), (T("signature_lights"), T("on")),
|
||||
(T("pump"), T("stopped")), (T("allowed_modes"), T("heating"))):
|
||||
y = label_pair(d, y, lab, val)
|
||||
|
||||
y = section_header(d, y + 20, T("accessories"))
|
||||
@@ -587,6 +738,9 @@ def plant_screen() -> Image.Image:
|
||||
|
||||
y = section_header(d, y + 20, T("errors"))
|
||||
d.text((M, y), T("no_errors"), font=font(24), fill=OK)
|
||||
|
||||
y = section_header(d, y + 40, T("diagnostics"))
|
||||
y = button(d, y, T("plant_check"))
|
||||
return img
|
||||
|
||||
|
||||
@@ -602,7 +756,8 @@ if __name__ == "__main__":
|
||||
|
||||
out = pathlib.Path("docs/anteprima") if lang == "en" else pathlib.Path("docs/anteprima") / lang
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for name, fn in (("dashboard", main_screen), ("dati", data_screen),
|
||||
for name, fn in (("dashboard", main_screen), ("regolazioni", setpoints_screen),
|
||||
("dati", data_screen),
|
||||
("impianto", plant_screen), ("comandi", commands_screen),
|
||||
("impostazioni", settings_screen), ("copertina", cover_screen)):
|
||||
path = out / f"{name}.png"
|
||||
|
||||
@@ -198,4 +198,82 @@ QString errorMessage(const QByteArray &body, int httpStatus)
|
||||
return QCoreApplication::translate("AiraJson", "HTTP error %1").arg(httpStatus);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Flag booleano del riassunto, con falso come ripiego.
|
||||
bool flagValue(const QVariantMap &summary, const QString &key)
|
||||
{
|
||||
return summary.value(key).toBool();
|
||||
}
|
||||
|
||||
int errorCount(const QVariantMap &summary)
|
||||
{
|
||||
return summary.value(QStringLiteral("errors_count")).toInt();
|
||||
}
|
||||
|
||||
bool pumpRunning(const QVariantMap &summary)
|
||||
{
|
||||
return summary.value(QStringLiteral("pump_active_state")).toString()
|
||||
.contains(QStringLiteral("RUNNING"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString stateShort(const QVariantMap &summary)
|
||||
{
|
||||
if (summary.isEmpty())
|
||||
return QCoreApplication::translate("AiraJson", "No data");
|
||||
const int errs = errorCount(summary);
|
||||
if (errs > 0)
|
||||
return (errs == 1)
|
||||
? QCoreApplication::translate("AiraJson", "Attention: one error")
|
||||
: QCoreApplication::translate("AiraJson", "Attention: %1 errors").arg(errs);
|
||||
if (flagValue(summary, QStringLiteral("away_mode")))
|
||||
return QCoreApplication::translate("AiraJson", "Away mode");
|
||||
if (flagValue(summary, QStringLiteral("night_mode")))
|
||||
return QCoreApplication::translate("AiraJson", "Night mode");
|
||||
if (flagValue(summary, QStringLiteral("force_heating")))
|
||||
return QCoreApplication::translate("AiraJson", "Heating at full power");
|
||||
if (flagValue(summary, QStringLiteral("hot_water_heating")))
|
||||
return QCoreApplication::translate("AiraJson", "Heating the hot water");
|
||||
if (pumpRunning(summary))
|
||||
return QCoreApplication::translate("AiraJson", "Working");
|
||||
return QCoreApplication::translate("AiraJson", "Everything is fine");
|
||||
}
|
||||
|
||||
QString stateLong(const QVariantMap &summary)
|
||||
{
|
||||
if (summary.isEmpty())
|
||||
return QString();
|
||||
const int errs = errorCount(summary);
|
||||
if (errs > 0)
|
||||
return (errs == 1)
|
||||
? QCoreApplication::translate("AiraJson", "Attention: one error on the plant")
|
||||
: QCoreApplication::translate("AiraJson", "Attention: %1 errors on the plant").arg(errs);
|
||||
if (flagValue(summary, QStringLiteral("away_mode")))
|
||||
return QCoreApplication::translate("AiraJson", "Away mode: the house is saving energy.");
|
||||
if (flagValue(summary, QStringLiteral("night_mode")))
|
||||
return QCoreApplication::translate("AiraJson", "Night mode is on.");
|
||||
if (flagValue(summary, QStringLiteral("force_heating")))
|
||||
return QCoreApplication::translate("AiraJson", "Boost: the house is heating at full power.");
|
||||
if (flagValue(summary, QStringLiteral("hot_water_heating")))
|
||||
return QCoreApplication::translate("AiraJson", "The heat pump is heating the hot water.");
|
||||
if (pumpRunning(summary))
|
||||
return QCoreApplication::translate("AiraJson", "The heat pump is working.");
|
||||
return QCoreApplication::translate("AiraJson", "Relax: the heat pump is running smoothly.");
|
||||
}
|
||||
|
||||
int statusLevel(const QVariantMap &summary)
|
||||
{
|
||||
if (summary.isEmpty())
|
||||
return 1;
|
||||
if (errorCount(summary) > 0)
|
||||
return 2;
|
||||
if (flagValue(summary, QStringLiteral("away_mode"))
|
||||
|| flagValue(summary, QStringLiteral("night_mode"))
|
||||
|| flagValue(summary, QStringLiteral("force_heating")))
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace AiraJson
|
||||
|
||||
@@ -30,6 +30,18 @@ QVariantMap parseStats(const QByteArray &body, bool *ok);
|
||||
// Codice di stato Aira ("OPERATING_STATUS_AUTO") in etichetta italiana.
|
||||
QString modeLabel(const QString &operatingStatus);
|
||||
|
||||
// Riga di stato per la Home: breve, per stare in una riga sola
|
||||
// ("Tutto regolare", "In assenza", "Attenzione: un errore").
|
||||
QString stateShort(const QVariantMap &summary);
|
||||
|
||||
// Frase estesa per la pagina Impianto (quella dell'app ufficiale:
|
||||
// "Relax: la pompa di calore funziona regolarmente").
|
||||
QString stateLong(const QVariantMap &summary);
|
||||
|
||||
// Livello dello stato, per il colore della riga: 0 regolare, 1 modo attivo
|
||||
// (assenza, notte, riscaldamento forzato), 2 errore sull'impianto.
|
||||
int statusLevel(const QVariantMap &summary);
|
||||
|
||||
// Temperatura leggibile: "51,0" oppure "—" se il valore manca.
|
||||
QString formatValue(const QVariant &value);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
#include "settings.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QLocale>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QTime>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
@@ -12,7 +14,45 @@ ApiClient::ApiClient(Settings *settings, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_settings(settings)
|
||||
, m_busy(false)
|
||||
, m_stale(false)
|
||||
, m_noticeIsError(false)
|
||||
, m_noticeTimer(0)
|
||||
, m_refreshTimer(0)
|
||||
{
|
||||
// Il messaggio dell'ultimo comando (o dell'ultimo errore) vale per ogni
|
||||
// pagina: lo stato sta qui, si spegne da solo e la UI lo mostra ancorato
|
||||
// in fondo alla schermata (NoticeBar), fuori dal contenuto che scorre.
|
||||
m_noticeTimer = new QTimer(this);
|
||||
m_noticeTimer->setSingleShot(true);
|
||||
m_noticeTimer->setInterval(7000);
|
||||
connect(m_noticeTimer, &QTimer::timeout, this, [this]() {
|
||||
if (m_notice.isEmpty())
|
||||
return;
|
||||
m_notice.clear();
|
||||
emit noticeChanged();
|
||||
});
|
||||
connect(this, &ApiClient::errorOccurred, this, [this](const QString &message) {
|
||||
setNotice(message, true);
|
||||
});
|
||||
connect(this, &ApiClient::commandFinished, this, [this](bool ok, const QString &message) {
|
||||
setNotice(message, !ok);
|
||||
});
|
||||
connect(this, &ApiClient::statsRefreshed, this, [this](bool ok, const QString &message) {
|
||||
setNotice(message, !ok);
|
||||
});
|
||||
|
||||
// Dopo un comando riuscito lo stato si rilegge da solo: l'impianto applica
|
||||
// in qualche istante, e senza questa rilettura gli interruttori della
|
||||
// pagina dei comandi resterebbero indietro rispetto alla pompa.
|
||||
m_refreshTimer = new QTimer(this);
|
||||
m_refreshTimer->setSingleShot(true);
|
||||
m_refreshTimer->setInterval(2000);
|
||||
connect(m_refreshTimer, &QTimer::timeout, this, &ApiClient::refresh);
|
||||
connect(this, &ApiClient::commandFinished, this, [this](bool ok, const QString &) {
|
||||
if (ok)
|
||||
m_refreshTimer->start();
|
||||
});
|
||||
|
||||
if (m_settings) {
|
||||
// La UI deve riflettere subito il passaggio da "non configurato" a
|
||||
// "configurato" quando si salvano indirizzo o token.
|
||||
@@ -29,6 +69,30 @@ bool ApiClient::configured() const
|
||||
&& !m_settings->token().trimmed().isEmpty();
|
||||
}
|
||||
|
||||
QString ApiClient::stateShort() const
|
||||
{
|
||||
return AiraJson::stateShort(m_summary);
|
||||
}
|
||||
|
||||
QString ApiClient::stateLong() const
|
||||
{
|
||||
return AiraJson::stateLong(m_summary);
|
||||
}
|
||||
|
||||
int ApiClient::statusLevel() const
|
||||
{
|
||||
return AiraJson::statusLevel(m_summary);
|
||||
}
|
||||
|
||||
void ApiClient::setNotice(const QString &message, bool isError)
|
||||
{
|
||||
m_notice = message;
|
||||
m_noticeIsError = isError;
|
||||
emit noticeChanged();
|
||||
if (!message.isEmpty())
|
||||
m_noticeTimer->start();
|
||||
}
|
||||
|
||||
void ApiClient::setBusy(bool value)
|
||||
{
|
||||
if (value == m_busy)
|
||||
@@ -102,6 +166,10 @@ void ApiClient::refresh()
|
||||
? tr("Server unreachable (timeout)")
|
||||
: AiraJson::errorMessage(body, status);
|
||||
qWarning() << "[api] summary non riuscito:" << status << msg;
|
||||
if (!m_stale) {
|
||||
m_stale = true;
|
||||
emit staleChanged();
|
||||
}
|
||||
emit errorOccurred(msg);
|
||||
return;
|
||||
}
|
||||
@@ -110,10 +178,22 @@ void ApiClient::refresh()
|
||||
const QVariantMap summary = AiraJson::parseSummary(body, &ok);
|
||||
if (!ok) {
|
||||
qWarning() << "[api] riassunto non interpretabile";
|
||||
if (!m_stale) {
|
||||
m_stale = true;
|
||||
emit staleChanged();
|
||||
}
|
||||
emit errorOccurred(tr("Unreadable response from the bridge"));
|
||||
return;
|
||||
}
|
||||
m_summary = summary;
|
||||
if (m_stale) {
|
||||
m_stale = false;
|
||||
emit staleChanged();
|
||||
}
|
||||
// Ora dell'ultimo aggiornamento riuscito: la Home la mostra, cosi' un
|
||||
// numero vecchio non passa per un numero fresco.
|
||||
m_lastUpdate = QLocale::system().toString(QTime::currentTime(), QLocale::ShortFormat);
|
||||
emit lastUpdateChanged();
|
||||
emit summaryChanged(summary);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <QVariantMap>
|
||||
|
||||
class QNetworkReply;
|
||||
class QTimer;
|
||||
class Settings;
|
||||
|
||||
// Client HTTP verso il bridge sul server (~/workspace/Carlo/Aira/aira_bridge.py).
|
||||
@@ -24,6 +25,16 @@ class ApiClient : public QObject
|
||||
// Statistiche della pagina "Dati" (calore, elettricita', COP, risparmi).
|
||||
// Separata dal riassunto: e' una chiamata piu' lenta, con cache lato bridge.
|
||||
Q_PROPERTY(QVariantMap stats READ stats NOTIFY statsChanged)
|
||||
// Salute dell'aggiornamento: "stale" = l'ultimo tentativo non e' riuscito,
|
||||
// quindi i numeri a schermo sono vecchi e vanno mostrati spenti.
|
||||
Q_PROPERTY(bool stale READ stale NOTIFY staleChanged)
|
||||
Q_PROPERTY(QString lastUpdate READ lastUpdate NOTIFY lastUpdateChanged)
|
||||
Q_PROPERTY(bool hasData READ hasData NOTIFY summaryChanged)
|
||||
// Messaggio dell'ultimo comando (o errore), mostrato in fondo alla pagina:
|
||||
// sta qui e non nel QML perche' vale per OGNI pagina, e perche' cosi' il
|
||||
// messaggio e' lo stesso ovunque si stia.
|
||||
Q_PROPERTY(QString notice READ notice NOTIFY noticeChanged)
|
||||
Q_PROPERTY(bool noticeIsError READ noticeIsError NOTIFY noticeChanged)
|
||||
|
||||
public:
|
||||
explicit ApiClient(Settings *settings, QObject *parent = 0);
|
||||
@@ -32,6 +43,18 @@ public:
|
||||
bool configured() const;
|
||||
QVariantMap summary() const { return m_summary; }
|
||||
QVariantMap stats() const { return m_stats; }
|
||||
bool stale() const { return m_stale; }
|
||||
QString lastUpdate() const { return m_lastUpdate; }
|
||||
bool hasData() const { return !m_summary.isEmpty(); }
|
||||
QString notice() const { return m_notice; }
|
||||
bool noticeIsError() const { return m_noticeIsError; }
|
||||
|
||||
// Stato leggibile in una riga (stateShort) e per esteso (stateLong), piu'
|
||||
// il livello da cui la UI prende il colore. Stanno in C++ perche' sono
|
||||
// logica pura: verificate in tests/core_test.cpp e non duplicate fra pagine.
|
||||
Q_INVOKABLE QString stateShort() const;
|
||||
Q_INVOKABLE QString stateLong() const;
|
||||
Q_INVOKABLE int statusLevel() const;
|
||||
|
||||
// Scarica il riassunto e lo emette con summaryChanged().
|
||||
Q_INVOKABLE void refresh();
|
||||
@@ -56,6 +79,9 @@ public:
|
||||
signals:
|
||||
void busyChanged();
|
||||
void configuredChanged();
|
||||
void staleChanged();
|
||||
void lastUpdateChanged();
|
||||
void noticeChanged();
|
||||
void summaryChanged(const QVariantMap &summary);
|
||||
void statsChanged(const QVariantMap &stats);
|
||||
void statsRefreshed(bool ok, const QString &message);
|
||||
@@ -68,10 +94,18 @@ private:
|
||||
const QByteArray &body, int timeoutSec);
|
||||
void armTimeout(QNetworkReply *reply, int seconds);
|
||||
void setBusy(bool value);
|
||||
// Mostra un messaggio in fondo alla pagina per qualche secondo.
|
||||
void setNotice(const QString &message, bool isError);
|
||||
|
||||
Settings *m_settings;
|
||||
QNetworkAccessManager m_nam;
|
||||
bool m_busy;
|
||||
bool m_stale;
|
||||
QString m_lastUpdate;
|
||||
QString m_notice;
|
||||
bool m_noticeIsError;
|
||||
QTimer *m_noticeTimer;
|
||||
QTimer *m_refreshTimer;
|
||||
QVariantMap m_summary;
|
||||
QVariantMap m_stats;
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
|
||||
|
||||
app->setOrganizationName(QStringLiteral("harbour"));
|
||||
app->setApplicationName(QStringLiteral("aira"));
|
||||
app->setApplicationVersion(QStringLiteral("0.5.0"));
|
||||
app->setApplicationVersion(QStringLiteral("0.6.1"));
|
||||
|
||||
Settings settings;
|
||||
ApiClient api(&settings);
|
||||
@@ -36,7 +36,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
|
||||
|
||||
// Prima riga del log: dice subito se il device sta girando la build nuova
|
||||
// o una stale (trappola nota del build engine).
|
||||
qDebug() << "harbour-aira v0.5.0 build" << __DATE__ << __TIME__;
|
||||
qDebug() << "harbour-aira v0.6.1 build" << __DATE__ << __TIME__;
|
||||
|
||||
view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings);
|
||||
view->rootContext()->setContextProperty(QStringLiteral("api"), &api);
|
||||
|
||||
@@ -190,6 +190,67 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
}
|
||||
|
||||
// --- stato in una riga e per esteso (Home e pagina Impianto) ----------- //
|
||||
{
|
||||
QVariantMap calmo;
|
||||
calmo.insert("errors_count", 0);
|
||||
calmo.insert("away_mode", false);
|
||||
calmo.insert("night_mode", false);
|
||||
calmo.insert("force_heating", false);
|
||||
calmo.insert("hot_water_heating", false);
|
||||
calmo.insert("pump_active_state", "PUMP_ACTIVE_STATE_IDLE");
|
||||
check(AiraJson::stateShort(calmo) == "Everything is fine",
|
||||
"stato breve: nessuna attivita'");
|
||||
check(AiraJson::stateLong(calmo) == "Relax: the heat pump is running smoothly.",
|
||||
"stato esteso: frase dell'app ufficiale");
|
||||
check(AiraJson::statusLevel(calmo) == 0, "livello: regolare");
|
||||
|
||||
QVariantMap lavora = calmo;
|
||||
lavora.insert("pump_active_state", "PUMP_ACTIVE_STATE_RUNNING");
|
||||
check(AiraJson::stateShort(lavora) == "Working", "stato breve: pompa in funzione");
|
||||
check(AiraJson::statusLevel(lavora) == 0,
|
||||
"livello: pompa in funzione e' comunque regolare");
|
||||
|
||||
QVariantMap acqua = calmo;
|
||||
acqua.insert("hot_water_heating", true);
|
||||
check(AiraJson::stateShort(acqua) == "Heating the hot water",
|
||||
"stato breve: sta scaldando l'acqua");
|
||||
check(AiraJson::stateLong(acqua) == "The heat pump is heating the hot water.",
|
||||
"stato esteso: acqua calda in carica");
|
||||
|
||||
QVariantMap assente = calmo;
|
||||
assente.insert("away_mode", true);
|
||||
check(AiraJson::stateShort(assente) == "Away mode", "stato breve: assenza");
|
||||
check(AiraJson::statusLevel(assente) == 1, "livello: modo attivo, non regolare");
|
||||
|
||||
QVariantMap uno = calmo;
|
||||
uno.insert("errors_count", 1);
|
||||
check(AiraJson::stateShort(uno) == "Attention: one error",
|
||||
"stato breve: un errore (singolare)");
|
||||
check(AiraJson::stateLong(uno) == "Attention: one error on the plant",
|
||||
"stato esteso: un errore");
|
||||
check(AiraJson::statusLevel(uno) == 2, "livello: errore");
|
||||
|
||||
QVariantMap due = calmo;
|
||||
due.insert("errors_count", 2);
|
||||
check(AiraJson::stateShort(due) == "Attention: 2 errors",
|
||||
"stato breve: due errori (plurale)");
|
||||
check(AiraJson::stateLong(due) == "Attention: 2 errors on the plant",
|
||||
"stato esteso: due errori");
|
||||
|
||||
// L'errore ha la precedenza su tutto: e' quello che va visto per primo.
|
||||
QVariantMap misto = assente;
|
||||
misto.insert("errors_count", 3);
|
||||
check(AiraJson::stateShort(misto) == "Attention: 3 errors",
|
||||
"stato breve: l'errore vince sul modo attivo");
|
||||
check(AiraJson::statusLevel(misto) == 2, "livello: errore prima di tutto");
|
||||
|
||||
check(AiraJson::stateShort(QVariantMap()) == "No data",
|
||||
"stato breve: nessun dato");
|
||||
check(AiraJson::stateLong(QVariantMap()).isEmpty(),
|
||||
"stato esteso: nessun dato = nessuna frase");
|
||||
}
|
||||
|
||||
qInfo() << "----";
|
||||
qInfo() << checks - failures << "/" << checks << "verifiche superate";
|
||||
return failures == 0 ? 0 : 1;
|
||||
|
||||
@@ -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))
|
||||