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
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