Files
epaper-dashboard/plugins/netatmo.py
T
Hermes fe1c06b306 FEAT-NETATMO-02: Komplettes Redesign auf 4x4 WarmNews-Layout
* Indoor: 5 Sensoren (1x Main + 4x NAModule4) prominent mit Tag, Temp, CO2-Bar
* Outdoor: Big-Temp + 12h-Verlaufsgraph + Min/Max/Luftfeuchte
* Wind/Regen: je mit Mini-Verlauf + 1h/24h-Bars
* Forecast: 3 Tage mit Min/Max + Regen-Bar
* Batterie-Indikatoren mit %-Anzeige an Aussen, Wind, Regen und allen 5 Indoor-Sensoren
* CO2-Bars mit ppm-Wert + Grün/Gelb/Rot-Schwellen
* Sans-Schrift only, WarmNews-Farbpalette (warm-weiss, Tinte-schwarz, Akzent-Farben)
* Subtile Box-Rahmen + Accent-Strips für visuelle Trennung

designbase.py neu hinzugefügt als Template-Modul.

tools/netatmo_auth.py: Auth-Flow leicht angepasst (Redirect-URI auf Pi-IP statt localhost).
2026-08-29 20:24:03 +04:00

741 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)