fix(station): V2/V3-Panelkapselung, wiedereintrittsfaehiges module_init, Unit-Fixes

Fixrunde 1 zu Task 6a: epd2in13_V2.init() braucht ein Pflichtargument und dessen
displayPartial() setzt selbst keine LUT (neue Panel-Klasse in display.py kapselt das statt
es zu verstreuen); epdconfig.module_init() war nicht wiedereintrittsfaehig und ein zu
breites except tarnte GPIOPinInUse als "Panel fehlt" (jetzt idempotent, gezielter
except); ninlight-display.service hatte ReadOnlyPaths=/run/ninlight-station (verhindert
den Start, wenn die Station regulaer aus ist), StartLimit* in der falschen Sektion und
SupplementaryGroups=spi gpio (harter Fehler auf reinem Debian ohne diese Gruppen); ein
fehlendes Pillow fuehrte zu ModuleNotFoundError statt zum vorgesehenen stillen Exit 0.
Dazu: Panel schlaeft nach jedem Zeichnen (Alterung), Modus-Vorrang liegt jetzt bei
RUN_DIR/mode statt bei status.json, export-Praefix in der Konfigurationsdatei wird
erkannt, MAX_COLS auf das mit Pillow nachgemessene Mass korrigiert, und build_state()
hat jetzt eigene Tests.
This commit is contained in:
Michael Sauer
2026-09-13 21:07:01 +02:00
parent 4a029a5d1c
commit 9b158c8c4f
5 changed files with 243 additions and 72 deletions
+117 -59
View File
@@ -19,11 +19,11 @@ wiederholen. Zwischen den beiden Versuchen kurz die Stromversorgung des Panels t
(--clear reicht nicht immer, falsche Revision kann das Panel in einem Zwischenzustand
lassen).
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder die
gpiozero/spidev-Bibliothek, faengt main() das als DisplayUnavailable ab, protokolliert
GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit einem Fehler, sonst wuerde
systemd (Restart=on-failure) den Dienst endlos neu starten und das Journal fluten. Ein
Display-Fehler darf niemals eine Migration verhindern.
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder eine
Bibliothek (gpiozero/spidev/Pillow), faengt main() das als DisplayUnavailable bzw.
ImportError ab, protokolliert GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit
einem Fehler, sonst wuerde systemd (Restart=on-failure) den Dienst endlos neu starten und
das Journal fluten. Ein Display-Fehler darf niemals eine Migration verhindern.
"""
from __future__ import annotations
@@ -73,8 +73,11 @@ def _read_json(path: Path) -> dict:
def _read_conf(path: Path) -> dict[str, str]:
"""Minimaler Parser fuer /etc/ninlight-station.conf: KEY='VALUE'-Zeilen, wie
setup_pi.sh sie schreibt. Kein echtes Shell-Sourcing (das CLI braucht das fuer $-
und `-Escaping in Passwoertern, siehe dessen Kommentar - display.py liest hier nur
setup_pi.sh sie schreibt, optional mit einem "export "-Praefix (setup_pi.sh prueft
beim Nachtragen fehlender Schluessel ausdruecklich auf "^(export[[:space:]]+)?KEY=" -
ein bereits vorhandenes "export SSID=..." muss also erkannt werden, nicht nur die
unpraefigierte Form). Kein echtes Shell-Sourcing (das CLI braucht das fuer $- und
`-Escaping in Passwoertern, siehe dessen Kommentar - display.py liest hier nur
SSID/MIGRATE_SSID, reine Bezeichner ohne Sonderzeichen)."""
values: dict[str, str] = {}
try:
@@ -85,6 +88,8 @@ def _read_conf(path: Path) -> dict[str, str]:
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
if line.startswith("export ") or line.startswith("export\t"):
line = line[len("export"):].lstrip()
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
@@ -106,12 +111,19 @@ def _read_mode_file(run_dir: Path) -> str:
return value if value in ("recovery", "migration") else "recovery"
# Nur beim ersten Mal protokollieren, dass systemctl selbst nicht erreichbar war (siehe
# _service_active) - das passiert bei jedem der Aufrufe alle 5 s erneut, und eine Zeile
# alle 5 s wuerde das Journal fluten, obwohl es sich nie aendert (Fixrunde 1, Kleinigkeit).
_service_active_fallback_logged = False
def _service_active(service_name: str) -> bool:
"""Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und
faehrt den Zugangspunkt herunter, laesst status.json dabei aber unangetastet liegen -
ein reines Lesen von status.json wuerde nach "off" also faelschlich weiter den letzten
Modus vor dem Abschalten zeigen. Deshalb ist die Dienstaktivitaet selbst (nicht das
Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off"."""
global _service_active_fallback_logged
try:
result = subprocess.run(
["systemctl", "is-active", "--quiet", service_name],
@@ -120,26 +132,33 @@ def _service_active(service_name: str) -> bool:
except (OSError, subprocess.SubprocessError):
# systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand
# der Statusdatei entscheiden statt "off" zu behaupten, ohne es zu wissen.
if not _service_active_fallback_logged:
log.warning("systemctl nicht erreichbar - Anzeige raet den Modus anhand von "
"RUN_DIR/mode statt der echten Dienstaktivitaet")
_service_active_fallback_logged = True
return True
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
service_name: str = SERVICE_NAME) -> dict:
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten -
status.json (served/recent/last_seen), control.json (phase/armed_target, nur im
Migrationsmodus massgeblich), die SSID aus der Konfigurationsdatei und "off", wenn
der Hauptdienst gar nicht laeuft."""
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
zuletzt geschrieben haben und unabhaengig davon verfuegbar, ob/wann der Serverprozess
seit dem letzten Start schon einmal status.json geschrieben hat. status.json selbst
liefert nur served/recent/last_seen; sein "mode"-Feld wird bewusst NICHT als
Modusquelle verwendet (Fixrunde 1: die urspruengliche Fassung hatte den Vorrang
umgekehrt).
Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt
alles, wenn der Hauptdienst gar nicht laeuft."""
if not _service_active(service_name):
return {"mode": "off"}
mode = _read_mode_file(run_dir)
status = _read_json(run_dir / "status.json")
mode = status.get("mode")
if mode not in ("recovery", "migration"):
# status.json fehlt/ist kaputt/stammt (noch) nicht vom laufenden Prozess -
# RUN_DIR/mode ist das, was "ninlight-station on"/"migrate on" zuletzt
# geschrieben hat und ist unabhaengig von status.json verfuegbar.
mode = _read_mode_file(run_dir)
conf = _read_conf(conf_path)
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent")}
@@ -148,13 +167,6 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
else: # migration
state["ssid"] = conf.get("MIGRATE_SSID")
state["last_seen"] = status.get("last_seen")
# Die Phase kommt bewusst aus control.json, nicht aus status.json: server.py
# schreibt status.json nur beim Start und nach einer Auslieferung, waehrend
# "ninlight-station arm"/"listen" nur die Steuerdatei anfasst (Spec/CLI
# status_cmd folgt demselben Muster). Und bewusst nur HIER gelesen - im
# Rettungsmodus ignoriert server.py control.json ebenfalls, ein
# liegengebliebener Inhalt aus einer frueheren Migration darf dort nicht
# faelschlich "Phase: FREIGEGEBEN" zeigen.
control = _read_json(run_dir / "control.json")
phase = control.get("phase")
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
@@ -177,28 +189,55 @@ def _load_epd_module(revision: str):
return epd_module
def _draw(epd, image, full: bool) -> None:
buf = epd.getbuffer(image)
if full:
# epd.init() laedt die volle LUT neu - noetig, weil displayPartial() unten immer
# die Teil-LUT setzt und die naechste display()-Auslieferung sonst mit der
# falschen Wellenform liefe.
epd.init()
epd.display(buf)
else:
epd.displayPartial(buf)
class Panel:
"""Kapselt die V2/V3-Unterschiede der Waveshare-Treiber hinter einer einheitlichen
Schnittstelle (Fixrunde 1, C1): epd2in13_V2.init() braucht ein Pflichtargument
(FULL_UPDATE/PART_UPDATE) und dessen displayPartial() setzt selbst keine LUT - vorher
muessen init(PART_UPDATE) und (fuer den allerersten Teilaufbau nach einem Vollaufbau)
displayPartBaseImage() gelaufen sein, sonst zeichnet das Panel mit der falschen
Wellenform oder gegen einen veralteten Referenzpuffer. epd2in13_V3.init() braucht
dagegen kein Argument und dessen displayPartial() setzt seine Teil-LUT selbst bei
jedem Aufruf - dort reicht ein einfaches init() vor jedem Vollaufbau."""
def __init__(self, revision: str) -> None:
self.revision = revision
self._epd_module = _load_epd_module(revision)
self.epd = self._epd_module.EPD()
def clear(self) -> None:
if self.revision == "V2":
self.epd.init(self.epd.FULL_UPDATE)
else:
self.epd.init()
self.epd.Clear(0xFF)
def draw_full(self, image) -> None:
buf = self.epd.getbuffer(image)
if self.revision == "V2":
self.epd.init(self.epd.FULL_UPDATE)
# displayPartBaseImage() zeichnet UND schreibt gleichzeitig beide RAM-Puffer
# des Controllers - genau das, was ein nachfolgender Teilaufbau als
# Referenzpuffer braucht. display() allein wuerde nur einen Puffer setzen und
# den ersten Teilaufbau danach gegen einen veralteten Referenzpuffer diffen
# lassen (Fixrunde 1, C1).
self.epd.displayPartBaseImage(buf)
else:
self.epd.init()
self.epd.display(buf)
def draw_partial(self, image) -> None:
buf = self.epd.getbuffer(image)
if self.revision == "V2":
self.epd.init(self.epd.PART_UPDATE)
self.epd.displayPartial(buf)
def sleep(self) -> None:
self.epd.sleep()
def run_test(revision: str) -> None:
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
von V3 unterscheidet."""
def _test_image(revision: str):
from PIL import Image, ImageDraw, ImageFont
epd_module = _load_epd_module(revision)
epd = epd_module.EPD()
epd.init()
epd.Clear(0xFF)
width, height = 250, 122
image = Image.new("1", (width, height), 255)
draw = ImageDraw.Draw(image)
@@ -211,25 +250,29 @@ def run_test(revision: str) -> None:
for i in range(4):
x0 = 4 + i * 20
draw.rectangle((x0, 50, x0 + 16, 90), outline=0, fill=0 if i % 2 == 0 else 255)
return image
epd.display(epd.getbuffer(image))
def run_test(revision: str) -> None:
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
von V3 unterscheidet."""
panel = Panel(revision)
panel.clear()
panel.draw_full(_test_image(revision))
panel.sleep()
log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
def run_clear(revision: str) -> None:
epd_module = _load_epd_module(revision)
epd = epd_module.EPD()
epd.init()
epd.Clear(0xFF)
epd.sleep()
panel = Panel(revision)
panel.clear()
panel.sleep()
log.info("Panel geleert (EPD_REVISION=%s)", revision)
def run_daemon(revision: str) -> None:
epd_module = _load_epd_module(revision)
epd = epd_module.EPD()
epd.init()
epd.Clear(0xFF)
panel = Panel(revision)
panel.clear()
last_lines: list[str] | None = None
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
@@ -244,9 +287,18 @@ def run_daemon(revision: str) -> None:
if current_lines != last_lines or need_full:
image = render.compose(state)
_draw(epd, image, full=need_full)
if need_full:
panel.draw_full(image)
last_full = now
else:
panel.draw_partial(image)
# Panel nach jedem Bildaufbau schlafen legen: die Pixel bleiben (e-Paper ist
# bistabil), aber ohne dauerhafte Spannung altert das Panel deutlich weniger -
# Waveshare warnt fuer diese Serie ausdruecklich davor, es dauerhaft unter
# Bias zu lassen (Fixrunde 1, I4). epd.sleep() gibt auch GPIO/SPI frei
# (epdconfig.module_exit()); der naechste Aufbau ruft ohnehin wieder init()
# und damit module_init() auf, das das sauber neu aufsetzt.
panel.sleep()
last_lines = current_lines
last_mode = mode
@@ -271,11 +323,17 @@ def main() -> None:
run_clear(revision)
else:
run_daemon(revision)
except DisplayUnavailable as e:
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Restart=on-failure
# (statt always) in der Unit greift hier nicht, weil 0 kein Fehlschlag ist; ein
# spaeter angestecktes Panel braucht also einen manuellen/zeitgesteuerten
# Neustart der Unit, nicht eine Neustartschleife.
except (DisplayUnavailable, ImportError) as e:
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Das deckt sowohl
# Hardwareprobleme (DisplayUnavailable: kein Panel/SPI) als auch eine fehlende
# Bibliothek ab - allen voran Pillow, das laut Spec auf dem Ziel-Pi absichtlich
# erst per "apt install python3-pil" nachkommt (Fixrunde 1, C4): render.compose()
# importiert Pillow erst hier, ein fehlendes Paket zeigt sich also erst als
# ModuleNotFoundError (eine ImportError-Unterklasse) beim ersten Zeichnen, nicht
# schon beim Start. Restart=on-failure (statt always) in der Unit greift hier
# nicht, weil 0 kein Fehlschlag ist; ein spaeter angestecktes Panel bzw.
# nachinstalliertes Paket braucht also einen manuellen/zeitgesteuerten Neustart
# der Unit, nicht eine Neustartschleife.
log.info("E-Paper-Anzeige nicht verfuegbar, beende ohne Fehler: %s", e)
sys.exit(0)
except KeyboardInterrupt:
+24 -3
View File
@@ -90,10 +90,23 @@ def spi_writebyte2(data) -> None:
def module_init() -> int:
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt irgendeiner
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss."""
"""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")
@@ -101,6 +114,14 @@ 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).
hardware_errors: tuple[type[BaseException], ...] = (OSError, ImportError)
if gpiozero is not None:
hardware_errors = hardware_errors + (gpiozero.GPIODeviceError,)
try:
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
_dc_pin = gpiozero.DigitalOutputDevice(DC_PIN)
@@ -110,7 +131,7 @@ def module_init() -> int:
_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
except hardware_errors as e:
module_exit()
raise DisplayUnavailable(f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}") from e
+7 -4
View File
@@ -6,9 +6,12 @@ Funktion, damit ein fehlendes PIL (das erst per "apt install python3-pil" auf de
kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht.
Das Panel ist 250x122 Pixel, quer verwendet. MAX_COLS begrenzt jede Textzeile so, dass sie
bei der in compose() verwendeten Schriftgroesse nicht umgebrochen werden muss - ueberlange
Werte (z.B. ein Produktname aus der Lampenerkennung) werden mit "..." gekuerzt, nie
umgebrochen (Spec: eine zu lange Zeile darf das Layout nicht sprengen).
bei der in compose() verwendeten Schriftgroesse (PIL.ImageFont.load_default(), Textursprung
x=2) nicht umgebrochen werden muss - ueberlange Werte (z.B. ein Produktname aus der
Lampenerkennung) werden mit "..." gekuerzt, nie umgebrochen (Spec: eine zu lange Zeile darf
das Layout nicht sprengen). Der Wert 40 ist mit genau dieser Schrift nachgemessen
(draw.textbbox(): 40 Zeichen reichen bis x=243, 42 bis x=255 - beim 250 px breiten Panel
waere das "..." selbst abgeschnitten gewesen, siehe Fixrunde 1, Kleinigkeit).
Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus
@@ -19,7 +22,7 @@ aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
from __future__ import annotations
MAX_COLS = 42
MAX_COLS = 40
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht