"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station. lines() ist bewusst ohne Pillow und ohne Hardwarezugriff importierbar und testbar (siehe tools/station/tests/test_render.py) - compose() importiert Pillow erst innerhalb der Funktion, damit ein fehlendes PIL (das erst per "apt install python3-pil" auf den Pi kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht. Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. lines() liefert UNGEKUERZTE Zeilen - die Breitenkuerzung (mit "..." statt Umbruch, Spec: eine zu lange Zeile darf das Layout nicht sprengen) passiert erst in compose(), per _truncate_to_width() gegen die tatsaechliche, schriftabhaengige Pixelbreite (PIL.ImageFont.load_default() ist seit Pillow 10.1 eine proportionale TrueType-Schrift, keine feste Bitmapschrift - eine Kuerzung nach Zeichenzahl (fruehere Fassung: MAX_COLS) ist dafuer keine gueltige Breitengrenze: "W"*40 misst bei dieser Schrift fast doppelt so breit wie "i"*40, und ausgerechnet grossbuchstabenlastige Werte - Produktnamen aus der Lampenerkennung wie "KLYQA GU10 RGB CW/WW" - waeren trotz weniger Zeichen als die alte Grenze ueber die Panelbreite hinausgelaufen (Fixrunde 2, MAX_COLS-Kleinigkeit war in Fixrunde 1 nicht wirklich behoben). _truncate_to_width() selbst importiert kein Pillow (sie nimmt eine Messfunktion entgegen, die der Aufrufer liefert) und bleibt damit wie lines() ohne Pillow testbar; nur compose() verdrahtet sie mit einer echten PIL-Textmessung. Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json (vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus /etc/ninlight-station.conf. display.py liest beides und reicht ein gemeinsames dict herein. Jedes Feld wird ueber .get() mit Vorgabewert gelesen: status.json, control.json und die Konfigurationsdatei koennen jederzeit fehlen, halb geschrieben oder aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im Schreiben, Neustart) - siehe test_missing_fields_do_not_raise.""" from __future__ import annotations from typing import Callable PANEL_WIDTH_PX = 250 PANEL_HEIGHT_PX = 122 TEXT_MARGIN_PX = 2 LINE_HEIGHT_PX = 14 MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"} # "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht # gelistet. PHASE_LABELS = {"auto": "AUTO", "listen": "HORCHT"} def _hhmm(ts: object) -> str: """"2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer aelteren Serverfassung ohne Zeitstempel) -> "--:--".""" if isinstance(ts, str) and len(ts) >= 16 and ts[10:11] == "T": return ts[11:16] return "--:--" def lines(state: dict) -> list[str]: """Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen fuers Panel (siehe Modul-Docstring, warum die Kuerzung hier bewusst nicht passiert). Wirft nie - fehlende/falsch geformte Felder werden ausgelassen statt eine Ausnahme zu werfen.""" state = state if isinstance(state, dict) else {} mode = state.get("mode") out: list[str] = [] if mode == "off": # Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt # diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen # (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und # gerade nicht aktiv). out.append("Station aus") return out if mode not in ("recovery", "migration"): # Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch # nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu # unterstellen. out.append(f"Status unbekannt ({mode})" if mode else "Status unbekannt") return out out.append(MODE_LABELS[mode]) ssid = state.get("ssid") if ssid: out.append(f"SSID: {ssid}") if mode == "migration": # Phase kommt aus control.json, nicht aus status.json (das schreibt der Server # nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als # Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine # Phase, deshalb dieser Block nur hier drin. phase = state.get("phase") if phase == "armed": target = state.get("armed_target") or "?" out.append(f"FREIGEGEBEN: {target}") elif phase in PHASE_LABELS: out.append(PHASE_LABELS[phase]) last_seen = state.get("last_seen") if not isinstance(last_seen, dict): last_seen = {} product_name = last_seen.get("product_name") if product_name: out.append(str(product_name)) target = last_seen.get("target") variant = last_seen.get("variant") if target or variant: out.append(f"{target or '?'} ({variant or '?'})") lamp_version = last_seen.get("lamp_version") if lamp_version: out.append(f"FW: {lamp_version}") served = state.get("served") out.append(f"Ausgeliefert: {served if isinstance(served, int) else 0}") recent = state.get("recent") if isinstance(recent, list) and recent: last = recent[-1] if isinstance(last, dict): out.append(f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} " f"{last.get('status') or '?'}") return out def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px: float) -> str: """Kuerzt `text` mit "…" am Ende, bis `measure(text)` nicht mehr breiter als `max_width_px` ist - nie umgebrochen, immer von rechts gekuerzt. Nimmt eine Messfunktion statt selbst Pillow zu importieren: so bleibt diese Funktion (und damit render.py ausserhalb von compose()) ohne Pillow testbar, z.B. mit `measure=len` fuer einen einfachen Zeichenzahl-Test, waehrend compose() unten eine echte `ImageDraw.textlength()`-Messung durchreicht. Zeichenweise gekuerzt (nicht wortweise): lines() liefert kurze Statuswerte, keinen Fliesstext - ein am Wortende abgeschnittener Produktname ist hier kein Problem, ein zu breiter oder umgebrochener waere eins.""" if measure(text) <= max_width_px: return text ellipsis = "…" trimmed = text while trimmed and measure(trimmed + ellipsis) > max_width_px: trimmed = trimmed[:-1] return (trimmed + ellipsis) if trimmed else ellipsis def compose(state: dict): """Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus lines(). Importiert Pillow erst hier - siehe Modul-Docstring.""" from PIL import Image, ImageDraw, ImageFont image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255) draw = ImageDraw.Draw(image) font = ImageFont.load_default() max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX def measure(text: str) -> float: return draw.textlength(text, font=font) y = TEXT_MARGIN_PX for line in lines(state): if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX: break fitted = _truncate_to_width(line, measure=measure, max_width_px=max_width_px) draw.text((TEXT_MARGIN_PX, y), fitted, font=font, fill=0) y += LINE_HEIGHT_PX return image