feat(station): E-Paper-Anzeige fuer Modus, Lampe und Auslieferungen

render.lines() zerlegt den zusammengefuehrten Zustand (status.json + control.json +
SSID aus der Konfigurationsdatei) in Textzeilen, ohne Pillow und ohne Hardware zu
brauchen - render.compose() importiert Pillow erst innerhalb der Funktion. epdconfig.py
ist eine eigene GPIO/SPI-Schicht auf gpiozero/spidev, weil RPi.GPIO auf Debian 13 mit
Python 3.13 nicht mehr traegt; epd2in13_V2.py/epd2in13_V3.py sind unveraendert aus dem
Waveshare-Repository uebernommen (siehe HERKUNFT.md), nur der epdconfig-Import zeigt auf
unser Modul. display.py laeuft als eigener systemd-Dienst (Wants=, Restart=on-failure),
fehlendes Panel/SPI/Bibliothek beendet ihn sauber mit sys.exit(0) statt einer
Neustartschleife - eine Migration darf davon nie abhaengen.

Verdrahtung in setup_pi.sh/deploy.sh/ninlight-station folgt in einem eigenen Durchgang.
This commit is contained in:
Michael Sauer
2026-09-13 20:44:02 +02:00
parent 1cb5359754
commit 4a029a5d1c
8 changed files with 1428 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
"""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.<name>` 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
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 module_init() -> int:
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt irgendeiner
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss."""
global _rst_pin, _dc_pin, _busy_pin, _spi
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)?")
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 Exception as e: # noqa: BLE001 - alles hier wird zu DisplayUnavailable
module_exit()
raise DisplayUnavailable(f"GPIO/SPI-Initialisierung fehlgeschlagen: {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