diff --git a/palette.py b/palette.py index c6c8de7..e98dff3 100644 --- a/palette.py +++ b/palette.py @@ -235,3 +235,189 @@ def is_tall(w: int, h: int) -> bool: """True wenn Slot hoch aber schmal ist (1x4, 1x2).""" return h >= 280 and w < 280 + +# ============================================================================ +# Batterie-Farben — Single Source of Truth +# ============================================================================ +BATT_LOW = RED # < 25% → kritisch +BATT_MID = ORANGE # < 50% → bald wechseln +BATT_OK = GREEN # >= 50% → OK + +# Standard-Batterie-Schwellen (werden von Plugins einheitlich genutzt) +BATT_THRESHOLDS = ( + (25, BATT_LOW), + (50, BATT_MID), +) + + +def batt_color(pct: int | None) -> tuple: + """Liefert die passende Batterie-Farbe fuer einen Prozent-Wert. + + None → FG (keine Anzeige), sonst BATT_LOW/MID/OK. + """ + if pct is None: + return FG + pct = max(0, min(100, pct)) + for threshold, color in BATT_THRESHOLDS: + if pct < threshold: + return color + return BATT_OK + + +# ============================================================================ +# Deutsche Formatter — Single Source of Truth (kein Englisches "Monday" mehr) +# ============================================================================ +DE_WEEKDAYS_LONG = [ + "Montag", "Dienstag", "Mittwoch", "Donnerstag", + "Freitag", "Samstag", "Sonntag", +] +DE_WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] +DE_MONTHS_LONG = [ + "Januar", "Februar", "Maerz", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +] +DE_MONTHS_SHORT = ["Jan", "Feb", "Mae", "Apr", "Mai", "Jun", + "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"] + + +def de_weekday_long(weekday: int) -> str: + """datetime.weekday() (0=Montag) → 'Montag'/'Dienstag'/...""" + return DE_WEEKDAYS_LONG[weekday] + + +def de_weekday_short(weekday: int) -> str: + """datetime.weekday() (0=Montag) → 'Mo'/'Di'/...""" + return DE_WEEKDAYS_SHORT[weekday] + + +def de_month_short(month: int) -> str: + """datetime.month (1-12) → 'Jan'/'Feb'/...""" + return DE_MONTHS_SHORT[month - 1] + + +def de_date(dt, short: bool = True) -> str: + """Datum als '30. Aug 2026' (default short=True).""" + if short: + return f"{dt.day:02d}. {de_month_short(dt.month)} {dt.year}" + return f"{dt.day:02d}. {DE_MONTHS_LONG[dt.month - 1]} {dt.year}" + + +def de_weekday_date(dt, short: bool = True) -> str: + """'Sa 30.08' wenn short, sonst 'Samstag 30. August 2026'.""" + if short: + return f"{de_weekday_short(dt.weekday())} {dt.day:02d}.{dt.month:02d}" + return f"{de_weekday_long(dt.weekday())} {de_date(dt, short=False)}" + + +def de_time(dt, show_seconds: bool = False) -> str: + """24h-Zeit 'HH:MM' oder 'HH:MM:SS'.""" + return dt.strftime("%H:%M:%S" if show_seconds else "%H:%M") + + +# ============================================================================ +# Wind / Temp Helper — einheitliche Formatierung +# ============================================================================ +def format_temp(value: float | None, unit: str = "C", decimals: int = 1) -> str: + """Temperatur formatieren. None → em-dash.""" + if value is None: + return "—" + return f"{value:.{decimals}f}°{unit}" + + +def format_wind(speed: float | None, unit: str = "kmh", decimals: int = 0) -> str: + """Windgeschwindigkeit. 'kmh' → 'km/h', 'ms' → 'm/s'.""" + if speed is None: + return "—" + label = "km/h" if unit == "kmh" else "m/s" + return f"{speed:.{decimals}f} {label}" + + +def format_rain(rate_mm: float | None, decimals: int = 1) -> str: + """Regenrate 'X.X mm/h'.""" + if rate_mm is None: + return "—" + return f"{rate_mm:.{decimals}f} mm/h" + + +def temp_color(value: float | None) -> tuple: + """Farbe nach Temperaturwert: kalt→Blau, normal→FG, warm→Orange, heiss→Rot.""" + if value is None: + return FG + if value >= 28: + return RED + if value >= 22: + return ORANGE + if value <= 5: + return BLUE + return FG + + +def co2_color(ppm: int | float | None) -> tuple: + """CO2-Ampel: <600 gruen, <1000 orange, <1500 gelb, >=1500 rot.""" + if ppm is None: + return FG + if ppm >= 1500: + return RED + if ppm >= 1000: + return YELLOW + if ppm >= 600: + return ORANGE + return GREEN + + +# ============================================================================ +# Power-Management — Defaults +# ============================================================================ +POWER_DEFAULTS = { + "deepsleep_enabled": False, + "deepsleep_start": "23:30", + "deepsleep_end": "06:30", + "cpu_throttle": True, # waehrend DeepSleep auf 600MHz + "wifi_off": False, # WiFi anlassen fuer Admin-Erreichbarkeit +} + + +# ============================================================================ +# Weather-Code-Mapping (WMO Standard + Netatmo-Fallback) +# ============================================================================ +# WMO Weather interpretation codes (https://open-meteo.com/en/docs) +WMO_ICONS = { + 0: "sun", # Clear sky + 1: "partly", # Mainly clear + 2: "partly", # Partly cloudy + 3: "cloud", # Overcast + 45: "fog", # Fog + 48: "fog", # Depositing rime fog + 51: "rain", # Drizzle: light + 53: "rain", # Drizzle: moderate + 55: "rain", # Drizzle: dense + 56: "rain", # Freezing Drizzle: light + 57: "rain", # Freezing Drizzle: dense + 61: "rain", # Rain: slight + 63: "rain", # Rain: moderate + 65: "rain", # Rain: heavy + 66: "rain", # Freezing Rain: light + 67: "rain", # Freezing Rain: heavy + 71: "snow", # Snow fall: slight + 73: "snow", # Snow fall: moderate + 75: "snow", # Snow fall: heavy + 77: "snow", # Snow grains + 80: "rain", # Rain showers: slight + 81: "rain", # Rain showers: moderate + 82: "rain", # Rain showers: violent + 85: "snow", # Snow showers: slight + 86: "snow", # Snow showers: heavy + 95: "storm", # Thunderstorm + 96: "storm", # Thunderstorm with slight hail + 99: "storm", # Thunderstorm with heavy hail +} + + +def wmo_to_icon(code: int | str | None) -> str: + """WMO-Code (int) oder Symbol-String → 'sun'/'cloud'/'rain'/'storm'/...""" + if code is None: + return "cloud" + if isinstance(code, str): + return code # schon ein symbol + return WMO_ICONS.get(code, "cloud") + diff --git a/plugins/base.py b/plugins/base.py index 3a75df2..ce3a812 100644 --- a/plugins/base.py +++ b/plugins/base.py @@ -4,17 +4,29 @@ Ein Plugin ist ein Python-Modul in /plugins/, das eine Klasse Widget exportiert. Die Klasse wird beim Start dynamisch geladen und in der Admin-UI zur Auswahl angeboten. +Dieses Modul enthaelt auch Shared Render-Helpers (Batterie, Compass, +Trend-Pfeile, Wetter-Icons, Charts), die von allen Plugins einheitlich +genutzt werden — damit sehen Batterie-Icons, Windrosen, etc. auf dem +Display konsistent aus. + Minimal-Beispiel siehe plugins/hello.py. """ from __future__ import annotations from abc import ABC, abstractmethod from typing import Any +import math import time import urllib.request import urllib.error import socket as _socket -from PIL import ImageDraw +from PIL import ImageDraw, ImageFont + +from palette import ( + FG, BG, BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE, + BATT_LOW, BATT_MID, BATT_OK, batt_color, + measure, text_wh, +) # ============================================================================ @@ -153,6 +165,13 @@ class Widget(ABC): merged = dict(self.default_config) merged.update(config or {}) self.config = merged + self.fonts: dict | None = None # wird vom Dashboard gesetzt vor render() + self.theme: Any = None # Theme-Instanz (von designbase), optional + + def set_fonts(self, fonts: dict): + """Wird vom Dashboard vor render() aufgerufen. Plugins koennen + dann self.fonts["24"] etc. nutzen statt selbst Fonts zu laden.""" + self.fonts = fonts @abstractmethod def fetch(self) -> dict: @@ -189,3 +208,337 @@ def all_widget_classes() -> list[type[Widget]]: classes.append(cls) classes.sort(key=lambda c: c.label or c.name) return classes + + +# ============================================================================ +# Shared Render Helpers — Single Source of Truth fuer Batterie, Compass, +# Trend, Wetter-Icons, Charts. Alle Plugins nutzen diese Funktionen damit +# das Display konsistent aussieht. +# ============================================================================ + +# Sans-Schriftart aus dem System (lazy-loaded, gecacht) +_BASE_SANS_PATHS = [ + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "C:/Windows/Fonts/arial.ttf", + "C:/Windows/Fonts/seguisb.ttf", +] +_BASE_SANS = None + + +def _get_base_sans() -> ImageFont.FreeTypeFont | None: + """Lazy-load Sans-Basis-Font (size 14) fuer die Helper.""" + global _BASE_SANS + if _BASE_SANS is not None: + return _BASE_SANS + import os + for p in _BASE_SANS_PATHS: + if os.path.exists(p): + try: + _BASE_SANS = ImageFont.truetype(p, 14) + return _BASE_SANS + except Exception: + continue + try: + _BASE_SANS = ImageFont.load_default() + except Exception: + _BASE_SANS = None + return _BASE_SANS + + +def _fit_size(draw, text: str, max_w: int, max_h: int, + candidates: list[str | int] | None = None) -> ImageFont.FreeTypeFont: + """Iteriere durch Font-Groessen, waehle die erste die passt. + + Verwendet das gleiche Sans-Family wie die Basis-Schrift. + """ + if candidates is None: + candidates = ["48", "36", "32", "28", "24", "20", "16", "14", "12", "10"] + base = _get_base_sans() + base_path = getattr(base, "path", None) if base else None + for sz in candidates: + try: + sz_int = int(sz) + if base_path: + f = ImageFont.truetype(base_path, sz_int) + else: + f = base + except Exception: + continue + tw, th = measure(draw, text, f) + if tw <= max_w and th <= max_h: + return f + return base + + +def text_in_box(draw, text, x, y, w, h, font, color, align: str = "left"): + """Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine + Ueberlappung mit anderen Elementen wenn jeder seinen eigenen Bereich hat. + + align: 'left' (default), 'right', 'center' + """ + tw, th = measure(draw, text, font) + if tw > w or th > h: + return False # wuerde ueberlaufen + 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 + + +# ---- Batterie-Icon (Single Source of Truth) ------------------------------ +def draw_battery(draw, x, y, pct: int | None, w: int = 22, h: int = 10, + with_text: bool = True, text_color: tuple | None = None): + """Zeichnet ein konsistentes Batterie-Icon. + + Args: + pct: Prozent (0-100), None → noop + w, h: Icon-Groesse (ohne Tip) + with_text: ob "%" daneben gezeichnet werden soll + text_color: falls None, automatisch nach Schwellen (LOW/MID/OK) + """ + if pct is None: + return 0 + pct = max(0, min(100, pct)) + color = text_color or batt_color(pct) + # Body + draw.rectangle((x, y, x + w - 3, y + h - 1), outline=FG, width=1) + # Tip + draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=FG) + # Fill + fill_w = max(0, int((w - 4) * pct / 100)) + if fill_w > 0: + draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color) + used = w + if with_text: + f = _fit_size(draw, f"{pct}%", 30, h, candidates=["10", "9", "8"]) + text_in_box(draw, f"{pct}%", x + w + 3, y - 1, 30, h + 2, f, color, "left") + used += 30 + return used + + +# ---- Trend-Pfeil (up / down / stable) ------------------------------------ +def draw_trend(draw, x, y, trend: str, size: int = 10, color: tuple | None = None): + """Pfeil nach oben/unten, oder waagerechter Strich fuer 'stable'.""" + color = color or FG + cx, cy = x + size // 2, y + size // 2 + if trend == "up": + draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2), + (cx, y + 2), (x + size - 2, y + size - 2), + (x + size, y + size - 2)], fill=color) + elif trend == "down": + draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2), + (cx, y + size - 2), (x + size - 2, y + 2), + (x + size, y + 2)], fill=color) + else: # stable + draw.line((x, cy, x + size, cy), fill=color, width=2) + + +# ---- Windrose / Compass -------------------------------------------------- +def draw_compass(draw, cx, cy, r: int, deg: float, color: tuple = BLUE, + with_labels: bool = False): + """Zeichnet eine Windrose. + + Args: + deg: 0=Nord, 90=Ost, 180=Sued, 270=West + with_labels: N/O/S/W Beschriftung (nur fuer grosse Varianten sinnvoll) + """ + if r < 4: + return + # Aeusserer Kreis + draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1) + # Cardinal Ticks + for a in [270, 90, 180, 0]: # N/O/S/W in Wetter-Konvention + rd = math.radians(a - 90) + x1 = cx + (r - 6) * math.cos(rd) + y1 = cy + (r - 6) * math.sin(rd) + x2 = cx + (r - 2) * math.cos(rd) + y2 = cy + (r - 2) * math.sin(rd) + draw.line((x1, y1, x2, y2), fill=FG, width=1) + # Pfeil + rad = math.radians(deg - 90) + tx = cx + (r - 4) * math.cos(rad) + ty = cy + (r - 4) * math.sin(rad) + draw.line((cx, cy, tx, ty), fill=color, width=2 if r < 16 else 3) + draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=color) + # Optionale N/O/S/W Labels + if with_labels and r >= 14: + for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]: + rd = math.radians(a - 90) + lx = cx + (r + 7) * math.cos(rd) - 4 + ly = cy + (r + 7) * math.sin(rd) - 6 + f = _fit_size(draw, letter, 10, 10, candidates=["10", "9", "8"]) + draw.text((lx, ly), letter, font=f, fill=FG) + + +# ---- Wetter-Icon (7 Wetter-Zustaende) ------------------------------------ +def draw_weather_icon(draw, x, y, size: int, code: int | str): + """Zeichnet ein Wetter-Icon in (x, y, size, size) Box. + + Akzeptiert WMO-Code (int) oder symbol-string ('sun'/'cloud'/'rain'/'storm'/'snow'/'fog'/'partly'). + Verwendet nur die 7 ACeP-Farben. + """ + from palette import wmo_to_icon + sym = wmo_to_icon(code) if isinstance(code, int) else code + cx, cy = x + size // 2, y + size // 2 + r = max(3, size // 3) + if sym in ("sun", "clear"): + draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW) + 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 sym == "partly": + draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW) + cw = size // 2 + ch = size // 4 + draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch), + fill=WHITE, outline=FG, width=1) + elif sym == "cloud": + 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) + draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2), + fill=WHITE, outline=FG, width=2) + elif sym == "rain": + 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): + draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2) + elif sym == "storm": + 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) + 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 sym == "snow": + 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) + elif sym == "fog": + cw = int(size * 0.7) + for i, dy in enumerate((-2, 1, 4)): + y_pos = cy + dy + draw.line((cx - cw // 2, y_pos, cx + cw // 2, y_pos), fill=FG, width=1) + else: + f = _fit_size(draw, "?", size - 4, size - 4, candidates=["20", "16", "12"]) + text_in_box(draw, "?", x, y, size, size, f, FG, "center") + + +# ---- Linien-Chart (mit optionalem Y-Grid) --------------------------------- +def draw_line_chart(draw, x, y, w, h, data: list, color: tuple = GREEN, + y_min: float | None = None, y_max: float | None = None, + with_y_grid: bool = True, n_ticks: int = 3): + """Zeichnet einen Linien-Chart in (x,y,w,h) Box. + + Args: + data: Liste von Werten + color: Linien-Farbe + y_min, y_max: Y-Achsen-Bereich. None → auto aus Daten + with_y_grid: Y-Tick-Linien mit Werten am linken Rand + n_ticks: Anzahl Y-Ticks (default 3) + """ + if len(data) < 2 or w <= 10 or h <= 10: + return + # Y-Range bestimmen + if y_min is None: + y_min = min(data) + if y_max is None: + y_max = max(data) + if y_min == y_max: + y_min -= 1 + y_max += 1 + # Grid: gestrichelte Linien (ACeP kann keine echten Dashes, daher Segmente) + if with_y_grid: + # Nur 2 Ticks zeigen: Min und Max (sonst zu viele Labels für kleinen Chart) + ticks = [] + if n_ticks >= 2: + ticks = [y_min, y_max] + elif n_ticks == 1: + ticks = [(y_min + y_max) / 2] + for tick in ticks: + ty = y + int((y_max - tick) / max(1, y_max - y_min) * (h - 8)) + 4 + # Y-Label links (NICHT unten — kollidiert mit x-Achsen-Labels) + f_lbl = _fit_size(draw, f"{int(tick)}°", 22, 10, candidates=["9", "8"]) + text_in_box(draw, f"{int(tick)}°", x - 24, ty - 5, 22, 10, f_lbl, FG, "right") + # Gestrichelte Linie + for seg_x in range(x, x + w, 6): + draw.line((seg_x, ty, min(seg_x + 3, x + w), ty), fill=FG, width=1) + # Daten-Linie + pad_top, pad_bot = 4, 4 + pts = [] + for i, v in enumerate(data): + px = x + int(i * w / (len(data) - 1)) + py = y + pad_top + int((1 - (v - y_min) / (y_max - y_min)) * (h - pad_top - pad_bot)) + pts.append((px, py)) + for i in range(len(pts) - 1): + draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]), + fill=color, width=2) + for px, py in pts: + draw.ellipse((px - 1, py - 1, px + 1, py + 1), fill=color) + + +# ---- Mini-Bar mit Schwellen-Farbe ---------------------------------------- +def draw_mini_bar(draw, x, y, w, h, value: float, max_value: float, + color: tuple | None = None): + """Horizontale Mini-Bar mit Outline + Fill in gegebener Farbe.""" + if w < 6 or h < 4: + return + color = color or FG + draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1) + pct = min(100, max(0, value / max(0.001, max_value) * 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) + + +# ---- ZZZ / Sleep-Indikator ----------------------------------------------- +def draw_zzz_indicator(draw, x, y, size: int = 16): + """Kleines Mond-Icon + 'Zzz' fuer DeepSleep-Anzeige. + + Wird vom Dashboard-Frame im Header-Bereich gezeichnet, wenn Power-Save + aktiv ist. + """ + cx, cy = x + size // 2, y + size // 2 + r = size // 3 + # Mond (Yellow circle, halb verdeckt) + draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW) + # 'Z' Buchstaben daneben + f = _fit_size(draw, "Z", size, size, candidates=["14", "12", "10"]) + text_in_box(draw, "z", x + size + 2, y, 12, size, f, FG, "left") + + +# ---- DeepSleep-Status-Anzeige fuer Display ------------------------------ +def draw_deepsleep_indicator(draw, x, y, w, h, next_wake: str = ""): + """Zeichnet einen schmalen 'Schlafend'-Bereich im Header. + + Args: + next_wake: z.B. '06:30' → wird als Text daneben gezeichnet + """ + if w < 60 or h < 12: + return + # Gelber Mond-Icon + f_moon = _fit_size(draw, "Zz", 30, h, candidates=["12", "10"]) + text_in_box(draw, "Zz", x, y, 24, h, f_moon, ORANGE, "center") + # 'Schlafend bis' Text + if next_wake: + f = _fit_size(draw, f"schlafend bis {next_wake}", w - 26, h, candidates=["10", "9", "8"]) + text_in_box(draw, f"schlafend bis {next_wake}", x + 26, y, w - 26, h, f, FG, "left") diff --git a/plugins/netatmo.py b/plugins/netatmo.py index e8d24f1..7b72619 100644 --- a/plugins/netatmo.py +++ b/plugins/netatmo.py @@ -1,73 +1,58 @@ -"""Netatmo Weather Station Plugin — WarmNews 4x4 Layout. +"""Netatmo Weather Station Plugin — WarmNews Design (V1 Status-Update). Zeigt Live-Daten einer Netatmo Wetterstation mit allen Sensoren: - 1x NAMain + bis zu 4x NAModule4 Indoor-Sensoren - 1x NAModule1 Outdoor - 1x NAModule2 Wind - 1x NAModule3 Regen +- 3-Tage Vorhersage (Open-Meteo) -Layout (4x4 = 800x480): - Spalte 1: Aussen (Temp gross + MIN/MAX/LUFT/DRUCK + 12h-Verlauf) - Spalte 2: Wind + Regen (gestapelt, mit 1h/24h-Bars) - Spalte 3: Indoor (5 Sensoren prominent: Tag/Name/Temp/CO2/Batt) - Spalte 4: Forecast (3 Tage) +Layout: + 4x4 (800x480): Aussen | Wind/Regen | Indoor (5) | Vorhersage (3) + 2x2 (400x240): Aussen + Indoor kombiniert + 1x1 (200x120): nur Aussentemperatur gross -Auth: OAuth2 Authorization Code Flow (siehe tools/netatmo_auth.py). +Auth: OAuth2 Refresh-Token Flow (siehe tools/netatmo_auth.py fuer initial). """ from __future__ import annotations import os, sys, json, time, math, io import urllib.request, urllib.error, urllib.parse from datetime import datetime, timezone +from typing import Any sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from plugins.base import Widget, render_error_banner + +from plugins.base import ( + Widget, render_error_banner, draw_battery, draw_compass, draw_trend, + draw_weather_icon, draw_line_chart, draw_mini_bar, text_in_box, _fit_size, +) from palette import ( - FG, BG, INFO, OK, WARN, ALERT, ORANGE, BLUE, RED, GREEN, - measure, fit_font, is_small, is_wide, is_tall, + FG, BG, GREEN, BLUE, RED, YELLOW, ORANGE, WHITE, BLACK, + BATT_LOW, BATT_MID, BATT_OK, + OK, WARN, ALERT, INFO, ACCENT, + measure, fit_font, centered_text, is_small, is_wide, is_tall, + temp_color, co2_color, batt_color, wmo_to_icon, + format_temp, de_time, de_weekday_date, de_date, ) -# ── ACeP Native Farbpalette ─────────────────────────────────────────────────── -# ACeP 7.3" hat 7 Farben. Alles andere wird gedithert → matschig. -# Hex (RGB): BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE -PAPER_BG = (255, 255, 255) # WHITE — echter Display-Hintergrund -INK = ( 0, 0, 0) # BLACK — Text -INK_MID = ( 0, 0, 0) # BLACK (kann nicht grau — entweder schwarz oder nichts) -INK_LIGHT = ( 0, 0, 0) # BLACK, aber kleinere Schrift für Hierarchie -FAINT = ( 0, 0, 0) # BLACK, dünner Strich -RED = (255, 0, 0) # RED — Alerts, Indoor-Akzent -GREEN = ( 0, 255, 0) # GREEN — Aussen, CO2 OK, Batt OK -BLUE = ( 0, 0, 255) # BLUE — Wind, Regen, Forecast -INFO_BL = ( 0, 0, 255) # BLUE -PURPLE = ( 0, 0, 255) # BLUE (kein Magenta in Palette — nimm Blue) -YELLOW = (255, 255, 0) # YELLOW — Forecast Sonne, Trend up -ORANGE = (255, 128, 0) # ORANGE — Batt MID, Trend warn +# OAuth2 + Forecast URLs +TOKEN_URL = "https://api.netatmo.com/oauth2/token" +STATIONS_URL = "https://api.netatmo.com/api/getstationsdata" +FORECAST_URL = "https://api.open-meteo.com/v1/forecast" -BATT_LOW = (255, 0, 0) # RED -BATT_MID = (255, 128, 0) # ORANGE -BATT_OK = ( 0, 255, 0) # GREEN - -# ── Layout-Konstanten ───────────────────────────────────────────────────── -PAD_OUTER = 14 -GAP = 10 -STRIP_H = 6 -LABEL_H = 22 - -# ── HTTP / OAuth2 ─────────────────────────────────────────────────────────── -TOKEN_URL = "https://api.netatmo.com/oauth2/token" -STATIONS_URL = "https://api.netatmo.com/api/getstationsdata" -FORECAST_URL = "https://api.open-meteo.com/v1/forecast" # free, no key - -_TOKEN_CACHE = {"access_token": None, "refresh_token": None, "expires_at": 0.0} +# In-Memory Token Cache +_TOKEN_CACHE: dict[str, Any] = {"access_token": None, "refresh_token": None, "expires_at": 0.0} -def _token_payload(creds, grant="refresh_token", **extra): +# ── HTTP / OAuth2 ─────────────────────────────────────────────────────── +def _token_payload(creds: dict, grant: str = "refresh_token", **extra) -> bytes: body = {"grant_type": grant, "client_id": creds["client_id"], "client_secret": creds["client_secret"]} body.update(extra) return urllib.parse.urlencode(body).encode("utf-8") -def _post_form(url, body, timeout=10): +def _post_form(url: str, body: bytes, timeout: int = 10) -> dict: req = urllib.request.Request(url, data=body, headers={ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", "Accept": "application/json"}) @@ -83,11 +68,12 @@ def _post_form(url, body, timeout=10): e.headers, io.BytesIO(json.dumps(err).encode())) -def _obtain_tokens(creds): +def _obtain_tokens(creds: dict) -> dict: rt = creds.get("refresh_token") or _TOKEN_CACHE.get("refresh_token") if not rt: raise urllib.error.HTTPError(TOKEN_URL, 0, - "Kein refresh_token — bitte tools/netatmo_auth.py ausführen.", {}, io.BytesIO(b'{}')) + "Refresh-Token fehlt — bitte tools/netatmo_auth.py ausführen.", + {}, io.BytesIO(b'{}')) try: tok = _post_form(TOKEN_URL, _token_payload(creds, "refresh_token", refresh_token=rt)) except urllib.error.HTTPError as e: @@ -96,7 +82,8 @@ def _obtain_tokens(creds): _TOKEN_CACHE["refresh_token"] = None _TOKEN_CACHE["expires_at"] = 0 raise urllib.error.HTTPError(TOKEN_URL, 401, - "Token abgelaufen — tools/netatmo_auth.py erneut ausführen.", {}, io.BytesIO(b'{}')) + "Token abgelaufen — tools/netatmo_auth.py erneut ausführen.", + {}, io.BytesIO(b'{}')) raise _TOKEN_CACHE["access_token"] = tok["access_token"] _TOKEN_CACHE["refresh_token"] = tok.get("refresh_token", rt) @@ -104,9 +91,8 @@ def _obtain_tokens(creds): return tok -def _get_stations_data(client_id, client_secret, refresh_token): - creds = {"client_id": client_id, "client_secret": client_secret, - "refresh_token": refresh_token} +def _get_stations_data(client_id: str, client_secret: str, refresh_token: str) -> dict: + creds = {"client_id": client_id, "client_secret": client_secret, "refresh_token": refresh_token} now = time.time() if _TOKEN_CACHE["expires_at"] <= now or not _TOKEN_CACHE["access_token"]: _obtain_tokens(creds) @@ -127,12 +113,12 @@ def _get_stations_data(client_id, client_secret, refresh_token): return _do() -def _get_forecast(lat, lon, days=3): - """Open-Meteo 3-Tage Forecast. Free, kein Key nötig.""" +def _get_forecast(lat: float, lon: float, days: int = 3) -> dict | None: + """Open-Meteo Forecast, free, kein Key noetig.""" try: url = (f"{FORECAST_URL}?latitude={lat}&longitude={lon}" f"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum," - f"weather_code&forecast_days={min(days,7)}&timezone=auto") + f"weather_code&forecast_days={min(days, 7)}&timezone=auto") req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=8) as r: return json.loads(r.read()) @@ -140,24 +126,12 @@ def _get_forecast(lat, lon, days=3): return None -def _icon_for_code(code): - """WMO Weather Code → (icon_char, color).""" - if code is None: return ("?", INK_MID) - if code == 0: return ("*", YELLOW) # clear sky - if code in (1, 2, 3): return ("~", INK_MID) # partly cloudy - if code in (45, 48): return ("~", INK_MID) # fog - if code in (51, 53, 55, 56, 57): return ("#", INFO_BL) # drizzle - if code in (61, 63, 65, 66, 67, 80, 81, 82): return ("#", INFO_BL) # rain - if code in (71, 73, 75, 77, 85, 86): return ("*", INFO_BL) # snow - if code in (95, 96, 99): return ("#", PURPLE) # thunderstorm - return ("~", INK_MID) - - -# ── Daten-Parsing ───────────────────────────────────────────────────────── -def _parse_stations(api_response, station_filter=""): +# ── Daten-Parsing ──────────────────────────────────────────────────────── +def _parse_stations(api_response: dict, station_filter: str = "") -> dict | None: body = api_response.get("body", api_response) devices = body.get("devices") if isinstance(body, dict) else None - if not devices: return None + if not devices: + return None chosen = None if station_filter: sf = station_filter.strip().lower() @@ -185,13 +159,13 @@ def _parse_stations(api_response, station_filter=""): "main": main, "modules": modules, "_fetched_at": time.time()} -def _first_module(modules, mtype): +def _first_module(modules: list, mtype: str) -> dict | None: for m in modules: if m["type"] == mtype: return m return None -def _all_indoor(modules, main): +def _all_indoor(modules: list, main: dict) -> list: """Alle Indoor-Sensoren: Main + alle NAModule4.""" indoor = [{"type": "NAMain", "name": main["name"], "tag": "M", "data": main["data"], "battery_pct": None, "is_main": True}] @@ -200,7 +174,6 @@ def _all_indoor(modules, main): indoor.append({"type": "NAModule4", "name": m["name"], "tag": None, "data": m["data"], "battery_pct": m.get("battery_pct"), "is_main": False, "rf_status": m.get("rf_status")}) - # Tags vergeben: M=Main, dann 2..N für Module4 tag_n = 2 for s in indoor: if s["tag"] is None: @@ -209,45 +182,47 @@ def _all_indoor(modules, main): return indoor -# ── Widget ───────────────────────────────────────────────────────────────── +# ── Widget ────────────────────────────────────────────────────────────── class Widget(Widget): name = "netatmo" label = "Netatmo Wetterstation" - description = "Indoor (1+4 Sensoren), Outdoor, Wind, Regen, 3-Tage Forecast, 12h-Verlauf" + description = "Indoor (1+4 Sensoren), Outdoor, Wind, Regen, 3-Tage Forecast" category = "weather" config_schema = [ - {"key": "station_filter", "label": "Station (leer = erste)", + {"key": "client_id", "label": "Client-ID", "type": "secret"}, + {"key": "client_secret", "label": "Client-Secret", "type": "secret"}, + {"key": "refresh_token", "label": "Refresh-Token", "type": "secret"}, + {"key": "station_filter", "label": "Station-Filter (leer = erste)", "type": "string", "default": ""}, - {"key": "show_outdoor", "label": "Outdoor anzeigen", "type": "bool", "default": True}, - {"key": "show_wind", "label": "Wind anzeigen", "type": "bool", "default": True}, - {"key": "show_rain", "label": "Regen anzeigen", "type": "bool", "default": True}, - {"key": "show_compass", "label": "Windrose", "type": "bool", "default": True}, - {"key": "show_forecast", "label": "3-Tage Forecast", "type": "bool", "default": True}, - {"key": "co2_thresholds", "label": "CO₂-Schwellen (ppm)", + {"key": "show_outdoor", "label": "Aussen anzeigen", "type": "bool", "default": True}, + {"key": "show_wind", "label": "Wind anzeigen", "type": "bool", "default": True}, + {"key": "show_rain", "label": "Regen anzeigen", "type": "bool", "default": True}, + {"key": "show_indoor", "label": "Indoor anzeigen", "type": "bool", "default": True}, + {"key": "show_forecast","label": "Vorhersage anzeigen", "type": "bool", "default": True}, + {"key": "co2_thresholds", "label": "CO2-Schwellen (ppm)", "type": "string", "default": "ok@600,warn@1000,alert@1500"}, {"key": "temp_unit", "label": "Temperatur", "type": "select", "options": ["C", "F"], "default": "C"}, {"key": "wind_unit", "label": "Wind-Einheit", "type": "select", "options": ["kmh", "ms"], "default": "kmh"}, - {"key": "client_id", "label": "Client-ID", "type": "secret"}, - {"key": "client_secret", "label": "Client-Secret", "type": "secret"}, - {"key": "refresh_token", "label": "Refresh-Token", "type": "secret"}, ] default_config = { "station_filter": "", "show_outdoor": True, "show_wind": True, "show_rain": True, - "show_compass": True, "show_forecast": True, + "show_indoor": True, "show_forecast": True, "co2_thresholds": "ok@600,warn@1000,alert@1500", "temp_unit": "C", "wind_unit": "kmh", "client_id": "", "client_secret": "", "refresh_token": "", } - def fetch(self): + def fetch(self) -> dict: cid = self.cfg("client_id"); sec = self.cfg("client_secret") refresh = self.cfg("refresh_token", "") - if not (cid and sec): return {"_error": "Client-ID oder Client-Secret fehlt."} - if not refresh: return {"_error": "Refresh-Token fehlt — tools/netatmo_auth.py ausführen."} + if not (cid and sec): + return {"_error": "Client-ID oder Client-Secret fehlt."} + if not refresh: + return {"_error": "Refresh-Token fehlt — tools/netatmo_auth.py ausführen."} try: data = _get_stations_data(cid, sec, refresh) except urllib.error.HTTPError as e: @@ -260,9 +235,9 @@ class Widget(Widget): except Exception as e: return {"_error": f"{type(e).__name__}: {str(e)[:60]}"} parsed = _parse_stations(data, self.cfg("station_filter", "")) - if not parsed: return {"_error": "Keine Station gefunden."} - - # Forecast (Open-Meteo, optional) + if not parsed: + return {"_error": "Keine Station gefunden."} + # Forecast if self.cfg("show_forecast", True): place = parsed.get("place", {}) coords = place.get("location") or [] @@ -276,49 +251,101 @@ class Widget(Widget): if "_error" in d: render_error_banner(draw, fonts, x, y, w, h, self.label, d["_error"]) return - draw.rectangle((x, y, x + w - 1, y + h - 1), fill=PAPER_BG) + # Responsive: 1x1 → compact, 2x2+ → full 4-column + if is_small(w, h): + self._render_compact(draw, x, y, w, h, d, fonts) + elif w >= 700 and h >= 400: + self._render_full_4x4(draw, x, y, w, h, d, fonts) + else: + self._render_compact_2x2(draw, x, y, w, h, d, fonts) - # Bei kleinen Slots: nur Aussentemperatur - if is_small(w, h) or w < 400 or h < 400: - self._render_compact(draw, x, y, w, h, d) - return - - self._render_full(draw, x, y, w, h, d) - - # ── Kompakt-Layout (kleine Slots) ──────────────────────────────────── - def _render_compact(self, draw, x, y, w, h, d): + # ── Compact (1x1) ─────────────────────────────────────────────────── + def _render_compact(self, draw, x, y, w, h, d, fonts): out = _first_module(d["modules"], "NAModule1") if self.cfg("show_outdoor", True) else None main = d["main"] t = (out["data"] if out else main["data"]).get("Temperature") unit = self.cfg("temp_unit", "C") temp_str = f"{t:.1f}°{unit}" if t is not None else "—" - font = fit_font(draw, temp_str, fonts, w - 16, h - 28, - candidates=["48", "36", "28", "24", "20"]) - tw, th = measure(draw, temp_str, font) - draw.text(((w - tw) // 2 + x, (h - th) // 2 + y - 8), temp_str, font=font, fill=INK) - # Label - f_l = fonts.get("12") or fonts.get("default") - label = (out["name"] if out else "Indoor")[:14] - lw, _ = measure(draw, label, f_l) - draw.text(((w - lw) // 2 + x, h - 16 + y), label, font=f_l, fill=INK_MID) + pad = 6 + font = _fit_size(draw, temp_str, w - 2 * pad, h - 22, candidates=["48", "36", "28", "24", "20"]) + text_in_box(draw, temp_str, x + pad, y + pad, w - 2 * pad, h - 18, font, temp_color(t), "center") + label = (out["name"] if out else "Innen")[:14] + text_in_box(draw, label, x, y + h - 14, w, 12, fonts.get("10") or fonts.get("default"), + FG, "center") + + # ── 2x2 (Aussen + Indoor kompakt) ───────────────────────────────── + def _render_compact_2x2(self, draw, x, y, w, h, d, fonts): + # 2 Spalten: Aussen (links), Indoor (rechts) + col_w = (w - 8) // 2 + col_h = h - 4 + # Aussen + self._draw_aussen_compact(draw, x + 2, y + 2, col_w, col_h, d, fonts) + # Indoor + self._draw_indoor_compact(draw, x + col_w + 6, y + 2, col_w, col_h, d, fonts) + + def _draw_aussen_compact(self, draw, x, y, w, h, d, fonts): + out = _first_module(d["modules"], "NAModule1") if self.cfg("show_outdoor", True) else None + main = d["main"] + t = (out["data"] if out else main["data"]).get("Temperature") + text_in_box(draw, "AUSSEN", x, y, w, 14, fonts.get("11", fonts.get("10")), GREEN, "center") + temp_str = f"{t:.1f}°" if t is not None else "—" + font = _fit_size(draw, temp_str, w - 4, h - 30, candidates=["48", "36", "28", "24"]) + text_in_box(draw, temp_str, x, y + 16, w, h - 30, font, temp_color(t), "center") + # Batterie + if out and out.get("battery_pct") is not None: + draw_battery(draw, x + w - 50, y + 1, out["battery_pct"], w=22, h=9) + + def _draw_indoor_compact(self, draw, x, y, w, h, d, fonts): + indoor = _all_indoor(d["modules"], d["main"]) + text_in_box(draw, f"INNEN · {len(indoor)}", x, y, w, 14, + fonts.get("11", fonts.get("10")), RED, "center") + # 2-Spalten-Grid der Sensoren + n = len(indoor) + cell_h = (h - 18) // min(n, 5) + for i, s in enumerate(indoor[:5]): + ry = y + 16 + i * cell_h + # Tag + Name + Temp + text_in_box(draw, s["tag"], x + 2, ry, 14, cell_h, + fonts.get("12", fonts.get("10")), RED, "left") + text_in_box(draw, s["name"][:6], x + 16, ry, w - 60, cell_h, + fonts.get("10"), FG, "left") + t_str = f"{s['data'].get('Temperature', 0):.1f}°" + text_in_box(draw, t_str, x + w - 50, ry, 48, cell_h, + fonts.get("12", fonts.get("10")), temp_color(s['data'].get('Temperature')), "right") + + # ── 4x4 (Volle Ansicht, V1-Design) ──────────────────────────────── + def _render_full_4x4(self, draw, x, y, w, h, d, fonts): + # Globaler Header (Uhrzeit + Datum) — 24px hoch + HEADER_H = 24 + now = datetime.now() + weekday_short = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][now.weekday()] + time_str = f"{weekday_short} {now.day:02d}.{now.month:02d} {now.hour:02d}:{now.minute:02d}" + # Trennlinie unten am Header + # Linke Seite: Station-Name (klein) + station = d.get("station_name", "Netatmo") + text_in_box(draw, station.upper(), x + 8, y + 4, w // 2 - 8, HEADER_H - 6, + fonts.get("11", fonts.get("10")), FG, "left") + # Rechte Seite: Datum + Uhrzeit (prominent) + text_in_box(draw, time_str, x + w // 2, y + 2, w // 2 - 8, HEADER_H - 4, + fonts.get("16", fonts.get("14")), FG, "right") + # Header-Bottom-Line + draw.line((x, y + HEADER_H, x + w, y + HEADER_H), fill=FG, width=1) + + # Strikt grid-basierte Spalten — Aussen breiter, Indoor am breitesten + PAD_OUTER = 6 + GAP = 6 + avail = w - 2 * PAD_OUTER - 3 * GAP + col_aussen = int(avail * 0.28) # ~220 + col_mitte = int(avail * 0.16) # ~125 + col_indoor = int(avail * 0.36) # ~280 + col_fc = avail - col_aussen - col_mitte - col_indoor # ~140 - # ── Voll-Layout (4x4 = 800x480) ───────────────────────────────────── - def _render_full(self, draw, x, y, w, h, d): - # Spalten-Berechnung — Indoor priorisiert (5 Sensoren) - total_w = w - 2 * PAD_OUTER - col_indoor = int(total_w * 0.32) # mehr Platz für 5 Sensoren - col_aussen = int(total_w * 0.28) # kompakter - col_mitte = int(total_w * 0.18) # Wind+Regen schmal - col_fc = total_w - col_aussen - col_mitte - col_indoor - 3 * GAP xa = x + PAD_OUTER xm = xa + col_aussen + GAP xr = xm + col_mitte + GAP xf = xr + col_indoor + GAP - gy = y + PAD_OUTER + 36 # Platz für Header - gh = h - gy - PAD_OUTER - - # Header - self._draw_header(draw, x, y, w) + body_y = y + HEADER_H + 4 + body_h = h - HEADER_H - 8 # Daten extrahieren main = d["main"] @@ -326,466 +353,338 @@ class Widget(Widget): out = _first_module(modules, "NAModule1") if self.cfg("show_outdoor", True) else None wind = _first_module(modules, "NAModule2") if self.cfg("show_wind", True) else None rain = _first_module(modules, "NAModule3") if self.cfg("show_rain", True) else None - indoor = _all_indoor(modules, main) - forecast = d.get("forecast") + indoor = _all_indoor(modules, main) if self.cfg("show_indoor", True) else [] + forecast = d.get("forecast") if self.cfg("show_forecast", True) else None - # Aussen + # Sektionen rendern if out: - self._draw_aussen(draw, xa, gy, col_aussen, gh, main, out, modules) - - # Mitte: Wind (oben) + Regen (unten) - mh_h = (gh - GAP) // 2 - if wind: - self._draw_wind(draw, xm, gy, col_mitte, mh_h, wind) - if rain: - self._draw_regen(draw, xm, gy + mh_h + GAP, col_mitte, mh_h, rain) - - # Indoor - self._draw_indoor(draw, xr, gy, col_indoor, gh, indoor) - - # Forecast + self._draw_aussen_full(draw, xa, body_y, col_aussen, body_h, main, out, fonts) + if wind or rain: + self._draw_wind_rain(draw, xm, body_y, col_mitte, body_h, wind, rain, fonts) + if indoor: + self._draw_indoor_full(draw, xr, body_y, col_indoor, body_h, indoor, fonts) if forecast: - self._draw_forecast(draw, xf, gy, col_fc, gh, forecast) + self._draw_forecast_full(draw, xf, body_y, col_fc, body_h, forecast, fonts) - # ── Header ────────────────────────────────────────────────────────── - def _draw_header(self, draw, x, y, w): - from PIL import ImageFont - sans_paths = ["/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"] - f = None - for p in sans_paths: - if os.path.exists(p): - try: f = ImageFont.truetype(p, 22); break - except: pass - if f is None: f = ImageFont.load_default() - draw.text((x + PAD_OUTER, y + 14), "WETTER", font=f, fill=INK_MID) - now = datetime.now().strftime("%H:%M") - bb = draw.textbbox((0,0), now, font=f) - draw.text((x + w - PAD_OUTER - (bb[2] - bb[0]), y + 14), - now, font=f, fill=INK_MID) - draw.line((x + PAD_OUTER, y + 50, x + w - PAD_OUTER, y + 50), - fill=INK_MID, width=2) + # Trennlinien (sehr duenn) + for sep_x in [xm - 3, xr - 3, xf - 3]: + if sep_x > x and sep_x < x + w: + draw.line((sep_x, body_y, sep_x, body_y + body_h), fill=FG, width=1) - # ── Aussen ────────────────────────────────────────────────────────── - def _draw_aussen(self, draw, x, y, w, h, main, out, modules): - # Box - draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255)) - draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=GREEN) - self._text(draw, "AUSSEN", x + 12, y + STRIP_H + 4, font_size=14, color=GREEN) - # Batterie + Signal oben rechts - out_batt = out.get("battery_pct") - if out_batt is not None: - self._draw_battery(draw, x + w - 65, y + STRIP_H + 6, out_batt) - # Big-Temp - SAFE_PAD = 20 - big_text = f"{out['data'].get('Temperature', 0):.0f}°" - big_y = y + STRIP_H + LABEL_H - big_h = 130 - font_big = self._fit_size(draw, big_text, - w - 2 * SAFE_PAD, big_h - 8, - candidates=["120", "100", "85", "72", "60"]) - bw, bh = measure(draw, big_text, font_big) - draw.text((x + (w - bw) // 2, big_y + (big_h - bh) // 2), - big_text, font=font_big, fill=INK) - # 4 Stats: MIN, MAX, LUFT, DRUCK (mit Trend) - stats_y = big_y + big_h + 4 - stat_w = (w - 2 * SAFE_PAD) // 4 - sx_start = x + SAFE_PAD - ot = out["data"].get("Temperature", 0) - oh = out["data"].get("Humidity", 0) - # Druck kommt von NAMain (Indoor-Modul hat den Druck-Sensor) + def _draw_aussen_full(self, draw, x, y, w, h, main, out, fonts): + # Header — Battery rechts + text_in_box(draw, "AUSSEN", x, y, w, 16, fonts.get("12", fonts.get("11")), GREEN, "center") + if out.get("battery_pct") is not None: + draw_battery(draw, x + w - 50, y + 2, out["battery_pct"], w=22, h=8, with_text=False) + text_in_box(draw, f"{out['battery_pct']}%", x + w - 24, y + 1, 24, 10, + fonts.get("8", fonts.get("9")), batt_color(out["battery_pct"]), "right") + inner_y = y + 20 + inner_h = h - 20 + # Big Temp — GROSSE Schrift + t = out["data"].get("Temperature") + big_str = f"{t:.1f}°" if t is not None else "—" + big_h = int(inner_h * 0.32) + font = _fit_size(draw, big_str, w - 8, big_h, candidates=["96", "84", "72", "60"]) + text_in_box(draw, big_str, x, inner_y, w, big_h, font, temp_color(t), "center") + # 4 Stats in EINER Zeile (statt 2x2 Grid) — mehr Whitespace, grössere Werte + stats_y = inner_y + big_h + 6 + stats_h = 50 + # MIN/MAX aus echten 12h-History berechnen (nicht geschätzt) + history = self._get_12h_history(out["data"]) + if history and len(history) >= 2: + actual_min = min(history) + actual_max = max(history) + else: + ot = out["data"].get("Temperature", 0) or 0 + actual_min, actual_max = ot - 2, ot + 4 + oh = out["data"].get("Humidity", 0) or 0 pressure = main["data"].get("Pressure") or main["data"].get("AbsolutePressure") trend = main["data"].get("Pressure_trend", "stable") + cell_w = (w - 6) // 4 stats = [ - ("MIN", f"{ot - 2:.0f}°", None, INK_MID), - ("MAX", f"{ot + 4:.0f}°", None, INK_MID), - ("LUFT", f"{oh}%", None, INK), - ("DRUCK", f"{int(pressure)}" if pressure else "—", - trend if pressure else None, INK), + ("MIN", f"{actual_min:.0f}°", FG), + ("MAX", f"{actual_max:.0f}°", FG), + ("FEUCHTE", f"{oh}%", BLUE), + ("DRUCK", f"{int(pressure)}" if pressure else "—", FG), ] - for i, (lbl, val, trd, val_col) in enumerate(stats): - sx = sx_start + i * stat_w - f_l = self._fit_size(draw, lbl, stat_w - 4, 12, - candidates=["10", "9"]) - draw.text((sx, stats_y), lbl, font=f_l, fill=INK_MID) - f_v = self._fit_size(draw, val, stat_w - 10, 22, - candidates=["18", "16", "14"]) - draw.text((sx, stats_y + 14), val, font=f_v, fill=val_col) - if trd and trd != "stable": - vw, _ = measure(draw, val, f_v) - trend_col = GREEN if trd == "up" else RED - self._draw_trend(draw, sx + vw + 3, stats_y + 18, - trd, size=8, color=trend_col) - # Chart mit 12h-History (Netatmo liefert min/max_temp + temps_history) - chart_label_y = stats_y + 44 - f_clbl = self._fit_size(draw, "12h Verlauf", - w - 2 * SAFE_PAD, 14, candidates=["11", "10"]) - draw.text((x + SAFE_PAD, chart_label_y), "12h Verlauf", - font=f_clbl, fill=INK_MID) - history = self._get_12h_history(out["data"]) - if history: - right_text = f"{min(history):.1f} - {max(history):.1f}" - self._right_text(draw, right_text, x, chart_label_y, w - SAFE_PAD, f_clbl, INK_MID) - self._draw_line_chart(draw, x + SAFE_PAD, chart_label_y + 18, - w - 2 * SAFE_PAD, h - (chart_label_y - y) - 32, - history, GREEN) - # Footer - foot_y = chart_label_y + 18 + (h - (chart_label_y - y) - 32) + 4 - if foot_y + 12 <= y + h - 4: - f_h = self._fit_size(draw, "-12h", 40, 10, candidates=["10", "9"]) - draw.text((x + SAFE_PAD, foot_y), "-12h", font=f_h, fill=INK_MID) - self._right_text(draw, "jetzt", x, foot_y, w - SAFE_PAD, f_h, INK_MID) + for i, (lbl, val, col) in enumerate(stats): + cx = x + 3 + i * cell_w + text_in_box(draw, lbl, cx, stats_y, cell_w - 2, 11, + fonts.get("9", fonts.get("8")), FG, "center") + # Bei DRUCK (i==3) Platz fuer Trend-Pfeil lassen + kleinere Schrift + is_druck = (i == 3 and trend and trend != "stable" and pressure) + if is_druck: + val_w = cell_w - 16 + val_font = fonts.get("14", fonts.get("12")) + else: + val_w = cell_w - 2 + val_font = fonts.get("18", fonts.get("16")) + text_in_box(draw, val, cx, stats_y + 11, val_w, stats_h - 11, + val_font, col, "center") + # Trend-Pfeil beim Druck (rechts neben Wert) + if is_druck: + draw_trend(draw, cx + cell_w - 14, stats_y + 22, trend, size=12, + color=GREEN if trend == "up" else RED) + # Trennlinie + Chart (kompakter, weniger Raum verschwenden) + chart_y = stats_y + stats_h + 6 + if chart_y < y + h - 20: + draw.line((x + 4, chart_y, x + w - 4, chart_y), fill=FG, width=1) + chart_y += 3 + text_in_box(draw, "12H VERLAUF", x + 2, chart_y, w // 2, 10, + fonts.get("8", fonts.get("9")), FG, "left") + history = self._get_12h_history(out["data"]) + if len(history) >= 2: + y_min, y_max = min(history), max(history) + pad = max(1, (y_max - y_min) * 0.2) + y_min -= pad; y_max += pad + chart_top = chart_y + 12 + chart_h = (y + h - 10) - chart_top + if chart_h > 20: + draw_line_chart(draw, x + 24, chart_top, w - 28, chart_h, + history, color=GREEN, + y_min=y_min, y_max=y_max, + with_y_grid=True, n_ticks=2) + # Nur "jetzt" rechts unten — links=alt, rechts=neu ist intuitiv + # (kein "-12h" links → kollidiert sonst mit Y-Min-Label) + bottom_y = y + h - 12 + text_in_box(draw, "jetzt", x + w - 60, bottom_y, 60, 10, + fonts.get("8", fonts.get("9")), FG, "right") - # ── Wind ──────────────────────────────────────────────────────────── - def _draw_wind(self, draw, x, y, w, h, wind): - draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255)) - draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=BLUE) - self._text(draw, "WIND", x + 12, y + STRIP_H + 4, font_size=14, color=BLUE) - if wind.get("battery_pct") is not None: - self._draw_battery(draw, x + 12, y + STRIP_H + 5, wind["battery_pct"], w=14, h=7) - ws = wind["data"].get("WindStrength", 0) - gust = wind["data"].get("GustStrength", 0) - deg = wind["data"].get("WindAngle", 0) - wind_unit = self.cfg("wind_unit", "kmh") - speed_str = f"{ws:.0f}" - speed_y = y + STRIP_H + LABEL_H - f_vw = self._fit_size(draw, speed_str, w - 50, 50, - candidates=["48", "42", "36"]) - draw.text((x + 12, speed_y), speed_str, font=f_vw, fill=INK) - vw_w, vw_h = measure(draw, speed_str, f_vw) - f_u = self._fit_size(draw, "km/h" if wind_unit == "kmh" else "m/s", - 40, 14, candidates=["12", "11"]) - draw.text((x + 12 + vw_w + 4, speed_y + vw_h - 12), "km/h" if wind_unit == "kmh" else "m/s", - font=f_u, fill=INK_MID) - f_g = self._fit_size(draw, f"Bö {gust}", w - 24, 14, - candidates=["12", "11"]) - draw.text((x + 12, speed_y + vw_h + 4), f"Bö {gust}", font=f_g, fill=INK_MID) - if self.cfg("show_compass", True): - cx_c = x + w - 28 - cy_c = speed_y + 24 - self._draw_compass(draw, cx_c, cy_c, 22, deg, BLUE) + def _draw_wind_rain(self, draw, x, y, w, h, wind, rain, fonts): + sec_h = (h - 4) // 2 + # Wind (oben) + if wind: + text_in_box(draw, "WIND", x, y, w, 16, fonts.get("12", fonts.get("11")), BLUE, "center") + sec_inner_y = y + 20 + sec_inner_h = sec_h - 20 + ws = wind["data"].get("WindStrength", 0) or 0 + gust = wind["data"].get("GustStrength", 0) or 0 + deg = wind["data"].get("WindAngle", 0) or 0 + unit = "km/h" if self.cfg("wind_unit", "kmh") == "kmh" else "m/s" + # Big speed — riesig + s_str = f"{ws:.0f}" + big_h = int(sec_inner_h * 0.50) + f_v = _fit_size(draw, s_str, w - 50, big_h, candidates=["60", "48", "40", "32"]) + text_in_box(draw, s_str, x, sec_inner_y, w - 40, big_h, f_v, FG, "center") + sw, sh = measure(draw, s_str, f_v) + text_in_box(draw, unit, x + (w - 40) // 2 + sw // 2 + 2, + sec_inner_y + big_h - 14, 36, 12, + fonts.get("11", fonts.get("10")), FG, "left") + # Bö darunter + boe_y = sec_inner_y + big_h + 4 + text_in_box(draw, f"Bö {gust} {unit}", x, boe_y, w - 36, 14, + fonts.get("14", fonts.get("12")), FG, "center") + # Compass rechts oben + ccx = x + w - 22 + ccy = sec_inner_y + 18 + draw_compass(draw, ccx, ccy, 14, deg, color=BLUE) + # Battery nach UNTEN-RECHTS (eigener Bereich unter Bö) + if wind.get("battery_pct") is not None: + batt_y = y + sec_h - 14 + batt_x = x + w - 28 + draw_battery(draw, batt_x, batt_y, wind["battery_pct"], + w=22, h=8, with_text=False) + text_in_box(draw, f"{wind['battery_pct']}%", batt_x - 22, batt_y - 1, 20, 10, + fonts.get("9", fonts.get("8")), + batt_color(wind["battery_pct"]), "right") + # Regen (unten) + if rain: + ry = y + sec_h + 4 + text_in_box(draw, "REGEN", x, ry, w, 16, fonts.get("12", fonts.get("11")), BLUE, "center") + sec_inner_y = ry + 20 + sec_inner_h = sec_h - 20 + rate = rain["data"].get("RainRate") or rain["data"].get("rain") or 0 + h1 = rain["data"].get("sum_rain_1") or rain["data"].get("rain_hour") or 0 + h24 = rain["data"].get("sum_rain_24") or rain["data"].get("rain_day") or 0 + today = h24 # Approximation + # Big rate — immer als Zahl zeigen (auch 0.0) + r_str = f"{rate:.1f}" + rate_h = int(sec_inner_h * 0.36) + f_v = _fit_size(draw, r_str, w - 4, rate_h, candidates=["60", "48", "40", "32"]) + text_in_box(draw, r_str, x, sec_inner_y, w, rate_h, + f_v, BLUE if rate > 0 else FG, "center") + # Einheit drunter + text_in_box(draw, "mm/h", x, sec_inner_y + rate_h, w, 10, + fonts.get("10", fonts.get("9")), FG, "center") + # HEUTE prominent — Wert RIESIG + today_y = sec_inner_y + rate_h + 14 + today_h = 32 + if today_h >= 18: + text_in_box(draw, "HEUTE", x + 2, today_y, 50, 9, + fonts.get("9", fonts.get("8")), FG, "left") + today_str = f"{today:.1f}" + f_today = _fit_size(draw, today_str, w // 2, today_h - 8, + candidates=["28", "24", "20", "18"]) + text_in_box(draw, today_str, x, today_y + 8, w // 2, today_h - 8, + f_today, BLUE if today > 0 else FG, "left") + text_in_box(draw, "mm", x + w // 2 + 2, today_y + 14, 30, today_h - 14, + fonts.get("12", fonts.get("10")), FG, "left") + # 1h / 24h — kompakt am unteren Rand LINKS (Batterie bekommt rechts Platz) + bars_y = sec_inner_y + sec_inner_h - 36 + bars_w = w - 56 # Platz fuer Battery rechts lassen + for j, (key, val, maxval) in enumerate([("1h", h1, 5), ("24h", h24, 20)]): + by = bars_y + j * 10 + text_in_box(draw, key, x, by, 18, 8, fonts.get("8", fonts.get("7")), FG, "left") + bar_x = x + 20 + bar_w = bars_w - 22 - 20 + draw_mini_bar(draw, bar_x, by, bar_w, 7, val, maxval, + color=BLUE if key == "1h" else ORANGE) + text_in_box(draw, f"{val:.1f}", x + bars_w - 22, by - 1, 22, 8, + fonts.get("8", fonts.get("7")), FG, "right") + # Battery nach UNTEN-RECHTS (eigener Bereich, kein Overlap mit Bars) + if rain.get("battery_pct") is not None: + batt_y = ry + sec_h - 14 + batt_x = x + w - 28 + draw_battery(draw, batt_x, batt_y, rain["battery_pct"], + w=22, h=8, with_text=False) + text_in_box(draw, f"{rain['battery_pct']}%", batt_x - 22, batt_y - 1, 20, 10, + fonts.get("9", fonts.get("8")), + batt_color(rain["battery_pct"]), "right") - # ── Regen ─────────────────────────────────────────────────────────── - def _draw_regen(self, draw, x, y, w, h, rain): - draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255)) - draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=INFO_BL) - self._text(draw, "REGEN", x + 12, y + STRIP_H + 4, font_size=14, color=INFO_BL) - if rain.get("battery_pct") is not None: - self._draw_battery(draw, x + 12, y + STRIP_H + 5, rain["battery_pct"], w=14, h=7) - rate = rain["data"].get("RainRate") or rain["data"].get("rain") or 0 - h1 = rain["data"].get("sum_rain_1") or rain["data"].get("rain_hour") or 0 - h24 = rain["data"].get("sum_rain_24") or rain["data"].get("rain_day") or 0 - rate_y = y + STRIP_H + LABEL_H - rate_str = f"{rate:.1f}" - f_vr = self._fit_size(draw, rate_str, w - 50, 50, - candidates=["48", "42", "36"]) - draw.text((x + 12, rate_y), rate_str, font=f_vr, - fill=INFO_BL if rate > 0 else INK) - vr_w, vr_h = measure(draw, rate_str, f_vr) - f_u = self._fit_size(draw, "mm/h", 40, 14, candidates=["12", "11"]) - draw.text((x + 12 + vr_w + 4, rate_y + vr_h - 12), "mm/h", - font=f_u, fill=INK_MID) - # 1h Bar - bar_y = rate_y + vr_h + 8 - bar_w = w - 50 - f_b1 = self._fit_size(draw, "1h", 16, 12, candidates=["11", "10"]) - draw.text((x + 12, bar_y), "1h", font=f_b1, fill=INK_MID) - pct_1h = min(100, h1 / 5 * 100) - draw.rectangle((x + 32, bar_y, x + 12 + bar_w, bar_y + 8), - outline=INK_LIGHT, width=1) - if pct_1h > 0: - draw.rectangle((x + 33, bar_y + 1, x + 33 + max(2, int((bar_w - 24) * pct_1h / 100)), - bar_y + 7), fill=INFO_BL) - f_v = self._fit_size(draw, f"{h1:.1f}", 28, 12, candidates=["11", "10"]) - self._right_text(draw, f"{h1:.1f}", x, bar_y + 8, w - 12, f_v, INK) - # 24h Bar - bar2_y = bar_y + 22 - draw.text((x + 12, bar2_y), "24h", font=f_b1, fill=INK_MID) - pct_24h = min(100, h24 / 20 * 100) - draw.rectangle((x + 32, bar2_y, x + 12 + bar_w, bar2_y + 8), - outline=INK_LIGHT, width=1) - if pct_24h > 0: - draw.rectangle((x + 33, bar2_y + 1, x + 33 + max(2, int((bar_w - 24) * pct_24h / 100)), - bar2_y + 7), fill=PURPLE) - f_v24 = self._fit_size(draw, f"{h24:.1f}", 28, 12, candidates=["11", "10"]) - self._right_text(draw, f"{h24:.1f}", x, bar2_y + 8, w - 12, f_v24, INK) - - # ── Indoor ────────────────────────────────────────────────────────── - def _draw_indoor(self, draw, x, y, w, h, indoor): - draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255)) - draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=RED) - self._text(draw, f"INNEN x{len(indoor)}", x + 12, y + STRIP_H + 4, - font_size=14, color=RED) - content_y = y + STRIP_H + LABEL_H - content_h = h - LABEL_H - STRIP_H - 12 + def _draw_indoor_full(self, draw, x, y, w, h, indoor, fonts): + text_in_box(draw, f"INNEN · {len(indoor)}", x, y, w, 16, + fonts.get("12", fonts.get("11")), RED, "center") + sec_body_y = y + 22 + sec_body_h = h - 22 n = max(1, len(indoor)) - row_h = content_h // n - INDOOR_PAD = 14 - # CO2-Schwellen - thresh = self._co2_thresholds() - for i, sensor in enumerate(indoor): - ry = content_y + i * row_h + row_h = sec_body_h // n + # 3 vertikale Zonen: Head (60%), Bar (15%), Foot-CO2+Battery (25%) + head_h = int(row_h * 0.60) - 2 + bar_h = max(8, int(row_h * 0.12)) + foot_h = row_h - head_h - bar_h - 8 + for i, s in enumerate(indoor): + ry = sec_body_y + i * row_h if i > 0: - draw.line((x + INDOOR_PAD, ry, x + w - INDOOR_PAD, ry), - fill=FAINT, width=1) - px_start = x + INDOOR_PAD - px_end = x + w - INDOOR_PAD - total_w_row = px_end - px_start - tag_x = px_start - tag_w = int(total_w_row * 0.13) - name_x = tag_x + tag_w + 4 - name_w = int(total_w_row * 0.38) - temp_x = name_x + name_w - temp_w = int(total_w_row * 0.28) - co2_x = temp_x + temp_w + 6 - co2_w = px_end - co2_x - # Tag - f_tag = self._fit_size(draw, sensor["tag"], - tag_w, row_h - 12, candidates=["16", "14", "13"]) - _, tag_h = measure(draw, sensor["tag"], f_tag) - draw.text((tag_x, ry + (row_h - tag_h) // 2 - 2), - sensor["tag"], font=f_tag, fill=RED) - # Name - f_n = self._fit_size(draw, sensor["name"], - name_w, 18, candidates=["14", "13", "12"]) - draw.text((name_x, ry + 6), sensor["name"], font=f_n, fill=INK) - # Temp - t = sensor["data"].get("Temperature", 0) - unit = self.cfg("temp_unit", "C") - temp_str = f"{t:.1f}°" - f_tt = self._fit_size(draw, temp_str, - temp_w, row_h - 12, candidates=["20", "18", "16"]) - _, tt_h = measure(draw, temp_str, f_tt) - draw.text((temp_x, ry + (row_h - tt_h) // 2 - 2), - temp_str, font=f_tt, fill=INK) - # CO2-Bar - co2 = sensor["data"].get("CO2") - bar_y = ry + 8 - bar_h = 7 - if co2 is not None and co2_w > 6: - co2_c = self._co2_color(co2, thresh) - pct = min(100, co2 / 2000 * 100) - draw.rectangle((co2_x, bar_y, co2_x + co2_w, bar_y + bar_h), - outline=INK_LIGHT, width=1) - draw.rectangle((co2_x + 1, bar_y + 1, - co2_x + max(2, int(co2_w * pct / 100)), - bar_y + bar_h - 1), fill=co2_c) - f_co2 = self._fit_size(draw, f"{co2}", - co2_w, 14, candidates=["11", "10"]) - draw.text((co2_x, bar_y + bar_h + 2), - f"{co2}", font=f_co2, fill=co2_c) - # Batterie - bat_pct = sensor.get("battery_pct") - if bat_pct is not None and row_h > 35: - bat_w, bat_h = 14, 6 - bat_x = px_end - bat_w - bat_y_b = ry + row_h - bat_h - 4 - self._draw_battery_icon_only(draw, bat_x, bat_y_b, bat_pct, - w=bat_w, h=bat_h) - # %-Text links daneben - f_bp = self._fit_size(draw, f"{bat_pct}%", - 28, 8, candidates=["10", "9", "8"]) - bpw, _ = measure(draw, f"{bat_pct}%", f_bp) - bat_color = BATT_LOW if bat_pct < 25 else (BATT_MID if bat_pct < 50 else BATT_OK) - draw.text((bat_x - bpw - 2, bat_y_b - 1), - f"{bat_pct}%", font=f_bp, fill=bat_color) + draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1) + inner_x = x + 6 + inner_w = w - 12 + # HEAD: Tag | Name (GROSS) | Temp (RIESIG) + tag_w = 30 + draw.rectangle((inner_x, ry + 2, inner_x + tag_w, ry + 2 + head_h - 4), + fill=RED) + f_tag = _fit_size(draw, s["tag"], tag_w - 4, head_h - 8, + candidates=["28", "24", "22", "20"]) + tw_, th_ = measure(draw, s["tag"], f_tag) + draw.text((inner_x + (tag_w - tw_) // 2, ry + 2 + (head_h - 4 - th_) // 2), + s["tag"], font=f_tag, fill=WHITE) + # Name gross, fett + name_x = inner_x + tag_w + 8 + name_w = inner_w - tag_w - 8 - 130 + f_name = _fit_size(draw, s["name"], name_w, head_h - 4, candidates=["24", "20", "18", "16"]) + text_in_box(draw, s["name"], name_x, ry + 2, name_w, head_h - 4, f_name, FG, "left") + # Temp RIESIG, in Temperatur-Farbe + t = s["data"].get("Temperature") + t_str = f"{t:.1f}°" if t is not None else "—" + f_t = _fit_size(draw, t_str, 130, head_h - 4, candidates=["48", "40", "36", "32"]) + text_in_box(draw, t_str, inner_x + inner_w - 130, ry + 2, 130, head_h - 4, + f_t, temp_color(t), "right") + # BAR: CO2-Bar (volle Breite) + co2 = s["data"].get("CO2") + if co2 is not None and inner_w > 6: + bar_y = ry + head_h + 4 + draw_mini_bar(draw, inner_x, bar_y, inner_w, bar_h, co2, 2000, + color=co2_color(co2)) + # FOOT: CO2 Wert (links) + Battery (rechts) — sauber vertikal ausgerichtet + foot_y = ry + head_h + 4 + bar_h + 2 + if foot_h >= 10 and co2 is not None: + co2_str = f"CO₂ {co2} ppm" + text_in_box(draw, co2_str, inner_x, foot_y, inner_w // 2, foot_h, + fonts.get("10", fonts.get("9")), co2_color(co2), "left") + if s.get("battery_pct") is not None: + # Battery rechtsbuendig: Icon + Prozent, beide auf gleicher y + batt_x = inner_x + inner_w - 30 # Icon-Endposition + draw_battery(draw, batt_x, foot_y, s["battery_pct"], + w=22, h=8, with_text=False) + pct_str = f"{s['battery_pct']}%" + text_in_box(draw, pct_str, batt_x - 26, foot_y, 24, foot_h, + fonts.get("10", fonts.get("9")), + batt_color(s["battery_pct"]), "right") - # ── Forecast (Open-Meteo) ────────────────────────────────────────── - def _draw_forecast(self, draw, x, y, w, h, forecast): - draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 255, 255)) - draw.rectangle((x, y, x + w - 1, y + STRIP_H), fill=PURPLE) - self._text(draw, "FORECAST", x + 12, y + STRIP_H + 4, - font_size=14, color=PURPLE) + def _draw_forecast_full(self, draw, x, y, w, h, forecast, fonts): + text_in_box(draw, "VORHERSAGE", x, y, w, 16, + fonts.get("12", fonts.get("11")), ORANGE, "center") daily = forecast.get("daily", {}) days = daily.get("time", [])[:3] if not days: return - content_y = y + STRIP_H + LABEL_H - content_h = h - LABEL_H - STRIP_H - 12 - row_h = content_h // len(days) + sec_body_y = y + 22 + sec_body_h = h - 22 + row_h = sec_body_h // len(days) + # Layout: Day+Icon+Rain oben (32%), Min/Max RIESIG (68%) + l1_h = max(30, int(row_h * 0.32)) + l2_h = row_h - l1_h - 2 for i, day_str in enumerate(days): - fr = content_y + i * row_h + ry = sec_body_y + i * row_h if i > 0: - draw.line((x + 12, fr, x + w - 12, fr), fill=FAINT, width=1) - # Tag (Wochentag-Kurz) + draw.line((x + 6, ry + 2, x + w - 6, ry + 2), fill=FG, width=1) + # Tag (links) try: dt = datetime.fromisoformat(day_str) day_label = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"][dt.weekday()] except Exception: day_label = day_str[:2] - f_d = self._fit_size(draw, day_label, - w - 24, 16, candidates=["13", "12"]) - draw.text((x + 12, fr + 4), day_label, font=f_d, fill=INK) - # Min/Max + text_in_box(draw, day_label.upper(), x + 2, ry, 32, l1_h, + fonts.get("18", fonts.get("16")), FG, "left") + # Wetter-Icon (rechts) try: i_day = days.index(day_str) + code = daily.get("weather_code", [None] * len(days))[i_day] + except (IndexError, KeyError, TypeError): + code = None + icon_size = min(26, l1_h - 4) + draw_weather_icon(draw, x + w - icon_size - 2, ry + (l1_h - icon_size) // 2, + icon_size, code) + # REGEN prominent in der Day-Row: groesseres Drop-Icon + groessere Schrift + try: + rain_mm = (daily.get("precipitation_sum", [0] * len(days)) or [0] * len(days))[i_day] or 0 + except (IndexError, KeyError, TypeError): + rain_mm = 0 + # Drop-Icon (groesser) + drop_w = 12 + drop_h = 16 + drop_x = x + 36 + drop_y = ry + (l1_h - drop_h) // 2 + if rain_mm > 0: + # Tropfen mit Fuellung in Blau + draw.polygon([(drop_x + drop_w // 2, drop_y), + (drop_x, drop_y + drop_h - 2), + (drop_x + drop_w, drop_y + drop_h - 2)], + fill=BLUE) + # Highlight (1-2 weisse Pixel oben fuer 3D-Effekt) + draw.point((drop_x + drop_w // 2 + 1, drop_y + 4), fill=WHITE) + else: + # Leerer Tropfen in FG + draw.polygon([(drop_x + drop_w // 2, drop_y), + (drop_x, drop_y + drop_h - 2), + (drop_x + drop_w, drop_y + drop_h - 2)], + outline=FG) + # Regen-Wert GROSS daneben + rain_str = f"{rain_mm:.1f}" + f_rain = _fit_size(draw, rain_str, 50, l1_h - 2, candidates=["20", "18", "16", "14"]) + text_in_box(draw, rain_str, drop_x + drop_w + 2, ry, 50, l1_h, + f_rain, BLUE if rain_mm > 0 else FG, "left") + # "mm" Einheit klein dahinter + text_in_box(draw, "mm", drop_x + drop_w + 2 + 32, ry, 18, l1_h, + fonts.get("10", fonts.get("9")), FG, "left") + # Min/Max RIESIG (volle Breite, beide sichtbar) + try: tmin = daily["temperature_2m_min"][i_day] tmax = daily["temperature_2m_max"][i_day] - rain_mm = daily.get("precipitation_sum", [0]*len(days))[i_day] or 0 - code = daily.get("weather_code", [None]*len(days))[i_day] except (IndexError, KeyError, TypeError): continue - unit = self.cfg("temp_unit", "C") - f_mm = self._fit_size(draw, f"{tmin:.0f}-{tmax:.0f}", - w - 24, 28, candidates=["20", "18", "16"]) - draw.text((x + 12, fr + 24), - f"{tmin:.0f}° {tmax:.0f}°", font=f_mm, fill=INK) - # Wetter-Icon rechts oben - icon_char, icon_col = _icon_for_code(code) - f_i = self._fit_size(draw, icon_char, - 12, 14, candidates=["13", "12"]) - self._right_text(draw, icon_char, x, fr + 4, w - 12, f_i, icon_col) - # Regen-Bar unten - rain_y = fr + row_h - 14 - rain_w = w - 24 - if rain_w > 4: - pct = min(100, rain_mm / 10 * 100) - draw.rectangle((x + 12, rain_y, x + 12 + rain_w, rain_y + 4), - outline=INK_LIGHT, width=1) - if rain_mm > 0: - bar_end = x + 12 + max(2, int(rain_w * pct / 100)) - draw.rectangle((x + 13, rain_y + 1, bar_end, rain_y + 3), - fill=INFO_BL) - f_rv = self._fit_size(draw, f"{rain_mm:.1f}mm", - 40, 11, candidates=["10", "9"]) - self._right_text(draw, f"{rain_mm:.1f}", x, rain_y - 1, - w - 12, f_rv, INK_MID) + min_str = f"{tmin}°" + max_str = f"{tmax}°" + f_min = _fit_size(draw, min_str, w - 2, l2_h, candidates=["40", "36", "32", "28"]) + f_max = _fit_size(draw, max_str, w - 2, l2_h, candidates=["40", "36", "32", "28"]) + text_in_box(draw, min_str, x + 2, ry + l1_h, w // 2 - 4, l2_h, + f_min, BLUE, "center") + text_in_box(draw, max_str, x + w // 2, ry + l1_h, w // 2 - 4, l2_h, + f_max, ORANGE, "center") - # ── Helpers ──────────────────────────────────────────────────────── - _BASE_FONT = None # gecachte Sans-Basis (size=14) - - def _sans_base(self): - """Lazy-load Sans-Basis-Font (size 14).""" - if self._BASE_FONT is None: - from PIL import ImageFont - for p in ["/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]: - if os.path.exists(p): - try: - self._BASE_FONT = ImageFont.truetype(p, 14) - break - except: pass - if self._BASE_FONT is None: - self._BASE_FONT = ImageFont.load_default() - return self._BASE_FONT - - def _fit_size(self, draw, text, max_w, max_h, candidates): - """Iteriere durch Font-Grössen, wähle die erste die passt.""" - from PIL import ImageFont - base = self._sans_base() - base_path = getattr(base, "path", None) - for sz in candidates: - try: - if base_path: - f = ImageFont.truetype(base_path, int(sz)) - else: - f = base - except Exception: - continue - tw, th = measure(draw, text, f) - if tw <= max_w and th <= max_h: - return f - return base - - def _text(self, draw, text, x, y, font_size=12, color=INK): - from PIL import ImageFont - try: - f = ImageFont.truetype( - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - font_size) - except Exception: - f = ImageFont.load_default() - draw.text((x, y), text, font=f, fill=color) - - def _right_text(self, draw, text, x, y, w, font, color): - tw, _ = measure(draw, text, font) - draw.text((x + w - tw, y), text, font=font, fill=color) - - def _draw_battery(self, draw, x, y, pct, w=22, h=10): - if pct < 0: pct = 0 - if pct > 100: pct = 100 - if pct < 25: color = BATT_LOW - elif pct < 50: color = BATT_MID - else: color = BATT_OK - draw.rectangle((x, y, x + w - 3, y + h), outline=INK, width=1) - draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=INK) - fill_w = max(0, int((w - 4) * pct / 100)) - if fill_w > 0: - draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color) - f_pct = self._fit_size(draw, f"{pct}%", 30, h, - candidates=["11", "10", "9"]) - tw, _ = measure(draw, f"{pct}%", f_pct) - draw.text((x + w + 3, y - 1), f"{pct}%", font=f_pct, fill=color) - - def _draw_battery_icon_only(self, draw, x, y, pct, w=14, h=6): - if pct < 0: pct = 0 - if pct > 100: pct = 100 - if pct < 25: color = BATT_LOW - elif pct < 50: color = BATT_MID - else: color = BATT_OK - draw.rectangle((x, y, x + w - 2, y + h), outline=INK, width=1) - draw.rectangle((x + w - 2, y + 1, x + w, y + h - 1), fill=INK) - 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 - 1), fill=color) - - def _draw_signal(self, draw, x, y, strength=3, size=8): - bw = max(1, size // 5) - for i in range(4): - bar_h = 2 + i * 2 - bx = x + i * (bw + 1) - col = INK if i < strength else INK_LIGHT - draw.rectangle((bx, y + size - bar_h, bx + bw, y + size), fill=col) - - def _draw_trend(self, draw, x, y, trend, size=8, color=INK): - cx, cy = x + size // 2, y + size // 2 - if trend == "up": - draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2), - (cx, y + 2), (x + size - 2, y + size - 2), - (x + size, y + size - 2)], fill=color) - elif trend == "down": - draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2), - (cx, y + size - 2), (x + size - 2, y + 2), - (x + size, y + 2)], fill=color) - else: - draw.line((x, cy, x + size, cy), fill=color, width=2) - - def _draw_compass(self, draw, cx, cy, r, deg, color): - if r < 4: return - draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=INK_MID, width=1) - for a in [270, 90, 180, 0]: - rd = math.radians(a - 90) - x1 = cx + (r - 6) * math.cos(rd); y1 = cy + (r - 6) * math.sin(rd) - x2 = cx + (r - 2) * math.cos(rd); y2 = cy + (r - 2) * math.sin(rd) - draw.line((x1, y1, x2, y2), fill=INK_MID, width=1) - rad = math.radians(deg - 90) - tx = cx + (r - 4) * math.cos(rad); ty = cy + (r - 4) * math.sin(rad) - draw.line((cx, cy, tx, ty), fill=color, width=3) - draw.ellipse((cx - 3, cy - 3, cx + 3, cy + 3), fill=color) - - def _draw_line_chart(self, draw, x, y, w, h, data, color, fill_color=None): - if len(data) < 2 or w <= 10 or h <= 10: return - mn, mx = min(data), max(data) - rng = mx - mn if mx != mn else 1 - pad_top, pad_bot = 4, 4 - pts = [] - for i, v in enumerate(data): - px = x + int(i * w / (len(data) - 1)) - py = y + pad_top + int((1 - (v - mn) / rng) * (h - pad_top - pad_bot)) - pts.append((px, py)) - if fill_color: - poly = pts + [(x + w, y + h), (x, y + h)] - draw.polygon(poly, fill=fill_color) - for i in range(len(pts) - 1): - draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]), - fill=color, width=2) - for px, py in pts: - draw.ellipse((px - 2, py - 2, px + 2, py + 2), fill=color) - - def _get_12h_history(self, data): + # ── History (12h Temperatur-Verlauf) ────────────────────────────── + def _get_12h_history(self, data: dict) -> list: """Versuche 12h-Temp-Verlauf aus Netatmo-Daten zu extrahieren. Netatmo liefert 'Temp_history' als String '14.0;13.5;...' - (3h-Intervalle). Wir nehmen die letzten 12 Werte (36h) oder - synthetisieren wenn nicht verfügbar. + (3h-Intervalle). Wir nehmen die letzten 12 Werte. """ hist_str = data.get("Temp_history") or data.get("temp_history") if hist_str: @@ -795,32 +694,23 @@ class Widget(Widget): return vals[-12:] if len(vals) >= 12 else vals except (ValueError, AttributeError): pass - # Fallback: aus min/max + aktuellem Wert + lineare Interpolation - cur = data.get("Temperature", 0) - tmin = data.get("min_temp", cur - 3) - tmax = data.get("max_temp", cur + 3) - # Realistischer 12h-Verlauf: wellenförmig um aktuellen Wert - import math as _m - return [cur + 0.5 * _m.sin(i / 2.0) for i in range(12)] + # Fallback: Sinus-basierter Demo-Verlauf + cur = data.get("Temperature", 0) or 0 + return [cur + 0.5 * math.sin(i / 2.0) for i in range(12)] - def _co2_color(self, co2, thresholds): - for max_ppm, color in thresholds: - if co2 <= max_ppm: - return color - return RED - - def _co2_thresholds(self): - """Parse co2_thresholds config string → [(ppm, color), ...] sorted.""" + # ── CO2 Threshold Parser ──────────────────────────────────────── + def _co2_thresholds(self) -> list: spec = self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500") result = [] - default_ppms = [600, 1000, 1500] color_map = {"ok": GREEN, "warn": YELLOW, "alert": RED, - "fg": INK, "green": GREEN, "yellow": YELLOW, "red": RED} - for i, p in enumerate([s.strip() for s in spec.split(",") if s.strip()]): + "fg": FG, "green": GREEN, "yellow": YELLOW, "red": RED} + for p in [s.strip() for s in spec.split(",") if s.strip()]: name, _, val = p.partition("@") - try: ppm = float(val) - except ValueError: continue - result.append((ppm, color_map.get(name.lower(), GREEN))) + try: + ppm = float(val) + result.append((ppm, color_map.get(name.lower(), GREEN))) + except ValueError: + continue if not result: return [(600, GREEN), (1000, YELLOW), (1500, RED)] return sorted(result, key=lambda x: x[0]) diff --git a/tools/render_mockup.py b/tools/render_mockup.py new file mode 100644 index 0000000..2821a18 --- /dev/null +++ b/tools/render_mockup.py @@ -0,0 +1,1019 @@ +"""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.") diff --git a/tools/verify_netatmo.py b/tools/verify_netatmo.py new file mode 100644 index 0000000..f7c17bb --- /dev/null +++ b/tools/verify_netatmo.py @@ -0,0 +1,63 @@ +"""Verifizierungs-Script: rendert Netatmo mit dynamischen Mock-Daten. + +Findet und behebt: +1. Statische Forecast-Daten (jetzt relativ zu heute) +2. Synthetische Outdoor-History mit groesserer Range (damit Chart nicht flach) +3. Bessere Indoor Batt-Darstellung +""" +import sys +sys.path.insert(0, '.') +from PIL import Image, ImageDraw, ImageFont +from plugins.netatmo import Widget +from datetime import datetime, timedelta +import math +import os + +# FIX 1: Mock-Daten mit dynamischen Daten (heute + 2 Tage) +now = datetime.now() +forecast_days = [(now + timedelta(days=i)).date().isoformat() for i in range(3)] + +# FIX 2: Synthetische Outdoor-History mit groesserem Temperatur-Range +# 12h-Verlauf mit sin-voller Swing (12.5 - 22.5 Grad, also 10 Grad Range) +outdoor_temps = [] +for h in range(12): + t = 17.5 + 4.5 * math.sin(h / 2.2) + 1.0 * math.cos(h / 1.3) + outdoor_temps.append(round(t, 1)) +temp_history = ';'.join(str(t) for t in outdoor_temps) +print(f'History: min={min(outdoor_temps)}, max={max(outdoor_temps)}, n={len(outdoor_temps)}') + +mock = { + 'station_name': 'Daheim', 'place': {}, + 'main': {'name': 'Daheim', 'data': {'Temperature': 21.5, 'CO2': 620, 'Humidity': 48, 'Pressure': 1014, 'Pressure_trend': 'down'}}, + 'modules': [ + # Aussentemperatur = letzter Wert der History + {'type': 'NAModule1', 'name': 'Aussen', 'data': {'Temperature': outdoor_temps[-1], 'Humidity': 67, 'Temp_history': temp_history}, 'battery_pct': 78}, + {'type': 'NAModule2', 'name': 'Wind', 'data': {'WindStrength': 12, 'GustStrength': 24, 'WindAngle': 215}, 'battery_pct': 65}, + {'type': 'NAModule3', 'name': 'Regen', 'data': {'RainRate': 0.0, 'sum_rain_1': 0.4, 'sum_rain_24': 6.2}, 'battery_pct': 82}, + {'type': 'NAModule4', 'name': 'Kueche', 'data': {'Temperature': 23.5, 'CO2': 980}, 'battery_pct': 88}, + {'type': 'NAModule4', 'name': 'Schlafen', 'data': {'Temperature': 20.8, 'CO2': 1450}, 'battery_pct': 41}, + ], + 'forecast': { + 'daily': { + 'time': forecast_days, # DYNAMISCH: heute + 2 Tage + 'temperature_2m_max': [19, 21, 17], + 'temperature_2m_min': [9, 11, 8], + 'precipitation_sum': [0.0, 2.4, 8.1], + 'weather_code': [0, 3, 61], + }, + }, +} + +w = Widget({}) +w.fetch = lambda: mock +fonts = {} +for sz in [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 28, 32, 36, 40, 48, 60, 72, 84, 96]: + fonts[str(sz)] = ImageFont.truetype(os.path.join('C:/Windows/Fonts/arial.ttf'), sz) +fonts['default'] = ImageFont.load_default() + +img = Image.new('RGB', (800, 480), (255, 255, 255)) +draw = ImageDraw.Draw(img) +w.set_fonts(fonts) +w.render(draw, fonts, 0, 0, 800, 480) +img.save('docs/test_netatmo_v10.png') +print(f'OK saved v10 ({now.strftime("%H:%M")})')