Sulla copertina compare l'icona del boost (CoverAction). Una sola azione: la documentazione Silica ammette piu' di una azione ma dice che lo stile delle app di sistema ne mostra una. CoverBackground eredita da Cover, quindi l'elenco dichiarato li' si attacca; l'icona e' iconSource (non icon.source del Button: sbagliarlo non da' errore, l'icona semplicemente non compare). La riga in fondo si ancora a Cover.coverActionArea, cosi' l'icona non la copre. Nuovo controllo statico tests/cover_action_check.py: elenco figlio diretto del Cover, una sola azione, icona che esiste, id del comando presente nella tabella del bridge. Il componente e' nominato nella guida Silica dell'SDK di Carlo.
786 lines
32 KiB
Python
786 lines
32 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",
|
|
"back_home": "Home",
|
|
"commands": "More",
|
|
"contorno": "Outside {a} · Thermostat {b} · Zones 1",
|
|
"setpoints": "Setpoints",
|
|
"current_target": "Current target",
|
|
"keep_now": "What the plant is keeping now",
|
|
"water_now": "Water now {v}",
|
|
"hot_water_boost": "Hot water boost",
|
|
"set_house_v": "Set house to {v} °C",
|
|
"set_water_v": "Set target {v} °C",
|
|
"plant_commands": "Plant commands",
|
|
"modes": "Modes",
|
|
"sw_away_desc": "Lowers the house and stops the hot water",
|
|
"sw_forced_desc": "Heats at full power until you stop it",
|
|
"sw_heating_desc": "The heating can turn on",
|
|
"sw_water_desc": "The heat pump keeps the water in temperature",
|
|
"legionella": "Legionella cycle",
|
|
"switches_note": "The switches show the current state of the plant.",
|
|
"commands_note": "Commands go through the bridge and may take a few seconds.",
|
|
"diagnostics": "Diagnostics",
|
|
"plant_check": "Plant check",
|
|
"manual_mode": "Manual mode",
|
|
"defrost": "Defrost",
|
|
"signature_lights": "Signature lights",
|
|
"save_and_test": "Save and test",
|
|
"cover_outdoor": "outside",
|
|
"cover_house": "House",
|
|
"cover_hot_water": "Hot water",
|
|
})
|
|
|
|
TEXTS["it"].update({
|
|
"house_cell": "Casa",
|
|
"hot_water_cell": "Acqua calda",
|
|
"cell_target": "obiettivo {v}",
|
|
"status_fine": "Tutto regolare",
|
|
"status_water": "Sta scaldando l'acqua",
|
|
"updated_at": "Aggiornato alle 21:18",
|
|
"pull_hint": "Trascina in giù: Aggiorna · Dati · Impianto · Impostazioni",
|
|
"tap_adjust": "Tocca un valore per regolarlo",
|
|
"quick_actions": "Azioni rapide",
|
|
"boost": "Boost",
|
|
"away_btn": "Assenza",
|
|
"back_home": "Torna",
|
|
"commands": "Altro",
|
|
"contorno": "Esterno {a} · Termostato {b} · Zone 1",
|
|
"setpoints": "Regolazioni",
|
|
"current_target": "Obiettivo attuale",
|
|
"keep_now": "Quello che l'impianto mantiene adesso",
|
|
"water_now": "Acqua ora {v}",
|
|
"hot_water_boost": "Boost acqua calda",
|
|
"set_house_v": "Imposta casa a {v} °C",
|
|
"set_water_v": "Imposta obiettivo {v} °C",
|
|
"plant_commands": "Comandi impianto",
|
|
"modes": "Modalità",
|
|
"sw_away_desc": "Abbassa la casa e spegne l'acqua calda",
|
|
"sw_forced_desc": "Scalda a pieno regime finché non lo fermi",
|
|
"sw_heating_desc": "Il riscaldamento può accendersi",
|
|
"sw_water_desc": "Mantiene l'acqua in temperatura",
|
|
"legionella": "Ciclo legionella",
|
|
"switches_note": "Gli interruttori mostrano lo stato attuale dell'impianto.",
|
|
"commands_note": "I comandi passano dal bridge e possono richiedere qualche secondo.",
|
|
"diagnostics": "Diagnostica",
|
|
"plant_check": "Verifica impianto",
|
|
"manual_mode": "Modalità manuale",
|
|
"defrost": "Sbrinamento",
|
|
"signature_lights": "Luci di firma",
|
|
"save_and_test": "Salva e prova",
|
|
"cover_outdoor": "esterna",
|
|
"night_one_hour": "Modalità notte per un'ora",
|
|
"cover_house": "Casa",
|
|
"cover_hot_water": "Acqua calda",
|
|
})
|
|
|
|
|
|
def T(key: str) -> str:
|
|
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
|
|
# icona a sinistra e testo di fianco, come fa il pulsante Silica
|
|
glifi = ("quick-boost.png", "quick-away.png", "quick-commands.png")
|
|
for i, testo in enumerate((T("boost"), T("away_btn"), T("commands"))):
|
|
x = M + i * (bw + gap)
|
|
d.rounded_rectangle([x, y, x + bw, y + 74], radius=18, fill=BTN_BG,
|
|
outline=BTN_EDGE, width=2)
|
|
icona = Image.open(OUT.parent.parent / "qml" / "icons" / glifi[i]).convert("RGBA")
|
|
# stessa proporzione del telefono: icona piccola del tema (32 su 540)
|
|
# riportata alla larghezza di questa anteprima
|
|
lato = 42
|
|
icona = icona.resize((lato, lato), Image.LANCZOS)
|
|
tw = d.textlength(testo, font=font(26))
|
|
blocco = lato + 20 + tw
|
|
x0 = x + (bw - blocco) / 2
|
|
img.paste(icona, (int(x0), int(y + (74 - lato) / 2)), icona)
|
|
d.text((x0 + lato + 20, y + 22), testo, font=font(26), fill=PRIMARY)
|
|
y += 104
|
|
|
|
# ---- letture di contorno ----------------------------------------------- #
|
|
d.text((M, y), T("contorno").format(a=dec(17.8) + " °C", b=dec(22.8) + " °C"),
|
|
font=font(22), fill=SECONDARY)
|
|
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] + 150
|
|
# due righe: etichetta a sinistra, poi l'attuale piu' grande e l'obiettivo
|
|
# piu' piccolo attaccato — come nella copertina vera
|
|
colonna = 34 + int((card[2] - card[0] - 68) * 0.42)
|
|
for lab, ora, obiettivo in ((T("cover_house"), dec(22.9), dec(19.0)),
|
|
(T("cover_hot_water"), dec(51.5), dec(55.0))):
|
|
d.text((card[0] + 34, y + 10), lab, font=font(22), fill=SECONDARY)
|
|
d.text((card[0] + colonna, y), ora, font=font(30, bold=True), fill=PRIMARY)
|
|
d.text((card[0] + colonna + d.textlength(ora, font=font(30, bold=True)), y + 4),
|
|
"/" + obiettivo + " °C", font=font(24), fill=SECONDARY)
|
|
y += 72
|
|
d.text((card[0] + 34, card[3] - 118),
|
|
T("cover_outdoor") + " " + dec(18.0) + " °C", font=font(22), fill=SECONDARY)
|
|
# l'azione sulla copertina (P8): il boost, nell'area in basso. Dove la
|
|
# disegni Silica lo decide lei; qui si mostra che c'e'.
|
|
icona = Image.open(OUT.parent.parent / "qml" / "icons" / "quick-boost.png").convert("RGBA")
|
|
lato = 64
|
|
icona = icona.resize((lato, lato), Image.LANCZOS)
|
|
img.paste(icona, (int(card[0] + (card[2] - card[0] - lato) / 2), card[3] - 96), icona)
|
|
return img
|
|
|
|
|
|
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)")
|