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:
+104
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user