"""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. # header_phase - die Phase (AUTO/LISTEN/FREIGEGEBEN), NUR im Migrationsmodus und # nur, wenn eine Phase bekannt ist. Steht in derselben Kopfzeile wie # header, rechtsbuendig, in kleinerer Schrift (zweite Hardware-Abnahme: # die Phase ging als eigene kleine Zeile unter dem Lampennamen unter). # Rettung/aus kennen keine Phase - dort bleibt die Kopfzeile # unveraendert (nur header, mittig, wie zuvor). NUR das kurze Label - # der Zielname der Freigabe steht als eigene Beiwerkzeile, siehe # ARMED_DETAIL_PREFIX. # 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. # footer - eine Zeile am UNTEREN Panelrand, ueber einen Trennstrich abgesetzt, # unabhaengig vom Zeilenfluss darueber. Genau ein Block benutzt sie: der # Bildsatz-Umschalter (mit oder ohne einkompilierte WLAN-Zugangsdaten). # Er gehoert dorthin, weil er ein DAUERZUSTAND ist und nicht zum # wechselnden Inhalt: er gilt in jedem Modus, auch bei ausgeschalteter # Station, und man soll ihn vor dem Einschalten sehen - nicht erst, # wenn zwanzig Lampen mit den eigenen Zugangsdaten geflasht sind. # Waere er eine gewoehnliche Beiwerkzeile, fiele er im vollsten Zustand # als letzte unten heraus, also ausgerechnet waehrend einer laufenden # Migration. ROLE_HEADER = "header" ROLE_HEADER_PHASE = "header_phase" ROLE_HERO = "hero" ROLE_DETAIL = "detail" ROLE_BIG = "big" ROLE_FOOTER = "footer" HEADER_FONT_PX = 20 HEADER_PHASE_FONT_PX = 13 HERO_FONT_PX = 16 DETAIL_FONT_PX = 11 BIG_FONT_PX = 26 FOOTER_FONT_PX = 11 # Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe: # Kopfbalken + grosse Zeile + fuenf Beiwerkzeilen) auf die 122 px passt, mit sichtbarem # Abstand nach unten uebrig (naechster Kommentar, BOTTOM_MARGIN_PX): # 22 + 2 + 20 + 5*13 = 109. Die Phase (frueher eine sechste Beiwerkzeile: "AUTO"/ # "FREIGEGEBEN: ...") steht seit der zweiten Hardware-Abnahme in der Kopfzeile # (ROLE_HEADER_PHASE) statt als eigene Zeile - das ist der Grund, warum nur noch fuenf # statt sechs Beiwerkzeilen uebrig bleiben, nicht eine nachtraeglich erfundene Kuerzung. HEADER_BAR_HEIGHT_PX = 22 HERO_LINE_HEIGHT_PX = 20 DETAIL_LINE_HEIGHT_PX = 13 # Hardware-Abnahme, zweiter Befund: auf dem echten Panel frisst ein Rahmen den unteren # Rand, der volleste Zustand (s.o., endet bei 109 px) wirkte dort gedraengt. compose() # bricht Beiwerkzeilen deshalb schon ab, bevor sie in die letzten BOTTOM_MARGIN_PX # Zeilen hineinragen wuerden (siehe die Abbruchbedingung dort) - mit den obigen 109 px # bleibt dabei auch im vollsten Zustand jede Zeile sichtbar, es wird nichts abgeschnitten. BOTTOM_MARGIN_PX = 10 # Das Band am unteren Rand fuer ROLE_FOOTER (Trennstrich + eine 11-px-Zeile). Es liegt # UEBER BOTTOM_MARGIN_PX, der Rand bleibt also weiterhin tintenfrei; der Zeilenfluss # darueber bricht entsprechend frueher ab. Das kostet im vollsten Zustand (Migration mit # erkannter Lampe) eine Beiwerkzeile - die unterste, also die unwichtigste nach der # Sortierung in blocks(). Der Preis ist gewollt: welcher Bildsatz gilt, ist die einzige # Auskunft auf diesem Panel, die man BRAUCHT, bevor etwas passiert. FOOTER_BAND_HEIGHT_PX = 14 # Abstand des Trennstrichs zur Fusszeile. FOOTER_RULE_GAP_PX = 2 # Mindestabstand zwischen dem Ende des Modus-Texts und dem Anfang der Phase in der # Kopfzeile (beide weiss auf demselben schwarzen Balken) - ohne diesen Puffer koennten # beide Texte bei einer langen Phase (z.B. "FREIGEGEBEN: ") # ineinanderlaufen. HEADER_PHASE_GAP_PX = 6 # Bleibt nach Modus-Text und Puffer weniger als das fuer die Phase uebrig, wird sie ganz # weggelassen statt ein einzelnes "…" hinzuzeichnen - das waere ohnehin keine Auskunft # mehr wert (dritte Option aus der Abnahme: kuerzen, verkleinern oder weglassen; die # ersten beiden greifen fuer die im Betrieb vorkommenden Werte immer). # # Dritte Hardware-Abnahme, Befund M2: der Reviewer hat zu Recht angemerkt, dass dieser # Zweig mit den heutigen Werten nicht feuern KANN - der Modus-Text im Migrationsmodus ist # die Konstante "MIGRATION" (MODE_LABELS), available_px ist damit fest (112 px DejaVu, # 127 px Arial) und nie < 24. Der Zweig bleibt trotzdem, aber er ist jetzt getestet # (test_header_phase_is_dropped_when_too_little_room_is_left): was ihn aufweckt, ist # nicht ein langer Zielname (den gibt es seit Befund I2 im Kopf gar nicht mehr), sondern # ein schmalerer Rest neben dem Modus - eine groessere HEADER_FONT_PX, eine deutlich # breitere Schrift, ein kuenftiger dritter Modus mit laengerem Namen oder ein groesserer # HEADER_PHASE_GAP_PX. Ohne den Zweig waere die Folge kein fehlendes Wort, sondern ein # "…", das sich mit dem Modus ueberlagert; das ist der Grund, warum hier ein # totgeglaubtes Sicherheitsnetz billiger ist als sein Fehlen. HEADER_PHASE_MIN_WIDTH_PX = 24 # 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 (es zieht zusaetzlich eine Beiwerkzeile mit dem # Zielnamen nach sich), deshalb hier nicht gelistet. PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"} # Dritte Hardware-Abnahme, Befund I2: in der Kopfzeile stand frueher # "FREIGEGEBEN: " - der Platz neben dem Modus reicht dafuer nicht (mit DejaVu # auf dem Pi bleiben 112 px, der Text misst 182 px), gezeichnet wurde also "FREIGEGEBE…": # ein Wortfragment, das bei JEDEM Ziel gleich aussieht. Ausgerechnet in "armed" muss der # Bediener aber sehen, WELCHES Ziel freigegeben ist, sonst gibt er im Zweifel die falsche # Lampe frei. Deshalb im Kopf nur noch das kurze Label (das auch bei einer deutlich # breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft # verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der # Zielname als eigene Beiwerkzeile ueber die volle Panelbreite. ARMED_LABEL = "FREIGEGEBEN" ARMED_DETAIL_PREFIX = "Freigabe: " # Der Bildsatz-Umschalter in der Fusszeile. Bewusst als Aussage ueber die LAMPE formuliert # und nicht ueber das Verzeichnis: "migrate-wlan" sagt dem Bediener am Geraet nichts, # "Lampe: sofort ins WLAN" sagt ihm genau das, was er wissen muss, bevor er einschaltet. # Der gefaehrliche Zustand steht in Grossbuchstaben - er ist der, den man uebersehen soll # und darf. IMAGESET_LABELS = { "ohne-wlan": "Lampe -> Onboarding-AP (ohne WLAN-Daten)", "mit-wlan": "Lampe -> SOFORT INS EIGENE WLAN", } IMAGESET_MISSING_SUFFIX = " - SATZ FEHLT" # Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant): # "-dev" kann in der Produkt-ID stehen, in der Version, oder in beiden. Die Quelle steht # mit auf dem Panel, weil genau hier am 2026-09-15 ein falsch signiertes Image # ausgeliefert wurde: wer vor der Freigabe sieht, dass "dev" nur aus der Version kommt # (oder dass "prod" bedeutet: nirgends ein "-dev"), kann eine Fehlklassifikation abfangen, # bevor die Lampe das Image ablehnt. Kurzformen, weil die Beiwerkzeile schmal ist. VARIANT_SOURCE_LABELS = {"both": "ID+Ver", "product_id": "ID", "version": "Ver"} def _variant_label(last_seen: dict) -> str: """"dev/Ver", "dev/ID", "dev/ID+Ver", "prod" - die Quelle nur, wo sie etwas aussagt. Bei "prod" gibt es keine ("none"), und eine aeltere Serverfassung ohne das Feld liefert wie bisher nur die Variante.""" variant = str(last_seen.get("variant") or "?") label = VARIANT_SOURCE_LABELS.get(last_seen.get("variant_source")) if not label: return variant return variant + "/" + label 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 {} # Laeuft gerade eine Bedienung am Taster, ERSETZT sie das Statusbild komplett - eine # Bedienung, die neben dem Statusbild um Platz kaempft, waere auf 250x122 unlesbar, # und in diesem Moment ist der Bedienzustand die einzige Auskunft, auf die der # Bediener wartet. display.read_ui() liefert {} (und damit kein "ui"), sobald nichts # laeuft - dann geht es unveraendert weiter wie zuvor. ui = state.get("ui") if isinstance(ui, dict) and ui.get("view") not in (None, "", "idle"): return _ui_blocks(ui) mode = state.get("mode") out: list[tuple[str, str]] = [] # Die Fusszeile haengt an JEDEM Statusbild - auch an "Station aus" und an "Status # unbekannt", die beide sofort zurueckkehren. Sie wird deshalb hier gebaut und unten # bei jedem Rueckgabepunkt angehaengt. footer = _imageset_blocks(state) 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 + footer 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 + footer out.append((MODE_LABELS[mode], ROLE_HEADER)) ssid = state.get("ssid") if mode == "recovery": # Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die # Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin # wie vor der zweiten Hardware-Abnahme, siehe dort). # # Die SSID ist 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. # # Seit der zweiten Hardware-Abnahme steht die Phase NICHT mehr als eigene # Beiwerkzeile unter dem Lampennamen (dort ging sie unter), sondern direkt # NEBEN dem Modus in der Kopfzeile (ROLE_HEADER_PHASE, rechtsbuendig) - deshalb # wird sie hier direkt nach dem ROLE_HEADER-Block angehaengt, nicht erst nach # der grossen Zeile. phase = state.get("phase") armed_target = None if phase == "armed": # Nur das Label in den Kopf, der Zielname kommt weiter unten als eigene # Beiwerkzeile (siehe ARMED_DETAIL_PREFIX) - in den Kopf passt er nicht, # und dort war er bis zur dritten Abnahme faktisch unsichtbar. out.append((ARMED_LABEL, ROLE_HEADER_PHASE)) armed_target = str(state.get("armed_target") or "?") elif phase in PHASE_LABELS: out.append((PHASE_LABELS[phase], ROLE_HEADER_PHASE)) 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)) if armed_target is not None: # ERSTE Beiwerkzeile, also die, die als letzte der unteren Randgrenze zum # Opfer fiele (compose() bricht von unten ab, blocks() ist nach Wichtigkeit # sortiert): in "armed" ist das freigegebene Ziel die Auskunft, wegen der # der Bediener ueberhaupt aufs Panel sieht. out.append((f"{ARMED_DETAIL_PREFIX}{armed_target}", ROLE_DETAIL)) if product_name: target = last_seen.get("target") variant = last_seen.get("variant") if target or variant: out.append(("{} ({})".format(target or "?", _variant_label(last_seen)), ROLE_DETAIL)) lamp_version = last_seen.get("lamp_version") if lamp_version: out.append((f"FW: {lamp_version}", ROLE_DETAIL)) if ssid and armed_target is None: # Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die # SSID zweimal auf dem Panel. # # Und nur ohne Freigabezeile: aufs Panel passen neben der grossen Zeile # fuenf Beiwerkzeilen (siehe BOTTOM_MARGIN_PX), die Freigabezeile ist die # sechste. Von den beiden gibt die SSID nach - sie ist die Auskunft, mit # der man das Netz FINDET, und in "armed" hat die Lampe es laengst # gefunden (sie wurde ja erkannt, sonst gaebe es kein product_name); # ohne erkannte Lampe steht die SSID ohnehin als grosse Zeile da. out.append((f"SSID: {ssid}", ROLE_DETAIL)) # Der optionale Zeitschalter, in BEIDEN Modi und vor den reinen Zaehlerzeilen: ein # Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner - man glaubt, # die Station laeuft, und sie ist laengst aus. Fehlt er (der Regelfall, denn er # entsteht nur mit "--for"), fehlt die Zeile ersatzlos. # # Er steht damit VOR "Ausgeliefert"/"zuletzt ausgeliefert": im vollsten Zustand # passen fuenf Beiwerkzeilen aufs Panel (siehe BOTTOM_MARGIN_PX), die sechste faellt # unten heraus. Von diesen beiden Auskuenften ist "wann geht die Station aus" # diejenige, nach der man ueberhaupt aufs Panel sieht - der Zaehler der letzten # Auslieferung steht auch noch in "ninlight-station status". autooff_minutes = state.get("autooff_minutes") if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool): if autooff_minutes > 0: out.append(("Auto-Aus in {} min".format(autooff_minutes), ROLE_DETAIL)) else: out.append(("Auto-Aus steht an", 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 + footer def _imageset_blocks(state: dict) -> list[tuple[str, str]]: """Die Fusszeile mit dem Bildsatz-Umschalter, oder [] wenn der Zustand keinen traegt (aeltere display.py-Fassung, von Hand zusammengesetzter Zustand in einem Test). Fehlt das Verzeichnis des GEWAEHLTEN Satzes, sagt die Zeile das - dann liefert server.py naemlich nichts aus, und ein Panel, das trotzdem nur den Namen zeigt, behauptet einen Betrieb, den es nicht gibt.""" name = state.get("imageset") label = IMAGESET_LABELS.get(name) if not label: return [] if state.get("imageset_ok") is False: label += IMAGESET_MISSING_SUFFIX return [(label, ROLE_FOOTER)] def _ui_blocks(ui: dict) -> list[tuple[str, str]]: """Der Bedienzustand des Tasterdienstes in Zeilen mit Rolle. Bewusst DUMM: welcher Text bei welcher Geste erscheint, entscheidet allein der Tasterdienst (dort ist es ohne Hardware pruefbar) - hier wird er nur noch den vorhandenen Rollen zugeordnet, damit Kopfbalken, Schriftgroessen, Breitenkuerzung und unterer Rand dieselben bleiben wie im Statusbild. Jedes Feld wird auf seinen Typ geprueft: die Datei kommt aus einem anderen Prozess (der aelter, neuer oder halb geschrieben sein kann) - genau wie status.json darf sie die Anzeige nie zum Absturz bringen.""" out: list[tuple[str, str]] = [] title = ui.get("title") if isinstance(title, str) and title: out.append((title, ROLE_HEADER)) hero = ui.get("hero") if isinstance(hero, str) and hero: out.append((hero, ROLE_HERO)) details = ui.get("details") if isinstance(details, list): for detail in details: if isinstance(detail, str) and detail: out.append((detail, ROLE_DETAIL)) if not out: # Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text # (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als # ein leeres Panel zu zeigen, das nach "haengt" aussieht. out.append(("Bedienung", ROLE_BIG)) 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 # Prozess-Cache fuer _load_font(). Dritte Hardware-Abnahme, Befund M4: compose() baute je # Aufruf FUENF Schriften neu (auf der Entwicklungsmaschine gemessen: 25,0 ms je compose(), # davon 21,2 ms Schriftaufrufe - auf einem ARMv6-Kern ohne NEON grob 0,5-1 s, und das auf # demselben Kern, auf dem server.py die Lampen bedient, bei JEDER ausgelieferten Lampe, # weil sich der Zaehler aendert). # # Der Schluessel enthaelt das hereingereichte ImageFont-MODUL und FONT_SEARCH_PATHS, nicht # nur Groesse/Fettung: beide werden in den Tests ersetzt (nachgebautes Modul, # monkeypatch auf eine leere oder andere Suchkette), und ein Cache, der das ignoriert, # wuerde einem Test die Schrift eines anderen unterschieben - genau der Grund, aus dem # Task 7b noch bewusst auf einen Cache verzichtet hatte. _FONT_CACHE: dict = {} 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. Das Ergebnis wird prozessweit zwischengespeichert (siehe _FONT_CACHE): dieselbe Schrift in derselben Groesse wird je Prozess genau einmal von der Platte gelesen und geparst. `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.""" # Das Modul selbst (nicht seine id()) im Schluessel: Module und die nachgebauten # Testmodule sind ueber ihre Identitaet hashbar, und solange der Schluessel das # Objekt festhaelt, kann keine recycelte id() zwei verschiedene Module # zusammenwerfen. key = (image_font, size_px, bold, FONT_SEARCH_PATHS) if key in _FONT_CACHE: return _FONT_CACHE[key] for regular_path, bold_path in FONT_SEARCH_PATHS: path = bold_path if bold else regular_path try: font = image_font.truetype(path, size_px) break except (OSError, ImportError): continue else: font = image_font.load_default() _FONT_CACHE[key] = font return font def _draw_text(draw, text: str, font, top: int, height: int, fill: int, align: str = "center") -> int: """Zeichnet `text` senkrecht mittig in das Band [top, top+height) und waagerecht nach `align`: "center" (Panelmitte - bisheriges Verhalten, fuer ROLE_BIG und die Kopfzeile ohne Phase), "left" (linker Rand + TEXT_MARGIN_PX - der Modus, sobald eine Phase daneben steht) oder "right" (rechter Rand - TEXT_MARGIN_PX - die Phase). 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. Gibt die gezeichnete Tintenbreite zurueck - compose() braucht sie fuer den Modus in der Kopfzeile, um daraus auszurechnen, wie viel Platz rechts noch fuer die Phase uebrig ist.""" bbox = draw.textbbox((0, 0), text, font=font) ink_width = bbox[2] - bbox[0] if align == "left": visual_x = TEXT_MARGIN_PX elif align == "right": visual_x = PANEL_WIDTH_PX - TEXT_MARGIN_PX - ink_width else: visual_x = (PANEL_WIDTH_PX - ink_width) // 2 y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1] draw.text((visual_x - bbox[0], y), text, font=font, fill=fill) return ink_width 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) # Je Rolle FAUL geladen, nicht alle fuenf im Voraus (Befund M4): "Station aus" und # "Status unbekannt" zeichnen genau eine Rolle (ROLE_BIG) - vier der fuenf Schriften # waeren dort reine Ladezeit. Zusammen mit _FONT_CACHE kostet jede Rolle im Betrieb # hoechstens einmal je Prozess etwas, danach nur noch einen Woerterbuchzugriff. role_fonts = { ROLE_HEADER: (HEADER_FONT_PX, True), ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True), ROLE_HERO: (HERO_FONT_PX, True), ROLE_DETAIL: (DETAIL_FONT_PX, False), ROLE_BIG: (BIG_FONT_PX, True), ROLE_FOOTER: (FOOTER_FONT_PX, True), } loaded: dict = {} def font_for(role: str): if role not in loaded: size_px, bold = role_fonts[role] loaded[role] = _load_font(ImageFont, size_px, bold=bold) return loaded[role] max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX def fit(raw_text: str, font, width_px: float = max_width_px) -> 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=width_px) block_list = blocks(state) # Nur wenn eine Phase folgt, ruckt der Modus in der Kopfzeile nach links (siehe # Rollen-Kommentar oben) - Rettung und "aus" kennen keinen ROLE_HEADER_PHASE-Block # und behalten die zentrierte Kopfzeile von vor der zweiten Hardware-Abnahme. has_header_phase = any(role == ROLE_HEADER_PHASE for _, role in block_list) header_mode_right_edge_px = 0.0 # Die Fusszeile sitzt an einem FESTEN Platz am unteren Rand, nicht im Zeilenfluss - # der Zeilenfluss darueber muss deshalb frueher abbrechen. Ohne Fusszeile bleibt die # Grenze exakt die alte (BOTTOM_MARGIN_PX), damit sich an allen bisherigen Bildern # nichts verschiebt. footer_texts = [text for text, role in block_list if role == ROLE_FOOTER] block_list = [(text, role) for text, role in block_list if role != ROLE_FOOTER] has_footer = bool(footer_texts) footer_top_px = PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX - FOOTER_BAND_HEIGHT_PX flow_limit_px = footer_top_px if has_footer else PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX # ROLE_BIG bekommt das ganze Panel (bisheriges Verhalten) bzw. alles oberhalb der # Fusszeile - der untere Rand ist dort kein Abbruchkriterium, sondern nur eine # Reserve fuer den Zeilenfluss. big_height_px = footer_top_px if has_footer else PANEL_HEIGHT_PX # Die Fusszeile wird VOR dem Zeilenfluss gezeichnet und ist aus block_list bereits # herausgenommen - sonst haette sie der "break" der Abbruchbedingung weiter unten # verschluckt, sobald der Zeilenfluss vorher voll ist (also ausgerechnet im vollsten # Zustand, waehrend einer laufenden Migration). Sie sitzt an festem Platz; ihre # Reihenfolge innerhalb von blocks() spielt fuer das Bild keine Rolle. if has_footer: footer_font = font_for(ROLE_FOOTER) # Trennstrich ueber die volle Breite, darunter die Zeile - so ist auf einen # Blick klar, dass das keine weitere Beiwerkzeile ist, sondern ein Zustand, # der unabhaengig vom Rest gilt. draw.line((0, footer_top_px, PANEL_WIDTH_PX - 1, footer_top_px), fill=0) _draw_text(draw, fit(footer_texts[0], footer_font), footer_font, top=footer_top_px + FOOTER_RULE_GAP_PX, height=FOOTER_BAND_HEIGHT_PX - FOOTER_RULE_GAP_PX, fill=0, align="center") y = TEXT_MARGIN_PX for raw_text, role in block_list: font = font_for(role) if role == ROLE_BIG: # Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze # Panel. text = fit(raw_text, font) # Mittig im Bereich UEBER der Fusszeile (ohne Fusszeile: im ganzen Panel) - # sonst sitzt "Station aus" sichtbar zu tief und beruehrt den Trennstrich. _draw_text(draw, text, font, top=0, height=big_height_px, fill=0, align="center") continue if role == ROLE_HEADER: text = fit(raw_text, font) draw.rectangle((0, 0, PANEL_WIDTH_PX - 1, HEADER_BAR_HEIGHT_PX - 1), fill=0) align = "left" if has_header_phase else "center" ink_width = _draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX, fill=255, align=align) if align == "left": header_mode_right_edge_px = TEXT_MARGIN_PX + ink_width y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX continue if role == ROLE_HEADER_PHASE: # Rechtsbuendig NEBEN dem Modus (der links steht, s.o.) im selben Balken - # gemessen ab dem tatsaechlichen rechten Rand des Modus-Texts, nicht ab # einer angenommenen festen Breite: "MIGRATION" und "RETTUNG" sind # unterschiedlich breit, und nur "MIGRATION" hat je eine Phase daneben. available_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - header_mode_right_edge_px - HEADER_PHASE_GAP_PX) if available_px >= HEADER_PHASE_MIN_WIDTH_PX: text = fit(raw_text, font, width_px=available_px) _draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX, fill=255, align="right") # sonst: Phase weglassen (dritte Option aus der Abnahme) - bei so wenig # Platz waere selbst ein gekuerztes "…" keine Auskunft mehr wert, und ein # Ueberlappen mit dem Modus ist nicht erlaubt. continue text = fit(raw_text, font) line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX if y + line_height > flow_limit_px: # Passt nicht mehr aufs Panel, ohne in den fuer BOTTOM_MARGIN_PX # reservierten unteren Rand zu ragen - 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