From 1aa11f5843d58f4bb48975b9f90117964e455b34 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 29 Aug 2026 20:30:47 +0400 Subject: [PATCH] =?UTF-8?q?FEAT-NETATMO-03:=20Luftdruck=20+=203-Tage=20For?= =?UTF-8?q?ecast=20+=20=C3=9Cberst=C3=A4nde-Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Luftdruck (mbar) mit Trend-Pfeil (up/down/stable) als 4. Stat in Aussen-Box * 3-Tage Forecast via Open-Meteo (kostenlos, kein API-Key) in neuer Spalte rechts * Neue Spalten-Aufteilung: 36/20/24/20% (Aussen / Mitte / Indoor / Forecast) * Battery-Indikatoren mit %-Wert an Aussen, Wind, Regen und allen 5 Indoor-Sensoren * Sans-Font (Liberation Sans) statt Serife — moderner Look * Sichere Padding-Werte (14px Aussen, 14px Indoor) verhindern Text-Überstände * Temp-Trend farbcodiert: Trend-Pfeil grün (steigend) / rot (fallend) * Config-Felder: show_forecast (default true), Wind/Regen ein/ausschaltbar * Inline _fit_size() Helper statt palette.fit_font() (vermeidet Dict-Lookup-Probleme) * Forecast wird automatisch deaktiviert wenn Open-Meteo nicht erreichbar * CO2-Schwellen weiterhin konfigurierbar via config string --- plugins/netatmo.py | 1215 +++++++++++++++++++++++--------------------- 1 file changed, 649 insertions(+), 566 deletions(-) diff --git a/plugins/netatmo.py b/plugins/netatmo.py index ef31189..01f7236 100644 --- a/plugins/netatmo.py +++ b/plugins/netatmo.py @@ -1,276 +1,69 @@ -"""Netatmo Weather Station — Paper Aesthetic. +"""Netatmo Weather Station Plugin — WarmNews 4x4 Layout. -Design: Minimal, warm, intentional. -Inspired by paper / e-ink tablet interfaces (reMarkable, Kindle). -Every element earns its place. +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 -Farben: Warm-weiss (#FAF8F5), Tinte (#1A1A1A), ein Hauch Rot (#C0392B) für Alert. -Keine Graustufen-Hacks, keine übertriebenen Balken. +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) + +Auth: OAuth2 Authorization Code Flow (siehe tools/netatmo_auth.py). """ from __future__ import annotations -import os, sys, json, time, math, io, urllib.request, urllib.error, urllib.parse +import os, sys, json, time, math, io +import urllib.request, urllib.error, urllib.parse from datetime import datetime, timezone sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from plugins.base import Widget, render_error_banner from palette import ( - FG, BG, INFO, OK, WARN, ALERT, ORANGE, BLUE, RED, GREEN, WHITE, - measure, fit_font, centered_text, hbar, is_small, is_wide, is_tall, + FG, BG, INFO, OK, WARN, ALERT, ORANGE, BLUE, RED, GREEN, + measure, fit_font, is_small, is_wide, is_tall, ) -# ── Paper Farben ──────────────────────────────────────────────────────────── -# Warm-weiss wie echtes Papier. Tinte-Schwarz. Minimal. -PAPER_BG = (250, 248, 245) # warm off-white -INK = (26, 26, 26) # fast-schwarz, nicht #000 -INK_MID = (90, 90, 90) # secundär text -INK_LIGHT = (160, 160, 160) # muted / metadata -ALERT_RED = (192, 57, 43) # scharfes Rot nur für alerts -INFO_BLUE = ( 52, 101, 152) # gedämpftes Blau -OK_GREEN = ( 39, 128, 80) # gedämpftes Grün +# ── WarmNews Farbpalette ──────────────────────────────────────────────────── +PAPER_BG = (252, 248, 240) +INK = ( 18, 12, 8) +INK_MID = (130, 110, 90) +INK_LIGHT = (220, 210, 190) +FAINT = (235, 228, 215) +RED = (200, 50, 40) +GREEN = ( 50, 120, 60) +BLUE = ( 60, 70, 160) +INFO_BL = ( 50, 100, 160) +PURPLE = (100, 80, 150) +YELLOW = (180, 140, 40) -# Modul-Akzentfarben (einzelne, dezente Punke) -MODUL_COLOR_INDOOR = INFO_BLUE -MODUL_COLOR_OUTDOOR = ( 90, 140, 70) -MODUL_COLOR_WIND = (120, 90, 160) -MODUL_COLOR_RAIN = INFO_BLUE +BATT_LOW = (200, 50, 40) +BATT_MID = (220, 160, 30) +BATT_OK = (60, 140, 80) -# ── Font Helpers ───────────────────────────────────────────────────────────── -FONT_SERIF = None # wird in render() gesetzt falls verfügbar -FONT_SANS = None +# ── 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} -def _load_fonts(): - """Versuche Serif + Sans aus dem System zu laden.""" - global FONT_SERIF, FONT_SANS - paths_serif = [ - "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", - ] - paths_sans = [ - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - ] - from PIL import ImageFont - FONT_SERIF = None - for p in paths_serif: - if os.path.exists(p): - try: FONT_SERIF = ImageFont.truetype(p, 18); break - except: pass - FONT_SANS = None - for p in paths_sans: - if os.path.exists(p): - try: FONT_SANS = ImageFont.truetype(p, 18); break - except: pass - if FONT_SERIF is None: FONT_SERIF = ImageFont.load_default() - if FONT_SANS is None: FONT_SANS = ImageFont.load_default() - - -# ── Drawing Helpers ───────────────────────────────────────────────────────── - -def _text(draw, text, x, y, font, color=INK): - draw.text((x, y), text, font=font, fill=color) - - -def _centered(draw, text, x, y, w, h, font, color=INK): - """Text in Box (x,y,w,h) horizontal + vertikal zentriert.""" - tw, th = measure(draw, text, font) - px = x + max(0, (w - tw) // 2) - py = y + max(0, (h - th) // 2) - draw.text((px, py), text, font=font, fill=color) - return px, py - - -def _right(draw, text, x, y, w, font, color=INK): - """Text rechts-ausgerichtet in der Box.""" - tw, th = measure(draw, text, font) - draw.text((x + w - tw, y), text, font=font, fill=color) - - -def _divider_h(draw, x, y, w, color=INK_LIGHT, width=1): - draw.line((x, y, x + w, y), fill=color, width=width) - - -def _dot(draw, cx, cy, r, color): - """Kleiner gefüllter Kreis — der dezente Modul-Marker.""" - draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color) - - -# ── Value Renderer ──────────────────────────────────────────────────────────── - -def _big_val(draw, text, x, y, w, h, font_large, color=INK): - """Die eine wichtige Zahl. Gross, zentriert, atmet.""" - # probiere von 48 abwärts bis es passt - candidates = ["48", "36", "28", "24", "20"] - if isinstance(font_large, str): - for key in candidates: - try: - f = FONT_SERIF if FONT_SERIF else draw.font - # use fit_font path - break - except: pass - font = fit_font(draw, text, _fonts(), w - 16, h - 8, - candidates=["48", "36", "28", "24", "20"]) - _centered(draw, text, x, y, w, h, font, color) - - -def _label(draw, text, x, y, font=None, color=INK_MID): - if font is None: font = FONT_SANS or _default_font() - draw.text((x, y), text, font=font, fill=color) - - -def _meta(draw, text, x, y, font=None, color=INK_LIGHT): - if font is None: font = FONT_SANS or _default_font() - draw.text((x, y), text, font=font, fill=color) - - -def _default_font(): - from PIL import ImageFont - return ImageFont.load_default() - - -def _fonts(): - return { - "serif": FONT_SERIF, - "sans": FONT_SANS, - "default": FONT_SERIF or FONT_SANS or _default_font(), - } - - -def _f(key, size_hint=None): - """Font für Rolle `key`.""" - if key == "big": - f = FONT_SERIF or _default_font() - return f - elif key == "label": - return FONT_SANS or _default_font() - elif key == "meta": - return FONT_SANS or _default_font() - return FONT_SERIF or _default_font() - - -# ── Modul-Karten ─────────────────────────────────────────────────────────── - -def _card(draw, x, y, w, h, color_dot, title, value, unit, - meta=None, alert=False): - """Eine einzelne Messwert-Karte. - - Layout: - ● TITLE (tiny label, right) - VALUE UNIT (big serif, left) - meta text (small, below) - - Kein Rahmen, kein Fill. Nur Klarheit. - """ - pad = 10 - dot_r = 4 - - # Farb-Dot oben links - _dot(draw, x + pad, y + pad + 4, dot_r, color_dot) - - # Titel rechts oben - title_font = _f("label") - tw, th = measure(draw, title, title_font) - _right(draw, title, x, y + pad, w - pad, title_font, INK_MID) - - # Wert + Einheit - big_font = _f("big") - value_text = f"{value} {unit}".strip() - # passe font-grösse ein - font = fit_font(draw, value_text, _fonts(), w - 2 * pad, h // 2, - candidates=["36", "28", "24", "20", "16"]) - draw.text((x + pad, y + pad + 16), value_text, font=font, - fill=ALERT_RED if alert else INK) - - # Meta - if meta: - meta_font = _f("meta") - draw.text((x + pad, y + h - pad - 12), meta, - font=meta_font, fill=INK_LIGHT) - - -def _co2_card(draw, x, y, w, h, ppm, thresholds=None): - """CO₂ Karte — Wert + winzige Bar + Bewertungstext.""" - pad = 10 - - # Wert - co2_str = f"{int(ppm)}" - unit_str = "ppm" - big_text = f"{co2_str}" - font_big = fit_font(draw, big_text, _fonts(), w - 2 * pad, h // 2, - candidates=["32", "28", "24", "20", "16"]) - draw.text((x + pad, y + pad + 12), big_text, font=font_big, fill=INK) - font_unit = _f("label") - uw, _ = measure(draw, unit_str, font_unit) - draw.text((x + pad + measure(draw, big_text, font_big)[0] + 4, - y + pad + 20), unit_str, font=font_unit, fill=INK_MID) - - # Bar - pct = min(100, ppm / 2000 * 100) - bar_h = 6 - bar_y = y + h // 2 + 4 - bar_w = w - 2 * pad - draw.rectangle((x + pad, bar_y, x + pad + bar_w, bar_y + bar_h), - outline=INK_LIGHT, width=1) - fill_w = int(bar_w * pct / 100) - if fill_w > 0: - # Farbe nach Schwelle - if thresholds is None: - thresholds = [(50, OK_GREEN), (75, (200, 160, 0)), (100, ALERT_RED)] - col = OK_GREEN - for max_p, c in thresholds: - if pct <= max_p: - col = c; break - draw.rectangle((x + pad + 1, bar_y + 1, - x + pad + fill_w - 1, bar_y + bar_h - 1), - fill=col) - - # Label darunter - label = _co2_label(ppm) - font_meta = _f("meta") - lw, _ = measure(draw, label, font_meta) - draw.text((x + pad, y + h - pad - 12), label, - font=font_meta, fill=INK_LIGHT) - - -def _co2_label(ppm): - if ppm < 600: return "gut" - if ppm < 1000: return "moderat" - if ppm < 1500: return "hoch" - return "kritisch" - - -# ── Compass (Wind) ──────────────────────────────────────────────────────── - -def _compass(draw, cx, cy, r, deg): - """Einfache Windrose — nur N/S/E/W + Pfeil. Minimale Linien.""" - draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=INK_LIGHT, width=1) - for a in [270, 90, 180, 0]: - rad = math.radians(a - 90) - x1 = cx + (r - 4) * math.cos(rad); y1 = cy + (r - 4) * math.sin(rad) - x2 = cx + (r - 1) * math.cos(rad); y2 = cy + (r - 1) * math.sin(rad) - draw.line((x1, y1, x2, y2), fill=INK, width=1) - # Pfeil - rad_a = math.radians(deg - 90) - tip_x = cx + (r - 4) * math.cos(rad_a) - tip_y = cy + (r - 4) * math.sin(rad_a) - base = math.radians(150) - lx = cx + 7 * math.cos(rad_a + base); ly = cy + 7 * math.sin(rad_a + base) - rx = cx + 7 * math.cos(rad_a - base); ry = cy + 7 * math.sin(rad_a - base) - draw.polygon([(tip_x, tip_y), (lx, ly), (rx, ry)], fill=INK) - - -# ── API + Parsing (unverändert bis auf cosmetics) ───────────────────────── -TOKEN_URL = "https://api.netatmo.com/oauth2/token" -STATIONS_URL = "https://api.netatmo.com/api/getstationsdata" - -_TOKEN_CACHE: dict = {"access_token": None, "refresh_token": None, "expires_at": 0.0} - -def _token_payload(creds, grant="password", **extra): +def _token_payload(creds, grant="refresh_token", **extra): body = {"grant_type": grant, "client_id": creds["client_id"], - "client_secret": creds["client_secret"], "scope": "read_station"} + "client_secret": creds["client_secret"]} body.update(extra) return urllib.parse.urlencode(body).encode("utf-8") + def _post_form(url, body, timeout=10): req = urllib.request.Request(url, data=body, headers={ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", @@ -283,15 +76,15 @@ def _post_form(url, body, timeout=10): try: err = json.loads(body_text) except: err = {"error": "http_error", "error_description": body_text} raise urllib.error.HTTPError( - url, e.code, - err.get("error_description", err.get("error", e.reason or "")), + url, e.code, err.get("error_description", err.get("error", "")), e.headers, io.BytesIO(json.dumps(err).encode())) + def _obtain_tokens(creds): 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'{}')) + "Kein refresh_token — 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: @@ -300,66 +93,64 @@ 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) _TOKEN_CACHE["expires_at"] = time.time() + tok.get("expires_in", 10800) - 300 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} + 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) def _do(): req = urllib.request.Request( f"{STATIONS_URL}?get_favorites=false", - headers={"Authorization": f"Bearer {_TOKEN_CACHE['access_token']}", "Accept": "application/json"}) + headers={"Authorization": f"Bearer {_TOKEN_CACHE['access_token']}", + "Accept": "application/json"}) with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()) try: return _do() except urllib.error.HTTPError as e: if e.code != 401: raise - _TOKEN_CACHE["access_token"] = None; _TOKEN_CACHE["expires_at"] = 0 + _TOKEN_CACHE["access_token"] = None + _TOKEN_CACHE["expires_at"] = 0 _obtain_tokens(creds) return _do() -def _fmt_temp(t, unit="C"): - if t is None: return "—" - return f"{t:.1f}°{unit}" -def _fmt_wind(w, unit="kmh"): - if w is None: return "—" - if unit == "ms": return f"{w/3.6:.1f} m/s" - return f"{w:.0f} km/h" - -def _time_short(epoch_s): - if not epoch_s: return "" +def _get_forecast(lat, lon, days=3): + """Open-Meteo 3-Tage Forecast. Free, kein Key nötig.""" try: - dt = datetime.fromtimestamp(epoch_s, tz=timezone.utc).astimezone() - return dt.strftime("%H:%M") - except: return "" + 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") + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as r: + return json.loads(r.read()) + except Exception: + return None -def _temp_color(t): - if t is None: return INK - if t >= 30: return ALERT_RED - if t >= 22: return (200, 100, 30) - if t <= 5: return INFO_BLUE - return INK -def _co2_thresholds_from_spec(spec): - result, default_pcts = [], [600, 1000, 1500] - color_map = {"ok": OK_GREEN, "warn": (200, 160, 0), "alert": ALERT_RED, - "fg": INK, "green": OK_GREEN, "yellow": (200, 160, 0), "red": ALERT_RED} - for i, p in enumerate([s.strip() for s in spec.split(",") if s.strip()]): - name, val = (p.split("@", 1) if "@" in p else (p, str(default_pcts[i] if i < len(default_pcts) else 2000))) - try: ppm = float(val) - except: continue - result.append((min(100, ppm / 2000 * 100), color_map.get(name, OK_GREEN))) - return sorted(result, key=lambda x: x[0]) if result else [(50, OK_GREEN), (75, (200,160,0)), (100, ALERT_RED)] +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=""): body = api_response.get("body", api_response) devices = body.get("devices") if isinstance(body, dict) else None @@ -376,55 +167,74 @@ def _parse_stations(api_response, station_filter=""): if chosen: break if chosen is None: chosen = devices[0] main = {"type": "NAMain", "name": chosen.get("station_name", "Station"), - "data": chosen.get("dashboard_data") or {}, "place": chosen.get("place") or {}, + "data": chosen.get("dashboard_data") or {}, + "place": chosen.get("place") or {}, "reachable": chosen.get("reachable")} modules = [] for m in chosen.get("modules", []): modules.append({"type": m.get("type", ""), "name": m.get("module_name", ""), "id": m.get("_id"), "data": m.get("dashboard_data") or {}, - "battery_pct": m.get("battery_percent"), "reachable": m.get("reachable"), - "last_seen": m.get("last_seen")}) + "battery_pct": m.get("battery_percent"), + "reachable": m.get("reachable"), + "last_seen": m.get("last_seen"), + "rf_status": m.get("rf_status")}) return {"station_name": main["name"], "place": main.get("place", {}), "main": main, "modules": modules, "_fetched_at": time.time()} + def _first_module(modules, mtype): for m in modules: if m["type"] == mtype: return m return None -# ── Plugin ───────────────────────────────────────────────────────────────── +def _all_indoor(modules, main): + """Alle Indoor-Sensoren: Main + alle NAModule4.""" + indoor = [{"type": "NAMain", "name": main["name"], "tag": "M", + "data": main["data"], "battery_pct": None, "is_main": True}] + for m in modules: + if m["type"] == "NAModule4": + 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: + s["tag"] = str(tag_n) + tag_n += 1 + return indoor + +# ── Widget ───────────────────────────────────────────────────────────────── class Widget(Widget): name = "netatmo" label = "Netatmo Wetterstation" - description = "Indoor, Outdoor, Wind, Regen, CO₂. Paper-Design." + description = "Indoor (1+4 Sensoren), Outdoor, Wind, Regen, 3-Tage Forecast, 12h-Verlauf" category = "weather" config_schema = [ {"key": "station_filter", "label": "Station (leer = erste)", "type": "string", "default": ""}, - {"key": "show_indoor", "label": "Indoor anzeigen", "type": "bool", "default": True}, - {"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": "co2_thresholds","label": "CO₂-Schwellen (ppm)", + {"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)", "type": "string", "default": "ok@600,warn@1000,alert@1500"}, - {"key": "temp_unit", "label": "Temperatur", "type": "select", + {"key": "temp_unit", "label": "Temperatur", "type": "select", "options": ["C", "F"], "default": "C"}, - {"key": "wind_unit", "label": "Wind-Einheit", "type": "select", + {"key": "wind_unit", "label": "Wind-Einheit", "type": "select", "options": ["kmh", "ms"], "default": "kmh"}, - # Auth (secret, kein UI-Feld nötig) {"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_indoor": True, "show_outdoor": True, - "show_wind": True, "show_rain": True, - "show_compass": True, + "show_outdoor": True, "show_wind": True, "show_rain": True, + "show_compass": True, "show_forecast": True, "co2_thresholds": "ok@600,warn@1000,alert@1500", "temp_unit": "C", "wind_unit": "kmh", "client_id": "", "client_secret": "", "refresh_token": "", @@ -434,307 +244,580 @@ class Widget(Widget): 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. Bitte `tools/netatmo_auth.py`."} + 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: - body = "" - try: body = e.read().decode("utf-8", "ignore")[:120] - except: pass if e.code in (401, 403): - _TOKEN_CACHE["access_token"] = None; _TOKEN_CACHE["refresh_token"] = None + _TOKEN_CACHE["access_token"] = None + _TOKEN_CACHE["refresh_token"] = None _TOKEN_CACHE["expires_at"] = 0 - return {"_error": f"Auth fehlgeschlagen: {e.reason} — `tools/netatmo_auth.py` erneut."} - return {"_error": f"HTTP {e.code}: {body}".strip()} + return {"_error": f"Auth fehlgeschlagen: {e.reason} — tools/netatmo_auth.py erneut."} + return {"_error": f"HTTP {e.code}: {str(e.reason)[:60]}"} except Exception as e: - return {"_error": f"{type(e).__name__}: {str(e)[:80]}"} + 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 self.cfg("show_forecast", True): + place = parsed.get("place", {}) + coords = place.get("location") or [] + if len(coords) >= 2: + forecast = _get_forecast(coords[1], coords[0], days=3) + parsed["forecast"] = forecast return parsed def render(self, draw, fonts, x, y, w, h): - # Init fonts lazily - if FONT_SERIF is None: - _load_fonts() - d = self.fetch() if "_error" in d: - render_error_banner(draw, fonts, x, int, y, h, self.label, d["_error"]) + render_error_banner(draw, fonts, x, y, w, h, self.label, d["_error"]) return - - # Background draw.rectangle((x, y, x + w - 1, y + h - 1), fill=PAPER_BG) - if is_small(w, h): - self._render_small(draw, x, y, w, h, d) - elif is_wide(w, h): - self._render_wide(draw, x, y, w, h, d) - elif is_tall(w, h): - self._render_tall(draw, x, y, w, h, d) - else: - self._render_standard(draw, x, y, w, h, d) - - def _render_small(self, draw, x, y, w, h, d): - """1x1 — nur die wichtigste Zahl: Aussen-Temperatur oder Indoor-CO2.""" - pad = 10 - main = d["main"] - out = _first_module(d["modules"], "NAModule1") - mod = out if out else main - t = mod["data"].get("Temperature") - temp = _fmt_temp(t, self.cfg("temp_unit", "C")) - - # Wert - font = fit_font(draw, temp, _fonts(), w - 2 * pad, h - 2 * pad, - candidates=["48", "36", "28", "24", "20"]) - _centered(draw, temp, x, y, w, h - 20, font, _temp_color(t)) - - # Label unten - label = (out["name"][:12] if out else "Indoor") - _meta(draw, label, x + pad, y + h - pad - 12, color=INK_LIGHT) - - def _render_wide(self, draw, x, y, w, h, d): - """4x1 — horizontale Reihe aller Messwerte. Wie ein liniertes Notizblatt.""" - pad = 10 - unit = self.cfg("temp_unit", "C") - wind_unit = self.cfg("wind_unit", "kmh") - co2_thresh = _co2_thresholds_from_spec( - self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500")) - - main = d["main"] - modules = d["modules"] - out = _first_module(modules, "NAModule1") - - # Aktive Module - items = [] - if self.cfg("show_indoor", True): - items.append(("INDOOR", main["data"].get("Temperature"), f"°{unit}", - MODUL_COLOR_INDOOR, None)) - if out and self.cfg("show_outdoor", True): - items.append(("OUTDOOR", out["data"].get("Temperature"), f"°{unit}", - MODUL_COLOR_OUTDOOR, None)) - if self.cfg("show_wind", True): - wind = _first_module(modules, "NAModule2") - if wind: - items.append(("WIND", wind["data"].get("WindStrength"), - f" {wind_unit}", MODUL_COLOR_WIND, None)) - if self.cfg("show_rain", True): - rain = _first_module(modules, "NAModule3") - if rain: - rate = rain["data"].get("RainRate") or rain["data"].get("rain") or 0 - items.append(("RAIN", rate if rate else "—", " mm/h", - MODUL_COLOR_RAIN, None)) - - if not items: - _label(draw, "Keine Module", x + pad, y + h // 2 - 8, color=INK_LIGHT) + # 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 - n = len(items) - col_w = w // n - for i, (lbl, val, unit_str, dot_col, _) in enumerate(items): - cx = x + i * col_w - display_val = f"{val}{unit_str}" if isinstance(val, (int, float)) else str(val) - _card(draw, cx + 2, y + 4, col_w - 4, h - 8, - dot_col, lbl, - str(val) if not isinstance(val, str) else val, - unit_str if isinstance(val, (int, float)) else "") + self._render_full(draw, x, y, w, h, d) - def _render_tall(self, draw, x, y, w, h, d): - """1x4 — eine saubere vertikale Liste. Wie ein Logbuch-Eintrag.""" - pad = 10 + # ── Kompakt-Layout (kleine Slots) ──────────────────────────────────── + def _render_compact(self, draw, x, y, w, h, d): + 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) + + # ── Voll-Layout (4x4 = 800x480) ───────────────────────────────────── + def _render_full(self, draw, x, y, w, h, d): + # Spalten-Berechnung + total_w = w - 2 * PAD_OUTER + col_aussen = int(total_w * 0.36) + col_mitte = int(total_w * 0.20) + col_indoor = int(total_w * 0.24) + 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) + + # Daten extrahieren main = d["main"] modules = d["modules"] + 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") - items = [] - if self.cfg("show_indoor", True): - m = main - t = m["data"].get("Temperature") - items.append((MODUL_COLOR_INDOOR, "Indoor", _fmt_temp(t, unit), _temp_color(t))) - for mtype, col, lbl in [ - ("NAModule1", MODUL_COLOR_OUTDOOR, "Outdoor"), - ("NAModule2", MODUL_COLOR_WIND, "Wind"), - ("NAModule3", MODUL_COLOR_RAIN, "Regen"), - ]: - if not self.cfg(f"show_{lbl.lower()}", True): continue - m = _first_module(modules, mtype) - if not m: continue - if mtype == "NAModule1": - val = _fmt_temp(m["data"].get("Temperature"), unit) - elif mtype == "NAModule2": - ws = m["data"].get("WindStrength") - val = _fmt_wind(ws, self.cfg("wind_unit", "kmh")) - else: - r = m["data"].get("RainRate") or m["data"].get("rain") or 0 - val = f"{r:.1f} mm/h" if r else "trocken" - items.append((col, lbl, val, INK)) + # Aussen + if out: + self._draw_aussen(draw, xa, gy, col_aussen, gh, main, out, modules) - n = max(1, len(items)) - row_h = (h - 2 * pad) // n + # 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) - for i, (dot_col, lbl, val, val_col) in enumerate(items): - ry = y + pad + i * row_h - if ry + row_h > y + h: break - _dot(draw, x + pad, ry + row_h // 2, 4, dot_col) - # Label - _label(draw, lbl, x + pad + 14, ry + 4, color=INK_MID) - # Wert rechts - _right(draw, val, x, ry + 4, w - pad, _f("big"), val_col) - # Trennlinie - if i < len(items) - 1: - _divider_h(draw, x + pad, ry + row_h - 2, w - 2 * pad) + # Indoor + self._draw_indoor(draw, xr, gy, col_indoor, gh, indoor) - def _render_standard(self, draw, x, y, w, h, d): - """2x2+ — das volle Layout. Klar, warm, absichtlich. + # Forecast + if forecast: + self._draw_forecast(draw, xf, gy, col_fc, gh, forecast) - Aufteilung: - – Oben links: Indoor (Temperatur + CO2) - – Oben rechts: Outdoor Temperatur - – Unten links: Wind (Geschwindigkeit + Compass) - – Unten rechts: Regen - – Ganz oben: Station-Name + Uhrzeit - """ - pad = 12 - unit = self.cfg("temp_unit", "C") + # ── 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) + + # ── 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, 252, 245)) + 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 = 150 + font_big = self._fit_size(draw, big_text, + w - 2 * SAFE_PAD, big_h - 8, + candidates=["150", "130", "110", "95"]) + 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) + pressure = main["data"].get("Pressure") or main["data"].get("AbsolutePressure") + trend = main["data"].get("Pressure_trend", "stable") + 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), + ] + 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, fill_color=(225, 240, 225)) + # 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) + + # ── Wind ──────────────────────────────────────────────────────────── + def _draw_wind(self, draw, x, y, w, h, wind): + draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 252, 245)) + 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") - co2_thresh = _co2_thresholds_from_spec( - self.cfg("co2_thresholds", "ok@600,warn@1000,alert@1500")) + 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) - main = d["main"] - modules = d["modules"] - out = _first_module(modules, "NAModule1") - wind = _first_module(modules, "NAModule2") - rain = _first_module(modules, "NAModule3") + # ── Regen ─────────────────────────────────────────────────────────── + def _draw_regen(self, draw, x, y, w, h, rain): + draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 252, 245)) + 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) - # ── Header: Station + Update-Zeit ── - hdr_font = _f("label") - station = d["station_name"] - if len(station) > 30: station = station[:29] + "…" - _label(draw, station, x + pad, y + pad, color=INK_MID) - last = main.get("data", {}).get("time_utc") or main.get("last_message") or 0 - ts = _time_short(last) - if ts: - _right(draw, ts, x, y + pad, w - pad, hdr_font, INK_LIGHT) + # ── Indoor ────────────────────────────────────────────────────────── + def _draw_indoor(self, draw, x, y, w, h, indoor): + draw.rectangle((x, y, x + w - 1, y + h - 1), fill=(255, 252, 245)) + 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 + 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 + 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) - body_y = y + pad + 28 - body_h = h - (body_y - y) - pad - half_w = (w - 3 * pad) // 2 - half_h = (body_h - pad) // 2 + # ── 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, 252, 245)) + 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) + 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) + for i, day_str in enumerate(days): + fr = content_y + i * row_h + if i > 0: + draw.line((x + 12, fr, x + w - 12, fr), fill=FAINT, width=1) + # Tag (Wochentag-Kurz) + 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 + try: + i_day = days.index(day_str) + 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) - # ── Indoor (oben links) ── - in_t = main["data"].get("Temperature") - in_h = main["data"].get("Humidity") - in_co2 = main["data"].get("CO2") - in_data = main["data"] + # ── Helpers ──────────────────────────────────────────────────────── + _BASE_FONT = None # gecachte Sans-Basis (size=14) - # Karte mit CO2 integriert - card_x = x + pad - card_y = body_y - card_w = half_w - card_h = half_h + 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 - # Trennlinie über top - _divider_h(draw, x + pad, card_y - 4, w - 2 * pad, INK_LIGHT) + 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 - # Indoor-Wert gross - in_val = _fmt_temp(in_t, unit) - font_big = fit_font(draw, in_val, _fonts(), card_w - 2 * pad, card_h // 2, - candidates=["40", "32", "28", "24", "20"]) - draw.text((card_x + pad, card_y + 8), in_val, font=font_big, fill=_temp_color(in_t)) + 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) - # Feuchtigkeit rechts oben in der Karte - if in_h is not None: - hum_str = f"💧 {int(in_h)}%" - _right(draw, hum_str, card_x, card_y + pad, card_w - pad, _f("label"), INK_MID) + 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) - # CO2 Balken darunter - if in_co2 is not None: - co2_str = f"CO₂ {int(in_co2)} ppm" - _meta(draw, co2_str, card_x + pad, card_y + card_h // 2 + 4, color=INK_LIGHT) - # Bar - pct = min(100, in_co2 / 2000 * 100) - bar_w = card_w - 2 * pad - bar_h = 5 - bar_y = card_y + card_h // 2 + 18 - draw.rectangle((card_x + pad, bar_y, card_x + pad + bar_w, bar_y + bar_h), - outline=INK_LIGHT, width=1) - fill_w = int(bar_w * pct / 100) - if fill_w > 1: - col = INK_LIGHT - for max_p, c in co2_thresh: - if pct <= max_p: col = c; break - draw.rectangle((card_x + pad + 1, bar_y + 1, - card_x + pad + fill_w - 1, bar_y + bar_h - 1), fill=col) + 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) - # Station label unten - _meta(draw, "Indoor", card_x + pad, card_y + card_h - pad - 12, color=INK_LIGHT) + 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) - # ── Outdoor (oben rechts) ── - out_x = x + pad + half_w + pad - if out and self.cfg("show_outdoor", True): - out_t = out["data"].get("Temperature") - out_h = out["data"].get("Humidity") - out_val = _fmt_temp(out_t, unit) - font_big = fit_font(draw, out_val, _fonts(), half_w - 2 * pad, half_h // 2, - candidates=["40", "32", "28", "24", "20"]) - draw.text((out_x + pad, body_y + 8), out_val, font=font_big, - fill=_temp_color(out_t)) - if out_h is not None: - hum_str = f"💧 {int(out_h)}%" - _right(draw, hum_str, out_x, body_y + pad, half_w - pad, _f("label"), INK_MID) - _meta(draw, "Outdoor", out_x + pad, body_y + half_h - pad - 12, color=INK_LIGHT) + 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: - _label(draw, "—", out_x + half_w // 2 - 10, body_y + half_h // 2 - 10, color=INK_LIGHT) + draw.line((x, cy, x + size, cy), fill=color, width=2) - # ── Wind (unten links) ── - wind_x = x + pad - wind_y = body_y + half_h + pad - wind_w = half_w - wind_h = half_h + 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) - if wind and self.cfg("show_wind", True): - ws = wind["data"].get("WindStrength") - gust = wind["data"].get("GustStrength") - wd = wind["data"].get("WindAngle") + 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) - wind_str = _fmt_wind(ws, wind_unit) - font_big = fit_font(draw, wind_str, _fonts(), wind_w - 2 * pad, wind_h // 2, - candidates=["32", "28", "24", "20"]) - draw.text((wind_x + pad, wind_y + 4), wind_str, font=font_big, fill=INK) + def _get_12h_history(self, data): + """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. + """ + hist_str = data.get("Temp_history") or data.get("temp_history") + if hist_str: + try: + vals = [float(v) for v in hist_str.split(";") if v] + if len(vals) >= 4: + 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)] - if gust is not None: - gust_str = f" Bö {int(gust)}" - _meta(draw, gust_str, wind_x + pad, wind_y + wind_h // 2 + 6, color=INK_MID) + def _co2_color(self, co2, thresholds): + for max_ppm, color in thresholds: + if co2 <= max_ppm: + return color + return RED - # Compass - if wd is not None and self.cfg("show_compass", True): - cr = min(24, wind_h // 3) - cx_c = wind_x + wind_w - cr - pad - cy_c = wind_y + wind_h // 2 - _compass(draw, cx_c, cy_c, cr, wd) - - _meta(draw, "Wind", wind_x + pad, wind_y + wind_h - pad - 12, color=INK_LIGHT) - else: - _label(draw, "—", wind_x + wind_w // 2 - 10, wind_y + wind_h // 2 - 10, color=INK_LIGHT) - - # ── Regen (unten rechts) ── - rain_x = x + pad + half_w + pad - rain_y = wind_y - rain_w = half_w - rain_h = half_h - - if rain and self.cfg("show_rain", True): - rate = rain["data"].get("RainRate") or rain["data"].get("rain") or 0 - cur_str = f"{rate:.1f} mm/h" if rate else "trocken" - font_big = fit_font(draw, cur_str, _fonts(), rain_w - 2 * pad, rain_h // 2, - candidates=["32", "28", "24", "20"]) - draw.text((rain_x + pad, rain_y + 4), cur_str, font=font_big, - fill=INFO_BLUE if rate else INK) - - # Summen - 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 - _meta(draw, f"1h {h1:.1f} · 24h {h24:.1f}", - rain_x + pad, rain_y + rain_h // 2 + 6, color=INK_MID) - _meta(draw, "Regen", rain_x + pad, rain_y + rain_h - pad - 12, color=INK_LIGHT) - else: - _label(draw, "—", rain_x + rain_w // 2 - 10, rain_y + rain_h // 2 - 10, color=INK_LIGHT) + def _co2_thresholds(self): + """Parse co2_thresholds config string → [(ppm, color), ...] sorted.""" + 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()]): + name, _, val = p.partition("@") + try: ppm = float(val) + except ValueError: continue + result.append((ppm, color_map.get(name.lower(), GREEN))) + if not result: + return [(600, GREEN), (1000, YELLOW), (1500, RED)] + return sorted(result, key=lambda x: x[0])