Files
ninlight-w600/tools/station/display/render.py
T
Michael Sauer 021089a68b feat(station): Panel und CLI auf Englisch, IP-Zeile im Off-Bild
Die deutschen Texte sahen auf dem 2,13"-Panel merkwuerdig aus - jetzt sind
alle Texte, die der Bediener am Geraet oder auf der Konsole sieht, englisch:
Modus-, Phasen-, Bildsatz- und Abschlussbeschriftungen in render.py, die
Bedientexte des Tasterdienstes und saemtliche Ausgaben von ninlight-station
inklusive des Hilfetexts. Die Testbeschreibungen der Suiten bleiben deutsch -
die liest nur, wer die Tests laufen laesst.

Neu im Off-Bild: die Adresse, unter der der Pi gerade erreichbar ist. Ist die
Station aus, ist der Pi ein gewoehnlicher Rechner im Heimnetz, und die
einzige Auskunft, die man dann am Panel braucht, ist die fuer SSH. Die
Adresse kommt aus einem UDP-Socket, der nur verbunden und nie benutzt wird
(kein Datenverkehr, kein DNS, kein Unterprozess in einer 5-s-Schleife) und
liefert genau die Adresse der Standardroute. Ohne Netz sagt die Zeile
ausdruecklich "no network": eine fehlende Zeile wuerde offen lassen, warum
SSH gerade scheitert.

Die Zeile gibt es NUR im Off-Bild - im Betrieb traegt wlan0 die Adresse des
Zugangspunkts, die ohnehin auf jedem Statusbild steht.
2026-09-20 00:28:33 +02:00

728 lines
39 KiB
Python

"""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: <langer Zielname>")
# 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": "RECOVERY", "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: <zielname>" - 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 = "ARMED"
ARMED_DETAIL_PREFIX = "Armed: "
# 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.
# Die Zeile muss UNGEKUERZT passen, und zwar auch mit einer breiteren Schrift als der
# hier gerade installierten: am Geraet (DejaVu) kam die alte, laengere Fassung als
# "Lampe -> Onboarding-AP (ohne WLA..." an - der Zusatz in der Klammer, also gerade die
# Aussage, fiel weg. Auf dem Mac des Entwicklers (Arial, schmaler) passte dieselbe Zeile
# mit 246,0 von 246 px haarscharf; deshalb hat es niemand bemerkt. Breitenbudget siehe
# test_imageset_footer_fits_the_panel_with_room_for_a_wider_font.
IMAGESET_LABELS = {
"ohne-wlan": "no Wi-Fi data (onboarding AP)",
"mit-wlan": "WITH Wi-Fi data (own network)",
}
# Fehlt das Verzeichnis des gewaehlten Satzes, ERSETZT die Warnung die Zeile, statt sie
# zu verlaengern. Als angehaengtes Suffix (" - SATZ FEHLT", bis 2026-09-15) war sie in
# BEIDEN Richtungen und in beiden Schriften ausserhalb der Panelbreite - die einzige
# Zeile, die sagt, dass die Station gar nichts ausliefern kann, war damit nie zu sehen.
IMAGESET_MISSING_LABELS = {
"ohne-wlan": "SET MISSING: no Wi-Fi data",
"mit-wlan": "SET MISSING: WITH Wi-Fi data",
}
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt
# oder neu gestartet wird. E-Paper ist bistabil - das zuletzt gezeichnete Bild bleibt fuer
# immer stehen, auch stromlos. Ohne diesen Schritt behauptet das Panel nach dem
# Ausschalten weiter den Zustand von vorher (bei der Abnahme stand dort die RUECKFRAGE,
# und der Pi sah aus, als warte er noch auf eine Eingabe).
#
# Die beiden Texte unterscheiden sich, und das ist der Punkt: beim Ausschalten DARF man
# den Stecker ziehen, beim Neustart NICHT - ein Zero W braucht gut anderthalb Minuten, bis
# der Anzeigedienst wieder zeichnet, und an genau diesem Pi hat ein Stromzug in dieser
# Wartezeit schon eine SD-Karte gekostet.
FINAL_LABELS = {
# "Strom kann getrennt werden" war in der Hero-Schrift 262,1 px breit und kam am
# Geraet als "Strom kann getrennt wer..." an - siehe die Fusszeile weiter oben, es
# ist derselbe Fehler. Alle festen Beschriftungen haengen jetzt an
# test_fixed_labels_fit_the_panel_with_room_for_a_wider_font.
"poweroff": ("POWERED OFF", "Safe to unplug", []),
"reboot": ("REBOOTING", "Do NOT unplug!",
["The Pi is back in about 2 min.",
"This screen stays until then."]),
}
# 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]] = []
if mode == "final":
# Abschlussbildschirm - KEINE Fusszeile und keine weiteren Zeilen: die Station ist
# im Begriff zu verschwinden, und die einzige Auskunft, auf die es jetzt ankommt,
# ist "darf ich den Stecker ziehen?".
label = FINAL_LABELS.get(state.get("final"))
if label is None:
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
# zu sagen, wenn gleich wieder gebootet wird.
return [("Shutting down", ROLE_BIG)]
title, hero, details = label
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
+ [(d, ROLE_DETAIL) for d in details])
# 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 off", ROLE_BIG))
# Die Adresse, unter der der Pi gerade erreichbar ist - die einzige Auskunft, die
# man bei ausgeschalteter Station am Geraet wirklich braucht (per SSH draufgehen,
# ohne den Router zu befragen). display.py legt sie nur in DIESEN Zustand; fehlt
# das Feld (aeltere display.py-Fassung, von Hand gebauter Zustand im Test), bleibt
# die Zeile weg statt eine Adresse zu erfinden. Ist das Feld da, aber leer, sagt
# die Zeile ausdruecklich, dass kein Netz da ist: "keine Zeile" und "kein Netz"
# sind zwei verschiedene Auskuenfte, und die zweite erklaert, warum SSH scheitert.
ip = state.get("ip")
if isinstance(ip, str):
out.append((f"IP {ip}" if ip else "no network", ROLE_DETAIL))
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 unknown ({mode})" if mode else "Status unknown", 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-off in {} min".format(autooff_minutes), ROLE_DETAIL))
else:
out.append(("Auto-off pending", ROLE_DETAIL))
served = state.get("served")
out.append((f"Served: {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_LABELS[name]
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(("Button menu", 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