"""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 # ============================================================================ # Batterie-Farben — Single Source of Truth # ============================================================================ BATT_LOW = RED # < 25% → kritisch BATT_MID = ORANGE # < 50% → bald wechseln BATT_OK = GREEN # >= 50% → OK # Standard-Batterie-Schwellen (werden von Plugins einheitlich genutzt) BATT_THRESHOLDS = ( (25, BATT_LOW), (50, BATT_MID), ) def batt_color(pct: int | None) -> tuple: """Liefert die passende Batterie-Farbe fuer einen Prozent-Wert. None → FG (keine Anzeige), sonst BATT_LOW/MID/OK. """ if pct is None: return FG pct = max(0, min(100, pct)) for threshold, color in BATT_THRESHOLDS: if pct < threshold: return color return BATT_OK # ============================================================================ # Deutsche Formatter — Single Source of Truth (kein Englisches "Monday" mehr) # ============================================================================ DE_WEEKDAYS_LONG = [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag", ] DE_WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] DE_MONTHS_LONG = [ "Januar", "Februar", "Maerz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", ] DE_MONTHS_SHORT = ["Jan", "Feb", "Mae", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"] def de_weekday_long(weekday: int) -> str: """datetime.weekday() (0=Montag) → 'Montag'/'Dienstag'/...""" return DE_WEEKDAYS_LONG[weekday] def de_weekday_short(weekday: int) -> str: """datetime.weekday() (0=Montag) → 'Mo'/'Di'/...""" return DE_WEEKDAYS_SHORT[weekday] def de_month_short(month: int) -> str: """datetime.month (1-12) → 'Jan'/'Feb'/...""" return DE_MONTHS_SHORT[month - 1] def de_date(dt, short: bool = True) -> str: """Datum als '30. Aug 2026' (default short=True).""" if short: return f"{dt.day:02d}. {de_month_short(dt.month)} {dt.year}" return f"{dt.day:02d}. {DE_MONTHS_LONG[dt.month - 1]} {dt.year}" def de_weekday_date(dt, short: bool = True) -> str: """'Sa 30.08' wenn short, sonst 'Samstag 30. August 2026'.""" if short: return f"{de_weekday_short(dt.weekday())} {dt.day:02d}.{dt.month:02d}" return f"{de_weekday_long(dt.weekday())} {de_date(dt, short=False)}" def de_time(dt, show_seconds: bool = False) -> str: """24h-Zeit 'HH:MM' oder 'HH:MM:SS'.""" return dt.strftime("%H:%M:%S" if show_seconds else "%H:%M") # ============================================================================ # Wind / Temp Helper — einheitliche Formatierung # ============================================================================ def format_temp(value: float | None, unit: str = "C", decimals: int = 1) -> str: """Temperatur formatieren. None → em-dash.""" if value is None: return "—" return f"{value:.{decimals}f}°{unit}" def format_wind(speed: float | None, unit: str = "kmh", decimals: int = 0) -> str: """Windgeschwindigkeit. 'kmh' → 'km/h', 'ms' → 'm/s'.""" if speed is None: return "—" label = "km/h" if unit == "kmh" else "m/s" return f"{speed:.{decimals}f} {label}" def format_rain(rate_mm: float | None, decimals: int = 1) -> str: """Regenrate 'X.X mm/h'.""" if rate_mm is None: return "—" return f"{rate_mm:.{decimals}f} mm/h" def temp_color(value: float | None) -> tuple: """Farbe nach Temperaturwert: kalt→Blau, normal→FG, warm→Orange, heiss→Rot.""" if value is None: return FG if value >= 28: return RED if value >= 22: return ORANGE if value <= 5: return BLUE return FG def co2_color(ppm: int | float | None) -> tuple: """CO2-Ampel: <600 gruen, <1000 orange, <1500 gelb, >=1500 rot.""" if ppm is None: return FG if ppm >= 1500: return RED if ppm >= 1000: return YELLOW if ppm >= 600: return ORANGE return GREEN # ============================================================================ # Power-Management — Defaults # ============================================================================ POWER_DEFAULTS = { "deepsleep_enabled": False, "deepsleep_start": "23:30", "deepsleep_end": "06:30", "cpu_throttle": True, # waehrend DeepSleep auf 600MHz "wifi_off": False, # WiFi anlassen fuer Admin-Erreichbarkeit } # ============================================================================ # Weather-Code-Mapping (WMO Standard + Netatmo-Fallback) # ============================================================================ # WMO Weather interpretation codes (https://open-meteo.com/en/docs) WMO_ICONS = { 0: "sun", # Clear sky 1: "partly", # Mainly clear 2: "partly", # Partly cloudy 3: "cloud", # Overcast 45: "fog", # Fog 48: "fog", # Depositing rime fog 51: "rain", # Drizzle: light 53: "rain", # Drizzle: moderate 55: "rain", # Drizzle: dense 56: "rain", # Freezing Drizzle: light 57: "rain", # Freezing Drizzle: dense 61: "rain", # Rain: slight 63: "rain", # Rain: moderate 65: "rain", # Rain: heavy 66: "rain", # Freezing Rain: light 67: "rain", # Freezing Rain: heavy 71: "snow", # Snow fall: slight 73: "snow", # Snow fall: moderate 75: "snow", # Snow fall: heavy 77: "snow", # Snow grains 80: "rain", # Rain showers: slight 81: "rain", # Rain showers: moderate 82: "rain", # Rain showers: violent 85: "snow", # Snow showers: slight 86: "snow", # Snow showers: heavy 95: "storm", # Thunderstorm 96: "storm", # Thunderstorm with slight hail 99: "storm", # Thunderstorm with heavy hail } def wmo_to_icon(code: int | str | None) -> str: """WMO-Code (int) oder Symbol-String → 'sun'/'cloud'/'rain'/'storm'/...""" if code is None: return "cloud" if isinstance(code, str): return code # schon ein symbol return WMO_ICONS.get(code, "cloud")