Files

766 lines
31 KiB
Python

#!/usr/bin/env python3
"""Anteprima di layout delle schermate di harbour-aira.
NON sono screenshot: sono disegni generati con Pillow che riproducono
disposizione, gerarchia e misure delle tre schermate (dashboard, impostazioni,
copertina) usando i dati reali dell'impianto. Servono a validare il layout
prima di compilare: l'app vera usa i componenti Silica, non questi disegni.
python3 scripts/render_mockups.py -> docs/anteprima/*.png
"""
from __future__ import annotations
import pathlib
from PIL import Image, ImageDraw, ImageFont
W, H = 720, 1280
M = 72 # margine laterale (= Theme.horizontalPageMargin circa)
OUT = pathlib.Path("docs/anteprima")
# Palette Silica, ambience scura
BG_TOP = (10, 12, 16)
BG_BOT = (22, 28, 36)
PRIMARY = (240, 242, 245)
SECONDARY = (139, 147, 156)
ACCENT = (79, 195, 232)
TRACK = (56, 64, 74)
BTN_BG = (32, 39, 48)
BTN_EDGE = (98, 110, 124)
OK = (122, 214, 150)
FONTS = "/usr/share/fonts/truetype/dejavu/"
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
name = "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf"
return ImageFont.truetype(FONTS + name, size)
def background() -> Image.Image:
img = Image.new("RGB", (W, H), BG_TOP)
d = ImageDraw.Draw(img)
for y in range(H):
t = y / H
d.line([(0, y), (W, y)], fill=(
int(BG_TOP[0] + (BG_BOT[0] - BG_TOP[0]) * t),
int(BG_TOP[1] + (BG_BOT[1] - BG_TOP[1]) * t),
int(BG_TOP[2] + (BG_BOT[2] - BG_TOP[2]) * t),
))
return img
def status_bar(d: ImageDraw.ImageDraw) -> None:
d.text((M, 26), "21:20", font=font(22), fill=SECONDARY)
# indicazioni di sistema (segno grafico, non testo reale)
x = W - M - 96
d.rounded_rectangle([x, 30, x + 44, 48], radius=5, outline=SECONDARY, width=2)
d.rounded_rectangle([x + 4, 34, x + 30, 44], radius=2, fill=SECONDARY)
d.line([(x + 22, 16), (x + 30, 16)], fill=SECONDARY, width=2)
d.line([(x + 8, 14), (x + 20, 14)], fill=SECONDARY, width=2)
d.line([(x + 38, 16), (x + 48, 16)], fill=SECONDARY, width=2)
for i in range(4):
d.line([(x + 62 + i * 9, 42 - i * 5), (x + 66 + i * 9, 42 - i * 5)],
fill=SECONDARY, width=3)
def page_header(d: ImageDraw.ImageDraw, title: str) -> None:
d.text((M, 84), title, font=font(44, bold=True), fill=PRIMARY)
def section_header(d: ImageDraw.ImageDraw, y: int, title: str) -> int:
d.text((M, y), title, font=font(30), fill=ACCENT)
return y + 52
def label_pair(d: ImageDraw.ImageDraw, y: int, left: str, right: str,
right_color=PRIMARY) -> int:
d.text((M, y), left, font=font(26), fill=SECONDARY)
rw = d.textlength(right, font=font(26))
d.text((W - M - rw, y), right, font=font(26), fill=right_color)
return y + 44
def progress(d: ImageDraw.ImageDraw, y: int, frac: float) -> None:
d.rounded_rectangle([M, y, W - M, y + 3], radius=2, fill=TRACK)
end = M + int((W - 2 * M) * frac)
d.rounded_rectangle([M, y, end, y + 3], radius=2, fill=ACCENT)
def slider(d: ImageDraw.ImageDraw, y: int, frac: float, value_text: str,
label: str = "Obiettivo ACS") -> int:
d.text((M, y), label, font=font(24), fill=SECONDARY)
vw = d.textlength(value_text, font=font(24))
d.text((W - M - vw, y), value_text, font=font(24), fill=PRIMARY)
ty = y + 58
d.rounded_rectangle([M, ty, W - M, ty + 3], radius=2, fill=TRACK)
end = M + int((W - 2 * M) * frac)
d.rounded_rectangle([M, ty, end, ty + 3], radius=2, fill=ACCENT)
d.ellipse([end - 9, ty - 7, end + 9, ty + 11], fill=PRIMARY)
return ty + 44
def button(d: ImageDraw.ImageDraw, y: int, text: str, primary: bool = False) -> int:
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(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 + 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
# --------------------------------------------------------------------------- #
# Testi delle anteprime. L'app e' in inglese (lingua sorgente) e italiano, quindi
# le anteprime esistono nelle due lingue: quelle di docs/anteprima/ sono in
# inglese, quelle di docs/anteprima/it/ in italiano.
TEXTS = {
"en": {
"app": "Aira",
"house_temperature": "House temperature",
"heating": "Heating",
"target": "target",
"new_house_target": "New house target",
"set_house": "Set house to {v} °C",
"dhw_section": "Domestic hot water",
"dhw_target": "DHW target",
"target_label": "Target",
"set_acs": "Set DHW target",
"start_boost": "Start DHW boost",
"stop_boost": "Stop DHW boost",
"plant": "Plant",
"outdoor": "Outdoor",
"indoor_pump": "Indoor (pump)",
"pull_to_refresh": "Pull down to refresh",
"settings": "Settings",
"language": "Language",
"system": "System",
"bridge": "Bridge",
"address_label": "Bridge address",
"token_label": "Bridge token",
"settings_note": "The address and the token stay in the local\n"
"configuration of the app, read-only for others.",
"test_connection": "Test connection",
"connection_ok": "Bridge reachable",
"save": "Save",
"cover_target": "target",
"cover_acs": "DHW",
"cover_outdoor": "outdoor",
"cover_footer": "house temperature",
"other_commands": "Other commands",
"current_state": "Current state",
"away": "Away mode",
"night_mode": "Night mode",
"forced_heating": "Forced heating",
"hot_water_active": "Hot water active",
"pump": "Pump",
"stopped": "Stopped",
"allowed_modes": "Allowed modes",
"on": "yes",
"off": "no",
"turn_away_on": "Turn away mode on",
"turn_away_off": "Turn away mode off",
"noise": "Noise",
"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",
"house_temperature": "Temperatura in casa",
"heating": "Riscaldamento",
"target": "obiettivo",
"new_house_target": "Nuovo obiettivo casa",
"set_house": "Imposta casa a {v} °C",
"dhw_section": "Acqua calda sanitaria",
"dhw_target": "Obiettivo acqua calda",
"target_label": "Obiettivo ACS",
"set_acs": "Imposta obiettivo ACS",
"start_boost": "Avvia boost ACS",
"stop_boost": "Ferma boost ACS",
"plant": "Impianto",
"outdoor": "Esterna",
"indoor_pump": "Interna (pompa)",
"pull_to_refresh": "Trascina verso il basso per aggiornare",
"settings": "Impostazioni",
"language": "Lingua",
"system": "Sistema",
"bridge": "Bridge",
"address_label": "Indirizzo del bridge",
"token_label": "Token del bridge",
"settings_note": "Indirizzo e token restano nella configurazione\n"
"locale dell'app, in sola lettura per gli altri.",
"test_connection": "Prova connessione",
"connection_ok": "Bridge raggiungibile",
"save": "Salva",
"cover_target": "obiettivo",
"cover_acs": "ACS",
"cover_outdoor": "esterna",
"cover_footer": "temperatura in casa",
"other_commands": "Altri comandi",
"current_state": "Stato attuale",
"away": "Assenza",
"night_mode": "Night mode",
"forced_heating": "Riscaldamento forzato",
"hot_water_active": "Acqua calda attiva",
"pump": "Pompa",
"stopped": "Ferma",
"allowed_modes": "Modalità consentite",
"on": "sì",
"off": "no",
"turn_away_on": "Attiva assenza",
"turn_away_off": "Disattiva assenza",
"noise": "Rumore",
"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.",
},
}
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]
def dec(value: float) -> str:
"""Numero a un decimale con il separatore della lingua, come fa l'app."""
out = f"{value:.1f}"
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 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:
"""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 = 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)
return img
def settings_screen() -> Image.Image:
img = background()
d = ImageDraw.Draw(img)
status_bar(d)
page_header(d, T("settings"))
# ---- lingua ----------------------------------------------------------- #
y = section_header(d, 156, T("language"))
y = label_pair(d, y, T("language"), T("system"))
y += 16
# ---- bridge ----------------------------------------------------------- #
y = section_header(d, y, T("bridge"))
for lab, val in ((T("address_label"), "192.168.3.21:8790"),
(T("token_label"), "••••••••••••••••••••••••••••")):
d.text((M, y), lab, font=font(24), fill=SECONDARY)
y += 36
d.text((M, y), val, font=font(30), fill=PRIMARY)
y += 52
d.line([(M, y), (W - M, y)], fill=BTN_EDGE, width=2)
y += 44
d.multiline_text((M, y), T("settings_note"), font=font(22), fill=SECONDARY, spacing=10)
y += 104
# 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)
return img
def cover_screen() -> Image.Image:
img = Image.new("RGB", (W, H), (16, 20, 26))
d = ImageDraw.Draw(img)
# griglia di app sullo sfondo, per collocare la copertina
for row in range(5):
for col in range(4):
cx = 108 + col * 168
cy = 150 + row * 180
d.rounded_rectangle([cx - 52, cy - 52, cx + 52, cy + 52], radius=22,
fill=(30, 36, 45))
# copertina
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
big = dec(23.0) + " °C"
d.text(((W - d.textlength(big, font=font(76, bold=True))) / 2, y),
big, font=font(76, bold=True), fill=PRIMARY)
y += 108
for lab, val in ((T("cover_target"), dec(19.0) + " °C"),
(T("cover_acs"), dec(51.0) + " °C"),
(T("cover_outdoor"), dec(18.0) + " °C")):
d.text((card[0] + 34, y), lab, font=font(24), fill=SECONDARY)
vw = d.textlength(val, font=font(24))
d.text((card[2] - 34 - vw, y), val, font=font(24), fill=PRIMARY)
y += 52
d.text((card[0] + 34, card[3] - 60), T("cover_footer"), font=font(22), fill=SECONDARY)
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("plant_commands"))
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 + 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 + 10, T("noise"))
y = button(d, y, T("night_one_hour"))
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()
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"))
# 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("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"))
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)
y = section_header(d, y + 40, T("diagnostics"))
y = button(d, y, T("plant_check"))
return img
if __name__ == "__main__":
import sys
lang = "en"
if "--lang" in sys.argv:
lang = sys.argv[sys.argv.index("--lang") + 1]
if lang not in TEXTS:
raise SystemExit(f"lingua non disponibile: {lang}")
LANG = lang
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), ("regolazioni", setpoints_screen),
("dati", data_screen),
("impianto", plant_screen), ("comandi", commands_screen),
("impostazioni", settings_screen), ("copertina", cover_screen)):
path = out / f"{name}.png"
fn().save(path)
print(f"scritto {path} ({path.stat().st_size} byte)")