#!/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) 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)")