Files
epaper-dashboard/plugins/base.py
T
Patrik 5a83826aa5 FEAT-NETATMO-06: V1 Status-Update Redesign + shared helpers foundation
Netatmo-Plugin komplett auf V1 Status-Update Layout umgebaut:
- 4-Sektionen: Aussen / Wind+Regen / Indoor / Vorhersage
- Echte 12h-History fuer MIN/MAX (vorher estimated +-2/+-4)
- Dynamische Forecast-Daten (datetime.now() + timedelta)
- DE strings durchgaengig (AUSSEN, FEUCHTE, DRUCK, Bo, HEUTE, Boerse, ...)
- 7-Farben ACeP palette (kein Greyscale, kein Anti-Alias)
- Indoor-Sensoren: rote Tag-Box + 22pt+ Temp + CO2-Bar + Batt aligned
- Chart mit 2-Tick Y-Grid (min/max), 12h Linie in Gruen
- Wetter-Icons (WMO codes) in Vorhersage, Regen-Drop-Icon pro Tag

Foundation / shared helpers:
- palette.py: BATT_*, DE_WEEKDAYS/MONTHS, WMO_ICONS, format_*,
              POWER_DEFAULTS, temp_color, co2_color, batt_color
- plugins/base.py: draw_battery, draw_compass, draw_trend,
                   draw_line_chart, draw_mini_bar, draw_weather_icon,
                   draw_deepsleep_indicator, draw_zzz_indicator,
                   _fit_size, _get_base_sans, de_* formatters
- designbase.py: THEMES dict (light/dark/retro/mag), Layout templates
                 (bereits importierbar)

Tools:
- tools/render_mockup.py: 3 design-Varianten Mockup-Generator
- tools/verify_netatmo.py: Self-verification mit dynamic mock data
2026-08-31 00:47:40 +02:00

545 lines
20 KiB
Python

"""Plugin ABC für das Waveshare 7.3" Dashboard.
Ein Plugin ist ein Python-Modul in /plugins/, das eine Klasse Widget
exportiert. Die Klasse wird beim Start dynamisch geladen und in der
Admin-UI zur Auswahl angeboten.
Dieses Modul enthaelt auch Shared Render-Helpers (Batterie, Compass,
Trend-Pfeile, Wetter-Icons, Charts), die von allen Plugins einheitlich
genutzt werden — damit sehen Batterie-Icons, Windrosen, etc. auf dem
Display konsistent aus.
Minimal-Beispiel siehe plugins/hello.py.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
import math
import time
import urllib.request
import urllib.error
import socket as _socket
from PIL import ImageDraw, ImageFont
from palette import (
FG, BG, BLACK, WHITE, GREEN, BLUE, RED, YELLOW, ORANGE,
BATT_LOW, BATT_MID, BATT_OK, batt_color,
measure, text_wh,
)
# ============================================================================
# Fetch-Helper mit Retry-Logik
# ============================================================================
def fetch_with_retry(fn, retries: int = 3, delay_s: float = 0.5):
"""Ruft fn() bis zu retries Mal auf. Bei 3x fail gibt es None + error_string."""
last_err = None
for attempt in range(1, retries + 1):
try:
return fn(), None
except Exception as e:
last_err = e
if attempt < retries:
time.sleep(delay_s * attempt)
return None, _format_error(last_err)
def fetch_url(url: str, headers=None, timeout: int = 10, retries: int = 3):
"""HTTP-GET mit Retry. Returns (data, error)."""
def _do():
req = urllib.request.Request(url, headers=headers or {})
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read()
data, err = fetch_with_retry(_do, retries=retries, delay_s=0.5)
if err:
return None, err
return data, None
def _format_error(exc):
"""Kurze, menschenlesbare Fehlermeldung."""
if isinstance(exc, urllib.error.HTTPError):
return f"HTTP {exc.code} {exc.reason or ''}".strip()
if isinstance(exc, urllib.error.URLError):
return f"URL-Fehler: {exc.reason}"
if isinstance(exc, _socket.timeout):
return "Timeout (keine Antwort)"
if isinstance(exc, _socket.gaierror):
return f"DNS-Fehler: {exc}"
if isinstance(exc, ConnectionRefusedError):
return "Connection refused"
if isinstance(exc, ConnectionResetError):
return "Connection reset"
if isinstance(exc, TimeoutError):
return "Timeout"
if isinstance(exc, (KeyError, ValueError, TypeError)):
return f"Datenfehler: {str(exc)[:60]}"
return f"{type(exc).__name__}: {str(exc)[:60]}"
# ============================================================================
# Error-Banner für fehlgeschlagene API-Plugins
# ============================================================================
ERROR_COLORS = {
"icon": (200, 0, 0),
"title": (180, 0, 0),
"subtitle": (60, 60, 60),
"muted": (120, 120, 120),
"bg": (245, 244, 240),
}
def render_error_banner(draw, fonts, x, y, w, h, plugin_name, error_msg,
last_success=None):
"""Zeichnet ein auffaelliges Fehler-Schild in den Slot.
Layout: Grosses rotes "!" Icon links, Plugin-Name + Fehler rechts.
"""
from palette import measure
pad = 12
# Border rot
draw.rectangle((x, y, x + w - 1, y + h - 1),
outline=ERROR_COLORS["icon"], width=3)
icon_size = min(h - 2 * pad, 100)
if icon_size < 30:
icon_size = 30
cx = x + pad
cy = y + pad
draw.rectangle((cx, cy, cx + icon_size - 1, cy + icon_size - 1),
fill=ERROR_COLORS["icon"])
font_icon = fonts.get(str(min(icon_size, 80))) or fonts.get("60") or fonts.get("default")
tw, th = measure(draw, "!", font_icon)
draw.text((cx + (icon_size - tw) // 2 - 2, cy + (icon_size - th) // 2 - 4),
"!", font=font_icon, fill=ERROR_COLORS["bg"])
tx = cx + icon_size + 12
tw_avail = w - (tx - x) - pad
title = f"Plugin: {plugin_name}"
font_title = fonts.get("24") or fonts.get("20") or fonts.get("default")
for try_font in [font_title, fonts.get("20"), fonts.get("16")]:
tw, _ = measure(draw, title, try_font)
if tw <= tw_avail or try_font is fonts.get("16"):
font_title = try_font
break
draw.text((tx, cy + 2), title, font=font_title, fill=ERROR_COLORS["title"])
sub_y = cy + 30
sub = f"API nicht erreichbar: {error_msg}"
font_sub = fonts.get("20") or fonts.get("16")
if measure(draw, sub, font_sub)[0] > tw_avail:
words = sub.split()
lines, cur = [], ""
for w in words:
cand = (cur + " " + w).strip()
if measure(draw, cand, font_sub)[0] <= tw_avail:
cur = cand
else:
if cur: lines.append(cur)
cur = w
if cur: lines.append(cur)
for i, ln in enumerate(lines[:3]):
draw.text((tx, sub_y + i * 22), ln, font=font_sub, fill=ERROR_COLORS["subtitle"])
else:
draw.text((tx, sub_y), sub, font=font_sub, fill=ERROR_COLORS["subtitle"])
if last_success:
font_meta = fonts.get("16") or fonts.get("default")
meta_y = y + h - 22
draw.text((tx, meta_y), f"Letzte Aktualisierung: {last_success}",
font=font_meta, fill=ERROR_COLORS["muted"])
class Widget(ABC):
# ---- Metadaten ----
name: str = ""
label: str = ""
description: str = ""
category: str = "general"
config_schema: list[dict] = []
default_config: dict = {}
def __init__(self, config: dict):
merged = dict(self.default_config)
merged.update(config or {})
self.config = merged
self.fonts: dict | None = None # wird vom Dashboard gesetzt vor render()
self.theme: Any = None # Theme-Instanz (von designbase), optional
def set_fonts(self, fonts: dict):
"""Wird vom Dashboard vor render() aufgerufen. Plugins koennen
dann self.fonts["24"] etc. nutzen statt selbst Fonts zu laden."""
self.fonts = fonts
@abstractmethod
def fetch(self) -> dict:
"""Daten holen.
Empfohlen: nutze `fetch_with_retry(self._fetch_internal)` für 3x retry.
Bei 3x fail: gib {"_error": "..."} zurück statt zu crashen.
"""
@abstractmethod
def render(self, draw, fonts, x: int, y: int, w: int, h: int) -> None:
"""Zeichne in den gegebenen Slot."""
def on_load(self) -> None:
pass
def on_unload(self) -> None:
pass
def cfg(self, key: str, default: Any = None) -> Any:
return self.config.get(key, default)
def all_widget_classes() -> list[type[Widget]]:
"""Lade alle Plugin-Klassen aus dem plugins/-Ordner."""
import os, importlib, pkgutil
plugins_pkg = os.path.join(os.path.dirname(__file__), "..", "plugins")
plugins_pkg = os.path.abspath(plugins_pkg)
classes: list[type[Widget]] = []
for _, modname, _ in pkgutil.iter_modules([plugins_pkg]):
mod = importlib.import_module(f"plugins.{modname}")
cls = getattr(mod, "Widget", None)
if cls and isinstance(cls, type) and issubclass(cls, Widget) and cls is not Widget:
classes.append(cls)
classes.sort(key=lambda c: c.label or c.name)
return classes
# ============================================================================
# Shared Render Helpers — Single Source of Truth fuer Batterie, Compass,
# Trend, Wetter-Icons, Charts. Alle Plugins nutzen diese Funktionen damit
# das Display konsistent aussieht.
# ============================================================================
# Sans-Schriftart aus dem System (lazy-loaded, gecacht)
_BASE_SANS_PATHS = [
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/seguisb.ttf",
]
_BASE_SANS = None
def _get_base_sans() -> ImageFont.FreeTypeFont | None:
"""Lazy-load Sans-Basis-Font (size 14) fuer die Helper."""
global _BASE_SANS
if _BASE_SANS is not None:
return _BASE_SANS
import os
for p in _BASE_SANS_PATHS:
if os.path.exists(p):
try:
_BASE_SANS = ImageFont.truetype(p, 14)
return _BASE_SANS
except Exception:
continue
try:
_BASE_SANS = ImageFont.load_default()
except Exception:
_BASE_SANS = None
return _BASE_SANS
def _fit_size(draw, text: str, max_w: int, max_h: int,
candidates: list[str | int] | None = None) -> ImageFont.FreeTypeFont:
"""Iteriere durch Font-Groessen, waehle die erste die passt.
Verwendet das gleiche Sans-Family wie die Basis-Schrift.
"""
if candidates is None:
candidates = ["48", "36", "32", "28", "24", "20", "16", "14", "12", "10"]
base = _get_base_sans()
base_path = getattr(base, "path", None) if base else None
for sz in candidates:
try:
sz_int = int(sz)
if base_path:
f = ImageFont.truetype(base_path, sz_int)
else:
f = base
except Exception:
continue
tw, th = measure(draw, text, f)
if tw <= max_w and th <= max_h:
return f
return base
def text_in_box(draw, text, x, y, w, h, font, color, align: str = "left"):
"""Zeichnet Text in reservierten Bereich (x,y,w,h). Garantiert keine
Ueberlappung mit anderen Elementen wenn jeder seinen eigenen Bereich hat.
align: 'left' (default), 'right', 'center'
"""
tw, th = measure(draw, text, font)
if tw > w or th > h:
return False # wuerde ueberlaufen
if align == "left":
draw.text((x, y + (h - th) // 2), text, font=font, fill=color)
elif align == "right":
draw.text((x + w - tw, y + (h - th) // 2), text, font=font, fill=color)
elif align == "center":
draw.text((x + (w - tw) // 2, y + (h - th) // 2), text, font=font, fill=color)
return True
# ---- Batterie-Icon (Single Source of Truth) ------------------------------
def draw_battery(draw, x, y, pct: int | None, w: int = 22, h: int = 10,
with_text: bool = True, text_color: tuple | None = None):
"""Zeichnet ein konsistentes Batterie-Icon.
Args:
pct: Prozent (0-100), None → noop
w, h: Icon-Groesse (ohne Tip)
with_text: ob "%" daneben gezeichnet werden soll
text_color: falls None, automatisch nach Schwellen (LOW/MID/OK)
"""
if pct is None:
return 0
pct = max(0, min(100, pct))
color = text_color or batt_color(pct)
# Body
draw.rectangle((x, y, x + w - 3, y + h - 1), outline=FG, width=1)
# Tip
draw.rectangle((x + w - 2, y + 3, x + w, y + h - 3), fill=FG)
# Fill
fill_w = max(0, int((w - 4) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 2, y + 2, x + 2 + fill_w, y + h - 2), fill=color)
used = w
if with_text:
f = _fit_size(draw, f"{pct}%", 30, h, candidates=["10", "9", "8"])
text_in_box(draw, f"{pct}%", x + w + 3, y - 1, 30, h + 2, f, color, "left")
used += 30
return used
# ---- Trend-Pfeil (up / down / stable) ------------------------------------
def draw_trend(draw, x, y, trend: str, size: int = 10, color: tuple | None = None):
"""Pfeil nach oben/unten, oder waagerechter Strich fuer 'stable'."""
color = color or FG
cx, cy = x + size // 2, y + size // 2
if trend == "up":
draw.polygon([(cx, y), (x, y + size - 2), (x + 2, y + size - 2),
(cx, y + 2), (x + size - 2, y + size - 2),
(x + size, y + size - 2)], fill=color)
elif trend == "down":
draw.polygon([(cx, y + size), (x, y + 2), (x + 2, y + 2),
(cx, y + size - 2), (x + size - 2, y + 2),
(x + size, y + 2)], fill=color)
else: # stable
draw.line((x, cy, x + size, cy), fill=color, width=2)
# ---- Windrose / Compass --------------------------------------------------
def draw_compass(draw, cx, cy, r: int, deg: float, color: tuple = BLUE,
with_labels: bool = False):
"""Zeichnet eine Windrose.
Args:
deg: 0=Nord, 90=Ost, 180=Sued, 270=West
with_labels: N/O/S/W Beschriftung (nur fuer grosse Varianten sinnvoll)
"""
if r < 4:
return
# Aeusserer Kreis
draw.ellipse((cx - r, cy - r, cx + r, cy + r), outline=FG, width=1)
# Cardinal Ticks
for a in [270, 90, 180, 0]: # N/O/S/W in Wetter-Konvention
rd = math.radians(a - 90)
x1 = cx + (r - 6) * math.cos(rd)
y1 = cy + (r - 6) * math.sin(rd)
x2 = cx + (r - 2) * math.cos(rd)
y2 = cy + (r - 2) * math.sin(rd)
draw.line((x1, y1, x2, y2), fill=FG, width=1)
# Pfeil
rad = math.radians(deg - 90)
tx = cx + (r - 4) * math.cos(rad)
ty = cy + (r - 4) * math.sin(rad)
draw.line((cx, cy, tx, ty), fill=color, width=2 if r < 16 else 3)
draw.ellipse((cx - 2, cy - 2, cx + 2, cy + 2), fill=color)
# Optionale N/O/S/W Labels
if with_labels and r >= 14:
for letter, a in [("N", 270), ("O", 0), ("S", 90), ("W", 180)]:
rd = math.radians(a - 90)
lx = cx + (r + 7) * math.cos(rd) - 4
ly = cy + (r + 7) * math.sin(rd) - 6
f = _fit_size(draw, letter, 10, 10, candidates=["10", "9", "8"])
draw.text((lx, ly), letter, font=f, fill=FG)
# ---- Wetter-Icon (7 Wetter-Zustaende) ------------------------------------
def draw_weather_icon(draw, x, y, size: int, code: int | str):
"""Zeichnet ein Wetter-Icon in (x, y, size, size) Box.
Akzeptiert WMO-Code (int) oder symbol-string ('sun'/'cloud'/'rain'/'storm'/'snow'/'fog'/'partly').
Verwendet nur die 7 ACeP-Farben.
"""
from palette import wmo_to_icon
sym = wmo_to_icon(code) if isinstance(code, int) else code
cx, cy = x + size // 2, y + size // 2
r = max(3, size // 3)
if sym in ("sun", "clear"):
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
for ang in range(0, 360, 45):
rad = math.radians(ang)
x1 = cx + int((r + 2) * math.cos(rad))
y1 = cy + int((r + 2) * math.sin(rad))
x2 = cx + int((r + 5) * math.cos(rad))
y2 = cy + int((r + 5) * math.sin(rad))
draw.line((x1, y1, x2, y2), fill=FG, width=1)
elif sym == "partly":
draw.ellipse((cx - r // 2 - 2, cy - r, cx + r // 2, cy + 1), fill=YELLOW)
cw = size // 2
ch = size // 4
draw.ellipse((cx - cw // 2, cy, cx + cw // 2, cy + ch),
fill=WHITE, outline=FG, width=1)
elif sym == "cloud":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch // 2, cx + cw // 2, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
draw.ellipse((cx - cw // 3, cy - ch, cx + cw // 3, cy + ch // 2),
fill=WHITE, outline=FG, width=2)
elif sym == "rain":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
draw.line((cx + dx, cy + ch, cx + dx, cy + ch + 5), fill=BLUE, width=2)
elif sym == "storm":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
draw.polygon([(cx - 3, cy + ch - 1), (cx + 1, cy + ch + 3),
(cx - 1, cy + ch + 3), (cx + 3, cy + ch + 8)],
fill=YELLOW)
elif sym == "snow":
cw = int(size * 0.7)
ch = size // 3
draw.ellipse((cx - cw // 2, cy - ch - 1, cx + cw // 2, cy + ch // 2 - 1),
fill=WHITE, outline=FG, width=2)
for dx in (-4, 0, 4):
for dy in (0, 5):
draw.line((cx + dx - 2, cy + ch + dy, cx + dx + 2, cy + ch + dy),
fill=FG, width=1)
draw.line((cx + dx, cy + ch + dy - 2, cx + dx, cy + ch + dy + 2),
fill=FG, width=1)
elif sym == "fog":
cw = int(size * 0.7)
for i, dy in enumerate((-2, 1, 4)):
y_pos = cy + dy
draw.line((cx - cw // 2, y_pos, cx + cw // 2, y_pos), fill=FG, width=1)
else:
f = _fit_size(draw, "?", size - 4, size - 4, candidates=["20", "16", "12"])
text_in_box(draw, "?", x, y, size, size, f, FG, "center")
# ---- Linien-Chart (mit optionalem Y-Grid) ---------------------------------
def draw_line_chart(draw, x, y, w, h, data: list, color: tuple = GREEN,
y_min: float | None = None, y_max: float | None = None,
with_y_grid: bool = True, n_ticks: int = 3):
"""Zeichnet einen Linien-Chart in (x,y,w,h) Box.
Args:
data: Liste von Werten
color: Linien-Farbe
y_min, y_max: Y-Achsen-Bereich. None → auto aus Daten
with_y_grid: Y-Tick-Linien mit Werten am linken Rand
n_ticks: Anzahl Y-Ticks (default 3)
"""
if len(data) < 2 or w <= 10 or h <= 10:
return
# Y-Range bestimmen
if y_min is None:
y_min = min(data)
if y_max is None:
y_max = max(data)
if y_min == y_max:
y_min -= 1
y_max += 1
# Grid: gestrichelte Linien (ACeP kann keine echten Dashes, daher Segmente)
if with_y_grid:
# Nur 2 Ticks zeigen: Min und Max (sonst zu viele Labels für kleinen Chart)
ticks = []
if n_ticks >= 2:
ticks = [y_min, y_max]
elif n_ticks == 1:
ticks = [(y_min + y_max) / 2]
for tick in ticks:
ty = y + int((y_max - tick) / max(1, y_max - y_min) * (h - 8)) + 4
# Y-Label links (NICHT unten — kollidiert mit x-Achsen-Labels)
f_lbl = _fit_size(draw, f"{int(tick)}°", 22, 10, candidates=["9", "8"])
text_in_box(draw, f"{int(tick)}°", x - 24, ty - 5, 22, 10, f_lbl, FG, "right")
# Gestrichelte Linie
for seg_x in range(x, x + w, 6):
draw.line((seg_x, ty, min(seg_x + 3, x + w), ty), fill=FG, width=1)
# Daten-Linie
pad_top, pad_bot = 4, 4
pts = []
for i, v in enumerate(data):
px = x + int(i * w / (len(data) - 1))
py = y + pad_top + int((1 - (v - y_min) / (y_max - y_min)) * (h - pad_top - pad_bot))
pts.append((px, py))
for i in range(len(pts) - 1):
draw.line((pts[i][0], pts[i][1], pts[i+1][0], pts[i+1][1]),
fill=color, width=2)
for px, py in pts:
draw.ellipse((px - 1, py - 1, px + 1, py + 1), fill=color)
# ---- Mini-Bar mit Schwellen-Farbe ----------------------------------------
def draw_mini_bar(draw, x, y, w, h, value: float, max_value: float,
color: tuple | None = None):
"""Horizontale Mini-Bar mit Outline + Fill in gegebener Farbe."""
if w < 6 or h < 4:
return
color = color or FG
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=FG, width=1)
pct = min(100, max(0, value / max(0.001, max_value) * 100))
fill_w = max(0, int((w - 2) * pct / 100))
if fill_w > 0:
draw.rectangle((x + 1, y + 1, x + 1 + fill_w, y + h - 2), fill=color)
# ---- ZZZ / Sleep-Indikator -----------------------------------------------
def draw_zzz_indicator(draw, x, y, size: int = 16):
"""Kleines Mond-Icon + 'Zzz' fuer DeepSleep-Anzeige.
Wird vom Dashboard-Frame im Header-Bereich gezeichnet, wenn Power-Save
aktiv ist.
"""
cx, cy = x + size // 2, y + size // 2
r = size // 3
# Mond (Yellow circle, halb verdeckt)
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=YELLOW)
# 'Z' Buchstaben daneben
f = _fit_size(draw, "Z", size, size, candidates=["14", "12", "10"])
text_in_box(draw, "z", x + size + 2, y, 12, size, f, FG, "left")
# ---- DeepSleep-Status-Anzeige fuer Display ------------------------------
def draw_deepsleep_indicator(draw, x, y, w, h, next_wake: str = ""):
"""Zeichnet einen schmalen 'Schlafend'-Bereich im Header.
Args:
next_wake: z.B. '06:30' → wird als Text daneben gezeichnet
"""
if w < 60 or h < 12:
return
# Gelber Mond-Icon
f_moon = _fit_size(draw, "Zz", 30, h, candidates=["12", "10"])
text_in_box(draw, "Zz", x, y, 24, h, f_moon, ORANGE, "center")
# 'Schlafend bis' Text
if next_wake:
f = _fit_size(draw, f"schlafend bis {next_wake}", w - 26, h, candidates=["10", "9", "8"])
text_in_box(draw, f"schlafend bis {next_wake}", x + 26, y, w - 26, h, f, FG, "left")