* 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).
741 lines
30 KiB
Python
741 lines
30 KiB
Python
"""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)
|