"""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