Files
epaper-dashboard/plugins/base.py
T
epaper-dashboardandHermes 1f142f5245 Sync to Pi: alle Features die live deployed sind
Aus dem Backup und Live-Pull vom Pi (10.11.3.144):
- dashboard.py: Grid-Linien nur im freien Hintergrund (nicht durch Widgets)
- templates/index.html: komplett redesigned mit Sidebar + Topbar + Toast + Modal
- plugins/clock.py: responsive Layout (1x1 bis 4x4)
- plugins/system.py, weather.py, minimax.py: mit Threshold-Bars und Color-Variants
- plugins/base.py: NEU — fetch_with_retry Helper (3x retry mit backoff)
  + render_error_banner für fehlgeschlagene API-Plugins
  (grosses rotes "!" Icon mit Plugin-Name und Fehler statt Crash)

Cleanup: Helfer-Chaos (renderer.py/2/3, design_a/b/c.html, clock_classic.py,
23x clock_*.png, alte test_*.py) wurde bereits im vorherigen Commit entfernt.

Co-Authored-By: Hermes <noreply@hermes.local>
2026-08-26 22:11:36 +04:00

192 lines
6.5 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.
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