"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station. blocks()/lines() sind 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. Die Schrift kommt aus FONT_SEARCH_PATHS (DejaVu auf beiden Pis) statt aus ImageFont.load_default(): default ist rund 10 px hoch und auf 250x122 aus zwei Metern nicht lesbar (Befund der Hardware-Abnahme) - siehe _load_font(), warum der naheliegende Weg load_default(size=...) auf Pi 2 (Pillow 9.4.0) ausscheidet. Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. blocks() 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 # --- Rollen und ihre Groessen ------------------------------------------------------ # # Bis zur Hardware-Abnahme zeichnete compose() alle Zeilen in derselben # ImageFont.load_default() (rund 10 px hoch) - auf 250x122 war das Kleingedrucktes und # aus zwei Metern nicht mehr lesbar (Befund Michaels am Geraet). blocks() vergibt # deshalb jeder Zeile eine ROLLE, und compose() setzt jede Rolle anders: # # header - Modus, fett, WEISS AUF SCHWARZEM BALKEN ueber die volle Panelbreite. # Invertiert kostet auf e-Paper nichts (bistabil, kein Stromverbrauch pro # Pixel) und trennt die Kopfzeile auf einen Blick vom Rest. # hero - die eine wichtigste Information des Zustands (Migration: der erkannte # Produktname; Rettung: die SSID), fett. # detail - Beiwerk (Ziel/Variante, Lampenversion, Zaehler, letzte Auslieferung). # big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"): gross und # mittig, damit man quer durch den Raum sieht, dass nichts laeuft. ROLE_HEADER = "header" ROLE_HERO = "hero" ROLE_DETAIL = "detail" ROLE_BIG = "big" HEADER_FONT_PX = 20 HERO_FONT_PX = 16 DETAIL_FONT_PX = 11 BIG_FONT_PX = 26 # Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe: # Kopfbalken + grosse Zeile + sechs Beiwerkzeilen) exakt auf die 122 px passt und die # letzte Zeile - "zuletzt ausgeliefert" - nicht mehr hinten abfaellt: # 22 + 2 + 20 + 6*13 = 122. HEADER_BAR_HEIGHT_PX = 22 HERO_LINE_HEIGHT_PX = 20 DETAIL_LINE_HEIGHT_PX = 13 # Suchkette fuer eine echte, skalierbare Schrift, in dieser Reihenfolge probiert # (Regular, Bold). ImageFont.load_default(size=...) waere der naheliegende Weg, ist aber # erst ab Pillow 10.1 vorhanden - Pi 2 (Raspberry Pi OS Bookworm) hat Pillow 9.4.0, und # dort wirft load_default(size=...) TypeError ("unexpected keyword argument 'size'"), am # Geraet gegengeprueft. Deshalb fuehrt der Hauptweg ueber ImageFont.truetype() auf eine # Datei im Dateisystem: /usr/share/fonts/truetype/dejavu/ liegt auf BEIDEN Pis # (Debian 13 trixie wie Bookworm) und ist damit der einzige Pfad, auf den es im Betrieb # ankommt. Alles darunter existiert nur, damit compose() auch auf Rechnern ohne den # Debian-Pfad eine skalierbare Schrift findet - insbesondere auf der macOS- # Entwicklungsmaschine, wo tools/station/tests/test_render.py laufen muss. # # Letzter Rueckfall (in _load_font(), nicht in dieser Liste) ist load_default() OHNE # Argument: dann ist die Schrift wieder klein, aber nichts stuerzt ab - die Zusage # "eine fehlende Schrift darf die Anzeige (und damit nie eine Migration) aufhalten" # gilt unveraendert. FONT_SEARCH_PATHS = ( # Debian/Raspberry Pi OS - der Betriebsfall auf beiden Pis. ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"), # Andere Linux-Distributionen legen dieselbe Schrift woanders ab. ("/usr/share/fonts/dejavu/DejaVuSans.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf"), ("/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"), # DejaVu per Homebrew (macOS), falls vorhanden. ("/opt/homebrew/share/fonts/DejaVuSans.ttf", "/opt/homebrew/share/fonts/DejaVuSans-Bold.ttf"), # macOS-Bordmittel - nur fuer die Entwicklungsmaschine, nie fuer den Pi. ("/System/Library/Fonts/Supplemental/Arial.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf"), ("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"), ) 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 blocks(state: dict) -> list[tuple[str, str]]: """Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen mit ihrer ROLLE (siehe Rollen-Kommentar oben, und den Modul-Docstring, warum die Kuerzung hier bewusst nicht passiert). Wirft nie - fehlende/falsch geformte Felder werden ausgelassen statt eine Ausnahme zu werfen. Bleibt wie lines() ohne Pillow importierbar und testbar: hier entsteht nur Text, kein Bild - die Rolle ist ein reiner Bezeichner, die zugehoerigen Schriftgroessen kennt ausschliesslich compose().""" state = state if isinstance(state, dict) else {} mode = state.get("mode") out: list[tuple[str, 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). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt. out.append(("Station aus", ROLE_BIG)) 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", ROLE_BIG)) return out out.append((MODE_LABELS[mode], ROLE_HEADER)) ssid = state.get("ssid") if mode == "recovery": # Im Rettungsmodus ist die SSID das, was der Bediener am Geraet braucht (er muss # sich mit genau diesem Netz verbinden) - deshalb gross und ohne "SSID: "-Praefix, # das den Platz nur wegnaehme. if ssid: out.append((str(ssid), ROLE_HERO)) else: # 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. last_seen = state.get("last_seen") if not isinstance(last_seen, dict): last_seen = {} product_name = last_seen.get("product_name") # Reihenfolge bewusst anders als frueher (da stand die SSID als zweite Zeile): # gross kommt das, was sich gerade aendert und worauf man wartet - die erkannte # Lampe. Solange keine erkannt ist, ruecke die SSID nach oben, damit die grosse # Zeile nie leer bleibt. if product_name: out.append((str(product_name), ROLE_HERO)) elif ssid: out.append((str(ssid), ROLE_HERO)) phase = state.get("phase") if phase == "armed": target = state.get("armed_target") or "?" out.append((f"FREIGEGEBEN: {target}", ROLE_DETAIL)) elif phase in PHASE_LABELS: out.append((PHASE_LABELS[phase], ROLE_DETAIL)) if product_name: target = last_seen.get("target") variant = last_seen.get("variant") if target or variant: out.append((f"{target or '?'} ({variant or '?'})", ROLE_DETAIL)) lamp_version = last_seen.get("lamp_version") if lamp_version: out.append((f"FW: {lamp_version}", ROLE_DETAIL)) if ssid: # Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die # SSID zweimal auf dem Panel. out.append((f"SSID: {ssid}", ROLE_DETAIL)) served = state.get("served") out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL)) 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 '?'}", ROLE_DETAIL)) return out def lines(state: dict) -> list[str]: """Nur die Texte aus blocks(), ohne Rollen - unveraenderte Schnittstelle fuer display.run_daemon(), das damit erkennt, ob sich der Inhalt geaendert hat (die Rolle ist dafuer unerheblich, sie haengt nur am Modus). Wie blocks() ohne Pillow benutzbar.""" return [text for text, _role in blocks(state)] 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 _load_font(image_font, size_px: int, bold: bool = False): """Laedt eine skalierbare Schrift der Groesse `size_px` aus FONT_SEARCH_PATHS - der erste Pfad, der sich oeffnen laesst, gewinnt. `image_font` ist das PIL.ImageFont-MODUL, hereingereicht statt hier importiert: so bleibt render.py (ausserhalb von compose()) ohne Pillow importierbar - dasselbe Muster wie die Messfunktion in _truncate_to_width() - und die Kette laesst sich mit einem nachgebauten Modul pruefen, ohne Schriftdateien anzulegen oder Pillow herunterzustufen. Gefangen wird OSError (Datei fehlt/unlesbar/kein gueltiges Schriftformat) UND ImportError: ein ohne FreeType gebautes Pillow laesst truetype() mit genau dieser Ausnahme scheitern - dann darf die Anzeige nicht ausfallen, sondern faellt bis auf load_default() durch. load_default() wird BEWUSST OHNE size-Argument gerufen: das gibt es erst ab Pillow 10.1, Pi 2 hat 9.4.0 (siehe FONT_SEARCH_PATHS). Ein `size=`-Aufruf waere dort ein TypeError mitten im Zeichnen - also genau in dem Pfad, der eigentlich der letzte Rettungsanker ist.""" for regular_path, bold_path in FONT_SEARCH_PATHS: path = bold_path if bold else regular_path try: return image_font.truetype(path, size_px) except (OSError, ImportError): continue return image_font.load_default() def _draw_centered(draw, text: str, font, top: int, height: int, fill: int) -> None: """Zeichnet `text` waagerecht mittig aufs Panel und senkrecht mittig in das Band [top, top+height). Misst mit textbbox() statt mit der nominellen Schriftgroesse: die tatsaechliche Tinte einer Zeile ist je nach Zeichen (Ober-/Unterlaengen) deutlich kleiner als die Zeilenhoehe, und ein nach Zeilenhoehe zentrierter Text sieht auf einem 122 px hohen Panel sichtbar zu tief aus.""" bbox = draw.textbbox((0, 0), text, font=font) x = (PANEL_WIDTH_PX - (bbox[2] - bbox[0])) // 2 - bbox[0] y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1] draw.text((x, y), text, font=font, fill=fill) def compose(state: dict): """Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus blocks(). 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) fonts = { ROLE_HEADER: _load_font(ImageFont, HEADER_FONT_PX, bold=True), ROLE_HERO: _load_font(ImageFont, HERO_FONT_PX, bold=True), ROLE_DETAIL: _load_font(ImageFont, DETAIL_FONT_PX), ROLE_BIG: _load_font(ImageFont, BIG_FONT_PX, bold=True), } max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX def fit(raw_text: str, font) -> str: # draw.textlength() wirft ValueError("can't measure length of multiline text"), # sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von # main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3, # F5). blocks() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen # (z.B. product_name) sind letztlich Daten von der Lampe/dem Netzwerk, kein # Wert, den blocks() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin # nur ein zerstoertes Layout, kein sinnvoller Inhalt. text = raw_text.replace("\n", " ") return _truncate_to_width( text, measure=lambda t: draw.textlength(t, font=font), max_width_px=max_width_px) y = TEXT_MARGIN_PX for raw_text, role in blocks(state): font = fonts[role] text = fit(raw_text, font) if role == ROLE_BIG: # Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze # Panel. _draw_centered(draw, text, font, top=0, height=PANEL_HEIGHT_PX, fill=0) continue if role == ROLE_HEADER: draw.rectangle((0, 0, PANEL_WIDTH_PX - 1, HEADER_BAR_HEIGHT_PX - 1), fill=0) _draw_centered(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX, fill=255) y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX continue line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX if y + line_height > PANEL_HEIGHT_PX: # Passt nicht mehr aufs Panel - abschneiden statt ueberzeichnen. Die # Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert. break draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0) y += line_height return image