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: