0.5.0: riordino come l'app ufficiale (home, Dati, Impianto)

- home: temperatura esterna e in casa/assente in alto, messaggio di stato,
  scheda impianto con temperatura stanza e acqua calda; comandi sotto
- pagina Dati: calore prodotto, elettricita' usata, COP acqua calda e
  riscaldamento, risparmio, CO2 evitata, Smart Tariff Control (mese per mese)
- pagina Impianto: stato, termostato a parete, versioni software, errori
- bridge 0.4.0: rotta /stats (servizio statistiche del cloud) e blocco
  dispositivo in /summary (ID breve, versioni, termostato)
- C++: parseStats, formatNumber, formatMoney, monthLabel (separatore e
  valuta secondo la lingua); 61/61 verifiche del nucleo
- anteprime aggiornate (dati, impianto) in inglese e italiano
This commit is contained in:
2026-10-10 08:59:03 +02:00
parent ec7333a185
commit 4f279a18b0
30 changed files with 2355 additions and 353 deletions
+257 -34
View File
@@ -101,13 +101,29 @@ def slider(d: ImageDraw.ImageDraw, y: int, frac: float, value_text: str,
def button(d: ImageDraw.ImageDraw, y: int, text: str, primary: bool = False) -> int:
h = 74
h = 66
d.rounded_rectangle([M, y, W - M, y + h], radius=h // 2,
fill=BTN_BG, outline=ACCENT if primary else BTN_EDGE, width=2)
tw = d.textlength(text, font=font(28, bold=primary))
d.text(((W - tw) / 2, y + (h - 34) / 2), text, font=font(28, bold=primary),
tw = d.textlength(text, font=font(26, bold=primary))
d.text(((W - tw) / 2, y + (h - 32) / 2), text, font=font(26, bold=primary),
fill=ACCENT if primary else PRIMARY)
return y + h + 16
return y + h + 14
def wrap(d: ImageDraw.ImageDraw, text: str, fnt, max_width: float) -> list:
"""Spezza il testo in righe che stanno nella larghezza data (come il QML,
che manda a capo le etichette lunghe invece di tagliarle)."""
lines, cur = [], ""
for word in text.split():
prova = (cur + " " + word).strip()
if not cur or d.textlength(prova, font=fnt) <= max_width:
cur = prova
else:
lines.append(cur)
cur = word
if cur:
lines.append(cur)
return lines
# --------------------------------------------------------------------------- #
@@ -164,6 +180,52 @@ TEXTS = {
"night_one_hour": "Night mode for one hour",
"force_heating": "Force heating",
"confirm_hint": "(asks for confirmation first)",
# --- riordino come l'app ufficiale (v0.5.0) ------------------------- #
"outside": "Outside {v}",
"at_home": "At home",
"status_relax": "Relax: the heat pump is running smoothly.",
"status_detail": "Automatic · last reading 21:18",
"device_name": "Aira heat pump",
"device_link": "Connected through the cloud",
"room_temperature": "Room temperature",
"target_value": "Target {v}",
"dhw_row": "Domestic hot water",
"heating_house": "Heating the house",
"setpoint_line": "Heating setpoint 19.0 °C · Heating",
"details": "Details",
"plant_accessories": "Plant and accessories",
"consumption": "Consumption and savings",
"data_title": "Data",
"data_note": "Numbers calculated by Aira in the cloud:\nthe app only reads them from your own bridge.",
"month": "Month of October 2026",
"heat_produced": "Heat produced",
"heat_sub": "What the heat pump gave the house",
"electricity": "Electricity used",
"electricity_sub": "What the heat pump took from the grid",
"eff_dhw": "Efficiency, domestic hot water",
"eff_heat": "Efficiency, heating",
"savings_title": "Your savings, last 30 days",
"saved_bill": "Saved on the bill",
"saved_sub": "Compared with a gas boiler",
"co2": "CO₂ avoided",
"smart": "Smart Tariff Control",
"no_data": "no data",
"status": "Status",
"connection": "Connection",
"cloud": "Cloud",
"mode": "Mode",
"automatic": "Automatic",
"accessories": "Accessories",
"thermostat": "Aira wall thermostat",
"sensors": "Temperature 22.8 °C · Humidity 73.3 % · Battery ok",
"versions": "Versions",
"device_id": "Device ID",
"platform": "Platform",
"connectivity": "Connectivity",
"climate_control": "Climate control",
"outdoor_unit": "Outdoor unit",
"errors": "Errors",
"no_errors": "No errors reported by the plant.",
},
"it": {
"app": "Aira",
@@ -214,6 +276,52 @@ TEXTS = {
"night_one_hour": "Night mode per un'ora",
"force_heating": "Forza riscaldamento",
"confirm_hint": "(chiede conferma prima di partire)",
# --- riordino come l'app ufficiale (v0.5.0) ------------------------- #
"outside": "Esterno {v}",
"at_home": "In casa",
"status_relax": "Relax: la pompa di calore funziona regolarmente.",
"status_detail": "Automatico · ultima lettura 21:18",
"device_name": "Aira Pompa di calore",
"device_link": "Connessa tramite Cloud",
"room_temperature": "Temperatura stanza",
"target_value": "Obiettivo {v}",
"dhw_row": "Acqua calda",
"heating_house": "Riscaldamento della casa",
"setpoint_line": "Obiettivo riscaldamento 19,0 °C · Riscaldamento",
"details": "Dettagli",
"plant_accessories": "Impianto e accessori",
"consumption": "Consumi e risparmi",
"data_title": "Dati",
"data_note": "Numeri calcolati da Aira nel cloud:\nl'app li legge soltanto dal tuo bridge.",
"month": "Mese di ottobre 2026",
"heat_produced": "Calore prodotto",
"heat_sub": "Quanto la pompa ha dato alla casa",
"electricity": "Elettricità usata",
"electricity_sub": "Quanta ne ha presa dalla rete",
"eff_dhw": "Efficienza, acqua calda",
"eff_heat": "Efficienza, riscaldamento",
"savings_title": "I tuoi risparmi, ultimi 30 giorni",
"saved_bill": "Risparmiati in bolletta",
"saved_sub": "Rispetto a una caldaia a gas",
"co2": "CO₂ evitata",
"smart": "Smart Tariff Control",
"no_data": "nessun dato",
"status": "Stato",
"connection": "Connessione",
"cloud": "Cloud",
"mode": "Modalità",
"automatic": "Automatico",
"accessories": "Accessori",
"thermostat": "Termostato a parete Aira",
"sensors": "Temperatura 22,8 °C · Umidità 73,3 % · Batteria ok",
"versions": "Versioni",
"device_id": "ID dispositivo",
"platform": "Piattaforma",
"connectivity": "Connettività",
"climate_control": "Controllo climatico",
"outdoor_unit": "Unità esterna",
"errors": "Errori",
"no_errors": "Nessun errore segnalato dall'impianto.",
},
}
@@ -230,52 +338,90 @@ def dec(value: float) -> str:
return out.replace(".", ",") if LANG == "it" else out
# --------------------------------------------------------------------------- #
def num(value: float, n: int = 1) -> str:
"""Numero con n decimali e il separatore della lingua, come fa l'app."""
out = f"{value:.{n}f}"
return out.replace(".", ",") if LANG == "it" else out
def card(d: ImageDraw.ImageDraw, y: int, h: int) -> None:
"""Scheda dello sfondo (BackgroundItem dell'app): angoli tondi, bordo tenue."""
d.rounded_rectangle([M, y, W - M, y + h], radius=18, fill=(26, 32, 41),
outline=BTN_EDGE, width=2)
def value_row(d: ImageDraw.ImageDraw, y: int, title: str, subtitle: str,
value: str) -> int:
"""Titolo e sottotitolo a sinistra, valore grande a destra (componente
ValueRow dell'app: e' la forma delle righe della scheda dell'impianto)."""
d.text((M + 28, y), title, font=font(26), fill=PRIMARY)
d.text((M + 28, y + 36), subtitle, font=font(22), fill=SECONDARY)
vw = d.textlength(value, font=font(42, bold=True))
d.text((W - M - 28 - vw, y + 6), value, font=font(42, bold=True), fill=PRIMARY)
return y + 96
# --------------------------------------------------------------------------- #
def main_screen() -> Image.Image:
"""Schermata iniziale, ordinata come quella dell'app ufficiale: temperatura
esterna in alto, messaggio di stato, scheda dell'impianto, poi i comandi."""
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("app"))
y = 156
# ---- casa: il dato principale, in cima ------------------------------- #
c = T("house_temperature")
d.text(((W - d.textlength(c, font=font(24))) / 2, y), c, font=font(24), fill=SECONDARY)
y += 40
big = dec(23.0) + " °C"
d.text(((W - d.textlength(big, font=font(92, bold=True))) / 2, y),
big, font=font(92, bold=True), fill=PRIMARY)
y += 116
progress(d, y, (23.0 - 5) / (30 - 5))
y += 22
t = f'{T("target")} {dec(19.0)} °C · {T("heating")}'
d.text(((W - d.textlength(t, font=font(24))) / 2, y), t, font=font(24), 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,
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
y = slider(d, y, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target"))
# ---- scheda dell'impianto -------------------------------------------- #
card(d, y, 282)
d.text((M + 28, y + 22), T("device_name"), font=font(26), fill=PRIMARY)
d.text((M + 28, y + 58), T("device_link"), font=font(22), fill=SECONDARY)
d.text((W - M - 46, y + 24), "›", font=font(42), fill=ACCENT)
value_row(d, y + 100, T("room_temperature"),
T("target_value").format(v=dec(19.0) + " °C"), dec(22.8) + " °C")
value_row(d, y + 186, T("dhw_row"),
T("target_value").format(v=dec(55.0) + " °C"), dec(51.0) + " °C")
y += 282 + 26
# ---- riscaldamento della casa ---------------------------------------- #
y = section_header(d, y, T("heating_house"))
line = T("setpoint_line")
d.text(((W - d.textlength(line, font=font(22))) / 2, y - 8), line,
font=font(22), fill=SECONDARY)
y = slider(d, y + 28, (19.0 - 5) / (30 - 5), dec(19.0) + " °C", T("new_house_target"))
y = button(d, y, T("set_house").format(v=dec(19.0)), primary=True)
# ---- acqua calda sanitaria ------------------------------------------- #
y = section_header(d, y + 24, T("dhw_section"))
acs = dec(51.0) + " °C"
d.text(((W - d.textlength(acs, font=font(56, bold=True))) / 2, y),
acs, font=font(56, bold=True), fill=PRIMARY)
y += 76
y = section_header(d, y + 6, T("dhw_section"))
progress(d, y, (51.0 - 20) / (65 - 20))
y += 22
y = label_pair(d, y, T("dhw_target"), dec(55.0) + " °C")
y = slider(d, y + 10, (55.0 - 45) / (60 - 45), "55 °C", T("target_label"))
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"))
# ---- stato impianto (scorre sotto) ----------------------------------- #
y = section_header(d, y + 24, T("plant"))
y = label_pair(d, y, T("outdoor"), dec(18.0) + " °C")
y = label_pair(d, y, T("indoor_pump"), dec(22.5) + " °C")
hint = T("pull_to_refresh")
d.text(((W - d.textlength(hint, font=font(22))) / 2, y + 8),
hint, font=font(22), fill=SECONDARY)
# ---- 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
@@ -368,6 +514,82 @@ def commands_screen() -> Image.Image:
return img
def data_screen() -> Image.Image:
"""Pagina "Dati": gli stessi numeri della scheda Dati dell'app ufficiale."""
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("data_title"))
y = 150
d.multiline_text((M, y), T("data_note"), font=font(22), fill=SECONDARY, spacing=8)
y += 96
# ---- energia e efficienza del mese ------------------------------------ #
y = section_header(d, y, T("month"))
d.text((M, y), T("heat_produced"), font=font(26), fill=PRIMARY)
d.text((M, y + 34), T("heat_sub"), font=font(22), fill=SECONDARY)
v = dec(70.8) + " kWh"
d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v,
font=font(40, bold=True), fill=PRIMARY)
y += 96
d.text((M, y), T("electricity"), font=font(26), fill=PRIMARY)
d.text((M, y + 34), T("electricity_sub"), font=font(22), fill=SECONDARY)
v = dec(29.3) + " kWh"
d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v,
font=font(40, bold=True), fill=PRIMARY)
y += 96
y = label_pair(d, y, T("eff_dhw"), num(4.42, 2))
y = label_pair(d, y, T("eff_heat"), T("no_data"))
# ---- risparmi ---------------------------------------------------------- #
y = section_header(d, y + 20, T("savings_title"))
d.text((M, y), T("saved_bill"), font=font(26), fill=PRIMARY)
d.text((M, y + 34), T("saved_sub"), font=font(22), fill=SECONDARY)
v = num(21.78, 2) + " €"
d.text((W - M - d.textlength(v, font=font(40, bold=True)), y + 4), v,
font=font(40, bold=True), fill=ACCENT)
y += 96
y = label_pair(d, y, T("co2"), dec(38.1) + " kg")
y = label_pair(d, y, T("smart"), num(2.71, 2) + " €", right_color=ACCENT)
hint = T("pull_to_refresh")
d.text(((W - d.textlength(hint, font=font(22))) / 2, H - 44),
hint, font=font(22), fill=SECONDARY)
return img
def plant_screen() -> Image.Image:
"""Pagina "Impianto": stato, accessori, versioni, errori."""
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("plant"))
y = 150
y = section_header(d, y, T("status"))
for lab, val in ((T("connection"), T("cloud")), (T("mode"), T("automatic")),
(T("away"), T("off")), (T("night_mode"), T("off")),
(T("forced_heating"), T("off"))):
y = label_pair(d, y, lab, val)
y = section_header(d, y + 20, T("accessories"))
d.text((M, y), T("thermostat"), font=font(26), fill=PRIMARY)
y += 36
d.text((M, y), T("sensors"), font=font(22), fill=SECONDARY)
y += 56
y = section_header(d, y, T("versions"))
for lab, val in ((T("device_id"), "1f4b26"), (T("platform"), "6.10.0"),
(T("connectivity"), "3.30.1"), (T("climate_control"), "3.9.12"),
(T("outdoor_unit"), "1.30.0")):
y = label_pair(d, y, lab, val)
y = section_header(d, y + 20, T("errors"))
d.text((M, y), T("no_errors"), font=font(24), fill=OK)
return img
if __name__ == "__main__":
import sys
@@ -380,7 +602,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), ("comandi", commands_screen),
for name, fn in (("dashboard", main_screen), ("dati", data_screen),
("impianto", plant_screen), ("comandi", commands_screen),
("impostazioni", settings_screen), ("copertina", cover_screen)):
path = out / f"{name}.png"
fn().save(path)