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>
238 lines
7.9 KiB
Python
238 lines
7.9 KiB
Python
"""7-Farben-Palette für Waveshare 7.3-inch ACeP (F) HAT.
|
||
|
||
Der Treiber epd7in3f.EPD.getbuffer() quantisiert ein RGB-Bild automatisch
|
||
auf die 7 vom Panel unterstützten Farben. Wir liefern RGB; das macht unseren
|
||
Renderer-Code lesbar.
|
||
|
||
Reihenfolge der Farbnamen folgt der Wiki-Spezifikation:
|
||
Black, White, Green, Blue, Red, Yellow, Orange
|
||
|
||
Zusätzlich semantische Aliase:
|
||
FG/BG = Vorder-/Hintergrund
|
||
OK/WARN/ALERT = Statusbalken
|
||
INFO = Blue
|
||
ACCENT = Orange
|
||
INVERT_FG/INVERT_BG = invertiertes Feld (UV/AQI high)
|
||
"""
|
||
from PIL import ImageDraw, ImageFont
|
||
|
||
# ---- 7 Panel-Farben (RGB) ----
|
||
BLACK = (0, 0, 0)
|
||
WHITE = (255, 255, 255)
|
||
GREEN = (0, 255, 0)
|
||
BLUE = (0, 0, 255)
|
||
RED = (255, 0, 0)
|
||
YELLOW = (255, 255, 0)
|
||
ORANGE = (255, 128, 0)
|
||
|
||
# ---- Semantische Aliase ----
|
||
FG = BLACK
|
||
BG = WHITE
|
||
INVERT_BG = BLACK
|
||
INVERT_FG = WHITE
|
||
|
||
OK = GREEN
|
||
WARN = YELLOW
|
||
ALERT = RED
|
||
|
||
INFO = BLUE
|
||
ACCENT = ORANGE
|
||
|
||
_TABLE = {
|
||
"black": BLACK, "white": WHITE, "green": GREEN, "blue": BLUE,
|
||
"red": RED, "yellow": YELLOW, "orange": ORANGE,
|
||
"fg": FG, "bg": BG,
|
||
"invert_fg": INVERT_FG, "invert_bg": INVERT_BG,
|
||
"ok": OK, "warn": WARN, "alert": ALERT, "info": INFO, "accent": ACCENT,
|
||
}
|
||
|
||
|
||
def fill_for(name: str) -> tuple:
|
||
if name not in _TABLE:
|
||
raise ValueError(f"unknown color name: {name!r}")
|
||
return _TABLE[name]
|
||
|
||
|
||
def measure(draw: ImageDraw.Draw, text: str, font: ImageFont.FreeTypeFont):
|
||
try:
|
||
b = draw.textbbox((0, 0), text, font=font)
|
||
return b[2] - b[0], b[3] - b[1]
|
||
except AttributeError:
|
||
return draw.textsize(text, font=font)
|
||
|
||
|
||
def text_wh(draw, text, font):
|
||
return measure(draw, text, font)
|
||
|
||
|
||
# ============================================================================
|
||
# Layout helpers — damit Widgets slot-relative und größen-responsive arbeiten
|
||
# ============================================================================
|
||
def fit_font(draw, text: str, fonts: dict, max_w: int, max_h: int,
|
||
candidates: list[str] | None = None) -> ImageFont.FreeTypeFont | None:
|
||
"""Wählt die größte Schrift aus `candidates` (oder '80','60','48','32','28','24','20','16'),
|
||
deren gerenderter Text in max_w × max_h passt.
|
||
|
||
`fonts` ist das Dictionary aus dashboard.load_fonts().
|
||
"""
|
||
if candidates is None:
|
||
candidates = ["clock", "80", "60", "48", "32", "28", "24", "20", "16"]
|
||
for key in candidates:
|
||
f = fonts.get(key)
|
||
if f is None:
|
||
continue
|
||
tw, th = measure(draw, text, f)
|
||
if tw <= max_w and th <= max_h:
|
||
return f
|
||
return fonts.get("16") or fonts.get("default")
|
||
|
||
|
||
def centered_text(draw, text, x, y, w, h, font, color):
|
||
"""Zentriert einen Text in der gegebenen Box (x,y,w,h). Returns (px, py)."""
|
||
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 hbar(draw, x, y, w, h, pct: float, fg=None, bg=None, border_w: int = 2,
|
||
thresholds: list | None = None, gradient: bool = False):
|
||
"""Horizontaler Prozentbalken mit konfigurierbarem Farbverlauf.
|
||
|
||
Args:
|
||
pct: 0..100 (wird geclampt)
|
||
fg: fallback-Farbe (falls keine thresholds)
|
||
thresholds: Liste von (max_pct, color) Tupeln, sortiert aufsteigend.
|
||
Beispiel: [(50, OK), (80, WARN), (100, ALERT)]
|
||
Bei pct < 50 → OK, pct < 80 → WARN, sonst ALERT.
|
||
gradient: wenn True, wird der Balken in mehrere Segmente mit den
|
||
Threshold-Farben aufgeteilt, statt ein einfarbiger Fill zu sein.
|
||
|
||
Wenn `gradient=False` und `thresholds` gesetzt: einfarbiger Fill in der
|
||
ersten passenden Threshold-Farbe.
|
||
Wenn beides None: einfarbig in fg (oder FG-Fallback).
|
||
"""
|
||
fg = fg or FG
|
||
bg = bg or BG
|
||
if h < 6 or w < 6:
|
||
return
|
||
|
||
pct = max(0.0, min(100.0, pct))
|
||
|
||
# Welche Farbe für pct?
|
||
def color_for(p: float) -> tuple:
|
||
if thresholds:
|
||
for max_p, c in thresholds:
|
||
if p <= max_p:
|
||
return c
|
||
return thresholds[-1][1]
|
||
return fg
|
||
|
||
# Frame
|
||
draw.rectangle((x, y, x + w - 1, y + h - 1), outline=fg, width=border_w)
|
||
|
||
if gradient and thresholds and len(thresholds) >= 2:
|
||
# Multi-Segment-Bar: zeichne für jeden Threshold-Bereich sein eigenes Segment
|
||
inner_x = x + border_w
|
||
inner_w = w - 2 * border_w
|
||
if inner_w > 0:
|
||
prev_max = 0.0
|
||
for max_p, c in thresholds:
|
||
seg_pct_start = prev_max
|
||
seg_pct_end = min(max_p, pct)
|
||
if seg_pct_end > seg_pct_start:
|
||
seg_x0 = inner_x + int(inner_w * seg_pct_start / 100)
|
||
seg_x1 = inner_x + int(inner_w * seg_pct_end / 100)
|
||
draw.rectangle((seg_x0, y + border_w,
|
||
seg_x1, y + h - 1 - border_w), fill=c)
|
||
prev_max = max_p
|
||
# Falls pct die höchste Threshold überschreitet
|
||
if pct > thresholds[-1][0]:
|
||
seg_x0 = inner_x + int(inner_w * thresholds[-1][0] / 100)
|
||
draw.rectangle((seg_x0, y + border_w,
|
||
inner_x + inner_w - 1, y + h - 1 - border_w),
|
||
fill=thresholds[-1][1])
|
||
else:
|
||
# Einfarbiger Fill in passender Farbe
|
||
fill_w = int((w - 2 * border_w) * pct / 100)
|
||
if fill_w > 0:
|
||
draw.rectangle((x + border_w, y + border_w,
|
||
x + border_w + fill_w, y + h - 1 - border_w),
|
||
fill=color_for(pct))
|
||
|
||
|
||
# Default-Schwellen: (max_pct, color)
|
||
DEFAULT_THRESHOLDS = [
|
||
(50, OK),
|
||
(80, WARN),
|
||
(95, ALERT),
|
||
]
|
||
|
||
|
||
def parse_thresholds(spec) -> list:
|
||
"""Parst Threshold-Spec aus Plugin-Config.
|
||
|
||
Akzeptiert entweder:
|
||
- String "ok,warn,alert" (3 stufig, default-Werte 50/80/95)
|
||
- String "ok@50,warn@80,alert@95" (custom Schwellen)
|
||
- Liste von Dicts [{"pct": 50, "color": "ok"}, ...]
|
||
- None → DEFAULT_THRESHOLDS
|
||
"""
|
||
if spec is None or spec == "":
|
||
return list(DEFAULT_THRESHOLDS)
|
||
if isinstance(spec, list):
|
||
result = []
|
||
for item in spec:
|
||
try:
|
||
if isinstance(item, dict):
|
||
p = float(item.get("pct", 100))
|
||
c = fill_for(item.get("color", "fg"))
|
||
else:
|
||
# tuple-like
|
||
p = float(item[0])
|
||
c = fill_for(item[1])
|
||
result.append((p, c))
|
||
except (ValueError, TypeError, KeyError):
|
||
continue
|
||
return sorted(result, key=lambda x: x[0]) if result else list(DEFAULT_THRESHOLDS)
|
||
if isinstance(spec, str):
|
||
parts = [s.strip() for s in spec.split(",") if s.strip()]
|
||
# Default-Stufen wenn keine @-Syntax
|
||
default_pcts = [50, 80, 95]
|
||
result = []
|
||
for i, p in enumerate(parts):
|
||
if "@" in p:
|
||
name, val = p.split("@", 1)
|
||
try:
|
||
pct = float(val)
|
||
except ValueError:
|
||
continue
|
||
else:
|
||
name = p
|
||
pct = default_pcts[i] if i < len(default_pcts) else 100
|
||
try:
|
||
color = fill_for(name)
|
||
except ValueError:
|
||
continue
|
||
result.append((pct, color))
|
||
return sorted(result, key=lambda x: x[0]) if result else list(DEFAULT_THRESHOLDS)
|
||
return list(DEFAULT_THRESHOLDS)
|
||
|
||
|
||
def is_small(w: int, h: int) -> bool:
|
||
"""True wenn Slot klein ist (1x1 oder ähnlich). Widgets können darauf
|
||
vereinfachtes Layout zeigen."""
|
||
return w < 280 or h < 180
|
||
|
||
|
||
def is_wide(w: int, h: int) -> bool:
|
||
"""True wenn Slot breit aber flach ist (4x1, 2x1)."""
|
||
return w >= 400 and h < 200
|
||
|
||
|
||
def is_tall(w: int, h: int) -> bool:
|
||
"""True wenn Slot hoch aber schmal ist (1x4, 1x2)."""
|
||
return h >= 280 and w < 280
|
||
|