Files
ninlight-w600/tools/station/display/render.py
T
Michael Sauer bb39105ac4 feat(display): grosse Schrift, invertierter Kopf, GPIO-Uebergabe
Zwei Befunde der Hardware-Abnahme.

1) Die Schrift war zu klein: compose() zeichnete alles in
ImageFont.load_default() (rund 10 px) - auf 250x122 aus zwei Metern
nicht lesbar. blocks() vergibt jetzt Rollen (header/hero/detail/big),
compose() setzt jede Rolle eigen: Modus fett und weiss auf schwarzem
Balken, die wichtigste Zeile gross (Migration: erkannte Lampe,
Rettung: SSID), Beiwerk klein, "Station aus" gross und mittig.

Die Schrift kommt per ImageFont.truetype() aus FONT_SEARCH_PATHS mit
DejaVu (/usr/share/fonts/truetype/dejavu/) als Hauptweg - auf beiden
Pis vorhanden. load_default(size=...) scheidet aus: das gibt es erst
ab Pillow 10.1, Pi 2 hat 9.4.0. Letzter Rueckfall bleibt
load_default() ohne Argument, damit eine fehlende Schrift die Anzeige
(und damit nie eine Migration) aufhaelt. lines() bleibt ohne Pillow
importierbar, die Breitenkuerzung misst weiter in Pixeln.

2) "display test" scheiterte bei laufendem Anzeigedienst mit einem
rohen "lgpio.error: 'GPIO busy'": lgpio.error ist keine
gpiozero.GPIOZeroError und fiel durch die Ausnahmeliste in
epdconfig.module_init(). Sie ist jetzt aufgefuehrt - weiterhin
gezielt, TypeError/AttributeError/NameError bleiben Programmierfehler -
und die Meldung nennt bei belegten Leitungen den Anzeigedienst.
"display test|clear" stoppt den Dienst fuer die Dauer des Handgriffs
und startet ihn danach wieder, aber nur, wenn er vorher lief.
2026-09-14 02:24:14 +02:00

333 lines
16 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.
# hero - die eine wichtigste Information des Zustands (Migration: der erkannte
# Produktname; Rettung: die SSID), fett.
# detail - Beiwerk (Ziel/Variante, Lampenversion, Zaehler, letzte Auslieferung).
# big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"): gross und
# mittig, damit man quer durch den Raum sieht, dass nichts laeuft.
ROLE_HEADER = "header"
ROLE_HERO = "hero"
ROLE_DETAIL = "detail"
ROLE_BIG = "big"
HEADER_FONT_PX = 20
HERO_FONT_PX = 16
DETAIL_FONT_PX = 11
BIG_FONT_PX = 26
# Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe:
# Kopfbalken + grosse Zeile + sechs Beiwerkzeilen) exakt auf die 122 px passt und die
# letzte Zeile - "zuletzt ausgeliefert" - nicht mehr hinten abfaellt:
# 22 + 2 + 20 + 6*13 = 122.
HEADER_BAR_HEIGHT_PX = 22
HERO_LINE_HEIGHT_PX = 20
DETAIL_LINE_HEIGHT_PX = 13
# Suchkette fuer eine echte, skalierbare Schrift, in dieser Reihenfolge probiert
# (Regular, Bold). ImageFont.load_default(size=...) waere der naheliegende Weg, ist aber
# erst ab Pillow 10.1 vorhanden - Pi 2 (Raspberry Pi OS Bookworm) hat Pillow 9.4.0, und
# dort wirft load_default(size=...) TypeError ("unexpected keyword argument 'size'"), am
# Geraet gegengeprueft. Deshalb fuehrt der Hauptweg ueber ImageFont.truetype() auf eine
# Datei im Dateisystem: /usr/share/fonts/truetype/dejavu/ liegt auf BEIDEN Pis
# (Debian 13 trixie wie Bookworm) und ist damit der einzige Pfad, auf den es im Betrieb
# ankommt. Alles darunter existiert nur, damit compose() auch auf Rechnern ohne den
# Debian-Pfad eine skalierbare Schrift findet - insbesondere auf der macOS-
# Entwicklungsmaschine, wo tools/station/tests/test_render.py laufen muss.
#
# Letzter Rueckfall (in _load_font(), nicht in dieser Liste) ist load_default() OHNE
# Argument: dann ist die Schrift wieder klein, aber nichts stuerzt ab - die Zusage
# "eine fehlende Schrift darf die Anzeige (und damit nie eine Migration) aufhalten"
# gilt unveraendert.
FONT_SEARCH_PATHS = (
# Debian/Raspberry Pi OS - der Betriebsfall auf beiden Pis.
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
# Andere Linux-Distributionen legen dieselbe Schrift woanders ab.
("/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf"),
("/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"),
# DejaVu per Homebrew (macOS), falls vorhanden.
("/opt/homebrew/share/fonts/DejaVuSans.ttf",
"/opt/homebrew/share/fonts/DejaVuSans-Bold.ttf"),
# macOS-Bordmittel - nur fuer die Entwicklungsmaschine, nie fuer den Pi.
("/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
)
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
# gelistet.
PHASE_LABELS = {"auto": "AUTO", "listen": "HORCHT"}
def _hhmm(ts: object) -> str:
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
aelteren Serverfassung ohne Zeitstempel) -> "--:--"."""
if isinstance(ts, str) and len(ts) >= 16 and ts[10:11] == "T":
return ts[11:16]
return "--:--"
def blocks(state: dict) -> list[tuple[str, str]]:
"""Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen mit ihrer ROLLE
(siehe Rollen-Kommentar oben, und den Modul-Docstring, warum die Kuerzung hier bewusst
nicht passiert). Wirft nie - fehlende/falsch geformte Felder werden ausgelassen statt
eine Ausnahme zu werfen.
Bleibt wie lines() ohne Pillow importierbar und testbar: hier entsteht nur Text, kein
Bild - die Rolle ist ein reiner Bezeichner, die zugehoerigen Schriftgroessen kennt
ausschliesslich compose()."""
state = state if isinstance(state, dict) else {}
mode = state.get("mode")
out: list[tuple[str, str]] = []
if mode == "off":
# Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
out.append(("Station aus", ROLE_BIG))
return out
if mode not in ("recovery", "migration"):
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
# unterstellen.
out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG))
return out
out.append((MODE_LABELS[mode], ROLE_HEADER))
ssid = state.get("ssid")
if mode == "recovery":
# Im Rettungsmodus ist die SSID das, was der Bediener am Geraet braucht (er muss
# sich mit genau diesem Netz verbinden) - deshalb gross und ohne "SSID: "-Praefix,
# das den Platz nur wegnaehme.
if ssid:
out.append((str(ssid), ROLE_HERO))
else:
# Phase kommt aus control.json, nicht aus status.json (das schreibt der Server
# nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als
# Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine
# Phase, deshalb dieser Block nur hier drin.
last_seen = state.get("last_seen")
if not isinstance(last_seen, dict):
last_seen = {}
product_name = last_seen.get("product_name")
# Reihenfolge bewusst anders als frueher (da stand die SSID als zweite Zeile):
# gross kommt das, was sich gerade aendert und worauf man wartet - die erkannte
# Lampe. Solange keine erkannt ist, ruecke die SSID nach oben, damit die grosse
# Zeile nie leer bleibt.
if product_name:
out.append((str(product_name), ROLE_HERO))
elif ssid:
out.append((str(ssid), ROLE_HERO))
phase = state.get("phase")
if phase == "armed":
target = state.get("armed_target") or "?"
out.append((f"FREIGEGEBEN: {target}", ROLE_DETAIL))
elif phase in PHASE_LABELS:
out.append((PHASE_LABELS[phase], ROLE_DETAIL))
if product_name:
target = last_seen.get("target")
variant = last_seen.get("variant")
if target or variant:
out.append((f"{target or '?'} ({variant or '?'})", ROLE_DETAIL))
lamp_version = last_seen.get("lamp_version")
if lamp_version:
out.append((f"FW: {lamp_version}", ROLE_DETAIL))
if ssid:
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
# SSID zweimal auf dem Panel.
out.append((f"SSID: {ssid}", ROLE_DETAIL))
served = state.get("served")
out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL))
recent = state.get("recent")
if isinstance(recent, list) and recent:
last = recent[-1]
if isinstance(last, dict):
out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
f"{last.get('status') or '?'}", ROLE_DETAIL))
return out
def lines(state: dict) -> list[str]:
"""Nur die Texte aus blocks(), ohne Rollen - unveraenderte Schnittstelle fuer
display.run_daemon(), das damit erkennt, ob sich der Inhalt geaendert hat (die Rolle
ist dafuer unerheblich, sie haengt nur am Modus). Wie blocks() ohne Pillow benutzbar."""
return [text for text, _role in blocks(state)]
def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px: float) -> str:
"""Kuerzt `text` mit "…" am Ende, bis `measure(text)` nicht mehr breiter als
`max_width_px` ist - nie umgebrochen, immer von rechts gekuerzt.
Nimmt eine Messfunktion statt selbst Pillow zu importieren: so bleibt diese Funktion
(und damit render.py ausserhalb von compose()) ohne Pillow testbar, z.B. mit
`measure=len` fuer einen einfachen Zeichenzahl-Test, waehrend compose() unten eine
echte `ImageDraw.textlength()`-Messung durchreicht. Zeichenweise gekuerzt (nicht
wortweise): lines() liefert kurze Statuswerte, keinen Fliesstext - ein am Wortende
abgeschnittener Produktname ist hier kein Problem, ein zu breiter oder umgebrochener
waere eins."""
if measure(text) <= max_width_px:
return text
ellipsis = "…"
trimmed = text
while trimmed and measure(trimmed + ellipsis) > max_width_px:
trimmed = trimmed[:-1]
return (trimmed + ellipsis) if trimmed else ellipsis
def _load_font(image_font, size_px: int, bold: bool = False):
"""Laedt eine skalierbare Schrift der Groesse `size_px` aus FONT_SEARCH_PATHS - der
erste Pfad, der sich oeffnen laesst, gewinnt.
`image_font` ist das PIL.ImageFont-MODUL, hereingereicht statt hier importiert: so
bleibt render.py (ausserhalb von compose()) ohne Pillow importierbar - dasselbe
Muster wie die Messfunktion in _truncate_to_width() - und die Kette laesst sich mit
einem nachgebauten Modul pruefen, ohne Schriftdateien anzulegen oder Pillow
herunterzustufen.
Gefangen wird OSError (Datei fehlt/unlesbar/kein gueltiges Schriftformat) UND
ImportError: ein ohne FreeType gebautes Pillow laesst truetype() mit genau dieser
Ausnahme scheitern - dann darf die Anzeige nicht ausfallen, sondern faellt bis auf
load_default() durch.
load_default() wird BEWUSST OHNE size-Argument gerufen: das gibt es erst ab
Pillow 10.1, Pi 2 hat 9.4.0 (siehe FONT_SEARCH_PATHS). Ein `size=`-Aufruf waere dort
ein TypeError mitten im Zeichnen - also genau in dem Pfad, der eigentlich der letzte
Rettungsanker ist."""
for regular_path, bold_path in FONT_SEARCH_PATHS:
path = bold_path if bold else regular_path
try:
return image_font.truetype(path, size_px)
except (OSError, ImportError):
continue
return image_font.load_default()
def _draw_centered(draw, text: str, font, top: int, height: int, fill: int) -> None:
"""Zeichnet `text` waagerecht mittig aufs Panel und senkrecht mittig in das Band
[top, top+height). Misst mit textbbox() statt mit der nominellen Schriftgroesse: die
tatsaechliche Tinte einer Zeile ist je nach Zeichen (Ober-/Unterlaengen) deutlich
kleiner als die Zeilenhoehe, und ein nach Zeilenhoehe zentrierter Text sieht auf
einem 122 px hohen Panel sichtbar zu tief aus."""
bbox = draw.textbbox((0, 0), text, font=font)
x = (PANEL_WIDTH_PX - (bbox[2] - bbox[0])) // 2 - bbox[0]
y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1]
draw.text((x, y), text, font=font, fill=fill)
def compose(state: dict):
"""Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus blocks().
Importiert Pillow erst hier - siehe Modul-Docstring."""
from PIL import Image, ImageDraw, ImageFont
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
draw = ImageDraw.Draw(image)
fonts = {
ROLE_HEADER: _load_font(ImageFont, HEADER_FONT_PX, bold=True),
ROLE_HERO: _load_font(ImageFont, HERO_FONT_PX, bold=True),
ROLE_DETAIL: _load_font(ImageFont, DETAIL_FONT_PX),
ROLE_BIG: _load_font(ImageFont, BIG_FONT_PX, bold=True),
}
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
def fit(raw_text: str, font) -> str:
# draw.textlength() wirft ValueError("can't measure length of multiline text"),
# sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von
# main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3,
# F5). blocks() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen
# (z.B. product_name) sind letztlich Daten von der Lampe/dem Netzwerk, kein
# Wert, den blocks() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
# nur ein zerstoertes Layout, kein sinnvoller Inhalt.
text = raw_text.replace("\n", " ")
return _truncate_to_width(
text, measure=lambda t: draw.textlength(t, font=font),
max_width_px=max_width_px)
y = TEXT_MARGIN_PX
for raw_text, role in blocks(state):
font = fonts[role]
text = fit(raw_text, font)
if role == ROLE_BIG:
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
# Panel.
_draw_centered(draw, text, font, top=0, height=PANEL_HEIGHT_PX, fill=0)
continue
if role == ROLE_HEADER:
draw.rectangle((0, 0, PANEL_WIDTH_PX - 1, HEADER_BAR_HEIGHT_PX - 1), fill=0)
_draw_centered(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX, fill=255)
y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX
continue
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
if y + line_height > PANEL_HEIGHT_PX:
# Passt nicht mehr aufs Panel - abschneiden statt ueberzeichnen. Die
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
break
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0)
y += line_height
return image