Files
ki 28124c5617 Initial commit: epaper-dashboard for 7.3" ACeP 7-Color display
- dashboard.py: plugin-based renderer with 4x4 grid layout
- admin.py: web UI with layout editor + plugin configs
- layout.py: pack algorithm, item placement, grid system
- plugins/: clock, weather, system, spotify, strava, gmail, minimax, hello
- network_watchdog.py: WiFi AP/client mode management
- waveshare_epd_init.py: vendor driver stub
2026-08-26 14:12:43 +04:00

238 lines
7.9 KiB
Python
Raw Permalink 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