Welche Version die Station gerade ausliefert, war am Geraet nur per SSH zu beantworten. Jetzt sagt es die Anzeige selbst, je nach Bild an der Stelle, wo Platz ist: - Aus-Zustand: rechtsbuendig neben der Adresse (neue Rolle ROLE_DETAIL_RIGHT, nach dem Muster der Phase neben dem Modus) - Rettung: rechtsbuendig in der Kopfzeile, dort steht keine Phase im Weg - Migration: als beschriftete Zeile "liefert: <version>" direkt hinter "FW:", NICHT in der Kopfzeile - daneben bleiben nur rund 112 px, daran ist schon "FREIGEGEBEN: <ziel>" gescheitert, und zwei nackte Versionsnummern nebeneinander waeren eine Verwechslungsfalle (FW: ist die der Lampe) Quelle ist der GEWAEHLTE Bildsatz: im Migrationsmodus manifest.json (die Migrationsabzuege tragen keinen ninlight-Anhaenger), im Rettungsmodus der Anhaenger der _ota.img. Weichen die Eintraege voneinander ab, steht da "gemischt" - ein zusammengefuehrter Deploy kann Alt-Eintraege mit anderer Version enthalten, und eine davon herauszugreifen waere geraten. Fehlt die Quelle, bleibt die Angabe weg statt eine Version zu erfinden.
807 lines
44 KiB
Python
807 lines
44 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"
|
|
# Rechtsbuendig in DERSELBEN Zeile wie die zuletzt gezeichnete Detailzeile.
|
|
# Genau ein Block benutzt sie: die ausgelieferte Firmware-Version neben der
|
|
# Adresse im Aus-Zustand. Eigene Rolle statt einer zweiten Zeile, weil der
|
|
# Platz auf 250x122 knapp ist und beide Angaben zusammengehoeren.
|
|
ROLE_DETAIL_RIGHT = "detail_right"
|
|
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"
|
|
# Praefix der Zeile mit der ausgelieferten Version - grenzt sie von "FW:" ab, das die
|
|
# Version der erkannten Lampe traegt.
|
|
DELIVERS_PREFIX = "liefert: "
|
|
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.
|
|
#
|
|
# Beschriftet mit dem GERAET ("wlan0: 192.168.2.96"), nicht mit "LAN": derselbe
|
|
# Bediener las "LAN" als Falschauskunft, weil dieser Pi per WLAN im Netz haengt
|
|
# und gar kein Kabel hat (Rueckmeldung 2026-09-23). Der Geraetename grenzt
|
|
# ausserdem genauso eindeutig vom Zugangspunkt-Netz ab, das auf jedem ANDEREN
|
|
# Statusbild als SSID steht - und er sagt zusaetzlich, WORUEBER der Pi haengt.
|
|
# Ohne Netz "no home network" statt eines nackten "no network": letzteres wurde
|
|
# auf den Migrations-/Rettungs-Zugangspunkt bezogen (der im Aus-Zustand ja
|
|
# tatsaechlich aus ist). Gemeint ist das GEGENTEIL - das gewoehnliche Heimnetz.
|
|
ip = state.get("ip")
|
|
if isinstance(ip, str):
|
|
geraet = state.get("iface")
|
|
if not ip:
|
|
out.append(("no home network", ROLE_DETAIL))
|
|
elif isinstance(geraet, str) and geraet:
|
|
out.append((f"{geraet}: {ip}", ROLE_DETAIL))
|
|
else:
|
|
out.append((f"IP: {ip}", ROLE_DETAIL))
|
|
out += _delivered_version_blocks(state, ROLE_DETAIL_RIGHT)
|
|
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":
|
|
out += _delivered_version_blocks(state, ROLE_HEADER_PHASE)
|
|
# 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))
|
|
# Beschriftet und unmittelbar hinter "FW:", weil beides Versionsnummern sind
|
|
# und nebeneinander sonst niemand weiss, welche wem gehoert: "FW:" ist die
|
|
# der erkannten LAMPE, "liefert:" die des Bildsatzes. Nicht in die Kopfzeile:
|
|
# dort steht im Migrationsmodus die Phase, und neben dem Modus bleiben nur
|
|
# rund 112 px - daran ist schon "FREIGEGEBEN: <ziel>" gescheitert.
|
|
for text, _ in _delivered_version_blocks(state, ROLE_DETAIL):
|
|
out.append((f"{DELIVERS_PREFIX}{text}", 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")
|
|
served_line = f"Served: {served if isinstance(served, int) else 0}"
|
|
# Lebenszeichen: ein kleiner Punkt, sobald IRGENDEINE Anfrage vor kurzem hereinkam
|
|
# (display.py:_recent_request() - auch eine erfolglose zaehlt). Angehaengt an eine
|
|
# bereits bestehende, immer vorhandene Zeile statt an einer eigenen Stelle im Bild
|
|
# gezeichnet: er braucht dadurch keine eigene Zeichenlogik in compose() und keinen
|
|
# eigenen Platz im knappen Zeilenbudget, und er faehrt ueber denselben
|
|
# Textvergleich (RefreshPlanner.decide() in display.py) mit, der ueber ein
|
|
# Neuzeichnen entscheidet - kein zusaetzlicher Takt, kein zusaetzliches Wachhalten
|
|
# des E-Papers, siehe display.py:ACTIVITY_WINDOW_S fuer die volle Begruendung.
|
|
if state.get("activity"):
|
|
served_line += " •"
|
|
out.append((served_line, 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 _delivered_version_blocks(state: dict, role: str) -> list[tuple[str, str]]:
|
|
"""Die Version, die die Station ausliefert - oder [], wenn sie unbekannt ist.
|
|
|
|
Bewusst keine Ersatzanzeige: eine erfundene oder veraltete Versionsnummer ist
|
|
schlimmer als keine, denn sie beantwortet genau die Frage falsch, fuer die sie da
|
|
ist. display.py liefert "" , wenn manifest.json bzw. die Abzuege fehlen.
|
|
"""
|
|
version = state.get("image_version")
|
|
if not isinstance(version, str) or not version:
|
|
return []
|
|
return [(version, role)]
|
|
|
|
|
|
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_DETAIL_RIGHT: (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
|
|
# Oberkante und rechter Rand der zuletzt gezeichneten Flusszeile - ROLE_DETAIL_RIGHT
|
|
# setzt seinen Text daneben.
|
|
letzte_zeile_oben: int | None = None
|
|
letzte_zeile_rechts = 0.0
|
|
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
|
|
|
|
if role == ROLE_DETAIL_RIGHT:
|
|
# Rechtsbuendig neben der zuletzt gezeichneten Zeile, gemessen ab deren
|
|
# tatsaechlichem rechten Rand - wie die Phase neben dem Modus. Ohne
|
|
# vorangehende Zeile (von Hand zusammengesetzter Zustand im Test) oder bei
|
|
# zu wenig Platz entfaellt die Angabe: ein Ueberlappen waere schlimmer als
|
|
# eine fehlende Zusatzauskunft.
|
|
if letzte_zeile_oben is None:
|
|
continue
|
|
frei_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - letzte_zeile_rechts
|
|
- HEADER_PHASE_GAP_PX)
|
|
if frei_px >= HEADER_PHASE_MIN_WIDTH_PX:
|
|
text = fit(raw_text, font, width_px=frei_px)
|
|
_draw_text(draw, text, font, top=letzte_zeile_oben,
|
|
height=DETAIL_LINE_HEIGHT_PX, fill=0, align="right")
|
|
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)
|
|
letzte_zeile_oben = y
|
|
letzte_zeile_rechts = TEXT_MARGIN_PX + draw.textlength(text, font=font)
|
|
y += line_height
|
|
|
|
return image
|