"""Mockup-Renderer für 3 Design-Varianten des Netatmo-4x4-Layouts. Erzeugt 3 PNGs (800x480) mit denselben Mock-Daten aber unterschiedlichem Design. So kann der User vor dem Refactor sehen welche Richtung gefällt. Verwendung: python tools/render_mockup.py Output: docs/mockup_variant_{1,2,3}.png Layout-Prinzip fuer Variante 1 (Status-Update): - Strikt berechnete Zellen, keine magic numbers - Jeder Text/Bereich hat reservierten Raum → keine Overlaps moeglich - Maximale Lesbarkeit: grosse Zahlen, klare Hierarchie, viel Weissraum - Farbe nur fuer echte Signale (CO2-Schwellen, Battery, Trend) """ from __future__ import annotations import os, math from pathlib import Path from PIL import Image, ImageDraw, ImageFont HERE = Path(__file__).resolve().parent.parent OUT_DIR = HERE / "docs" OUT_DIR.mkdir(exist_ok=True) # ── 7-Farben ACeP-Palette ──────────────────────────────────────────────────── BLACK = (0, 0, 0) WHITE = (255, 255, 255) GREEN = (0, 255, 0) BLUE = (0, 0, 255) RED = (255, 0, 0) YELLOW = (255, 255, 0) ORANGE = (255, 128, 0) FG, BG = BLACK, WHITE OK, WARN, ALERT = GREEN, YELLOW, RED INFO, ACCENT = BLUE, ORANGE # ── Fonts (Windows) ───────────────────────────────────────────────────────── WIN_FONTS = r"C:\Windows\Fonts" F_BODY = os.path.join(WIN_FONTS, "arial.ttf") F_BOLD = os.path.join(WIN_FONTS, "arialbd.ttf") F_MONO = os.path.join(WIN_FONTS, "consola.ttf") def fnt(size, bold=False, mono=False): path = F_MONO if mono else (F_BOLD if bold else F_BODY) if not os.path.exists(path): path = F_BODY return ImageFont.truetype(path, size) # ── Display & Layout-Master-Grid ──────────────────────────────────────────── DISP_W, DISP_H = 800, 480 # Vertikale Zonen (gestapelt, keine Ueberlappung moeglich) HEADER_H = 32 # oberste Zeile: Station + Zeit FOOTER_H = 22 # unterste Zeile: Letztes Update BODY_Y = HEADER_H + 4 BODY_H = DISP_H - BODY_Y - FOOTER_H - 4 # 416 px # Spalten (im Body-Bereich). Jede Spalte hat feste Breite + Inhalt. COL_GAP = 8 COL_LEFT = 10 COL1_X = COL_LEFT COL1_W = 220 COL2_X = COL1_X + COL1_W + COL_GAP COL2_W = 160 COL3_X = COL2_X + COL2_W + COL_GAP COL3_W = 250 COL4_X = COL3_X + COL3_W + COL_GAP COL4_W = DISP_W - COL4_X - COL_LEFT # 142 # ── Mock-Daten ────────────────────────────────────────────────────────────── MOCK = { "station": "Daheim", "time": "SO 30.08 18:24", "update": "vor 12 s", "outdoor": {"name": "Aussen", "temp": 18.4, "hum": 67, "battery": 78}, "indoor": [ {"tag": "M", "name": "Wohnen", "temp": 22.1, "co2": 720, "battery": None, "hum": 48}, {"tag": "2", "name": "Kueche", "temp": 23.5, "co2": 980, "battery": 88, "hum": 52}, {"tag": "3", "name": "Schlafen", "temp": 20.8, "co2": 1450, "battery": 41, "hum": 55}, {"tag": "4", "name": "Buero", "temp": 24.2, "co2": 610, "battery": 92, "hum": 44}, {"tag": "5", "name": "Keller", "temp": 17.9, "co2": 580, "battery": 12, "hum": 60}, ], "wind": {"speed": 12, "gust": 24, "deg": 215, "battery": 65}, "rain": {"rate": 0.0, "h1": 0.4, "h24": 6.2, "today": 8.4, "battery": 82}, "pressure":{"value": 1014, "trend": "down"}, "forecast":[ {"day": "MO", "tmin": 9, "tmax": 19, "rain": 0.0, "code": "sun"}, {"day": "DI", "tmin": 11, "tmax": 21, "rain": 2.4, "code": "cloud"}, {"day": "MI", "tmin": 8, "tmax": 17, "rain": 8.1, "code": "rain"}, ], } # ── Low-Level Helpers ─────────────────────────────────────────────────────── def measure(draw, text, font): b = draw.textbbox((0, 0), text, font=font) return b[2] - b[0], b[3] - b[1] def fit_font(draw, text, fonts_dict, max_w, max_h): for key in sorted(fonts_dict.keys(), reverse=True): f = fonts_dict[key] tw, th = measure(draw, text, f) if tw <= max_w and th <= max_h: return f return fonts_dict[min(fonts_dict.keys())] def text_in_box(draw, text, x, y, w, h, font, color, align="left"): """Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine Ueberlappung mit anderen Elementen.""" tw, th = measure(draw, text, font) if tw > w or th > h: # Fallback: verkleinern return False if align == "left": draw.text((x, y + (h - th) // 2), text, font=font, fill=color) elif align == "right": draw.text((x + w - tw, y + (h - th) // 2), text, font=font, fill=color) elif align == "center": draw.text((x + (w - tw) // 2, y + (h - th) // 2), text, font=font, fill=color) return True def temp_color(t): if t >= 28: return RED if t >= 22: return ORANGE if t <= 5: return BLUE return FG def co2_color(co2): if co2 is None: return FG if co2 >= 1500: return RED if co2 >= 1000: return YELLOW if co2 >= 600: return ORANGE return GREEN def batt_color(pct): if pct is None: return FG if pct < 25: return RED if pct < 50: return ORANGE return GREEN def standard_fonts(): return {8: fnt(8), 10: fnt(10), 11: fnt(11), 12: fnt(12), 13: fnt(13), 14: fnt(14), 16: fnt(16), 18: fnt(18), 20: fnt(20), 24: fnt(24), 28: fnt(28), 32: fnt(32), 36: fnt(36), 40: fnt(40), 48: fnt(48), 60: fnt(60), 72: fnt(72), 84: fnt(84), 96: fnt(96)} def draw_battery_compact(draw, x, y, w, h, pct): """Batterie-Icon, passt in (w x h) Box. Gibt genutzte Breite zurueck.""" if pct is None: return 0 pct = max(0, min(100, pct)) color = batt_color(pct) # Body draw.rectangle((x, y, x + w - 2, y + h - 1), outline=FG, width=1) # Tip draw.rectangle((x + w - 2, y + h // 4, x + w, y + 3 * h // 4 - 1), fill=FG) # Fill fill_w = max(0, int((w - 3) * pct / 100)) if fill_w > 0: draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color) return w def co2_bar_in_box(draw, x, y, w, h, co2): """CO2-Bar mit Schwellen-Farbe, in (x,y,w,h) Box.""" if co2 is None or w < 10 or h < 4: return color = co2_color(co2) # Outline draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1) # Fill pct = min(100, co2 / 2000 * 100) fill_w = max(0, int((w - 2) * pct / 100)) if fill_w > 0: draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color) # WMO-artige Wetter-Icons (nur 7 Farben, einfache Geometrie) def draw_weather_icon(draw, x, y, size, code): """Zeichnet ein Wetter-Icon in (x, y, size, size) Box. Codes: 'sun', 'partly', 'cloud', 'rain', 'storm', 'snow', 'fog' Verwendet nur 7 ACeP-Farben. """ cx, cy = x + size // 2, y + size // 2 r = max(3, size // 3) if code == "sun" or code == 0: # Gelbe Sonne mit Strahlen draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW) # Strahlen for ang in range(0, 360, 45): rad = math.radians(ang) x1 = cx + int((r + 2) * math.cos(rad)) y1 = cy + int((r + 2) * math.sin(rad)) x2 = cx + int((r + 5) * math.cos(rad)) y2 = cy + int((r + 5) * math.sin(rad)) draw.line((x1, y1, x2, y2), fill=FG, width=1) elif code == "partly" or code in (1, 2, 3): # Sonne + kleine Wolke draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW) # Wolke cw = size // 2 ch = size // 4 draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch), fill=WHITE, outline=FG, width=1) elif code == "cloud" or code in (45, 48): # Wolke (Outline only, sonst nichts sichtbar auf weiss) cw = int(size * 0.7) ch = size // 3 draw.ellipse((cx - cw // 2, cy - ch // 2, cx + cw // 2, cy + ch // 2), fill=WHITE, outline=FG, width=2) # Zweite Halbkugel für die "Wolkenform" draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2), fill=WHITE, outline=FG, width=2) elif code == "rain" or code in (51, 53, 55, 61, 63, 65, 80, 81, 82): # Wolke + 3 blaue Tropfen cw = int(size * 0.7) ch = size // 3 draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1), fill=WHITE, outline=FG, width=2) # Tropfen for dx in (-4, 0, 4): draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2) elif code == "storm" or code in (95, 96, 99): # Wolke + Blitz (gelb/orange) cw = int(size * 0.7) ch = size // 3 draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1), fill=WHITE, outline=FG, width=2) # Blitz-Zickzack draw.polygon([(cx - 3, cy + ch - 1), (cx + 1, cy + ch + 3), (cx - 1, cy + ch + 3), (cx + 3, cy + ch + 8)], fill=YELLOW) elif code == "snow" or code in (71, 73, 75, 77, 85, 86): # Wolke + weisse Schneeflocken (auf weiss unsichtbar, daher als X aus Strichen) cw = int(size * 0.7) ch = size // 3 draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1), fill=WHITE, outline=FG, width=2) for dx in (-4, 0, 4): for dy in (0, 5): draw.line((cx + dx - 2, cy + ch + dy, cx + dx + 2, cy + ch + dy), fill=FG, width=1) draw.line((cx + dx, cy + ch + dy - 2, cx + dx, cy + ch + dy + 2), fill=FG, width=1) else: # Fallback: ? Symbol f = fnt(size - 4, bold=True) tw, th = measure(draw, "?", f) draw.text((x + (size - tw) // 2, y + (size - th) // 2 - 2), "?", font=f, fill=FG) # ════════════════════════════════════════════════════════════════════════════ # VARIANTE 1: "Status-Update" — Minimalistisch, lesbar, strikt grid-basiert. # Layout-Prinzip: jede Sektion ist eine Box mit bekannten Bounds. Kein Element # kann ueber die Box-Grenze hinauszeichnen. # ════════════════════════════════════════════════════════════════════════════ def render_v1(): img = Image.new("RGB", (DISP_W, DISP_H), BG) draw = ImageDraw.Draw(img) fonts = standard_fonts() # === HEADER (y: 0-32) =================================================== draw.text((COL_LEFT, 8), MOCK["station"].upper(), font=fnt(12, bold=True), fill=FG) # Zeit rechts f_time = fnt(11) tw, _ = measure(draw, MOCK["time"], f_time) draw.text((DISP_W - COL_LEFT - tw, 10), MOCK["time"], font=f_time, fill=FG) # Trennlinie draw.line((COL_LEFT, HEADER_H, DISP_W - COL_LEFT, HEADER_H), fill=FG, width=2) # === BODY (y: 36-462) ================================================== # Spalten-Trennlinien (sehr duenn, nur visuell) for sep_x in [COL2_X - 4, COL3_X - 4, COL4_X - 4]: draw.line((sep_x, BODY_Y, sep_x, BODY_Y + BODY_H), fill=FG, width=1) # ── SPALTE 1: AUSSEN (x:10-230, y:36-462) ──────────────────────────── aussen_box = (COL1_X, BODY_Y, COL1_W, BODY_H) sec_header_h = 16 # Header-Zeile text_in_box(draw, "AUSSEN", COL1_X, BODY_Y, COL1_W, sec_header_h, fnt(11, bold=True), GREEN, "center") # Body aufteilen in Big-Temp / Stats / Chart body_inner_y = BODY_Y + sec_header_h + 2 body_inner_h = BODY_H - sec_header_h - 2 # Big Temp Block: 0..130 big_h = 100 out = MOCK["outdoor"] big = f"{out['temp']:.1f}°" f_big = fit_font(draw, big, fonts, COL1_W - 8, big_h) text_in_box(draw, big, COL1_X, body_inner_y, COL1_W, big_h, f_big, FG, "center") # Battery oben rechts in der Big-Temp-Box if out.get("battery") is not None: by = body_inner_y + 2 bx = COL1_X + COL1_W - 56 draw_battery_compact(draw, bx, by, 28, 9, out["battery"]) text_in_box(draw, f"{out['battery']}%", bx + 30, by - 1, 24, 11, fnt(9, bold=True), batt_color(out["battery"]), "left") # 4 Stats: 2x2 Grid stats_y = body_inner_y + big_h stats_h = 56 cell_w = (COL1_W - 6) // 2 cell_h = stats_h // 2 stats = [ ("MIN", f"{out['temp']-2:.0f}°", FG), ("MAX", f"{out['temp']+4:.0f}°", FG), ("FEUCHTE", f"{out['hum']}%", BLUE), ("DRUCK", f"{MOCK['pressure']['value']}", FG), ] for i, (lbl, val, col) in enumerate(stats): cx = COL1_X + (i % 2) * (cell_w + 6) + 2 cy = stats_y + (i // 2) * cell_h text_in_box(draw, lbl, cx, cy, cell_w, 11, fnt(8, bold=True), FG, "left") text_in_box(draw, val, cx, cy + 11, cell_w, cell_h - 11, fnt(15, bold=True), col, "left") # Trend-Pfeil beim Druck (Zelle 4) if lbl == "DRUCK": trend = MOCK["pressure"]["trend"] if trend != "stable": tcol = RED if trend == "down" else GREEN ax = cx + cell_w - 18 ay = cy + 12 if trend == "down": draw.polygon([(ax+6, ay+2), (ax, ay+10), (ax+12, ay+10)], fill=tcol) else: draw.polygon([(ax, ay+2), (ax+12, ay+2), (ax+6, ay+10)], fill=tcol) # Trennlinie vor Chart chart_sep_y = stats_y + stats_h + 2 draw.line((COL1_X + 4, chart_sep_y, COL1_X + COL1_W - 4, chart_sep_y), fill=FG, width=1) # Chart-Bereich chart_y = chart_sep_y + 2 chart_h = body_inner_y + body_inner_h - chart_y # Chart-Label text_in_box(draw, "12H VERLAUF", COL1_X, chart_y, COL1_W // 2, 12, fnt(8, bold=True), FG, "left") text_in_box(draw, f"{out['temp']-3:.0f}-{out['temp']+2:.0f}°", COL1_X + COL1_W // 2, chart_y, COL1_W // 2, 12, fnt(8, bold=True), FG, "right") # Chart-Y-Grid (3 Linien mit Temperatur-Labels) # Werte basieren auf Min/Max der Mock-Daten y_min = math.floor((out['temp'] - 3) / 5) * 5 y_max = math.ceil((out['temp'] + 2) / 5) * 5 y_ticks = [y_min, (y_min + y_max) // 2, y_max] line_y_top = chart_y + 14 line_y_bot = chart_y + chart_h - 14 line_h_avail = line_y_bot - line_y_top grid_left = COL1_X + 22 # Platz fuer Y-Labels grid_right = COL1_X + COL1_W - 4 for y_temp in y_ticks: # y-Position: oben = max, unten = min y_px = line_y_top + int((y_max - y_temp) / max(1, y_max - y_min) * line_h_avail) # Y-Label links text_in_box(draw, f"{y_temp}°", COL1_X + 2, y_px - 6, 20, 12, fnt(8, bold=True), FG, "left") # Gitter-Linie (sehr duenn, gestrichelt simuliert durch kurze Segmente) for seg_x in range(grid_left, grid_right, 6): draw.line((seg_x, y_px, min(seg_x + 3, grid_right), y_px), fill=FG, width=1) # Chart-Linie (streng im reservierten Bereich) pts = [] for i in range(36): x = grid_left + int(i * (grid_right - grid_left) / 35) # Y-Wert zwischen y_min und y_max mappen # Synthetische Kurve val = out['temp'] + 2.5 * math.sin(i / 2.5) + 1.0 * math.cos(i / 1.7) val = max(y_min, min(y_max, val)) y_px = line_y_top + int((y_max - val) / max(1, y_max - y_min) * line_h_avail) pts.append((x, y_px)) for a, b in zip(pts, pts[1:]): draw.line([a, b], fill=GREEN, width=2) # -12h / jetzt Labels (unter dem Chart) text_in_box(draw, "-12h", COL1_X, line_y_bot, COL1_W // 2, 12, fnt(8), FG, "left") text_in_box(draw, "jetzt", COL1_X + COL1_W // 2, line_y_bot, COL1_W // 2, 12, fnt(8, bold=True), FG, "right") # ── SPALTE 2: WIND / REGEN (x:238-398, y:36-462) ───────────────────── sec_h = (BODY_H - 4) // 2 # 2 Sub-Sections mit 4px Gap # WIND sec_y = BODY_Y text_in_box(draw, "WIND", COL2_X, sec_y, COL2_W, sec_header_h, fnt(11, bold=True), BLUE, "center") # Battery oben rechts if MOCK["wind"].get("battery") is not None: bx = COL2_X + COL2_W - 50 by = sec_y + 2 draw_battery_compact(draw, bx, by, 24, 8, MOCK["wind"]["battery"]) text_in_box(draw, f"{MOCK['wind']['battery']}%", bx + 26, by - 1, 22, 10, fnt(8, bold=True), batt_color(MOCK["wind"]["battery"]), "left") # Big Speed sec_body_y = sec_y + sec_header_h + 2 sec_body_h = sec_h - sec_header_h - 4 big_val = f"{MOCK['wind']['speed']}" f_big = fit_font(draw, big_val, fonts, COL2_W - 30, sec_body_h - 30) text_in_box(draw, big_val, COL2_X, sec_body_y, COL2_W - 24, sec_body_h - 30, f_big, FG, "center") # km/h daneben tw_, th_ = measure(draw, big_val, f_big) text_in_box(draw, "km/h", COL2_X + (COL2_W - 24) // 2 + tw_ // 2 + 2, sec_body_y + (sec_body_h - 30) - 14, 30, 14, fnt(11, bold=True), FG, "left") # Bö text_in_box(draw, f"Bö {MOCK['wind']['gust']} km/h", COL2_X, sec_body_y + sec_body_h - 24, COL2_W, 14, fnt(11, bold=True), FG, "center") # Mini-Compass rechts ccx = COL2_X + COL2_W - 22 ccy = sec_body_y + 26 cr = 14 draw.ellipse((ccx - cr, ccy - cr, ccx + cr, ccy + cr), outline=FG, width=1) rad = math.radians(MOCK["wind"]["deg"] - 90) tx = ccx + (cr - 3) * math.cos(rad) ty = ccy + (cr - 3) * math.sin(rad) draw.line((ccx, ccy, tx, ty), fill=BLUE, width=2) draw.ellipse((ccx - 2, ccy - 2, ccx + 2, ccy + 2), fill=BLUE) # REGEN sec_y = BODY_Y + sec_h + 4 text_in_box(draw, "REGEN", COL2_X, sec_y, COL2_W, sec_header_h, fnt(11, bold=True), BLUE, "center") if MOCK["rain"].get("battery") is not None: bx = COL2_X + COL2_W - 50 by = sec_y + 2 draw_battery_compact(draw, bx, by, 24, 8, MOCK["rain"]["battery"]) text_in_box(draw, f"{MOCK['rain']['battery']}%", bx + 26, by - 1, 22, 10, fnt(8, bold=True), batt_color(MOCK["rain"]["battery"]), "left") sec_body_y = sec_y + sec_header_h + 2 sec_body_h = sec_h - sec_header_h - 4 # Layout: Rate DOMINANT oben, HEUTE mittig, 1h/24h kompakt unten rate_row_h = 80 today_row_h = 32 bars_row_h = sec_body_h - rate_row_h - today_row_h - 4 rate = MOCK["rain"]["rate"] rate_str = f"{rate:.1f}" if rate > 0 else "—" # Rate RIESIG, ohne "mm/h" Mini-Label (Einheit unten als eigener Block) f_big = fit_font(draw, rate_str, fonts, COL2_W - 20, rate_row_h - 16) text_in_box(draw, rate_str, COL2_X, sec_body_y, COL2_W - 4, rate_row_h, f_big, BLUE if rate > 0 else FG, "center") # Einheit "mm/h" zentriert unter der Rate unit_y = sec_body_y + rate_row_h - 14 text_in_box(draw, "mm/h", COL2_X, unit_y, COL2_W - 4, 12, fnt(10, bold=True), FG, "center") # HEUTE prominent today_y = sec_body_y + rate_row_h + 4 today_val_str = f"{MOCK['rain']['today']:.1f}" # "HEUTE" Label klein oben, Wert GROSS darunter text_in_box(draw, "HEUTE", COL2_X + 2, today_y, 50, 10, fnt(8, bold=True), FG, "left") text_in_box(draw, today_val_str, COL2_X, today_y + 10, COL2_W // 2 - 4, today_row_h - 10, fnt(20, bold=True), BLUE if MOCK['rain']['today'] > 0 else FG, "left") # "mm" rechts vom Wert text_in_box(draw, "mm", COL2_X + COL2_W // 2, today_y + 14, COL2_W // 2 - 4, today_row_h - 14, fnt(10, bold=True), FG, "left") # 1h / 24h - kompakt, nur Wert ohne Label (zu klein sonst) bar_h = 10 for j, (key, maxval) in enumerate([("h1", 5), ("h24", 20)]): val = MOCK["rain"][key] by = sec_body_y + sec_body_h - 2 * bar_h - 2 + j * (bar_h + 2) # Label + Wert text_in_box(draw, f"{key[:-1]}h", COL2_X, by, 18, bar_h, fnt(8, bold=True), FG, "left") # Mini-Bar bar_x = COL2_X + 22 bar_w = COL2_W - 22 - 28 draw.rectangle((bar_x, by + 2, bar_x + bar_w - 1, by + bar_h - 1), outline=FG, width=1) pct = min(100, val / maxval * 100) if pct > 0: fw = max(2, int((bar_w - 3) * pct / 100)) draw.rectangle((bar_x + 1, by + 3, bar_x + 1 + fw, by + bar_h - 2), fill=BLUE) # Wert rechts text_in_box(draw, f"{val:.1f}", COL2_X + COL2_W - 28, by, 26, bar_h, fnt(9, bold=True), FG, "right") # ── SPALTE 3: INDOOR (x:406-656, y:36-462) ─────────────────────────── text_in_box(draw, f"INNEN · {len(MOCK['indoor'])}", COL3_X, BODY_Y, COL3_W, sec_header_h, fnt(11, bold=True), RED, "center") indoor = MOCK["indoor"] # Verfuegbarer Platz pro Sensor sec_body_y = BODY_Y + sec_header_h + 2 sec_body_h = BODY_H - sec_header_h - 2 row_h = sec_body_h // len(indoor) # = 80 line_h = 22 # Zeile 1: Tag + Name + Temp (groesser!) bar_h = 10 # Zeile 2: CO2 Bar val_h = 12 # Zeile 3: CO2 Wert + Battery ROW_L1_Y_OFF = 2 ROW_L2_Y_OFF = ROW_L1_Y_OFF + line_h + 2 ROW_L3_Y_OFF = ROW_L2_Y_OFF + bar_h + 2 for i, s in enumerate(indoor): ry = sec_body_y + i * row_h if i > 0: draw.line((COL3_X + 4, ry, COL3_X + COL3_W - 4, ry), fill=FG, width=1) inner_x = COL3_X + 6 inner_w = COL3_W - 12 # Zeile 1: Tag | Name (GROSS) | Temp (GROSS, Farbe nach Temperatur) tag_w = 18 text_in_box(draw, s["tag"], inner_x, ry + ROW_L1_Y_OFF, tag_w, line_h, fnt(18, bold=True), RED, "center") name_x = inner_x + tag_w + 4 name_w = inner_w - tag_w - 4 - 70 text_in_box(draw, s["name"], name_x, ry + ROW_L1_Y_OFF, name_w, line_h, fnt(16, bold=True), FG, "left") # Temp fett und gross, in Temperatur-Farbe text_in_box(draw, f"{s['temp']:.1f}°", inner_x + inner_w - 70, ry + ROW_L1_Y_OFF, 70, line_h, fnt(22, bold=True), temp_color(s["temp"]), "right") # Zeile 2: CO2 Bar co2_bar_in_box(draw, inner_x, ry + ROW_L2_Y_OFF, inner_w, bar_h, s["co2"]) # Zeile 3: CO2 Wert + Battery (ohne "CO2" Label, nur Zahl+ppm) co2_str = f"{s['co2']} ppm" if s["co2"] is not None else "—" text_in_box(draw, co2_str, inner_x, ry + ROW_L3_Y_OFF, inner_w // 2, val_h, fnt(12, bold=True), co2_color(s["co2"]), "left") if s.get("battery") is not None: pct_str = f"{s['battery']}%" text_in_box(draw, pct_str, inner_x + inner_w - 24, ry + ROW_L3_Y_OFF - 1, 24, val_h, fnt(10, bold=True), batt_color(s["battery"]), "right") draw_battery_compact(draw, inner_x + inner_w - 22, ry + ROW_L3_Y_OFF, 20, 8, s["battery"]) # ── SPALTE 4: VORHERSAGE (x:664-790, y:36-462) ─────────────────────── text_in_box(draw, "VORHERSAGE", COL4_X, BODY_Y, COL4_W, sec_header_h, fnt(11, bold=True), ORANGE, "center") fc = MOCK["forecast"] sec_body_y = BODY_Y + sec_header_h + 2 sec_body_h = BODY_H - sec_header_h - 2 row_h_fc = sec_body_h // len(fc) # Layout: grosses Icon + Tag oben, dann Min/Max RIESIG, dann Regen-Wert fc_l1_h = 32 # Tag + Icon (groesser) fc_l2_h = 44 # Min/Max (DOMINANT) fc_l3_h = row_h_fc - fc_l1_h - fc_l2_h for i, day in enumerate(fc): ry = sec_body_y + i * row_h_fc if i > 0: draw.line((COL4_X + 4, ry, COL4_X + COL4_W - 4, ry), fill=FG, width=1) # Tag (links, fett, gross) text_in_box(draw, day["day"], COL4_X + 4, ry, 50, fc_l1_h, fnt(18, bold=True), FG, "left") # Wetter-Icon (rechts, gross, 28x28) icon_size = min(28, fc_l1_h - 4) draw_weather_icon(draw, COL4_X + COL4_W - icon_size - 4, ry + (fc_l1_h - icon_size) // 2, icon_size, day.get("code", "cloud")) # Min/Max (DOMINANT, riesig) text_in_box(draw, f"{day['tmin']}°", COL4_X + 4, ry + fc_l1_h, COL4_W // 2, fc_l2_h, fnt(28, bold=True), BLUE, "left") text_in_box(draw, f"{day['tmax']}°", COL4_X + COL4_W // 2, ry + fc_l1_h, COL4_W // 2 - 4, fc_l2_h, fnt(28, bold=True), ORANGE, "left") # Regen-Icon + Wert (ohne "REGEN" Text-Label, nur das Symbol) rb_y = ry + fc_l1_h + fc_l2_h + 2 if fc_l3_h > 10: # Mini-Tropfen-Icon links vom Wert icon_x = COL4_X + 4 icon_y = rb_y + 1 if day["rain"] > 0: # Blauer Tropfen draw.polygon([(icon_x + 4, icon_y), (icon_x, icon_y + 5), (icon_x + 8, icon_y + 5)], fill=BLUE) else: # Leeres Tropfen-Outline draw.polygon([(icon_x + 4, icon_y), (icon_x, icon_y + 5), (icon_x + 8, icon_y + 5)], outline=FG, width=1) # Wert gross rain_color = BLUE if day["rain"] > 0 else FG text_in_box(draw, f"{day['rain']:.1f} mm", COL4_X + 18, rb_y, COL4_W - 22, 16, fnt(14, bold=True), rain_color, "left") # === FOOTER (y: 462-480) ================================================ f_foot = fnt(9) draw.text((COL_LEFT, DISP_H - FOOTER_H + 4), f"NETATMO · letzte Aktualisierung {MOCK['update']}", font=f_foot, fill=FG) # Quellen rechts draw.text((DISP_W - COL_LEFT - 130, DISP_H - FOOTER_H + 4), "Daten: Netatmo + Open-Meteo", font=f_foot, fill=FG) # Trennlinie draw.line((COL_LEFT, DISP_H - FOOTER_H, DISP_W - COL_LEFT, DISP_H - FOOTER_H), fill=FG, width=1) img.save(OUT_DIR / "mockup_variant_1.png") print(f" -> {OUT_DIR / 'mockup_variant_1.png'}") # ════════════════════════════════════════════════════════════════════════════ # VARIANTE 2: "Warm Weather Station" — Farbige Header-Strips, traditionell. # ════════════════════════════════════════════════════════════════════════════ def render_v2(): img = Image.new("RGB", (DISP_W, DISP_H), BG) draw = ImageDraw.Draw(img) fonts = standard_fonts() draw.rectangle((0, 0, DISP_W, 6), fill=ORANGE) draw.text((COL_LEFT, 14), "WETTER · " + MOCK["station"].upper(), font=fnt(13, bold=True), fill=FG) f_time = fnt(11) tw, _ = measure(draw, MOCK["time"], f_time) draw.text((DISP_W - COL_LEFT - tw, 16), MOCK["time"], font=f_time, fill=FG) draw.line((COL_LEFT, 36, DISP_W - COL_LEFT, 36), fill=FG, width=2) body_y = 46 PAD = 16 body_h = DISP_H - body_y - PAD strip_h = 5 label_h = 22 inner_pad = 10 def card(x, y, w, h, color, label): draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=2) draw.rectangle((x, y, x + w - 1, y + strip_h), fill=color) draw.text((x + inner_pad, y + strip_h + 4), label, font=fnt(13, bold=True), fill=color) # Aussen ax, aw = COL1_X, COL1_W card(ax, body_y, aw, body_h, GREEN, "AUSSEN") out = MOCK["outdoor"] if out.get("battery") is not None: bx = ax + aw - 50 by = body_y + strip_h + 8 draw_battery_compact(draw, bx, by, 24, 8, out["battery"]) text_in_box(draw, f"{out['battery']}%", bx + 26, by - 1, 22, 10, fnt(8, bold=True), batt_color(out["battery"]), "left") big = f"{out['temp']:.0f}°" big_y = body_y + strip_h + label_h + 6 big_h = 100 f_big = fit_font(draw, big, fonts, aw - 2 * inner_pad, big_h) text_in_box(draw, big, ax, big_y, aw, big_h, f_big, FG, "center") stats_y = big_y + big_h + 4 s_w = (aw - 2 * inner_pad) // 4 stats = [ ("MIN", f"{out['temp']-2:.0f}°", FG), ("MAX", f"{out['temp']+4:.0f}°", FG), ("FEUCHTE", f"{out['hum']}%", BLUE), ("DRUCK", f"{MOCK['pressure']['value']}", FG), ] for i, (lbl, val, col) in enumerate(stats): sx = ax + inner_pad + i * s_w text_in_box(draw, lbl, sx, stats_y, s_w, 10, fnt(8, bold=True), FG, "left") text_in_box(draw, val, sx, stats_y + 10, s_w, 18, fit_font(draw, val, fonts, s_w, 18), col, "left") chart_y = stats_y + 38 chart_h = body_h - (chart_y - body_y) - 8 if chart_h > 20: text_in_box(draw, "12H VERLAUF", ax + inner_pad, chart_y, aw // 2, 12, fnt(8, bold=True), FG, "left") text_in_box(draw, f"{out['temp']-3:.0f}-{out['temp']+2:.0f}°", ax + aw // 2, chart_y, aw // 2 - inner_pad, 12, fnt(8), FG, "right") line_y = chart_y + 14 line_h = chart_h - 24 pts = [] for i in range(24): x = ax + inner_pad + int(i * (aw - 2 * inner_pad) / 23) amp = max(4, line_h // 3) y = line_y + line_h // 2 + int(amp * math.sin(i / 2.5)) pts.append((x, y)) for a, b in zip(pts, pts[1:]): draw.line([a, b], fill=GREEN, width=2) text_in_box(draw, "-12h", ax + inner_pad, line_y + line_h, aw // 2, 12, fnt(8), FG, "left") text_in_box(draw, "jetzt", ax + aw // 2, line_y + line_h, aw // 2 - inner_pad, 12, fnt(8, bold=True), FG, "right") # Wind/Regen mx, mw = COL2_X, COL2_W mh = (body_h - 6) // 2 # Wind card(mx, body_y, mw, mh, BLUE, "WIND") wind = MOCK["wind"] if wind.get("battery") is not None: bx = mx + mw - 50 by = body_y + strip_h + 8 draw_battery_compact(draw, bx, by, 24, 8, wind["battery"]) text_in_box(draw, f"{wind['battery']}%", bx + 26, by - 1, 22, 10, fnt(8, bold=True), batt_color(wind["battery"]), "left") sb_y = body_y + strip_h + label_h + 4 sb_h = mh - strip_h - label_h - 4 s_str = f"{wind['speed']}" f_ws = fit_font(draw, s_str, fonts, mw - 50, sb_h - 30) text_in_box(draw, s_str, mx + inner_pad, sb_y, mw - 50, sb_h - 30, f_ws, FG, "left") sw, sh = measure(draw, s_str, f_ws) text_in_box(draw, "km/h", mx + inner_pad + sw + 4, sb_y + (sb_h - 30) - 14, 40, 14, fnt(11), FG, "left") text_in_box(draw, f"Bö {wind['gust']} km/h", mx + inner_pad, sb_y + sb_h - 24, mw - inner_pad, 14, fnt(10), FG, "left") ccx = mx + mw - 26 ccy = sb_y + 24 cr = 18 draw.ellipse((ccx - cr, ccy - cr, ccx + cr, ccy + cr), outline=FG, width=1) rad = math.radians(wind["deg"] - 90) tx = ccx + (cr - 4) * math.cos(rad) ty = ccy + (cr - 4) * math.sin(rad) draw.line((ccx, ccy, tx, ty), fill=BLUE, width=2) draw.ellipse((ccx - 2, ccy - 2, ccx + 2, ccy + 2), fill=BLUE) # Regen rry = body_y + mh + 6 card(mx, rry, mw, mh, BLUE, "REGEN") rain = MOCK["rain"] if rain.get("battery") is not None: bx = mx + mw - 50 by = rry + strip_h + 8 draw_battery_compact(draw, bx, by, 24, 8, rain["battery"]) text_in_box(draw, f"{rain['battery']}%", bx + 26, by - 1, 22, 10, fnt(8, bold=True), batt_color(rain["battery"]), "left") rb_y = rry + strip_h + label_h + 4 rb_h = mh - strip_h - label_h - 4 r_str = f"{rain['rate']:.1f}" f_rs = fit_font(draw, r_str, fonts, mw - 50, rb_h - 50) text_in_box(draw, r_str, mx + inner_pad, rb_y, mw - 50, rb_h - 50, f_rs, BLUE if rain["rate"] > 0 else FG, "left") rw, rh = measure(draw, r_str, f_rs) text_in_box(draw, "mm/h", mx + inner_pad + rw + 4, rb_y + (rb_h - 50) - 12, 40, 12, fnt(11), FG, "left") b1y = rb_y + rb_h - 50 text_in_box(draw, "1h", mx + inner_pad, b1y, 20, 10, fnt(8, bold=True), FG, "left") b1x = mx + inner_pad + 22 b1w = mw - 2 * inner_pad - 22 - 26 draw.rectangle((b1x, b1y, b1x + b1w, b1y + 8), outline=FG, width=1) p1 = min(100, rain["h1"] / 5 * 100) if p1 > 0: draw.rectangle((b1x + 1, b1y + 1, b1x + 1 + max(2, int((b1w - 2) * p1 / 100)), b1y + 7), fill=BLUE) text_in_box(draw, f"{rain['h1']:.1f}", mx + mw - inner_pad - 24, b1y - 1, 24, 10, fnt(10, bold=True), FG, "right") b2y = b1y + 18 text_in_box(draw, "24h", mx + inner_pad, b2y, 20, 10, fnt(8, bold=True), FG, "left") draw.rectangle((b1x, b2y, b1x + b1w, b2y + 8), outline=FG, width=1) p24 = min(100, rain["h24"] / 20 * 100) if p24 > 0: draw.rectangle((b1x + 1, b2y + 1, b1x + 1 + max(2, int((b1w - 2) * p24 / 100)), b2y + 7), fill=BLUE) text_in_box(draw, f"{rain['h24']:.1f}", mx + mw - inner_pad - 24, b2y - 1, 24, 10, fnt(10, bold=True), FG, "right") # Indoor ix, iw = COL3_X, COL3_W card(ix, body_y, iw, body_h, RED, f"INNEN · {len(MOCK['indoor'])} SENSOREN") inner_x = ix + 8 inner_w = iw - 16 sec_body_y = body_y + strip_h + label_h + 6 sec_body_h = body_h - strip_h - label_h - 12 row_h = sec_body_h // len(MOCK["indoor"]) line_h, bar_h, val_h = 18, 10, 12 for i, s in enumerate(MOCK["indoor"]): ry = sec_body_y + i * row_h if i > 0: draw.line((inner_x, ry, inner_x + inner_w, ry), fill=FG, width=1) # Zeile 1: Tag | Name | Temp tag_w = 22 text_in_box(draw, s["tag"], inner_x, ry, tag_w, line_h, fnt(14, bold=True), RED, "left") text_in_box(draw, s["name"], inner_x + tag_w, ry, inner_w - tag_w - 50, line_h, fit_font(draw, s["name"], fonts, inner_w - tag_w - 50, line_h), FG, "left") text_in_box(draw, f"{s['temp']:.1f}°", inner_x + inner_w - 50, ry, 50, line_h, fnt(13, bold=True), temp_color(s["temp"]), "right") # Zeile 2: CO2 Bar co2_bar_in_box(draw, inner_x, ry + 20, inner_w, bar_h, s["co2"]) # Zeile 3: CO2 Wert + Battery co2_text = f"CO₂ {s['co2']} ppm" if s["co2"] is not None else "—" text_in_box(draw, co2_text, inner_x, ry + 20 + bar_h + 2, inner_w // 2, val_h, fnt(9, bold=True), co2_color(s["co2"]), "left") if s.get("battery") is not None: pct_str = f"{s['battery']}%" tw_p, _ = measure(draw, pct_str, fnt(9, bold=True)) text_in_box(draw, pct_str, inner_x + inner_w - 22 - tw_p - 2, ry + 20 + bar_h + 1, tw_p + 2, val_h, fnt(9, bold=True), batt_color(s["battery"]), "right") draw_battery_compact(draw, inner_x + inner_w - 22, ry + 20 + bar_h + 2, 20, 8, s["battery"]) # Forecast fx, fw = COL4_X, COL4_W card(fx, body_y, fw, body_h, ORANGE, "VORHERSAGE") sec_body_y = body_y + strip_h + label_h + 4 sec_body_h = body_h - strip_h - label_h - 8 row_h_fc = sec_body_h // len(MOCK["forecast"]) for i, day in enumerate(MOCK["forecast"]): ry = sec_body_y + i * row_h_fc if i > 0: draw.line((fx + 8, ry, fx + fw - 8, ry), fill=FG, width=1) text_in_box(draw, day["day"], fx + 8, ry, fw - 16, 16, fnt(13, bold=True), FG, "left") text_in_box(draw, f"{day['tmin']}° {day['tmax']}°", fx + 8, ry + 18, fw - 16, 24, fnt(16, bold=True), FG, "left") rb_y = ry + row_h_fc - 10 rbar_w = fw - 56 if rbar_w > 10: draw.rectangle((fx + 8, rb_y, fx + 8 + rbar_w, rb_y + 6), outline=FG, width=1) if day["rain"] > 0: fill_w = max(2, int((rbar_w - 2) * min(100, day["rain"] * 10) / 100)) draw.rectangle((fx + 9, rb_y + 1, fx + 9 + fill_w, rb_y + 5), fill=BLUE) text_in_box(draw, f"{day['rain']:.1f}", fx + fw - 32, rb_y - 2, 28, 10, fnt(10, bold=True), BLUE if day["rain"] > 0 else FG, "right") # Footer f_foot = fnt(9) draw.text((COL_LEFT, DISP_H - FOOTER_H + 4), f"NETATMO · letzte Aktualisierung {MOCK['update']}", font=f_foot, fill=FG) draw.line((COL_LEFT, DISP_H - FOOTER_H, DISP_W - COL_LEFT, DISP_H - FOOTER_H), fill=FG, width=1) img.save(OUT_DIR / "mockup_variant_2.png") print(f" -> {OUT_DIR / 'mockup_variant_2.png'}") # ════════════════════════════════════════════════════════════════════════════ # VARIANTE 3: "Old-School ePaper" — Mono-Font, dicke schwarze Linien. # ════════════════════════════════════════════════════════════════════════════ def render_v3(): img = Image.new("RGB", (DISP_W, DISP_H), BG) draw = ImageDraw.Draw(img) fonts = standard_fonts() # Heavy header draw.rectangle((0, 0, DISP_W, 32), fill=BLACK) draw.text((COL_LEFT, 8), "WETTERSTATION // " + MOCK["station"].upper(), font=fnt(13, bold=True, mono=True), fill=WHITE) f_time = fnt(12, mono=True) tw, _ = measure(draw, MOCK["time"], f_time) draw.text((DISP_W - COL_LEFT - tw, 10), MOCK["time"], font=f_time, fill=WHITE) body_y = 44 body_h = DISP_H - body_y - 16 line_w = 3 # 4 dicke schwarze Rahmen um jede Spalte for (x, w) in [(COL1_X, COL1_W), (COL2_X, COL2_W), (COL3_X, COL3_W), (COL4_X, COL4_W)]: draw.rectangle((x, body_y, x + w - 1, body_y + body_h - 1), outline=BLACK, width=line_w) # Aussen ax, aw = COL1_X, COL1_W draw.rectangle((ax, body_y, ax + aw - 1, body_y + 24), fill=BLACK) draw.text((ax + 8, body_y + 5), "AUSSEN", font=fnt(13, bold=True, mono=True), fill=WHITE) out = MOCK["outdoor"] if out.get("battery") is not None: text_in_box(draw, f"BAT {out['battery']}%", ax + aw - 70, body_y + 8, 64, 12, fnt(10, bold=True, mono=True), GREEN, "right") big = f"{out['temp']:04.1f}°" f_big = fit_font(draw, big, fonts, aw - 20, 90) text_in_box(draw, big, ax, body_y + 30, aw, 90, f_big, BLACK, "center") stats_y = body_y + 130 draw.line((ax + 10, stats_y, ax + aw - 10, stats_y), fill=BLACK, width=line_w) stats = [ ("MIN", f"{out['temp']-2:.0f}"), ("MAX", f"{out['temp']+4:.0f}"), ("FEUCHTE", f"{out['hum']}%"), ("DRUCK", f"{MOCK['pressure']['value']}"), ] for i, (lbl, val) in enumerate(stats): cx = ax + 10 + (i % 2) * (aw // 2 - 10) cy = stats_y + 6 + (i // 2) * 36 text_in_box(draw, lbl + ":", cx, cy, aw // 2 - 10, 12, fnt(10, bold=True, mono=True), BLACK, "left") text_in_box(draw, val, cx, cy + 12, aw // 2 - 10, 20, fit_font(draw, val, fonts, aw // 2 - 10, 20), BLACK, "left") chart_y = stats_y + 80 draw.line((ax + 10, chart_y, ax + aw - 10, chart_y), fill=BLACK, width=line_w) text_in_box(draw, "12H VERLAUF", ax + 10, chart_y + 4, aw - 20, 12, fnt(10, bold=True, mono=True), BLACK, "left") line_y = chart_y + 20 pts = [] for i in range(24): x = ax + 10 + int(i * (aw - 20) / 23) y = line_y + int(8 * math.sin(i / 2.5)) pts.append((x, y)) for a, b in zip(pts, pts[1:]): draw.line([a, b], fill=BLACK, width=2) # Wind/Regen mx, mw = COL2_X, COL2_W mh = (body_h - line_w) // 2 # Wind draw.rectangle((mx, body_y, mx + mw - 1, body_y + 24), fill=BLACK) draw.text((mx + 8, body_y + 5), "WIND", font=fnt(13, bold=True, mono=True), fill=WHITE) wind = MOCK["wind"] text_in_box(draw, f"BAT {wind['battery']}%", mx + mw - 70, body_y + 8, 64, 12, fnt(10, bold=True, mono=True), GREEN, "right") ws = f"{wind['speed']:02d}" f_ws = fit_font(draw, ws, fonts, mw - 16, 60) text_in_box(draw, ws, mx + 8, body_y + 36, mw - 16, 60, f_ws, BLACK, "left") sw, sh = measure(draw, ws, f_ws) text_in_box(draw, "km/h", mx + 8 + sw + 4, body_y + 36 + sh - 14, 50, 12, fnt(11, mono=True), BLACK, "left") text_in_box(draw, f"BOE {wind['gust']:02d} km/h", mx + 8, body_y + 100, mw - 16, 14, fnt(10, mono=True), BLACK, "left") ccx = mx + mw - 28 ccy = body_y + 50 cr = 18 draw.ellipse((ccx - cr, ccy - cr, ccx + cr, ccy + cr), outline=BLACK, width=2) for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]: rad = math.radians(a - 90) tx = ccx + (cr + 8) * math.cos(rad) - 4 ty = ccy + (cr + 8) * math.sin(rad) - 6 draw.text((tx, ty), letter, font=fnt(8, bold=True, mono=True), fill=BLACK) rad = math.radians(wind["deg"] - 90) tx = ccx + (cr - 4) * math.cos(rad) ty = ccy + (cr - 4) * math.sin(rad) draw.line((ccx, ccy, tx, ty), fill=BLACK, width=3) draw.ellipse((ccx - 3, ccy - 3, ccx + 3, ccy + 3), fill=BLACK) # Regen rry = body_y + mh + line_w rain = MOCK["rain"] draw.rectangle((mx, rry, mx + mw - 1, rry + 24), fill=BLACK) draw.text((mx + 8, rry + 5), "REGEN", font=fnt(13, bold=True, mono=True), fill=WHITE) text_in_box(draw, f"BAT {rain['battery']}%", mx + mw - 70, rry + 8, 64, 12, fnt(10, bold=True, mono=True), GREEN, "right") rs = f"{rain['rate']:04.1f}" f_rs = fit_font(draw, rs, fonts, mw - 16, 50) text_in_box(draw, rs, mx + 8, rry + 36, mw - 16, 50, f_rs, BLACK, "left") rw, rh = measure(draw, rs, f_rs) text_in_box(draw, "mm/h", mx + 8 + rw + 4, rry + 36 + rh - 12, 50, 12, fnt(11, mono=True), BLACK, "left") b1y = rry + 36 + rh + 8 text_in_box(draw, "1H:", mx + 8, b1y, 30, 10, fnt(10, bold=True, mono=True), BLACK, "left") b1x = mx + 36 b1w = mw - 70 draw.rectangle((b1x, b1y, b1x + b1w, b1y + 10), outline=BLACK, width=2) p1 = min(100, rain["h1"] / 5 * 100) if p1 > 0: draw.rectangle((b1x + 2, b1y + 2, b1x + 2 + int((b1w - 4) * p1 / 100), b1y + 8), fill=BLACK) text_in_box(draw, f"{rain['h1']:.1f}", mx + mw - 36, b1y - 1, 30, 12, fnt(11, bold=True, mono=True), BLACK, "right") b2y = b1y + 20 text_in_box(draw, "24H:", mx + 8, b2y, 30, 10, fnt(10, bold=True, mono=True), BLACK, "left") draw.rectangle((b1x, b2y, b1x + b1w, b2y + 10), outline=BLACK, width=2) p24 = min(100, rain["h24"] / 20 * 100) if p24 > 0: draw.rectangle((b1x + 2, b2y + 2, b1x + 2 + int((b1w - 4) * p24 / 100), b2y + 8), fill=BLACK) text_in_box(draw, f"{rain['h24']:.1f}", mx + mw - 36, b2y - 1, 30, 12, fnt(11, bold=True, mono=True), BLACK, "right") # Indoor ix, iw = COL3_X, COL3_W draw.rectangle((ix, body_y, ix + iw - 1, body_y + 24), fill=BLACK) draw.text((ix + 8, body_y + 5), f"INNEN // {len(MOCK['indoor'])} SENSOREN", font=fnt(13, bold=True, mono=True), fill=WHITE) inner_x = ix + 8 inner_w = iw - 16 row_h = (body_h - 28 - 8) // len(MOCK["indoor"]) for i, s in enumerate(MOCK["indoor"]): ry = body_y + 28 + i * row_h if i > 0: draw.line((inner_x, ry, inner_x + inner_w, ry), fill=BLACK, width=1) tag_w = 20 tag_h = 18 draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + tag_h), fill=BLACK, outline=BLACK) f_tag = fnt(13, bold=True, mono=True) tag_str = s["tag"] tw_, th_ = measure(draw, tag_str, f_tag) draw.text((inner_x + (tag_w - tw_) // 2 - 1, ry + 2 + (tag_h - th_) // 2 - 1), tag_str, font=f_tag, fill=WHITE) name_x = inner_x + tag_w + 6 name_w = 70 f_name = fit_font(draw, s["name"], fonts, name_w, 14) draw.text((name_x, ry + 4), s["name"], font=f_name, fill=BLACK) t_str = f"{s['temp']:04.1f}" text_in_box(draw, t_str, inner_x + inner_w - 56, ry + 4, 56, 14, fnt(13, bold=True, mono=True), BLACK, "right") cby = ry + 22 co2_bar_in_box(draw, inner_x, cby, inner_w, 5, s["co2"]) vwy = cby + 7 co2_str = f"CO2 {s['co2']}ppm" if s["co2"] is not None else "CO2 ----" draw.text((inner_x, vwy), co2_str, font=fnt(8, bold=True, mono=True), fill=co2_color(s["co2"])) if s.get("battery") is not None: pct_str = f"{s['battery']}%" tw_p, _ = measure(draw, pct_str, fnt(8, bold=True, mono=True)) text_in_box(draw, pct_str, inner_x + inner_w - 22 - tw_p, vwy - 1, tw_p, 10, fnt(8, bold=True, mono=True), batt_color(s["battery"]), "right") draw_battery_compact(draw, inner_x + inner_w - 20, vwy - 1, 18, 8, s["battery"]) # Forecast fx, fw = COL4_X, COL4_W draw.rectangle((fx, body_y, fx + fw - 1, body_y + 24), fill=BLACK) draw.text((fx + 8, body_y + 5), "VORHERSAGE // 3TAGE", font=fnt(12, bold=True, mono=True), fill=WHITE) row_h_fc = (body_h - 28) // len(MOCK["forecast"]) for i, day in enumerate(MOCK["forecast"]): ry = body_y + 28 + i * row_h_fc if i > 0: draw.line((fx + 8, ry, fx + fw - 8, ry), fill=BLACK, width=1) draw.text((fx + 8, ry + 4), day["day"], font=fnt(14, bold=True, mono=True), fill=BLACK) mm_str = f"{day['tmin']:02d}-{day['tmax']:02d}" f_mm = fnt(18, bold=True, mono=True) draw.text((fx + 8, ry + 22), mm_str, font=f_mm, fill=BLACK) rb_y = ry + row_h_fc - 14 rbar_x = fx + 8 rbar_w = fw - 50 draw.rectangle((rbar_x, rb_y, rbar_x + rbar_w, rb_y + 8), outline=BLACK, width=2) if day["rain"] > 0: fill_w = max(2, int((rbar_w - 4) * min(100, day["rain"] * 10) / 100)) draw.rectangle((rbar_x + 2, rb_y + 2, rbar_x + 2 + fill_w, rb_y + 6), fill=BLACK) text_in_box(draw, f"{day['rain']:04.1f}", fx + fw - 36, rb_y - 2, 32, 12, fnt(11, bold=True, mono=True), BLACK, "right") # Footer f_foot = fnt(9, mono=True) draw.text((COL_LEFT, DISP_H - 14), f"UPDATED {MOCK['update']} // NETATMO+OPEN-METEO", font=f_foot, fill=BLACK) img.save(OUT_DIR / "mockup_variant_3.png") print(f" -> {OUT_DIR / 'mockup_variant_3.png'}") if __name__ == "__main__": print("Rendere 3 Mockup-Varianten...") render_v1() render_v2() render_v3() print("Fertig.")