"""GPIO/SPI-Unterbau fuer die vendored Waveshare-Panel-Treiber (epd2in13_V2.py, epd2in13_V3.py), geschrieben gegen gpiozero + spidev statt gegen Waveshares eigenes epdconfig.py, das RPi.GPIO voraussetzt - ein Modul, das auf Debian 13 (trixie) mit Python 3.13 nicht mehr baut/importiert. gpiozero, lgpio (gpiozero's Pin-Factory auf trixie) und spidev sind auf dem Ziel-Pi vorhanden. Stellt genau die Schnittstelle bereit, die die beiden Treiber erwarten: die Pin-Konstanten RST_PIN/DC_PIN/CS_PIN/BUSY_PIN sowie digital_write, digital_read, delay_ms, spi_writebyte, spi_writebyte2, module_init und module_exit als Modulfunktionen (kein Klassen-Wrapper - die Treiber greifen direkt auf `epdconfig.` zu). CS_PIN wird NICHT als GPIO geschaltet: spidev.SpiDev(0, 0) steuert die Hardware-CS0-Leitung selbst bei jeder Übertragung, ein zusaetzliches Software-Toggle wuerde nur mit der Hardware um die Leitung konkurrieren. digital_write()/digital_read() sind fuer CS_PIN deshalb No-ops - genau das Muster, das Waveshares eigene, neuere epdconfig.py-Fassung fuer Raspberry Pi inzwischen ebenfalls verwendet (dort ist die GPIO-Instanziierung fuer CS_PIN auskommentiert). Ein fehlender Import (gpiozero/spidev nicht installiert) oder ein fehlendes /dev/spidev0.0 wirft DisplayUnavailable - display.py faengt das ab, protokolliert eine Zeile und beendet sich sauber (sys.exit(0)), statt dass ein fehlendes Panel eine Migration verhindern koennte (siehe Modul-Docstring von display.py).""" from __future__ import annotations import os import time try: import gpiozero except ImportError: # pragma: no cover - auf dem Pi immer vorhanden, hier nur defensiv gpiozero = None try: import spidev 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 Hardwarezugriff selbst ist fehlgeschlagen (z.B. Berechtigung, Pin belegt).""" # Dieselben Pin-Nummern wie in Waveshares eigenem epdconfig.py fuer den Raspberry Pi. RST_PIN = 17 DC_PIN = 25 CS_PIN = 8 BUSY_PIN = 24 _SPI_BUS = 0 _SPI_DEVICE = 0 _SPI_HZ = 4_000_000 _rst_pin = None _dc_pin = None _busy_pin = None _spi = None def digital_write(pin: int, value: int) -> None: if pin == RST_PIN: _rst_pin.value = bool(value) elif pin == DC_PIN: _dc_pin.value = bool(value) elif pin == CS_PIN: pass # Hardware-CS ueber spidev, siehe Modul-Docstring. # Andere Pins fragen die Treiber nicht ab - kein else-Zweig noetig. def digital_read(pin: int) -> int: if pin == BUSY_PIN: return int(bool(_busy_pin.value)) if pin == RST_PIN: return int(bool(_rst_pin.value)) if pin == DC_PIN: return int(bool(_dc_pin.value)) return 0 def delay_ms(ms: int) -> None: time.sleep(ms / 1000.0) def spi_writebyte(data) -> None: _spi.writebytes(data) 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. Idempotent: ein zweiter Aufruf, waehrend die Pins/das SPI-Geraet noch offen sind (die Panel-Treiber rufen dies bei JEDEM epd.init() erneut auf - Vollaufbau bei Moduswechsel oder alle 30 Minuten loest also ohne diese Absicherung einen zweiten, unnoetigen Oeffnungsversuch aus), kehrt sofort zurueck statt gpiozero.DigitalOutputDevice(17) ein zweites Mal anzulegen. Das wuerde mit GPIOPinInUse scheitern - vom breiten except unten (Fixrunde 1, C2) frueher faelschlich als "Panel fehlt" verschluckt, obwohl es ein Programmierfehler in der Aufrufreihenfolge ist. Nach module_exit() (z.B. durch epd.sleep()) ist _spi wieder None, ein echter Neuaufbau laeuft dann normal.""" global _rst_pin, _dc_pin, _busy_pin, _spi if _spi is not None: return 0 if gpiozero is None or spidev is None: raise DisplayUnavailable("gpiozero/spidev nicht installiert") spi_path = f"/dev/spidev{_SPI_BUS}.{_SPI_DEVICE}" if not os.path.exists(spi_path): raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?") hardware_errors = _hardware_error_types() try: _rst_pin = gpiozero.DigitalOutputDevice(RST_PIN) _dc_pin = gpiozero.DigitalOutputDevice(DC_PIN) _busy_pin = gpiozero.DigitalInputDevice(BUSY_PIN, pull_up=False) _spi = spidev.SpiDev() _spi.open(_SPI_BUS, _SPI_DEVICE) _spi.max_speed_hz = _SPI_HZ _spi.mode = 0b00 except hardware_errors as e: module_exit() raise DisplayUnavailable( f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}{_busy_hint(e)}") from e return 0 def module_exit() -> None: """Gibt GPIO-Pins und SPI-Geraet frei. Verzeiht, wenn module_init() nie (vollstaendig) gelaufen ist - display.py ruft das u.a. im Fehlerpfad von module_init() selbst auf.""" global _rst_pin, _dc_pin, _busy_pin, _spi if _spi is not None: try: _spi.close() except Exception: # noqa: BLE001 - Aufraeumen darf nicht seinerseits scheitern pass _spi = None for attr_name in ("_rst_pin", "_dc_pin", "_busy_pin"): device = globals()[attr_name] if device is not None: try: device.close() except Exception: # noqa: BLE001 pass globals()[attr_name] = None