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.
This commit is contained in:
Michael Sauer
2026-09-14 02:24:14 +02:00
parent f138e88f4e
commit bb39105ac4
5 changed files with 667 additions and 82 deletions
+58 -13
View File
@@ -35,6 +35,15 @@ try:
except ImportError: # pragma: no cover - dito except ImportError: # pragma: no cover - dito
spidev = None 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): class DisplayUnavailable(Exception):
"""Kein Panel ansprechbar: Bibliothek fehlt, /dev/spidev0.0 fehlt, oder der """Kein Panel ansprechbar: Bibliothek fehlt, /dev/spidev0.0 fehlt, oder der
@@ -89,6 +98,52 @@ def spi_writebyte2(data) -> None:
_spi.writebytes2(data) _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: def module_init() -> int:
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen """Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss. Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss.
@@ -114,18 +169,7 @@ def module_init() -> int:
if not os.path.exists(spi_path): if not os.path.exists(spi_path):
raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?") raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?")
# Nur Fehler, die tatsaechlich von einer fehlenden/blockierten Hardware kommen, werden hardware_errors = _hardware_error_types()
# 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,)
try: try:
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN) _rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
@@ -138,7 +182,8 @@ def module_init() -> int:
_spi.mode = 0b00 _spi.mode = 0b00
except hardware_errors as e: except hardware_errors as e:
module_exit() 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 return 0
+206 -43
View File
@@ -1,11 +1,16 @@
"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station. """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 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 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. 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 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() Zeile darf das Layout nicht sprengen) passiert erst in compose(), per _truncate_to_width()
gegen die tatsaechliche, schriftabhaengige Pixelbreite (PIL.ImageFont.load_default() ist gegen die tatsaechliche, schriftabhaengige Pixelbreite (PIL.ImageFont.load_default() ist
@@ -33,7 +38,72 @@ from typing import Callable
PANEL_WIDTH_PX = 250 PANEL_WIDTH_PX = 250
PANEL_HEIGHT_PX = 122 PANEL_HEIGHT_PX = 122
TEXT_MARGIN_PX = 2 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"} MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht # "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
@@ -49,74 +119,103 @@ def _hhmm(ts: object) -> str:
return "--:--" return "--:--"
def lines(state: dict) -> list[str]: def blocks(state: dict) -> list[tuple[str, str]]:
"""Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen fuers Panel (siehe """Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen mit ihrer ROLLE
Modul-Docstring, warum die Kuerzung hier bewusst nicht passiert). Wirft nie - (siehe Rollen-Kommentar oben, und den Modul-Docstring, warum die Kuerzung hier bewusst
fehlende/falsch geformte Felder werden ausgelassen statt eine Ausnahme zu werfen.""" 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 {} state = state if isinstance(state, dict) else {}
mode = state.get("mode") mode = state.get("mode")
out: list[str] = [] out: list[tuple[str, str]] = []
if mode == "off": if mode == "off":
# Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt # Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen # diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und # (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
# gerade nicht aktiv). # gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
out.append("Station aus") out.append(("Station aus", ROLE_BIG))
return out return out
if mode not in ("recovery", "migration"): if mode not in ("recovery", "migration"):
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch # Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu # nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
# unterstellen. # 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 return out
out.append(MODE_LABELS[mode]) out.append((MODE_LABELS[mode], ROLE_HEADER))
ssid = state.get("ssid") 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 # 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 # nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als
# Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine # Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine
# Phase, deshalb dieser Block nur hier drin. # 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") last_seen = state.get("last_seen")
if not isinstance(last_seen, dict): if not isinstance(last_seen, dict):
last_seen = {} last_seen = {}
product_name = last_seen.get("product_name") 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: if product_name:
out.append(str(product_name))
target = last_seen.get("target") target = last_seen.get("target")
variant = last_seen.get("variant") variant = last_seen.get("variant")
if target or 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") lamp_version = last_seen.get("lamp_version")
if 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") 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") recent = state.get("recent")
if isinstance(recent, list) and recent: if isinstance(recent, list) and recent:
last = recent[-1] last = recent[-1]
if isinstance(last, dict): if isinstance(last, dict):
out.append(f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} " out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
f"{last.get('status') or '?'}") f"{last.get('status') or '?'}", ROLE_DETAIL))
return out 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: 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 """Kuerzt `text` mit "…" am Ende, bis `measure(text)` nicht mehr breiter als
`max_width_px` ist - nie umgebrochen, immer von rechts gekuerzt. `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 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): 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.""" Importiert Pillow erst hier - siehe Modul-Docstring."""
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255) image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
draw = ImageDraw.Draw(image) 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 max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
def measure(text: str) -> float: def fit(raw_text: str, font) -> str:
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
# draw.textlength() wirft ValueError("can't measure length of multiline text"), # draw.textlength() wirft ValueError("can't measure length of multiline text"),
# sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von # sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von
# main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3, # 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 # (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. # nur ein zerstoertes Layout, kein sinnvoller Inhalt.
line = raw_line.replace("\n", " ") text = raw_text.replace("\n", " ")
fitted = _truncate_to_width(line, measure=measure, max_width_px=max_width_px) return _truncate_to_width(
draw.text((TEXT_MARGIN_PX, y), fitted, font=font, fill=0) text, measure=lambda t: draw.textlength(t, font=font),
y += LINE_HEIGHT_PX 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 return image
+42 -3
View File
@@ -265,6 +265,43 @@ display_run() {
sudo env EPD_REVISION="$EPD_REVISION" python3 "$STATION_LIB/display/display.py" "$1" 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 - # 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. # umgekehrt zeichnet der noch laufende Daemon binnen 5 s sein altes Bild wieder darauf.
# #
@@ -474,8 +511,8 @@ PY
display_cmd() { display_cmd() {
case "${1:-}" in case "${1:-}" in
test) display_run --test ;; test) display_handover --test ;;
clear) display_run --clear ;; clear) display_handover --clear ;;
off) display_off ;; off) display_off ;;
*) usage; exit 2 ;; *) usage; exit 2 ;;
esac esac
@@ -491,7 +528,9 @@ usage() {
echo " /etc/ninlight-station.conf falsch - auf die andere Revision" >&2 echo " /etc/ninlight-station.conf falsch - auf die andere Revision" >&2
echo " umstellen (V2 statt V3 oder umgekehrt), das Panel kurz" >&2 echo " umstellen (V2 statt V3 oder umgekehrt), das Panel kurz" >&2
echo " stromlos machen und erneut 'display test'." >&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 echo " display off Anzeigedienst stoppen und das Panel leeren (macht 'off' mit)." >&2
} }
+81 -21
View File
@@ -123,12 +123,21 @@ RUN_DIR="$rundir"
UNIT_FILE="$unitfile" UNIT_FILE="$unitfile"
case "\$1" in case "\$1" in
is-active) 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 <unit>" bzw. "is-active <unit>").
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 echo active
exit 0 exit 0
else else
echo inactive echo inactive
exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT}" exit "\$code"
fi fi
;; ;;
restart|stop) restart|stop)
@@ -137,15 +146,14 @@ case "\$1" in
# RuntimeDirectory bei JEDEM Stop/Restart, nicht nur beim allerersten Start, # RuntimeDirectory bei JEDEM Stop/Restart, nicht nur beim allerersten Start,
# und legt es leer wieder an. # und legt es leer wieder an.
# #
# ACHTUNG, Stub-Erfindung (Task 6b F1): die Regel wird hier auf JEDE # Die Regel gilt nur fuer ninlight-station - \$UNIT_FILE ist deren Unit-Datei.
# Unit angewandt, also auch auf ninlight-display - die traegt aber gar kein # ninlight-display traegt gar kein RuntimeDirectory=, ein "stop ninlight-display"
# RuntimeDirectory=, ein "stop ninlight-display" raeumt in Wirklichkeit nichts # raeumt in Wirklichkeit nichts weg (frueher tat der Stub das trotzdem fuer JEDE
# weg. \$UNIT_FILE ist immer ninlight-station.service, unabhaengig davon, welche # Unit; harmlos, solange kein Testfall die Anzeige stoppt und danach noch Dateien
# Unit im Aufruf steht. Heute folgenlos (kein Testfall stoppt die Anzeige und # in RUN_DIR erwartet - genau das tun 'display test'/'display clear' jetzt aber,
# erwartet danach noch Dateien in RUN_DIR), aber niemand darf sich darauf # die den Anzeigedienst waehrend des Handgriffs stoppen).
# verlassen: wer kuenftig prueft, was ein "display off" mit RUN_DIR macht, muss if [ "\$2" = "ninlight-station" ] \
# den Stub vorher nach Unit unterscheiden lassen. && ! grep -q '^RuntimeDirectoryPreserve=restart' "\$UNIT_FILE" 2>/dev/null; then
if ! grep -q '^RuntimeDirectoryPreserve=restart' "\$UNIT_FILE" 2>/dev/null; then
rm -rf "\$RUN_DIR" rm -rf "\$RUN_DIR"
mkdir -p "\$RUN_DIR" mkdir -p "\$RUN_DIR"
fi fi
@@ -274,8 +282,8 @@ OUT=$(run_cli "$SBX" migrate on 2>&1)
RC=$? RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$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) 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) 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 if [ -n "$DOWN_LINE" ] && [ -n "$UP_LINE" ] && [ "$DOWN_LINE" -lt "$UP_LINE" ]; then
pass "ninlight-recovery wird VOR qconnex-firmware heruntergefahren" pass "ninlight-recovery wird VOR qconnex-firmware heruntergefahren"
else 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: # er ist wertlos, wenn er nicht die Revision aus der Konfiguration durchreicht:
# EPD_REVISION wird per ". $CONF" nur als Shell-Variable gesetzt, nicht exportiert. # 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")" 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 Anzeigedienst haelt die GPIO-Leitungen; ohne ihn kurz zu stoppen scheitert der
# der Abnahme, kein Ausschalten. # Handgriff am Geraet mit "lgpio.error: 'GPIO busy'" (Hardware-Abnahme). Frueher stand
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" # 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
echo "=== Fall D2: 'display clear' ruft display.py mit --clear auf ===" 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" [ "$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 --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'" 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
echo "=== Fall D3 (Task 6b, Punkt 4): 'display off' stoppt den Dienst VOR dem Leeren des Panels ===" 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=$? RC=$?
set -e set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$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) 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) 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 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)" pass "Anzeigedienst wird gestoppt, BEVOR das Panel geleert wird (stop=$STOP_LINE, clear=$CLEAR_LINE)"
else else
@@ -649,8 +709,8 @@ set -e
[ "$RC" -eq 0 ] && pass "Exit-Code 0" || fail "Exit-Code $RC, Ausgabe: $OUT" [ "$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 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")" 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) 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) 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 if [ -n "$STOP_LINE" ] && [ -n "$CLEAR_LINE" ] && [ "$STOP_LINE" -lt "$CLEAR_LINE" ]; then
pass "auch bei 'off': erst stoppen, dann leeren" pass "auch bei 'off': erst stoppen, dann leeren"
else else
@@ -658,7 +718,7 @@ else
fi fi
# Das Panel muss geleert sein, BEVOR ap_switch das Ergebnis von "off" verkuenden kann - # 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). # 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 if [ -n "$UP_LINE" ] && [ "$CLEAR_LINE" -lt "$UP_LINE" ]; then
pass "Panel ist geleert, bevor wlan0 ins Heim-WLAN zurueckgeschaltet wird" pass "Panel ist geleert, bevor wlan0 ins Heim-WLAN zurueckgeschaltet wird"
else else
+280 -2
View File
@@ -2,6 +2,8 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display")) sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display"))
import display # noqa: E402 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, (empirisch mit echtem Pillow bestaetigt: die rechte Kante liegt bei ca. Spalte 237,
deutlich vor Spalte 249). Mit deaktivierter Kuerzung fuellt der zeichenweise laengere deutlich vor Spalte 249). Mit deaktivierter Kuerzung fuellt der zeichenweise laengere
Text die Zeile bis zum Rand - ebenfalls empirisch bestaetigt (die letzte Spalte 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": [], state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [],
"ssid": "qconnex-firmware", "ssid": "qconnex-firmware",
"last_seen": {"product_name": "W" * 200, "target": "e27_rgb", "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() pixels = image.load()
last_col = render.PANEL_WIDTH_PX - 1 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" 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(): def test_missing_fields_do_not_raise():
"""status.json kann jederzeit halb geschrieben oder aus einer aelteren Version sein.""" """status.json kann jederzeit halb geschrieben oder aus einer aelteren Version sein."""
for state in ({}, {"mode": "migration"}, {"mode": "migration", "last_seen": {}}): for state in ({}, {"mode": "migration"}, {"mode": "migration", "last_seen": {}}):
@@ -243,6 +447,80 @@ def _stub_hardware(monkeypatch):
monkeypatch.setattr(epdconfig, "_busy_pin", None) 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): def test_v3_partial_draw_after_sleep_does_not_crash(monkeypatch):
"""Regressionstest zu Fixrunde 2, N1 - genau der vom Reviewer nachgestellte Ablauf: """Regressionstest zu Fixrunde 2, N1 - genau der vom Reviewer nachgestellte Ablauf:
Panel("V3") -> clear -> draw_full -> sleep -> draw_partial. Vorher stuerzte das mit Panel("V3") -> clear -> draw_full -> sleep -> draw_partial. Vorher stuerzte das mit