"""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. Minimal-Beispiel siehe plugins/hello.py. """ from __future__ import annotations from abc import ABC, abstractmethod from typing import Any import time import urllib.request import urllib.error import socket as _socket from PIL import ImageDraw # ============================================================================ # 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 @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