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>
This commit is contained in:
co-authored by
Hermes
parent
af9a99a379
commit
1f142f5245
+141
-19
@@ -10,45 +10,168 @@ 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 (Klassenattribute) ----
|
||||
name: str = "" # Eindeutiger Identifier, lowercase, keine Leerzeichen
|
||||
label: str = "" # Anzeigename in der UI
|
||||
description: str = "" # Kurzbeschreibung in der UI
|
||||
category: str = "general" # "info" | "system" | "weather" | "smart-home" | ...
|
||||
# ---- Metadaten ----
|
||||
name: str = ""
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
category: str = "general"
|
||||
|
||||
# Optional: Schema der Config-Felder (für UI-Form-Generierung).
|
||||
# Liste von Dicts mit keys: key, label, type, default, secret, choices, help
|
||||
# type ∈ {"string", "int", "float", "bool", "secret", "select", "lat_lon"}
|
||||
config_schema: list[dict] = []
|
||||
default_config: dict = {}
|
||||
|
||||
def __init__(self, config: dict):
|
||||
# Merge defaults mit übergebener Config
|
||||
merged = dict(self.default_config)
|
||||
merged.update(config or {})
|
||||
self.config = merged
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self) -> dict:
|
||||
"""Daten holen. Sollte schnell sein — wird alle refresh_interval Sekunden
|
||||
aufgerufen, plus einmal vor jedem Render. Exceptions werden geloggt und
|
||||
führen zur Beibehaltung der letzten Daten."""
|
||||
"""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 (x,y,w,h) auf den draw-Context.
|
||||
Renderer nutzt RGB-Palette aus palette.py."""
|
||||
"""Zeichne in den gegebenen Slot."""
|
||||
|
||||
# ---- Optionale Lifecycle-Hooks ----
|
||||
def on_load(self) -> None:
|
||||
"""Wird einmal beim Plugin-Load aufgerufen."""
|
||||
pass
|
||||
|
||||
def on_unload(self) -> None:
|
||||
"""Wird beim Beenden aufgerufen."""
|
||||
pass
|
||||
|
||||
# ---- Helper für Plugins ----
|
||||
def cfg(self, key: str, default: Any = None) -> Any:
|
||||
return self.config.get(key, default)
|
||||
|
||||
@@ -64,6 +187,5 @@ def all_widget_classes() -> list[type[Widget]]:
|
||||
cls = getattr(mod, "Widget", None)
|
||||
if cls and isinstance(cls, type) and issubclass(cls, Widget) and cls is not Widget:
|
||||
classes.append(cls)
|
||||
# alphabetisch
|
||||
classes.sort(key=lambda c: c.label or c.name)
|
||||
return classes
|
||||
|
||||
+72
-25
@@ -1,15 +1,23 @@
|
||||
"""Clock: Uhrzeit + Datum. Responsive fuer alle Slot-Groessen."""
|
||||
"""Clock-Plugin: Uhrzeit + Datum, responsive fuer alle Slot-Groessen.
|
||||
|
||||
Layout-Strategie pro Slot-Groesse:
|
||||
is_small (1x1 ~200x120): nur Big-Time zentriert
|
||||
is_wide (4x1/2x1): Time links gross, Datum rechts klein
|
||||
sonst (1x2/2x2/4x4): Datum oben klein, Big-Time mittig, Wochentag unten
|
||||
"""
|
||||
import os, sys
|
||||
from datetime import datetime
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from plugins.base import Widget
|
||||
from palette import FG, INFO, ACCENT, measure, fit_font, centered_text, is_small, is_wide
|
||||
from palette import (FG, INFO, ACCENT, BLUE, OK,
|
||||
measure, fit_font, centered_text,
|
||||
is_small, is_wide, is_tall)
|
||||
|
||||
|
||||
class Widget(Widget):
|
||||
name = "clock"
|
||||
label = "Uhrzeit / Datum"
|
||||
description = "Aktuelle Uhrzeit und Datum. Responsives Layout fuer 1x1 bis 4x4."
|
||||
description = "Aktuelle Uhrzeit und Datum. Responsiv fuer 1x1 bis 4x4 Slots."
|
||||
category = "info"
|
||||
|
||||
config_schema = [
|
||||
@@ -17,54 +25,93 @@ class Widget(Widget):
|
||||
{"key": "show_seconds", "label": "Sekunden anzeigen", "type": "bool", "default": False},
|
||||
{"key": "show_date", "label": "Datum anzeigen", "type": "bool", "default": True},
|
||||
{"key": "show_weekday", "label": "Wochentag anzeigen", "type": "bool", "default": True},
|
||||
{"key": "accent_color", "label": "Akzentfarbe", "type": "select",
|
||||
"choices": ["fg", "info", "accent", "ok", "warn", "alert"], "default": "fg"},
|
||||
{"key": "accent_color", "label": "Akzentfarbe",
|
||||
"type": "select",
|
||||
"choices": ["info", "blue", "accent", "ok", "warn", "alert"],
|
||||
"default": "info",
|
||||
"help": "Farbe fuer Datum und Wochentag"},
|
||||
{"key": "weekday_color", "label": "Wochentag-Farbe",
|
||||
"type": "select",
|
||||
"choices": ["accent", "blue", "info", "ok", "warn", "alert"],
|
||||
"default": "accent"},
|
||||
]
|
||||
default_config = {"format_24h": True, "show_seconds": False,
|
||||
"show_date": True, "show_weekday": True, "accent_color": "fg"}
|
||||
default_config = {
|
||||
"format_24h": True, "show_seconds": False,
|
||||
"show_date": True, "show_weekday": True,
|
||||
"accent_color": "info", "weekday_color": "accent",
|
||||
}
|
||||
|
||||
def fetch(self):
|
||||
return {}
|
||||
|
||||
def render(self, draw, fonts, x, y, w, h):
|
||||
from palette import fill_for, measure
|
||||
from palette import fill_for
|
||||
now = datetime.now()
|
||||
pad = 8
|
||||
accent = fill_for(self.cfg("accent_color", "fg"))
|
||||
pad = 10
|
||||
|
||||
time_str = now.strftime("%H:%M:%S" if self.cfg("show_seconds") else "%H:%M")
|
||||
date_str = now.strftime("%d %b %Y")
|
||||
day_str = now.strftime("%A").upper()
|
||||
|
||||
accent = fill_for(self.cfg("accent_color", "info"))
|
||||
weekday_c = fill_for(self.cfg("weekday_color", "accent"))
|
||||
|
||||
# === Mini-Modus: 1x1 oder sehr klein ===
|
||||
if is_small(w, h):
|
||||
# mini: nur Uhr, zentriert
|
||||
font = fit_font(draw, time_str, fonts, w - 2 * pad, h - 2 * pad)
|
||||
centered_text(draw, time_str, x, y, w, h, font, FG)
|
||||
return
|
||||
|
||||
# === Wide-Strip-Modus: 4x1 oder 2x1 ===
|
||||
if is_wide(w, h):
|
||||
# Wide strip: Uhrzeit links gross, Datum rechts klein
|
||||
# Big Time links
|
||||
font_time = fit_font(draw, time_str, fonts, w // 2 - 2 * pad, h - 2 * pad)
|
||||
centered_text(draw, time_str, x, y, w // 2, h, font_time, FG)
|
||||
# Datum + Wochentag rechts
|
||||
if self.cfg("show_date"):
|
||||
font_date = fit_font(draw, date_str, fonts, w // 2 - 2 * pad, h // 2)
|
||||
font_date = fit_font(draw, date_str, fonts, w // 2 - 2 * pad, h // 2 - 4)
|
||||
centered_text(draw, date_str, x + w // 2, y, w // 2, h // 2, font_date, accent)
|
||||
if self.cfg("show_weekday"):
|
||||
font_day = fit_font(draw, day_str, fonts, w // 2 - 2 * pad, h // 2)
|
||||
centered_text(draw, day_str, x + w // 2, y + h // 2, w // 2, h // 2, font_day, INFO)
|
||||
font_day = fit_font(draw, day_str, fonts, w // 2 - 2 * pad, h // 2 - 4)
|
||||
centered_text(draw, day_str, x + w // 2, y + h // 2, w // 2, h // 2, font_day, weekday_c)
|
||||
return
|
||||
|
||||
# Standard: Header (Datum), Big Time, Day unten
|
||||
# Big Time zentriert
|
||||
font_time = fit_font(draw, time_str, fonts, w - 2 * pad, int(h * 0.55))
|
||||
centered_text(draw, time_str, x, y + pad, w, int(h * 0.55), font_time, FG)
|
||||
# === Tall-Modus: schmal aber hoch ===
|
||||
if is_tall(w, h):
|
||||
# Vertikal: Time oben, dann Date, dann Weekday
|
||||
font_time = fit_font(draw, time_str, fonts, w - 2 * pad, int(h * 0.45))
|
||||
centered_text(draw, time_str, x, y, w, int(h * 0.45), font_time, FG)
|
||||
if self.cfg("show_date"):
|
||||
font_date = fit_font(draw, date_str, fonts, w - 2 * pad, int(h * 0.25))
|
||||
centered_text(draw, date_str, x, y + int(h * 0.45), w, int(h * 0.25), font_date, accent)
|
||||
if self.cfg("show_weekday"):
|
||||
font_day = fit_font(draw, day_str, fonts, w - 2 * pad, int(h * 0.20))
|
||||
centered_text(draw, day_str, x, y + int(h * 0.70), w, int(h * 0.20), font_day, weekday_c)
|
||||
return
|
||||
|
||||
# === Standard-Modus: 2x2 oder groesser, quadratisch oder landscape ===
|
||||
# Drei Sektionen: Date (15%) | Time (60%) | Weekday (25%)
|
||||
date_h = int(h * 0.18) if self.cfg("show_date") else 0
|
||||
weekday_h = int(h * 0.18) if self.cfg("show_weekday") else 0
|
||||
time_h = h - date_h - weekday_h
|
||||
cur_y = y
|
||||
|
||||
# Datum darunter
|
||||
if self.cfg("show_date"):
|
||||
font_date = fit_font(draw, date_str, fonts, w - 2 * pad, h // 5)
|
||||
centered_text(draw, date_str, x, y + int(h * 0.55), w, h // 5, font_date, accent)
|
||||
font_date = fit_font(draw, date_str, fonts, w - 2 * pad, date_h - 4)
|
||||
centered_text(draw, date_str, x, cur_y, w, date_h, font_date, accent)
|
||||
cur_y += date_h
|
||||
|
||||
time_x = x
|
||||
time_w = w
|
||||
# Wenn weekday und date beide aus: Time zentriert ueber alles
|
||||
if not (self.cfg("show_date") or self.cfg("show_weekday")):
|
||||
time_y = y
|
||||
else:
|
||||
time_y = cur_y
|
||||
font_time = fit_font(draw, time_str, fonts, time_w - 2 * pad, time_h - 4)
|
||||
centered_text(draw, time_str, time_x, time_y, time_w, time_h, font_time, FG)
|
||||
cur_y += time_h
|
||||
|
||||
# Wochentag
|
||||
if self.cfg("show_weekday"):
|
||||
font_day = fit_font(draw, day_str, fonts, w - 2 * pad, h // 6)
|
||||
centered_text(draw, day_str, x, y + int(h * 0.78), w, h // 6, font_day, INFO)
|
||||
font_day = fit_font(draw, day_str, fonts, w - 2 * pad, weekday_h - 4)
|
||||
centered_text(draw, day_str, x, cur_y, w, weekday_h, font_day, weekday_c)
|
||||
|
||||
Reference in New Issue
Block a user