fix(display): Freigabe-Ziel lesbar, Tests schriftunabhaengig

Sechs Review-Befunde an e2d836a/2aab1b0.

C1: die Pixelschwellen in test_render.py waren gegen Pillow 12/Arial
kalibriert und auf dem Pi wie auf Debian 13 (DejaVu) rot - "> 150
schwarze Pixel in Zeile 11" sind dort nur 143. Die Zusagen ueber den
Kopfbalken sind jetzt Anteile und Symmetrien statt Pixelzahlen
(mindestens 55 % des Balkens schwarz, weisse Schrift links UND rechts
der Mitte, Balken ueber die volle Breite). Zwei weitere schriftabhaengige
Schwellen ebenso: die grosse Zeile hebt sich ueber das VERHAELTNIS der
Tintenhoehen vom Beiwerk ab, und die Randgrenze zaehlt Zeilenbaender
statt Tinten-Cluster.

I2: im Zustand "armed" stand "FREIGEGEBEN: <ziel>" in der Kopfzeile,
wo 112 px Platz sind und der Text 182 px misst - gezeichnet wurde
"FREIGEGEBE…", bei jedem Ziel gleich. Im Kopf steht jetzt nur noch das
Label, der Zielname als eigene Beiwerkzeile ueber die volle Breite; die
SSID-Zeile gibt dafuer in "armed" ihren Platz ab. Geprueft wird das
gezeichnete Bild (Pixelvergleich gegen eine Referenzzeile), nicht
lines().

I3: der Kommentar an display_handover() begruendete die
Rueckgabewert-Durchreichung damit, dass der Handgriff bei stummem Panel
rot werde. Das stimmt nicht - display.py beendet sich bei fehlendem
Panel/SPI/Pillow mit 0. Der Kommentar sagt jetzt, dass man die Revision
am Panel beurteilt und nicht am Exit-Code.

I4: zwischen "systemctl stop" und "systemctl start" gab es keinen
Signalhandler - ein Strg-C waehrend "display test" (der dokumentierte
Weg, die Revision zu bestimmen, bei falscher Revision haengt das Panel)
liess den Anzeigedienst gestoppt zurueck. trap auf INT/TERM/HUP, das
Wiederanfahren in display_service_resume(), Handler beenden selbst mit
128 + Signalnummer.

M2: HEADER_PHASE_MIN_WIDTH_PX bleibt als Sicherheitsnetz, ist jetzt aber
getestet statt toter Code.

M4: compose() baute je Aufruf fuenf Schriften neu. Prozess-Cache (mit
ImageFont-Modul und FONT_SEARCH_PATHS im Schluessel, damit die Tests
nicht verfaelscht werden) und faules Laden je Rolle: 25,0 ms -> 7,0 ms.

Die Pixeltests laufen zusaetzlich unter Pillow 11.1 mit echtem DejaVu
und unter fuenf weiteren Schriften. Sieben Mutationsproben angewandt,
angesehen und zurueckgenommen. tools/test_host.sh gruen.
This commit is contained in:
Michael Sauer
2026-09-14 15:12:45 +02:00
parent e77d3da292
commit f105a0eb69
4 changed files with 516 additions and 49 deletions
+104 -19
View File
@@ -50,12 +50,14 @@ TEXT_MARGIN_PX = 2
# 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/HORCHT/FREIGEGEBEN: ...), NUR im Migrationsmodus und
# header_phase - die Phase (AUTO/HORCHT/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).
# 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).
@@ -100,8 +102,19 @@ 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, das hier ist nur
# das Sicherheitsnetz fuer einen theoretisch sehr langen Zielnamen).
# 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
@@ -138,10 +151,22 @@ FONT_SEARCH_PATHS = (
)
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
# gelistet.
# "armed" wird gesondert behandelt (es zieht zusaetzlich eine Beiwerkzeile mit dem
# Zielnamen nach sich), deshalb hier nicht gelistet.
PHASE_LABELS = {"auto": "AUTO", "listen": "HORCHT"}
# 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 = "FREIGEGEBEN"
ARMED_DETAIL_PREFIX = "Freigabe: "
def _hhmm(ts: object) -> str:
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
@@ -205,9 +230,13 @@ def blocks(state: dict) -> list[tuple[str, str]]:
# 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":
target = state.get("armed_target") or "?"
out.append((f"FREIGEGEBEN: {target}", ROLE_HEADER_PHASE))
# 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))
@@ -225,6 +254,13 @@ def blocks(state: dict) -> list[tuple[str, str]]:
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")
@@ -233,9 +269,16 @@ def blocks(state: dict) -> list[tuple[str, str]]:
lamp_version = last_seen.get("lamp_version")
if lamp_version:
out.append((f"FW: {lamp_version}", ROLE_DETAIL))
if ssid:
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))
served = state.get("served")
@@ -278,9 +321,25 @@ def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px:
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.
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
@@ -297,13 +356,26 @@ def _load_font(image_font, size_px: int, bold: bool = False):
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:
return image_font.truetype(path, size_px)
font = image_font.truetype(path, size_px)
break
except (OSError, ImportError):
continue
return image_font.load_default()
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,
@@ -341,13 +413,26 @@ def compose(state: dict):
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_HEADER_PHASE: _load_font(ImageFont, HEADER_PHASE_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),
# 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),
}
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:
@@ -372,7 +457,7 @@ def compose(state: dict):
y = TEXT_MARGIN_PX
for raw_text, role in block_list:
font = fonts[role]
font = font_for(role)
if role == ROLE_BIG:
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze