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