Files
epaper-dashboard/.backup/2026-08-26-active/palette.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

238 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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