"""Netatmo Weather Station — Paper Aesthetic. Design: Minimal, warm, intentional. Inspired by paper / e-ink tablet interfaces (reMarkable, Kindle). Every element earns its place. Farben: Warm-weiss (#FAF8F5), Tinte (#1A1A1A), ein Hauch Rot (#C0392B) für Alert. Keine Graustufen-Hacks, keine übertriebenen Balken. """ from __future__ import annotations import os, sys, json, time, math, io, 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, ) # ── 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 # 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 # ── Font Helpers ───────────────────────────────────────────────────────────── FONT_SERIF = None # wird in render() gesetzt falls verfügbar FONT_SANS = None 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): body = {"grant_type": grant, "client_id": creds["client_id"], "client_secret": creds["client_secret"], "scope": "read_station"} 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", "Accept": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read()) except urllib.error.HTTPError as e: body_text = e.read().decode("utf-8", "ignore")[:200] 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 "")), 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'{}')) try: tok = _post_form(TOKEN_URL, _token_payload(creds, "refresh_token", refresh_token=rt)) except urllib.error.HTTPError as e: if e.code in (400, 401): _TOKEN_CACHE["access_token"] = None _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'{}')) 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} 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"}) 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 _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 "" try: dt = datetime.fromtimestamp(epoch_s, tz=timezone.utc).astimezone() return dt.strftime("%H:%M") except: return "" 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 _parse_stations(api_response, station_filter=""): body = api_response.get("body", api_response) devices = body.get("devices") if isinstance(body, dict) else None if not devices: return None chosen = None if station_filter: sf = station_filter.strip().lower() for dev in devices: if sf in (dev.get("station_name") or "").lower(): chosen = dev; break for m in dev.get("modules", []): if sf in (m.get("module_name") or "").lower(): chosen = dev; break 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 {}, "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")}) 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 ───────────────────────────────────────────────────────────────── class Widget(Widget): name = "netatmo" label = "Netatmo Wetterstation" description = "Indoor, Outdoor, Wind, Regen, CO₂. Paper-Design." 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)", "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"}, # 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, "co2_thresholds": "ok@600,warn@1000,alert@1500", "temp_unit": "C", "wind_unit": "kmh", "client_id": "", "client_secret": "", "refresh_token": "", } def fetch(self): 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`."} 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["expires_at"] = 0 return {"_error": f"Auth fehlgeschlagen: {e.reason} — `tools/netatmo_auth.py` erneut."} return {"_error": f"HTTP {e.code}: {body}".strip()} except Exception as e: return {"_error": f"{type(e).__name__}: {str(e)[:80]}"} parsed = _parse_stations(data, self.cfg("station_filter", "")) if not parsed: return {"_error": "Keine Station gefunden."} 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"]) 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) 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 "") def _render_tall(self, draw, x, y, w, h, d): """1x4 — eine saubere vertikale Liste. Wie ein Logbuch-Eintrag.""" pad = 10 unit = self.cfg("temp_unit", "C") main = d["main"] modules = d["modules"] 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)) n = max(1, len(items)) row_h = (h - 2 * pad) // n 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) def _render_standard(self, draw, x, y, w, h, d): """2x2+ — das volle Layout. Klar, warm, absichtlich. 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") 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") wind = _first_module(modules, "NAModule2") rain = _first_module(modules, "NAModule3") # ── 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) body_y = y + pad + 28 body_h = h - (body_y - y) - pad half_w = (w - 3 * pad) // 2 half_h = (body_h - pad) // 2 # ── 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"] # Karte mit CO2 integriert card_x = x + pad card_y = body_y card_w = half_w card_h = half_h # Trennlinie über top _divider_h(draw, x + pad, card_y - 4, w - 2 * pad, INK_LIGHT) # 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)) # 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) # 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) # Station label unten _meta(draw, "Indoor", card_x + pad, card_y + card_h - pad - 12, color=INK_LIGHT) # ── 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) else: _label(draw, "—", out_x + half_w // 2 - 10, body_y + half_h // 2 - 10, color=INK_LIGHT) # ── Wind (unten links) ── wind_x = x + pad wind_y = body_y + half_h + pad wind_w = half_w wind_h = half_h if wind and self.cfg("show_wind", True): ws = wind["data"].get("WindStrength") gust = wind["data"].get("GustStrength") wd = wind["data"].get("WindAngle") 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) 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) # 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)