diff --git a/tools/station/display/render.py b/tools/station/display/render.py index 59aa7b7..239fbca 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -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: " - 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 diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index 4c07923..9dcbdd9 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -282,9 +282,24 @@ display_run() { # veraendert, was 'off' zuvor bewusst hergestellt hatte. Deshalb "is-active" vorher # fragen statt blind "stop" und "start". # -# Rueckgabewert ist der von display.py - der Handgriff muss weiterhin rot werden, wenn -# das Panel nicht antwortet; das Wiederanfahren des Dienstes darf ihn weder verdecken -# noch (bei eigenem Fehlschlag) einen Panel-Erfolg in einen Fehler verwandeln. +# Der Rueckgabewert von display.py wird unveraendert durchgereicht, und das +# Wiederanfahren des Dienstes darf ihn weder verdecken noch (bei eigenem Fehlschlag) in +# einen Fehler verwandeln. +# +# Was dieser Rueckgabewert NICHT ist: eine Aussage ueber das Panel. display.py's main() +# faengt DisplayUnavailable/ImportError ab, protokolliert eine Zeile und beendet sich mit +# sys.exit(0) - die ueber mehrere Fixrunden erkaempfte Zusage "die Anzeige ist nirgends +# Voraussetzung". Fehlendes Panel, fehlendes SPI, fehlendes Pillow, lgpio 'GPIO busy': +# alles rc=0. Rot wird der Handgriff nur bei einem echten Absturz (ein Traceback kommt +# unveraendert durch). Ob die Revision stimmt, beurteilst du deshalb AM PANEL - zeigt es +# ein sauberes, seitenrichtiges Testbild? - und nie am Exit-Code; "Exit 0" heisst hier +# ausdruecklich nicht "Panel in Ordnung". +display_service_resume() { + [ "${_was_active:-0}" -eq 1 ] || return 0 + sudo systemctl start ninlight-display 2>/dev/null \ + || echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2 +} + display_handover() { _was_active=0 if systemctl is-active --quiet ninlight-display 2>/dev/null; then @@ -292,13 +307,26 @@ display_handover() { sudo systemctl stop ninlight-display 2>/dev/null || true fi + # Zwischen "stop" und "start" darf uns kein Signal aus der Hand nehmen, den Dienst + # wieder anzufahren. Der Fall ist nicht theoretisch: die Abnahmeprozedur in + # display.py's Modul-Docstring sagt ausdruecklich, dass bei falscher Revision + # ReadBusy() NIE zurueckkehrt - der Abnehmer probiert EPD_REVISION=V3, das Panel + # haengt, er drueckt Strg-C. Ohne Handler stirbt display_handover mit, der + # Anzeigedienst bleibt aus, das Panel bleibt schwarz, und mitten in der Lampenabnahme + # sieht das aus wie "Panel kaputt". + # + # Die Handler beenden SELBST (130/143/129 = 128 + Signalnummer): ein Handler ohne + # "exit" laesst die Shell hinter dem unterbrochenen Kommando weiterlaufen - in diesem + # Projekt schon einmal ein Befund (setup_pi.sh, Profilschreiben). + trap 'display_service_resume; exit 130' INT + trap 'display_service_resume; exit 143' TERM + trap 'display_service_resume; exit 129' HUP + _rc=0 display_run "$1" || _rc=$? - if [ "$_was_active" -eq 1 ]; then - sudo systemctl start ninlight-display 2>/dev/null \ - || echo "Warnung: Anzeigedienst konnte nicht wieder gestartet werden - 'systemctl status ninlight-display' pruefen" >&2 - fi + trap - INT TERM HUP + display_service_resume return "$_rc" } diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh index 375d32a..fbd298d 100755 --- a/tools/station/tests/test_cli.sh +++ b/tools/station/tests/test_cli.sh @@ -198,11 +198,35 @@ setup_sandbox() { cp "$STATION_DIR/products.py" "$sandbox/lib/products.py" cat >"$sandbox/lib/display/display.py" <"))) + +# Testschalter fuer Fall D2c: stellt das Strg-C des Abnehmers nach, waehrend display.py +# laeuft (bei falscher Panel-Revision kehrt ReadBusy() nie zurueck - das ist der +# dokumentierte Weg, die Revision zu bestimmen, und damit der Regelfall fuer ein Strg-C). +# +# Nachgestellt wird, was das Terminal wirklich tut: das Signal geht an die GANZE +# Vordergrund-Prozessgruppe, also an die ninlight-station-Shell UND an diesen Prozess. +# Der Elternprozess ist dabei die Shell selbst ("sudo" und "env" sind Stub bzw. +# exec-Aufruf, der Python-Prozess haengt also direkt unter ihr). +# +# Dass dieser Prozess AM SIGNAL STIRBT (Standardbehandlung) statt sich mit 128+n zu +# beenden, ist kein Detail: eine Shell unterscheidet beides. Beendet sich das Kind +# normal, verwirft /bin/sh (bash im sh-Modus, macOS) das inzwischen zugestellte SIGINT +# und laeuft weiter - der Testfall waere dann auch ohne Signalhandler in +# display_handover() gruen und wuerde gar nichts pruefen (beim Schreiben dieses Tests +# genau so passiert). +signal_name = os.environ.get("FAKE_DISPLAY_SIGNAL", "") +if signal_name: + number = getattr(signal, "SIG" + signal_name) + os.kill(os.getppid(), number) + signal.signal(number, signal.SIG_DFL) + os.kill(os.getpid(), number) + sys.exit(int(os.environ.get("FAKE_DISPLAY_EXIT", "0"))) EOF @@ -672,6 +696,62 @@ set -e [ "$RC" -eq 1 ] && pass "Exit-Code 1 wird durchgereicht" || fail "Exit-Code $RC statt 1: $OUT" grep -Fq "STUB systemctl start ninlight-display" "$SBX/cmd.log" && pass "Anzeigedienst laeuft trotz Panel-Fehler wieder" || fail "Anzeigedienst blieb nach dem Fehler aus: $(cat "$SBX/cmd.log")" +echo +echo "=== Fall D2c: Strg-C waehrend 'display test' laesst den Anzeigedienst NICHT gestoppt zurueck ===" +# Der Ablauf aus der Abnahme: bei falscher Revision kehrt ReadBusy() nie zurueck +# (display.py-Docstring), der Abnehmer bricht mit Strg-C ab. Ohne Signalhandler zwischen +# "stop" und "start" stirbt display_handover mit - der Anzeigedienst bleibt aus, das +# Panel schwarz, und das sieht mitten in der Lampenabnahme aus wie "Panel kaputt". +# Geprueft wird fuer alle drei Signale, die das ausloesen koennen. +for SIG_NAME in INT TERM HUP; do + case "$SIG_NAME" in + INT) EXPECT_RC=130 ;; + TERM) EXPECT_RC=143 ;; + HUP) EXPECT_RC=129 ;; + esac + SBX="$TMPROOT/tD2c$SIG_NAME" + setup_sandbox "$SBX" + : >"$SBX/cmd.log" + set +e + OUT=$(FAKE_DISPLAY_SIGNAL="$SIG_NAME" run_cli "$SBX" display test 2>&1) + RC=$? + set -e + grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" \ + && pass "SIG$SIG_NAME: der Anzeigedienst war fuer den Handgriff gestoppt (Vorbedingung)" \ + || fail "SIG$SIG_NAME: Vorbedingung fehlt, der Dienst wurde gar nicht erst gestoppt: $(cat "$SBX/cmd.log")" + # GENAU einmal gestartet. Die Anzahl, nicht nur das Vorhandensein: der Handler muss + # selbst beenden (in diesem Projekt schon einmal ein Befund, setup_pi.sh). Ohne "exit" + # laeuft die Shell hinter dem unterbrochenen Kommando einfach weiter und faehrt den + # Dienst ein zweites Mal an - der Exit-Code sieht dabei zufaellig richtig aus (er ist + # dann der des am Signal gestorbenen Kindes), die doppelte Startzeile nicht. Genau + # diese Mutation ("trap ohne exit") war gegen eine blosse Vorhandensein-Pruefung + # gruen. + START_COUNT=$(grep -c "STUB systemctl start ninlight-display" "$SBX/cmd.log" || true) + [ "$START_COUNT" -eq 1 ] \ + && pass "SIG$SIG_NAME: der Anzeigedienst wird trotz Abbruch genau einmal wieder gestartet" \ + || fail "SIG$SIG_NAME: $START_COUNT Startversuche (1 erwartet) - Anzeigedienst bleibt aus oder der Handler beendet nicht selbst: $(cat "$SBX/cmd.log")" + [ "$RC" -eq "$EXPECT_RC" ] \ + && pass "SIG$SIG_NAME: Exit-Code $EXPECT_RC (128 + Signalnummer)" \ + || fail "SIG$SIG_NAME: Exit-Code $RC statt $EXPECT_RC: $OUT" +done + +echo +echo "=== Fall D2d: war der Anzeigedienst aus, startet ihn auch ein Strg-C nicht ===" +# Spiegelbild zu D2a: der Signalhandler darf den Zustand nicht anders behandeln als der +# normale Ausgang - sonst schaltete ein abgebrochener Handgriff die Anzeige ein, die +# 'off' zuvor bewusst ausgeschaltet hat. +SBX="$TMPROOT/tD2d" +setup_sandbox "$SBX" +: >"$SBX/cmd.log" +set +e +OUT=$(FAKE_DISPLAY_SERVICE_ACTIVE_EXIT=3 FAKE_DISPLAY_SIGNAL=INT run_cli "$SBX" display test 2>&1) +RC=$? +set -e +[ "$RC" -eq 130 ] && pass "Exit-Code 130 auch ohne laufenden Anzeigedienst" || fail "Exit-Code $RC statt 130: $OUT" +grep -Fq "STUB systemctl start ninlight-display" "$SBX/cmd.log" \ + && fail "der Signalhandler hat den Anzeigedienst eingeschaltet, obwohl er aus war: $(cat "$SBX/cmd.log")" \ + || pass "kein 'start' fuer einen Dienst, der gar nicht lief" + echo echo "=== Fall D3 (Task 6b, Punkt 4): 'display off' stoppt den Dienst VOR dem Leeren des Panels ===" # Umgekehrte Reihenfolge waere wirkungslos: der Daemon fragt alle 5 s nach und diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index 20b5f91..425d49c 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -148,25 +148,94 @@ def test_off_screen_is_drawn_big_and_centered(): assert abs(top - bottom) <= 4, f"nicht senkrecht mittig (oben {top}, unten {bottom})" -def test_header_is_a_black_bar_with_white_text(): - """Die Kopfzeile ist invertiert: schwarzer Balken ueber die volle Breite, Modus in - Weiss darin. Geprueft an einer Zeile mitten im Balken - die muss ueberwiegend schwarz - sein UND weisse Pixel enthalten (sonst stuende dort kein Text, sondern nur ein - Balken).""" - image = render.compose(_migration_state()) - pixels = image.load() - row = render.HEADER_BAR_HEIGHT_PX // 2 +HEADER_MIN_BLACK_SHARE = 0.55 - black = sum(1 for x in range(render.PANEL_WIDTH_PX) if pixels[x, row] == 0) - white = sum(1 for x in range(render.PANEL_WIDTH_PX) if pixels[x, row] != 0) - assert black > 150, f"Kopfzeile ist kein schwarzer Balken (nur {black} schwarze Pixel)" - assert white >= 5, "keine weisse Schrift im Balken - der Balken uebermalt den Modus" + +def _assert_header_bar_with_text_on_both_sides(image, hint=""): + """Die Zusage ueber den Kopfbalken, SCHRIFTUNABHAENGIG formuliert (dritte + Hardware-Abnahme, Befund C1). + + Vorher stand hier eine absolute Pixelzahl ("> 150 schwarze Pixel in Zeile 11"), gegen + Pillow 12 mit ARIAL auf der Entwicklungsmaschine kalibriert. Auf dem Pi und auf der + Debian-13-Maschine zeichnet DejaVu, das merklich breiter laeuft - dort blieben 149 + schwarze Pixel uebrig und die Suite war rot, ohne dass sich am Code etwas geaendert + hatte. Die Schwelle einfach auf 145 nachzuziehen haette nur gegen die naechste + Schrift kalibriert. + + Stattdessen drei Zusagen, die keine Schrift kennen: + * ANTEIL statt Anzahl, und ueber den GANZEN Balken statt ueber eine einzelne Zeile: + mindestens 55 % des Balkens sind schwarz. Ueber sechs sehr verschiedene Schriften + gemessen (Arial, Arial Black, Verdana, Courier New, Impact, Georgia) liegt der + Anteil zwischen 69 % und 93 %, mit load_default() bei 93 % - die Schwelle trennt + also "Balken mit Schrift darin" von "kein Balken" (ohne Rechteck: rund 5 %), ohne + an einer Schriftbreite zu haengen. Eine einzelne Zeile allein taugt dafuer nicht: + dieselben sechs Schriften streuen in Zeile 11 zwischen 43 % und 69 %. + * Weisse Schrift LINKS UND RECHTS der Panelmitte: der Modus steht links, die Phase + rechts. Faellt eine von beiden aus (oder uebermalt der Balken den Text), ist die + betreffende Haelfte leer. + * Der Balken reicht ueber die volle Breite: die aeussersten Spalten sind + durchgehend schwarz - sie liegen vor TEXT_MARGIN_PX, dort kann keine Schrift + stehen.""" + pixels = image.load() + height = render.HEADER_BAR_HEIGHT_PX + width = render.PANEL_WIDTH_PX + middle = width // 2 + + black = sum(1 for y in range(height) for x in range(width) if pixels[x, y] == 0) + share = black / float(height * width) + assert share >= HEADER_MIN_BLACK_SHARE, ( + f"Kopfzeile ist kein schwarzer Balken (nur {share:.0%} schwarz){hint}") + + white_left = sum(1 for y in range(height) for x in range(middle) if pixels[x, y] != 0) + white_right = sum(1 for y in range(height) for x in range(middle, width) + if pixels[x, y] != 0) + assert white_left >= 5, f"kein Modus-Text links im Balken{hint}" + assert white_right >= 5, f"keine Phase rechts im Balken{hint}" + + for x in (0, width - 1): + assert all(pixels[x, y] == 0 for y in range(height)), ( + f"Spalte {x} des Kopfbalkens ist nicht durchgehend schwarz{hint}") + + +def test_header_is_a_black_bar_with_white_text(): + """Die Kopfzeile ist invertiert: schwarzer Balken ueber die volle Breite, Modus links + und Phase rechts in Weiss darin.""" + _assert_header_bar_with_text_on_both_sides(render.compose(_migration_state())) + + +def test_header_promise_survives_a_noticeably_wider_font(monkeypatch): + """Gegenprobe zu C1: dieselbe Zusage muss auch dann halten, wenn die Schrift deutlich + mehr Platz braucht als die hier gefundene. + + Statt eine zweite Schriftdatei vorauszusetzen (DejaVu gibt es auf der macOS- + Entwicklungsmaschine nicht, Arial nicht auf dem Pi - ein Test, der die eine oder die + andere braucht, waere auf je einer der beiden Maschinen nur ein Skip) wird dieselbe + Schrift 25 % groesser gezogen: das ist genau der Effekt, den eine breitere Schrift auf + diese Zusage hat, und es laeuft auf jeder Maschine gleich. Der Abstand DejaVu-zu-Arial + liegt bei rund 13 % Breite ("MIGRATION": 128 px gegen 113 px), hier also mit Puffer + ueberschritten. Dass die Phase dabei gekuerzt wird, ist erlaubt - sie darf nur nicht + verschwinden oder den Modus ueberlaufen.""" + monkeypatch.setattr(render, "HEADER_FONT_PX", int(render.HEADER_FONT_PX * 1.25)) + monkeypatch.setattr(render, "HEADER_PHASE_FONT_PX", + int(render.HEADER_PHASE_FONT_PX * 1.25)) + + _assert_header_bar_with_text_on_both_sides( + render.compose(_migration_state()), hint=" (mit 25 % breiterer Schrift)") def test_hero_line_is_bigger_than_the_details(): """Die wichtigste Zeile (hier: der erkannte Produktname) muss sich vom Beiwerk - abheben - sonst ist die Rollenvergabe in blocks() folgenlos. Gemessen: 15 px Tinte - fuer die grosse Zeile gegen 10 px fuer die erste Beiwerkzeile.""" + abheben - sonst ist die Rollenvergabe in blocks() folgenlos. + + Als VERHAELTNIS der Tintenhoehen, nicht als absoluter Pixelabstand (dritte + Hardware-Abnahme, C1: auch die uebrigen Schwellen dieser Datei schriftunabhaengig + machen). Die Zusage kommt aus dem Verhaeltnis der Schriftgroessen - + HERO_FONT_PX/DETAIL_FONT_PX = 16/11 = 1,45 - und das ueberlebt einen + Schriftwechsel, ein "mindestens 3 px mehr" nicht: ueber sieben Schriften gemessen + liegt das Verhaeltnis zwischen 1,30 (Courier New) und 1,60 (Verdana), der absolute + Abstand dagegen zwischen 3 px und 6 px - mit 3 px waere Courier New genau auf der + Kante. Mit load_default() (beide Rollen rund 8 px) liegt das Verhaeltnis bei 1,0, + der Test also weiterhin rot, wenn die Schriftsuche ausfaellt.""" image = render.compose(_migration_state()) hero_top = render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX hero_bottom = hero_top + render.HERO_LINE_HEIGHT_PX @@ -177,9 +246,10 @@ def test_hero_line_is_bigger_than_the_details(): hero_height = hero[3] - hero[1] + 1 detail_height = detail[3] - detail[1] + 1 - assert hero_height >= detail_height + 3, ( + assert hero_height >= detail_height * 1.2, ( f"grosse Zeile ({hero_height} px) hebt sich nicht vom Beiwerk " - f"({detail_height} px) ab") + f"({detail_height} px) ab - Verhaeltnis " + f"{hero_height / float(detail_height):.2f}, erwartet mindestens 1,20") def test_the_last_delivery_line_still_fits_on_the_panel(): @@ -239,12 +309,22 @@ def test_compose_stops_drawing_details_before_the_bottom_margin(monkeypatch): image = render.compose({"mode": "migration"}) pixels = image.load() - rows_with_ink = [ - any(pixels[x, y] == 0 for x in range(render.PANEL_WIDTH_PX)) - for y in range(render.HEADER_BAR_HEIGHT_PX, render.PANEL_HEIGHT_PX)] + # Je Zeile IHR eigenes Band gezaehlt, nicht zusammenhaengende Tinten-Cluster + # (dritte Hardware-Abnahme, C1: "auch anderswo schriftabhaengige Schwellen suchen"): + # die Clusterzaehlung setzte stillschweigend voraus, dass zwischen zwei Zeilen eine + # komplett tintenfreie Pixelzeile bleibt. Das ist eine Aussage ueber die + # Schrifthoehe bei DETAIL_LINE_HEIGHT_PX Zeilenabstand - mit einer hoeher bauenden + # Schrift waeren zwei Zeilen zu einem Cluster verschmolzen und der Test rot, ohne + # dass am Code etwas falsch waere. Bandweise gezaehlt ist es egal, ob sich die + # Zeilen beruehren. + first_top = render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX + bands = range((render.PANEL_HEIGHT_PX - first_top) // render.DETAIL_LINE_HEIGHT_PX) drawn_lines = sum( - 1 for i, has_ink in enumerate(rows_with_ink) - if has_ink and not (i > 0 and rows_with_ink[i - 1])) + 1 for i in bands + if any(pixels[x, y] == 0 + for y in range(first_top + i * render.DETAIL_LINE_HEIGHT_PX, + first_top + (i + 1) * render.DETAIL_LINE_HEIGHT_PX) + for x in range(render.PANEL_WIDTH_PX))) assert drawn_lines == 6, ( f"{drawn_lines} Beiwerkzeilen gezeichnet, sechs erwartet - " @@ -350,6 +430,115 @@ def test_long_armed_target_does_not_overlap_the_mode_in_the_header(): ) +def test_header_phase_is_dropped_when_too_little_room_is_left(monkeypatch): + """Deckt den "Weglassen"-Zweig ab (dritte Hardware-Abnahme, Befund M2): bleibt neben + dem Modus weniger als HEADER_PHASE_MIN_WIDTH_PX uebrig, wird die Phase ganz + weggelassen statt ein einsames, rechtsbuendiges "…" zu zeichnen, das sich mit dem + Modus ueberlagert. + + Mit den heutigen Werten kann der Zweig nicht von selbst feuern - der Modus-Text im + Migrationsmodus ist die Konstante "MIGRATION", available_px also fest (112 px DejaVu, + 127 px Arial). Hier wird deshalb der Mindestabstand hochgedreht: das ist der + direkteste, von jeder Schrift unabhaengige Weg, available_px unter die Schwelle zu + druecken, und genau das ist die Bedingung, um die es geht.""" + monkeypatch.setattr(render, "HEADER_PHASE_GAP_PX", 200) + + image = render.compose(_migration_state()) + pixels = image.load() + header_rows = range(render.HEADER_BAR_HEIGHT_PX) + white_cols = [x for x in range(render.PANEL_WIDTH_PX) + if any(pixels[x, y] == 255 for y in header_rows)] + + assert white_cols, "der Modus fehlt jetzt auch" + # Vorbedingung: der Modus selbst reicht nicht in den geprueften rechten Streifen - + # sonst wuerde der Test etwas anderes messen als "die Phase fehlt". + reserved_from = render.PANEL_WIDTH_PX - render.HEADER_PHASE_MIN_WIDTH_PX + assert max(white_cols) < reserved_from - 4, ( + f"der Modus-Text reicht bis Spalte {max(white_cols)} - der Test kann so nicht " + "zwischen Modus und Phase unterscheiden") + assert not [x for x in white_cols if x >= reserved_from], ( + "die Phase wurde trotz fehlenden Platzes gezeichnet - rechtsbuendig, also " + "quer ueber den Modus") + + +# --- Schriftladen: Prozess-Cache und faules Laden (Befund M4) ------------------------ +# +# compose() baute je Aufruf fuenf Schriften neu: auf der Entwicklungsmaschine 21,2 ms von +# 25,0 ms, auf einem ARMv6-Kern grob 0,5-1 s - bei JEDER ausgelieferten Lampe, auf +# demselben Kern wie server.py. + +def test_font_cache_reuses_the_same_font_object(): + """Zweimal dieselbe Rolle -> die Schriftdatei wird nur einmal gelesen.""" + image_font = _FakeImageFontModule(available=(DEJAVU_REGULAR, DEJAVU_BOLD)) + + first = render._load_font(image_font, 11) + second = render._load_font(image_font, 11) + + assert first is second + assert len(image_font.truetype_calls) == 1, ( + f"Schrift mehrfach geladen: {image_font.truetype_calls}") + + +def test_font_cache_keeps_different_modules_and_search_paths_apart(monkeypatch): + """Der Cache-Schluessel muss das hereingereichte ImageFont-Modul UND + FONT_SEARCH_PATHS enthalten - beides wird in den Tests ersetzt. Ein Cache, der nur + Groesse/Fettung kennt, wuerde z.B. test_compose_still_works_without_any_font_file + (leere Suchkette) eine vorher geladene echte Schrift unterschieben und die Zusage + "eine fehlende Schrift haelt nichts auf" stillschweigend entwerten.""" + first_module = _FakeImageFontModule(available=(DEJAVU_REGULAR,)) + second_module = _FakeImageFontModule(available=(DEJAVU_REGULAR,)) + + render._load_font(first_module, 11) + render._load_font(second_module, 11) + assert len(second_module.truetype_calls) == 1, "Cache reicht Modulgrenzen hinueber" + + monkeypatch.setattr(render, "FONT_SEARCH_PATHS", ()) + assert render._load_font(first_module, 11) == ("default",), ( + "leere Suchkette liefert eine zwischengespeicherte Schrift von vorher") + + +def test_compose_loads_only_the_roles_it_actually_draws(monkeypatch): + """"Station aus" zeichnet genau eine Rolle - die anderen vier Schriften duerfen gar + nicht erst gebaut werden (faules Laden, Befund M4).""" + real_load_font = render._load_font + requested = [] + + def spy(image_font, size_px, bold=False): + requested.append(size_px) + return real_load_font(image_font, size_px, bold=bold) + + monkeypatch.setattr(render, "_load_font", spy) + render.compose({"mode": "off"}) + + assert requested == [render.BIG_FONT_PX], ( + f"compose() hat {len(requested)} Schriften angefordert, gezeichnet wird eine: " + f"{requested}") + + +def test_compose_reads_no_font_file_a_second_time(monkeypatch): + """Der Prozess-Cache am echten Pillow: der zweite compose()-Aufruf desselben + Prozesses fasst keine Schriftdatei mehr an. Das ist die Zusage, an der auf dem Pi die + Zeichenzeit haengt - der Daemon zeichnet bei jeder ausgelieferten Lampe neu.""" + from PIL import ImageFont + + state = _migration_state() + render.compose(state) # Cache fuellen + + truetype_calls = [] + real_truetype = ImageFont.truetype + + def counting_truetype(*args, **kwargs): + truetype_calls.append(args) + return real_truetype(*args, **kwargs) + + monkeypatch.setattr(ImageFont, "truetype", counting_truetype) + render.compose(state) + + assert truetype_calls == [], ( + f"Schriften werden bei jedem compose() neu von der Platte gelesen: " + f"{truetype_calls}") + + def test_compose_still_works_without_any_font_file(monkeypatch): """Die Zusage "eine fehlende Schrift haelt die Anzeige nicht auf" gilt unveraendert: ohne einen einzigen Treffer in der Suchkette faellt compose() auf load_default() @@ -451,10 +640,95 @@ def test_missing_fields_do_not_raise(): assert render.lines(state) -def test_armed_phase_is_visible(): +# --- "armed": WELCHE Lampe ist freigegeben? (dritte Hardware-Abnahme, Befund I2) ----- +# +# Frueher stand "FREIGEGEBEN: " als Phase im Kopfbalken. Neben dem Modus bleiben +# dort 112 px (DejaVu) - der Text misst 182 px, gezeichnet wurde "FREIGEGEBE…", also ein +# Wortfragment, das bei jedem Ziel gleich aussieht. Der alte Test dazu +# (test_armed_phase_is_visible) prueft lines(), also den UNGEKUERZTEN Text, und war +# deshalb gruen, waehrend auf dem Panel nichts davon zu lesen war. Die Tests hier pruefen +# das GEZEICHNETE BILD. + +def _armed_state(): + """Wie _migration_state(), aber mit einem Produktnamen OHNE Unterlaengen: die + Bildvergleiche unten schneiden die erste Beiwerkzeile zeilengenau aus, und ein 'y' + oder 'q' der grossen Zeile darueber ragte in diesen Ausschnitt hinein.""" + state = _migration_state() + state["last_seen"] = dict(state["last_seen"], product_name="KLYQA GU10 RGB") + return state + + +def _detail_band(index): + """(oben, unten) der `index`-ten Beiwerkzeile unter der grossen Zeile.""" + top = (render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX + + render.HERO_LINE_HEIGHT_PX + index * render.DETAIL_LINE_HEIGHT_PX) + return top, top + render.DETAIL_LINE_HEIGHT_PX + + +def _band_pixels(image, top, bottom): + pixels = image.load() + return [tuple(pixels[x, y] for x in range(render.PANEL_WIDTH_PX)) + for y in range(top, bottom)] + + +def _reference_detail_line(text, top): + """Zeichnet `text` allein, mit derselben Schrift und an derselben Stelle, die + compose() fuer eine Beiwerkzeile benutzt. Der Vergleich mit dem echten Bild ist damit + schriftunabhaengig: er sagt "genau dieser Text, ungekuerzt", ohne eine Pixelbreite zu + behaupten.""" + from PIL import Image, ImageDraw, ImageFont + + image = Image.new("1", (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX), 255) + draw = ImageDraw.Draw(image) + draw.text((render.TEXT_MARGIN_PX, top), text, + font=render._load_font(ImageFont, render.DETAIL_FONT_PX), fill=0) + return image + + +def test_armed_target_is_drawn_in_full_as_its_own_line(): + """Der Zielname muss vollstaendig und lesbar auf dem Panel stehen - als eigene + Beiwerkzeile ueber die volle Breite, nicht als Rest eines im Kopf abgeschnittenen + Texts. Geprueft durch Pixelvergleich mit einer unabhaengig gezeichneten Referenzzeile: + kuerzt compose() den Namen (oder laesst die Zeile ganz weg), stimmt der Ausschnitt + nicht mehr ueberein.""" + state = _armed_state() + expected = render.ARMED_DETAIL_PREFIX + state["armed_target"] + assert expected in render.lines(state), render.lines(state) + + top, bottom = _detail_band(0) + drawn = _band_pixels(render.compose(state), top, bottom) + reference = _band_pixels(_reference_detail_line(expected, top), top, bottom) + + assert drawn == reference, ( + f"die erste Beiwerkzeile zeigt nicht ungekuerzt {expected!r} - der Bediener " + "kann nicht sehen, welche Lampe freigegeben ist") + + +def test_header_does_not_depend_on_the_armed_target_name(): + """Die Gegenrichtung, und die eigentliche Mutationswache fuer I2: der Kopfbalken darf + den Zielnamen gar nicht mehr enthalten. Das laesst sich ohne jede Schriftannahme + pruefen - zwei sehr verschiedene Zielnamen muessen denselben Kopfbalken ergeben. + Zeichnet ihn jemand wieder in den Kopf, unterscheiden sich die beiden Balken (oder, + bei fruehem Abschneiden, spaetestens beim langen Namen).""" + short = _armed_state() + long_name = _armed_state() + long_name["armed_target"] = "sehr_langer_zielname_" * 4 + + band = (0, render.HEADER_BAR_HEIGHT_PX) + assert (_band_pixels(render.compose(short), *band) + == _band_pixels(render.compose(long_name), *band)), ( + "der Kopfbalken haengt am Zielnamen - dort ist kein Platz dafuer, er wird " + "abgeschnitten und ist bei jedem Ziel gleich unlesbar") + + +def test_armed_phase_label_is_visible(): + """Das kurze Label bleibt (es sagt, DASS etwas freigegeben ist) - nur der Zielname + steht nicht mehr daneben.""" lines = render.lines({"mode": "migration", "phase": "armed", "armed_target": "g95_cw", "served": 0, "recent": [], "ssid": "qconnex-firmware"}) - assert "FREIGEGEBEN" in "\n".join(lines) + text = "\n".join(lines) + assert render.ARMED_LABEL in text + assert "g95_cw" in text # --- display.build_state() (Fixrunde 1, I3) ---------------------------------------