From bb39105ac470d0bd8490da729d6f2dd7fe2b29e7 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Mon, 14 Sep 2026 02:24:14 +0200 Subject: [PATCH] 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. --- tools/station/display/epdconfig.py | 71 ++++++-- tools/station/display/render.py | 249 ++++++++++++++++++++----- tools/station/ninlight-station | 45 ++++- tools/station/tests/test_cli.sh | 102 ++++++++--- tools/station/tests/test_render.py | 282 ++++++++++++++++++++++++++++- 5 files changed, 667 insertions(+), 82 deletions(-) diff --git a/tools/station/display/epdconfig.py b/tools/station/display/epdconfig.py index 875aff7..93f9ade 100644 --- a/tools/station/display/epdconfig.py +++ b/tools/station/display/epdconfig.py @@ -35,6 +35,15 @@ try: except ImportError: # pragma: no cover - dito spidev = None +# lgpio ist die Pin-Factory, die gpiozero auf beiden Pis benutzt (Debian 13 trixie wie +# Bookworm). Sie meldet einen belegten Pin nicht als gpiozero-Ausnahme, sondern als +# lgpio.error - siehe _hardware_error_types(). Auf der Entwicklungsmaschine fehlt das +# Modul; dann bleibt es None und die Fehlerliste kommt ohne es aus. +try: + import lgpio +except ImportError: # pragma: no cover - auf dem Pi immer vorhanden + lgpio = None + class DisplayUnavailable(Exception): """Kein Panel ansprechbar: Bibliothek fehlt, /dev/spidev0.0 fehlt, oder der @@ -89,6 +98,52 @@ def spi_writebyte2(data) -> None: _spi.writebytes2(data) +def _busy_hint(error: BaseException) -> str: + """Zusatz fuer die Fehlermeldung, wenn die Leitungen belegt sind. "GPIO busy" heisst + auf dieser Station praktisch immer "der Anzeigedienst haelt die Pins" - und trifft + ausgerechnet den Befehl, den der Hilfetext als ersten Handgriff der Abnahme nennt. + Die Meldung soll den Bediener also nicht zum Suchen schicken, sondern sagen, was los + ist. Leer, wenn es nicht danach aussieht - ein erfundener Hinweis waere schlimmer als + keiner.""" + if "busy" in str(error).lower(): + return (" - haelt der Anzeigedienst die Leitungen? " + "Pruefen mit 'systemctl status ninlight-display'") + return "" + + +def _hardware_error_types() -> tuple: + """Die Ausnahmen, die als "keine/blockierte Hardware" gelten und damit zu + DisplayUnavailable werden. Bewusst eine ENUMERATION, kein `except Exception`: ein + echter Programmierfehler an anderer Stelle (TypeError, AttributeError, NameError) + soll weiterhin als das erkennbar bleiben, was er ist, statt hier als "Panel fehlt" + getarnt zu werden (Fixrunde 1, C2, zweiter Teil). + + gpiozero.GPIOZeroError (nicht nur GPIODeviceError, Fixrunde 2, N4): die PinError- + Familie (PinInvalidPin, PinUnsupported, ... - z.B. bei falscher/fehlender + Pin-Factory-Zuordnung) haengt direkt unter GPIOZeroError, nicht unter + GPIODeviceError - per `gpiozero.PinInvalidPin.__mro__` nachgeprueft. BadPinFactory + erbt zusaetzlich von ImportError und war dadurch schon abgedeckt. + + lgpio.error (Hardware-Abnahme): laeuft der Anzeigedienst, haelt er die GPIO-Leitungen, + und ein gleichzeitiges "ninlight-station display test" bekommt beim Anlegen des ersten + Pins ein `lgpio.error: 'GPIO busy'`. Das ist KEINE gpiozero.GPIOZeroError - gpiozero + reicht die Ausnahme seiner Pin-Factory unveraendert durch - und fiel deshalb durch + diese Liste hindurch: statt der vorgesehenen einen Protokollzeile gab es einen rohen + Stapelabzug und rc=1. lgpio.error erbt direkt von Exception, die drei + Programmierfehler oben bleiben davon also unberuehrt. + + Wird bei JEDEM Aufruf neu gebaut statt einmal beim Import: nur so wirkt ein + monkeypatch.setattr(epdconfig, "lgpio", ...) im Test ueberhaupt (dasselbe Muster wie + bei gpiozero/spidev in tools/station/tests/test_render.py).""" + types: tuple = (OSError, ImportError) + if gpiozero is not None: + types = types + (gpiozero.GPIOZeroError,) + lgpio_error = getattr(lgpio, "error", None) if lgpio is not None else None + if isinstance(lgpio_error, type) and issubclass(lgpio_error, BaseException): + types = types + (lgpio_error,) + return types + + def module_init() -> int: """Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss. @@ -114,18 +169,7 @@ def module_init() -> int: if not os.path.exists(spi_path): raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?") - # Nur Fehler, die tatsaechlich von einer fehlenden/blockierten Hardware kommen, werden - # zu DisplayUnavailable - alles andere (ein echter Programmierfehler an anderer - # Stelle) soll weiterhin als das erkennbar bleiben, was es ist, statt hier als - # "Panel fehlt" getarnt zu werden (Fixrunde 1, C2, zweiter Teil). gpiozero.GPIOZeroError - # (nicht nur GPIODeviceError, Fixrunde 2, N4): die PinError-Familie (PinInvalidPin, - # PinUnsupported, ... - z.B. bei falscher/fehlender Pin-Factory-Zuordnung) haengt - # direkt unter GPIOZeroError, nicht unter GPIODeviceError - per - # `gpiozero.PinInvalidPin.__mro__` nachgeprueft. BadPinFactory erbt zusaetzlich von - # ImportError und war dadurch schon abgedeckt. - hardware_errors: tuple[type[BaseException], ...] = (OSError, ImportError) - if gpiozero is not None: - hardware_errors = hardware_errors + (gpiozero.GPIOZeroError,) + hardware_errors = _hardware_error_types() try: _rst_pin = gpiozero.DigitalOutputDevice(RST_PIN) @@ -138,7 +182,8 @@ def module_init() -> int: _spi.mode = 0b00 except hardware_errors as e: module_exit() - raise DisplayUnavailable(f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}") from e + raise DisplayUnavailable( + f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}{_busy_hint(e)}") from e return 0 diff --git a/tools/station/display/render.py b/tools/station/display/render.py index 6af297f..c45177c 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -1,11 +1,16 @@ """Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station. -lines() ist bewusst ohne Pillow und ohne Hardwarezugriff importierbar und testbar (siehe +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. -Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. lines() liefert +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 @@ -33,7 +38,72 @@ from typing import Callable PANEL_WIDTH_PX = 250 PANEL_HEIGHT_PX = 122 TEXT_MARGIN_PX = 2 -LINE_HEIGHT_PX = 14 + +# --- 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 @@ -49,74 +119,103 @@ def _hhmm(ts: object) -> str: return "--:--" -def lines(state: dict) -> list[str]: - """Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen fuers Panel (siehe - Modul-Docstring, warum die Kuerzung hier bewusst nicht passiert). Wirft nie - - fehlende/falsch geformte Felder werden ausgelassen statt eine Ausnahme zu werfen.""" +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[str] = [] + 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). - out.append("Station aus") + # 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") + out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG)) return out - out.append(MODE_LABELS[mode]) + out.append((MODE_LABELS[mode], ROLE_HEADER)) ssid = state.get("ssid") - if ssid: - out.append(f"SSID: {ssid}") - if mode == "migration": + 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. - phase = state.get("phase") - if phase == "armed": - target = state.get("armed_target") or "?" - out.append(f"FREIGEGEBEN: {target}") - elif phase in PHASE_LABELS: - out.append(PHASE_LABELS[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)) + + 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: - out.append(str(product_name)) target = last_seen.get("target") variant = last_seen.get("variant") if target or variant: - out.append(f"{target or '?'} ({variant or '?'})") + 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}") + 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}") + 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 '?'}") + 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. @@ -137,33 +236,97 @@ def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px: 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 lines(). + """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) - font = ImageFont.load_default() + 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 measure(text: str) -> float: - return draw.textlength(text, font=font) - - y = TEXT_MARGIN_PX - for raw_line in lines(state): - if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX: - break + 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). lines() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen + # 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 lines() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin + # Wert, den blocks() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin # nur ein zerstoertes Layout, kein sinnvoller Inhalt. - line = raw_line.replace("\n", " ") - fitted = _truncate_to_width(line, measure=measure, max_width_px=max_width_px) - draw.text((TEXT_MARGIN_PX, y), fitted, font=font, fill=0) - y += LINE_HEIGHT_PX + 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 diff --git a/tools/station/ninlight-station b/tools/station/ninlight-station index abf57ed..4c07923 100755 --- a/tools/station/ninlight-station +++ b/tools/station/ninlight-station @@ -265,6 +265,43 @@ display_run() { sudo env EPD_REVISION="$EPD_REVISION" python3 "$STATION_LIB/display/display.py" "$1" } +# Handbetrieb am Panel ("display test"/"display clear"), waehrend der Anzeigedienst +# laufen kann. Beide wollen dieselben GPIO-Leitungen, und die kann nur einer haben: bei +# laufendem Dienst scheiterte der Handgriff mit "lgpio.error: 'GPIO busy'", rc=1. +# +# Warum stoppen-und-wieder-starten statt "brich mit einer Anweisung ab": 'display test' +# ist laut Hilfetext der ERSTE Handgriff der Hardware-Abnahme und die einzige +# Moeglichkeit, die Panel-Revision zu bestimmen. Ein Abbruch wuerde also ausgerechnet dem +# Abnehmer, der die Station gerade zum ersten Mal in der Hand hat, eine Hausaufgabe +# stellen ("erst den Dienst stoppen, dann nochmal") - und zwar bei jedem einzelnen +# Versuch, denn die Revision bestimmt man durch Ausprobieren beider Werte. Das Stoppen +# ist ausserdem gefahrlos: der Dienst zeichnet nur, er ist an keiner Migration beteiligt. +# +# Der Dienst wird NUR wieder gestartet, wenn er vorher lief - sonst haette ein +# Abnahme-Handgriff den Dienst nebenbei eingeschaltet und den Zustand der Station +# 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. +display_handover() { + _was_active=0 + if systemctl is-active --quiet ninlight-display 2>/dev/null; then + _was_active=1 + sudo systemctl stop ninlight-display 2>/dev/null || true + fi + + _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 + return "$_rc" +} + # Nimmt die Anzeige mit aus. Reihenfolge: erst den Dienst stoppen, dann das Panel leeren - # umgekehrt zeichnet der noch laufende Daemon binnen 5 s sein altes Bild wieder darauf. # @@ -474,8 +511,8 @@ PY display_cmd() { case "${1:-}" in - test) display_run --test ;; - clear) display_run --clear ;; + test) display_handover --test ;; + clear) display_handover --clear ;; off) display_off ;; *) usage; exit 2 ;; esac @@ -491,7 +528,9 @@ usage() { echo " /etc/ninlight-station.conf falsch - auf die andere Revision" >&2 echo " umstellen (V2 statt V3 oder umgekehrt), das Panel kurz" >&2 echo " stromlos machen und erneut 'display test'." >&2 - echo " display clear Panel leeren." >&2 + echo " Ein laufender Anzeigedienst wird dafuer kurz gestoppt und" >&2 + echo " danach wieder gestartet (er haelt sonst die GPIO-Leitungen)." >&2 + echo " display clear Panel leeren (ebenfalls mit kurzer Unterbrechung des Dienstes)." >&2 echo " display off Anzeigedienst stoppen und das Panel leeren (macht 'off' mit)." >&2 } diff --git a/tools/station/tests/test_cli.sh b/tools/station/tests/test_cli.sh index 2651214..375d32a 100755 --- a/tools/station/tests/test_cli.sh +++ b/tools/station/tests/test_cli.sh @@ -123,12 +123,21 @@ RUN_DIR="$rundir" UNIT_FILE="$unitfile" case "\$1" in is-active) - if [ "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}" -eq 0 ]; then + # Getrennt schaltbar je Unit: der Handbetrieb am Panel fragt nach ninlight-display + # (laeuft der Anzeigedienst?), alles andere nach ninlight-station. Die Unit ist + # immer das letzte Argument ("is-active --quiet " bzw. "is-active "). + unit="\${!#}" + if [ "\$unit" = "ninlight-display" ]; then + code="\${FAKE_DISPLAY_SERVICE_ACTIVE_EXIT:-0}" + else + code="\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-0}" + fi + if [ "\$code" -eq 0 ]; then echo active exit 0 else echo inactive - exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT}" + exit "\$code" fi ;; restart|stop) @@ -137,15 +146,14 @@ case "\$1" in # RuntimeDirectory bei JEDEM Stop/Restart, nicht nur beim allerersten Start, # und legt es leer wieder an. # - # ACHTUNG, Stub-Erfindung (Task 6b F1): die Regel wird hier auf JEDE - # Unit angewandt, also auch auf ninlight-display - die traegt aber gar kein - # RuntimeDirectory=, ein "stop ninlight-display" raeumt in Wirklichkeit nichts - # weg. \$UNIT_FILE ist immer ninlight-station.service, unabhaengig davon, welche - # Unit im Aufruf steht. Heute folgenlos (kein Testfall stoppt die Anzeige und - # erwartet danach noch Dateien in RUN_DIR), aber niemand darf sich darauf - # verlassen: wer kuenftig prueft, was ein "display off" mit RUN_DIR macht, muss - # den Stub vorher nach Unit unterscheiden lassen. - if ! grep -q '^RuntimeDirectoryPreserve=restart' "\$UNIT_FILE" 2>/dev/null; then + # Die Regel gilt nur fuer ninlight-station - \$UNIT_FILE ist deren Unit-Datei. + # ninlight-display traegt gar kein RuntimeDirectory=, ein "stop ninlight-display" + # raeumt in Wirklichkeit nichts weg (frueher tat der Stub das trotzdem fuer JEDE + # Unit; harmlos, solange kein Testfall die Anzeige stoppt und danach noch Dateien + # in RUN_DIR erwartet - genau das tun 'display test'/'display clear' jetzt aber, + # die den Anzeigedienst waehrend des Handgriffs stoppen). + if [ "\$2" = "ninlight-station" ] \ + && ! grep -q '^RuntimeDirectoryPreserve=restart' "\$UNIT_FILE" 2>/dev/null; then rm -rf "\$RUN_DIR" mkdir -p "\$RUN_DIR" fi @@ -274,8 +282,8 @@ OUT=$(run_cli "$SBX" migrate on 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" -DOWN_LINE=$(grep -n "nmcli con down ninlight-recovery" "$SBX/cmd.log" | head -n1 | cut -d: -f1) -UP_LINE=$(grep -n "nmcli con up qconnex-firmware" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +DOWN_LINE=$(grep -n "nmcli con down ninlight-recovery" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) +UP_LINE=$(grep -n "nmcli con up qconnex-firmware" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) if [ -n "$DOWN_LINE" ] && [ -n "$UP_LINE" ] && [ "$DOWN_LINE" -lt "$UP_LINE" ]; then pass "ninlight-recovery wird VOR qconnex-firmware heruntergefahren" else @@ -595,9 +603,25 @@ grep -Fq "STUB display.py --test" "$SBX/cmd.log" && pass "display.py wurde mit - # er ist wertlos, wenn er nicht die Revision aus der Konfiguration durchreicht: # EPD_REVISION wird per ". $CONF" nur als Shell-Variable gesetzt, nicht exportiert. grep -Fq "STUB display.py --test (EPD_REVISION=V3)" "$SBX/cmd.log" && pass "EPD_REVISION aus der Konfiguration erreicht den Python-Prozess" || fail "EPD_REVISION kam nicht durch: $(cat "$SBX/cmd.log")" -# Nicht gleichzeitig den Anzeigedienst umbringen: 'display test' ist ein Handgriff bei -# der Abnahme, kein Ausschalten. -grep -Fq "systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'display test' stoppt den Anzeigedienst - das soll nur 'display off'/'off'" || pass "'display test' laesst den Anzeigedienst in Ruhe" +# Der Anzeigedienst haelt die GPIO-Leitungen; ohne ihn kurz zu stoppen scheitert der +# Handgriff am Geraet mit "lgpio.error: 'GPIO busy'" (Hardware-Abnahme). Frueher stand +# hier die umgekehrte Zusage ("laesst den Anzeigedienst in Ruhe") - die war genau der +# Grund, warum der erste Handgriff der Abnahme nicht funktionierte. +STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) +TEST_LINE=$(grep -n "STUB display.py --test" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) +START_LINE=$(grep -n "STUB systemctl start ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) +if [ -n "$STOP_LINE" ] && [ -n "$TEST_LINE" ] && [ "$STOP_LINE" -lt "$TEST_LINE" ]; then + pass "der Anzeigedienst wird gestoppt, BEVOR display.py die GPIO-Leitungen anfasst" +else + fail "Anzeigedienst nicht (rechtzeitig) gestoppt (stop=$STOP_LINE, test=$TEST_LINE): $(cat "$SBX/cmd.log")" +fi +# Und danach wieder an: ein Abnahme-Handgriff darf die Station nicht dauerhaft ohne +# Anzeige zuruecklassen. +if [ -n "$START_LINE" ] && [ -n "$TEST_LINE" ] && [ "$TEST_LINE" -lt "$START_LINE" ]; then + pass "der Anzeigedienst laeuft danach wieder (start=$START_LINE)" +else + fail "Anzeigedienst wurde nicht wieder gestartet (test=$TEST_LINE, start=$START_LINE): $(cat "$SBX/cmd.log")" +fi echo echo "=== Fall D2: 'display clear' ruft display.py mit --clear auf ===" @@ -611,6 +635,42 @@ set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" grep -Fq "STUB display.py --clear" "$SBX/cmd.log" && pass "display.py wurde mit --clear aufgerufen" || fail "kein '--clear'-Aufruf im Log: $(cat "$SBX/cmd.log")" grep -Fq "STUB display.py --test" "$SBX/cmd.log" && fail "'display clear' hat --test aufgerufen" || pass "kein --test bei 'display clear'" +# Dieselbe GPIO-Kollision wie bei 'display test' - auch 'display clear' braucht die +# Leitungen fuer sich. +grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" && pass "'display clear' stoppt den Anzeigedienst fuer die Dauer" || fail "'display clear' laesst den Anzeigedienst laufen - das gibt 'GPIO busy': $(cat "$SBX/cmd.log")" +grep -Fq "STUB systemctl start ninlight-display" "$SBX/cmd.log" && pass "'display clear' startet den Anzeigedienst danach wieder" || fail "'display clear' laesst den Anzeigedienst aus: $(cat "$SBX/cmd.log")" + +echo +echo "=== Fall D2a: war der Anzeigedienst aus, bleibt er nach 'display test' aus ===" +# Sonst haette ein Abnahme-Handgriff die Station nebenbei umgeschaltet: nach +# 'ninlight-station off' ist der Anzeigedienst bewusst gestoppt, und 'display test' +# darf ihn nicht wieder hochziehen. +SBX="$TMPROOT/tD2a" +setup_sandbox "$SBX" +: >"$SBX/cmd.log" +set +e +OUT=$(FAKE_DISPLAY_SERVICE_ACTIVE_EXIT=3 run_cli "$SBX" display test 2>&1) +RC=$? +set -e +[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" +grep -Fq "STUB display.py --test" "$SBX/cmd.log" && pass "Testbild wurde trotzdem gezeichnet" || fail "kein '--test'-Aufruf: $(cat "$SBX/cmd.log")" +grep -Fq "STUB systemctl start ninlight-display" "$SBX/cmd.log" && fail "'display test' hat den Anzeigedienst eingeschaltet, obwohl er aus war" || pass "kein 'start' fuer einen Dienst, der gar nicht lief" +grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" && fail "'display test' stoppt einen Dienst, der gar nicht laeuft" || pass "kein ueberfluessiges 'stop'" + +echo +echo "=== Fall D2b: scheitert das Panel, meldet 'display test' das - der Dienst laeuft trotzdem wieder ===" +# Der Rueckgabewert von display.py ist das Ergebnis des Handgriffs; ein erfolgreiches +# Wiederanfahren des Dienstes darf ihn nicht in eine Erfolgsmeldung verwandeln. Und +# umgekehrt darf ein gescheitertes Testbild den Dienst nicht dauerhaft aus lassen. +SBX="$TMPROOT/tD2b" +setup_sandbox "$SBX" +: >"$SBX/cmd.log" +set +e +OUT=$(FAKE_DISPLAY_EXIT=1 run_cli "$SBX" display test 2>&1) +RC=$? +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 D3 (Task 6b, Punkt 4): 'display off' stoppt den Dienst VOR dem Leeren des Panels ===" @@ -624,8 +684,8 @@ OUT=$(run_cli "$SBX" display off 2>&1) RC=$? set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" -STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1) -CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) +CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) if [ -n "$STOP_LINE" ] && [ -n "$CLEAR_LINE" ] && [ "$STOP_LINE" -lt "$CLEAR_LINE" ]; then pass "Anzeigedienst wird gestoppt, BEVOR das Panel geleert wird (stop=$STOP_LINE, clear=$CLEAR_LINE)" else @@ -649,8 +709,8 @@ set -e [ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" grep -Fq "STUB systemctl stop ninlight-display" "$SBX/cmd.log" && pass "'off' stoppt den Anzeigedienst" || fail "'off' stoppt den Anzeigedienst nicht: $(cat "$SBX/cmd.log")" grep -Fq "STUB display.py --clear" "$SBX/cmd.log" && pass "'off' leert das Panel" || fail "'off' leert das Panel nicht: $(cat "$SBX/cmd.log")" -STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1) -CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +STOP_LINE=$(grep -n "STUB systemctl stop ninlight-display" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) +CLEAR_LINE=$(grep -n "STUB display.py --clear" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) if [ -n "$STOP_LINE" ] && [ -n "$CLEAR_LINE" ] && [ "$STOP_LINE" -lt "$CLEAR_LINE" ]; then pass "auch bei 'off': erst stoppen, dann leeren" else @@ -658,7 +718,7 @@ else fi # Das Panel muss geleert sein, BEVOR ap_switch das Ergebnis von "off" verkuenden kann - # und vor allem, bevor ap_switch bei haengendem NetworkManager abbricht (Fall D6). -UP_LINE=$(grep -n "nmcli con up home-wifi" "$SBX/cmd.log" | head -n1 | cut -d: -f1) +UP_LINE=$(grep -n "nmcli con up home-wifi" "$SBX/cmd.log" | head -n1 | cut -d: -f1 || true) if [ -n "$UP_LINE" ] && [ "$CLEAR_LINE" -lt "$UP_LINE" ]; then pass "Panel ist geleert, bevor wlan0 ins Heim-WLAN zurueckgeschaltet wird" else diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index 68db65d..59c1f4a 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -2,6 +2,8 @@ import json import sys from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display")) import display # noqa: E402 @@ -75,7 +77,12 @@ def test_compose_does_not_draw_into_the_last_column_for_long_lines(): (empirisch mit echtem Pillow bestaetigt: die rechte Kante liegt bei ca. Spalte 237, deutlich vor Spalte 249). Mit deaktivierter Kuerzung fuellt der zeichenweise laengere Text die Zeile bis zum Rand - ebenfalls empirisch bestaetigt (die letzte Spalte - enthaelt dann einen schwarzen Pixel).""" + enthaelt dann einen schwarzen Pixel). + + Geprueft wird erst UNTERHALB des Kopfbalkens: der ist seit der Schriftumstellung + absichtlich vollflaechig schwarz (weisse Schrift auf schwarzem Grund) und faerbt die + letzte Spalte in seinen eigenen Zeilen mit - das ist Layout, keine ueberlaufende + Zeile. Die absichtlich zu lange Zeile (der Produktname) steht ohnehin darunter.""" state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [], "ssid": "qconnex-firmware", "last_seen": {"product_name": "W" * 200, "target": "e27_rgb", @@ -85,10 +92,207 @@ def test_compose_does_not_draw_into_the_last_column_for_long_lines(): pixels = image.load() last_col = render.PANEL_WIDTH_PX - 1 - black_in_last_column = any(pixels[last_col, y] == 0 for y in range(render.PANEL_HEIGHT_PX)) + black_in_last_column = any( + pixels[last_col, y] == 0 + for y in range(render.HEADER_BAR_HEIGHT_PX, render.PANEL_HEIGHT_PX)) assert not black_in_last_column, "Text erreicht die letzte Spalte - Kuerzung wirkt nicht" +# --- Schriftgroesse/Layout (Hardware-Abnahme: "die Schrift ist zu klein") ----------- +# +# Alle Tests hier messen die TINTE im tatsaechlich gezeichneten Bild, nicht die +# Konstanten oben: eine Zusage ueber die Schriftgroesse, die nur die Konstante liest, +# haelt niemanden auf, der die Schriftsuche entfernt und wieder auf +# ImageFont.load_default() (rund 10 px) zurueckfaellt - genau die Mutation, gegen die +# diese Tests wachen sollen. + +def _ink_box(image, y0=0, y1=None): + """(links, oben, rechts, unten) der schwarzen Pixel im Band [y0, y1), oder None.""" + y1 = image.height if y1 is None else y1 + pixels = image.load() + xs, ys = [], [] + for y in range(y0, y1): + for x in range(image.width): + if pixels[x, y] == 0: + xs.append(x) + ys.append(y) + if not xs: + return None + return (min(xs), min(ys), max(xs), max(ys)) + + +def _migration_state(): + return {"mode": "migration", "phase": "armed", "armed_target": "gu10_rgb", + "served": 3, "ssid": "qconnex-firmware", + "last_seen": {"product_name": "Klyqa GU10 RGB", "target": "gu10_rgb", + "variant": "prod", "lamp_version": "V1.1.0.1138"}, + "recent": [{"target": "gu10_rgb", "status": "ok", "ts": "2026-09-13T13:04:02Z"}]} + + +def test_off_screen_is_drawn_big_and_centered(): + """"Station aus" hat fast keinen Inhalt und muss aus zwei Metern lesbar sein. + + Gemessen mit echtem Pillow: mit der Schriftsuche ist die Tinte 19 px hoch und links + wie rechts 56 px vom Rand entfernt; faellt compose() auf load_default() zurueck, sind + es 8 px - die Schwelle 14 trennt beide Faelle eindeutig.""" + image = render.compose({"mode": "off"}) + box = _ink_box(image) + assert box is not None, "gar nichts gezeichnet" + + height = box[3] - box[1] + 1 + assert height >= 14, f"Schrift nur {height} px hoch - Schriftsuche wirkt nicht" + + left, right = box[0], render.PANEL_WIDTH_PX - 1 - box[2] + assert abs(left - right) <= 3, f"nicht waagerecht mittig (links {left}, rechts {right})" + top, bottom = box[1], render.PANEL_HEIGHT_PX - 1 - box[3] + 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 + + 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 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.""" + 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 + + hero = _ink_box(image, hero_top, hero_bottom) + detail = _ink_box(image, hero_bottom, hero_bottom + render.DETAIL_LINE_HEIGHT_PX) + assert hero is not None and detail is not None + + hero_height = hero[3] - hero[1] + 1 + detail_height = detail[3] - detail[1] + 1 + assert hero_height >= detail_height + 3, ( + f"grosse Zeile ({hero_height} px) hebt sich nicht vom Beiwerk " + f"({detail_height} px) ab") + + +def test_the_last_delivery_line_still_fits_on_the_panel(): + """Der vollste Zustand (Kopfbalken, grosse Zeile, sechs Beiwerkzeilen) muss + vollstaendig aufs Panel passen - mit groesserer Schrift ist das keine + Selbstverstaendlichkeit mehr, und ausgerechnet die letzte Zeile ("zuletzt + ausgeliefert") faellt sonst stumm hinten herunter.""" + state = _migration_state() + expected = render.lines(state) + assert len(expected) == 8, expected # sonst misst dieser Test etwas anderes + + image = render.compose(state) + last_line_top = (render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX + + render.HERO_LINE_HEIGHT_PX + 5 * render.DETAIL_LINE_HEIGHT_PX) + assert _ink_box(image, last_line_top, render.PANEL_HEIGHT_PX) is not None, ( + "letzte Zeile fehlt - sie passt nicht mehr aufs Panel") + + +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() + zurueck und zeichnet - klein, aber vorhanden.""" + monkeypatch.setattr(render, "FONT_SEARCH_PATHS", ()) + image = render.compose({"mode": "off"}) + assert _ink_box(image) is not None + + +# --- Die Schriftsuche selbst, ohne echte Schriftdateien und ohne altes Pillow ------- + +class _FakeImageFontModule: + """Nachbau der zwei ImageFont-Funktionen, die _load_font() benutzt. + + `available` sind die Pfade, die es "gibt"; jeder andere truetype()-Aufruf scheitert + mit OSError, genau wie Pillow es bei einer fehlenden Datei tut. + + load_default() bildet Pillow 9.4.0 auf Pi 2 nach: KEIN size-Argument (das gibt es + erst ab 10.1). Ein `size=`-Aufruf schlaegt hier deshalb mit demselben TypeError fehl + wie am Geraet - so laesst sich die 9.4-Lage pruefen, ohne Pillow herunterzustufen.""" + + def __init__(self, available=()): + self.available = set(available) + self.truetype_calls = [] + self.load_default_calls = 0 + + def truetype(self, path, size): + self.truetype_calls.append((path, size)) + if path not in self.available: + raise OSError(f"cannot open resource: {path}") + return ("truetype", path, size) + + def load_default(self, *args, **kwargs): + self.load_default_calls += 1 + if args or kwargs: + raise TypeError( + "load_default() got an unexpected keyword argument 'size'") + return ("default",) + + +DEJAVU_REGULAR = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +DEJAVU_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + + +def test_font_search_takes_dejavu_first_on_the_pi(): + """Auf beiden Pis liegt DejaVu unter /usr/share/fonts/truetype/dejavu/ - das ist der + Hauptweg, und er muss VOR jedem Rueckfall kommen. Rot, sobald jemand die Suche + entfernt und wieder direkt auf load_default() geht.""" + image_font = _FakeImageFontModule(available=(DEJAVU_REGULAR, DEJAVU_BOLD)) + + regular = render._load_font(image_font, 11) + bold = render._load_font(image_font, 20, bold=True) + + assert regular == ("truetype", DEJAVU_REGULAR, 11) + assert bold == ("truetype", DEJAVU_BOLD, 20) + assert image_font.truetype_calls[0] == (DEJAVU_REGULAR, 11), \ + "DejaVu wurde nicht zuerst probiert" + assert image_font.load_default_calls == 0, \ + "load_default() benutzt, obwohl DejaVu da ist - die Schrift waere wieder winzig" + + +def test_font_search_falls_through_to_a_later_candidate(): + """Auf der Entwicklungsmaschine (macOS) gibt es den Debian-Pfad nicht - dann muss die + Kette weiterlaufen statt sofort auf load_default() zu fallen, sonst laeuft der Test + oben dort gegen eine 10-px-Schrift.""" + arial = "/System/Library/Fonts/Supplemental/Arial.ttf" + image_font = _FakeImageFontModule(available=(arial,)) + + assert render._load_font(image_font, 16) == ("truetype", arial, 16) + assert image_font.load_default_calls == 0 + + +def test_font_search_ends_at_load_default_without_a_size_argument(): + """Der letzte Rueckfall auf einem Rechner ohne jede Schriftdatei. Entscheidend ist, + dass load_default() OHNE size gerufen wird: auf Pi 2 (Pillow 9.4.0) waere + load_default(size=...) ein TypeError - der Fake wirft ihn hier genauso.""" + image_font = _FakeImageFontModule() + + assert render._load_font(image_font, 26, bold=True) == ("default",) + assert image_font.load_default_calls == 1 + assert len(image_font.truetype_calls) == len(render.FONT_SEARCH_PATHS) + + +def test_font_search_survives_a_pillow_without_freetype(): + """Ein ohne FreeType gebautes Pillow laesst truetype() mit ImportError scheitern - + auch das darf die Anzeige nicht umbringen.""" + + class _NoFreetype(_FakeImageFontModule): + def truetype(self, path, size): + raise ImportError("The _imagingft C module is not installed") + + image_font = _NoFreetype() + assert render._load_font(image_font, 20) == ("default",) + + def test_missing_fields_do_not_raise(): """status.json kann jederzeit halb geschrieben oder aus einer aelteren Version sein.""" for state in ({}, {"mode": "migration"}, {"mode": "migration", "last_seen": {}}): @@ -243,6 +447,80 @@ def _stub_hardware(monkeypatch): monkeypatch.setattr(epdconfig, "_busy_pin", None) +# --- "GPIO busy" (Hardware-Abnahme, zweiter Befund) --------------------------------- + +class _FakeLgpioModule: + """Nur das, was epdconfig von lgpio kennt: die Ausnahmeklasse. Wie im echten Modul + erbt sie direkt von Exception - deshalb faengt eine Liste, die sie enthaelt, keinen + TypeError/AttributeError/NameError mit ein.""" + + class error(Exception): + pass + + +def _gpiozero_raising(exception): + """Ein gpiozero-Ersatz, dessen erster Pin-Aufbau `exception` wirft.""" + + class _Raising: + GPIOZeroError = _FakeGpiozeroModule.GPIOZeroError + DigitalInputDevice = _FakeDigitalInputDevice + + @staticmethod + def DigitalOutputDevice(pin): + raise exception + + return _Raising + + +def test_lgpio_gpio_busy_becomes_display_unavailable(monkeypatch): + """Der Befund vom Geraet: laeuft der Anzeigedienst, haelt er die GPIO-Leitungen, und + "ninlight-station display test" bekam ein rohes `lgpio.error: 'GPIO busy'` samt + Stapelabzug und rc=1 - lgpio.error ist KEINE gpiozero.GPIOZeroError und fiel durch + die Ausnahmeliste in module_init(). Erwartet ist stattdessen DisplayUnavailable, + das display.main() zu genau einer Protokollzeile und sys.exit(0) macht.""" + _stub_hardware(monkeypatch) + monkeypatch.setattr(epdconfig, "lgpio", _FakeLgpioModule) + monkeypatch.setattr(epdconfig, "gpiozero", + _gpiozero_raising(_FakeLgpioModule.error("'GPIO busy'"))) + + with pytest.raises(epdconfig.DisplayUnavailable) as excinfo: + epdconfig.module_init() + + message = str(excinfo.value) + assert "GPIO busy" in message + # Und die Meldung muss dem Bediener sagen, was hier praktisch immer los ist, statt + # ihn den lgpio-Fehler googeln zu lassen. + assert "ninlight-display" in message, f"kein Hinweis auf den Anzeigedienst: {message}" + + +def test_gpio_busy_hint_stays_away_from_unrelated_errors(monkeypatch): + """Der Hinweis auf den Anzeigedienst gilt nur fuer belegte Leitungen - bei einem + anderen Fehler waere er eine Falschaussage, die den Bediener in die falsche Richtung + schickt.""" + _stub_hardware(monkeypatch) + monkeypatch.setattr(epdconfig, "lgpio", _FakeLgpioModule) + monkeypatch.setattr(epdconfig, "gpiozero", + _gpiozero_raising(OSError("kein solches Geraet"))) + + with pytest.raises(epdconfig.DisplayUnavailable) as excinfo: + epdconfig.module_init() + + assert "ninlight-display" not in str(excinfo.value) + + +def test_programming_errors_are_still_not_disguised_as_missing_hardware(monkeypatch): + """Die Ausnahmeliste bleibt gezielt (Fixrunde 1, C2): ein TypeError ist ein + Programmierfehler und darf nicht als "Panel fehlt" durchgehen - sonst verschwindet er + hinter einer INFO-Zeile und sys.exit(0).""" + _stub_hardware(monkeypatch) + monkeypatch.setattr(epdconfig, "lgpio", _FakeLgpioModule) + monkeypatch.setattr(epdconfig, "gpiozero", + _gpiozero_raising(TypeError("falsches Argument"))) + + with pytest.raises(TypeError): + epdconfig.module_init() + + def test_v3_partial_draw_after_sleep_does_not_crash(monkeypatch): """Regressionstest zu Fixrunde 2, N1 - genau der vom Reviewer nachgestellte Ablauf: Panel("V3") -> clear -> draw_full -> sleep -> draw_partial. Vorher stuerzte das mit