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
+116 -58
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 (--clear reicht nicht immer, falsche Revision kann das Panel in einem Zwischenzustand
lassen). lassen).
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder die Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder eine
gpiozero/spidev-Bibliothek, faengt main() das als DisplayUnavailable ab, protokolliert Bibliothek (gpiozero/spidev/Pillow), faengt main() das als DisplayUnavailable bzw.
GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit einem Fehler, sonst wuerde ImportError ab, protokolliert GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit
systemd (Restart=on-failure) den Dienst endlos neu starten und das Journal fluten. Ein einem Fehler, sonst wuerde systemd (Restart=on-failure) den Dienst endlos neu starten und
Display-Fehler darf niemals eine Migration verhindern. das Journal fluten. Ein Display-Fehler darf niemals eine Migration verhindern.
""" """
from __future__ import annotations from __future__ import annotations
@@ -73,8 +73,11 @@ def _read_json(path: Path) -> dict:
def _read_conf(path: Path) -> dict[str, str]: def _read_conf(path: Path) -> dict[str, str]:
"""Minimaler Parser fuer /etc/ninlight-station.conf: KEY='VALUE'-Zeilen, wie """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 $- setup_pi.sh sie schreibt, optional mit einem "export "-Praefix (setup_pi.sh prueft
und `-Escaping in Passwoertern, siehe dessen Kommentar - display.py liest hier nur 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).""" SSID/MIGRATE_SSID, reine Bezeichner ohne Sonderzeichen)."""
values: dict[str, str] = {} values: dict[str, str] = {}
try: try:
@@ -85,6 +88,8 @@ def _read_conf(path: Path) -> dict[str, str]:
line = raw_line.strip() line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line: if not line or line.startswith("#") or "=" not in line:
continue continue
if line.startswith("export ") or line.startswith("export\t"):
line = line[len("export"):].lstrip()
key, _, value = line.partition("=") key, _, value = line.partition("=")
key = key.strip() key = key.strip()
value = value.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" 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: def _service_active(service_name: str) -> bool:
"""Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und """Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und
faehrt den Zugangspunkt herunter, laesst status.json dabei aber unangetastet liegen - 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 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 Modus vor dem Abschalten zeigen. Deshalb ist die Dienstaktivitaet selbst (nicht das
Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off".""" Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off"."""
global _service_active_fallback_logged
try: try:
result = subprocess.run( result = subprocess.run(
["systemctl", "is-active", "--quiet", service_name], ["systemctl", "is-active", "--quiet", service_name],
@@ -120,26 +132,33 @@ def _service_active(service_name: str) -> bool:
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
# systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand # systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand
# der Statusdatei entscheiden statt "off" zu behaupten, ohne es zu wissen. # 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 return True
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH, def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
service_name: str = SERVICE_NAME) -> dict: service_name: str = SERVICE_NAME) -> dict:
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten - """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 Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie
der Hauptdienst gar nicht laeuft.""" 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): if not _service_active(service_name):
return {"mode": "off"} return {"mode": "off"}
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) mode = _read_mode_file(run_dir)
status = _read_json(run_dir / "status.json")
conf = _read_conf(conf_path) conf = _read_conf(conf_path)
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent")} 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 else: # migration
state["ssid"] = conf.get("MIGRATE_SSID") state["ssid"] = conf.get("MIGRATE_SSID")
state["last_seen"] = status.get("last_seen") 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") control = _read_json(run_dir / "control.json")
phase = control.get("phase") phase = control.get("phase")
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto" state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
@@ -177,28 +189,55 @@ def _load_epd_module(revision: str):
return epd_module return epd_module
def _draw(epd, image, full: bool) -> None: class Panel:
buf = epd.getbuffer(image) """Kapselt die V2/V3-Unterschiede der Waveshare-Treiber hinter einer einheitlichen
if full: Schnittstelle (Fixrunde 1, C1): epd2in13_V2.init() braucht ein Pflichtargument
# epd.init() laedt die volle LUT neu - noetig, weil displayPartial() unten immer (FULL_UPDATE/PART_UPDATE) und dessen displayPartial() setzt selbst keine LUT - vorher
# die Teil-LUT setzt und die naechste display()-Auslieferung sonst mit der muessen init(PART_UPDATE) und (fuer den allerersten Teilaufbau nach einem Vollaufbau)
# falschen Wellenform liefe. displayPartBaseImage() gelaufen sein, sonst zeichnet das Panel mit der falschen
epd.init() Wellenform oder gegen einen veralteten Referenzpuffer. epd2in13_V3.init() braucht
epd.display(buf) 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: else:
epd.displayPartial(buf) 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: def _test_image(revision: str):
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
von V3 unterscheidet."""
from PIL import Image, ImageDraw, ImageFont 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 width, height = 250, 122
image = Image.new("1", (width, height), 255) image = Image.new("1", (width, height), 255)
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
@@ -211,25 +250,29 @@ def run_test(revision: str) -> None:
for i in range(4): for i in range(4):
x0 = 4 + i * 20 x0 = 4 + i * 20
draw.rectangle((x0, 50, x0 + 16, 90), outline=0, fill=0 if i % 2 == 0 else 255) 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) log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
def run_clear(revision: str) -> None: def run_clear(revision: str) -> None:
epd_module = _load_epd_module(revision) panel = Panel(revision)
epd = epd_module.EPD() panel.clear()
epd.init() panel.sleep()
epd.Clear(0xFF)
epd.sleep()
log.info("Panel geleert (EPD_REVISION=%s)", revision) log.info("Panel geleert (EPD_REVISION=%s)", revision)
def run_daemon(revision: str) -> None: def run_daemon(revision: str) -> None:
epd_module = _load_epd_module(revision) panel = Panel(revision)
epd = epd_module.EPD() panel.clear()
epd.init()
epd.Clear(0xFF)
last_lines: list[str] | None = None last_lines: list[str] | None = None
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf 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: if current_lines != last_lines or need_full:
image = render.compose(state) image = render.compose(state)
_draw(epd, image, full=need_full)
if need_full: if need_full:
panel.draw_full(image)
last_full = now 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_lines = current_lines
last_mode = mode last_mode = mode
@@ -271,11 +323,17 @@ def main() -> None:
run_clear(revision) run_clear(revision)
else: else:
run_daemon(revision) run_daemon(revision)
except DisplayUnavailable as e: except (DisplayUnavailable, ImportError) as e:
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Restart=on-failure # Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Das deckt sowohl
# (statt always) in der Unit greift hier nicht, weil 0 kein Fehlschlag ist; ein # Hardwareprobleme (DisplayUnavailable: kein Panel/SPI) als auch eine fehlende
# spaeter angestecktes Panel braucht also einen manuellen/zeitgesteuerten # Bibliothek ab - allen voran Pillow, das laut Spec auf dem Ziel-Pi absichtlich
# Neustart der Unit, nicht eine Neustartschleife. # 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) log.info("E-Paper-Anzeige nicht verfuegbar, beende ohne Fehler: %s", e)
sys.exit(0) sys.exit(0)
except KeyboardInterrupt: except KeyboardInterrupt:
+24 -3
View File
@@ -90,10 +90,23 @@ def spi_writebyte2(data) -> None:
def module_init() -> int: def module_init() -> int:
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt irgendeiner """Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss.""" 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 global _rst_pin, _dc_pin, _busy_pin, _spi
if _spi is not None:
return 0
if gpiozero is None or spidev is None: if gpiozero is None or spidev is None:
raise DisplayUnavailable("gpiozero/spidev nicht installiert") raise DisplayUnavailable("gpiozero/spidev nicht installiert")
@@ -101,6 +114,14 @@ def module_init() -> int:
if not os.path.exists(spi_path): if not os.path.exists(spi_path):
raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?") 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: try:
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN) _rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
_dc_pin = gpiozero.DigitalOutputDevice(DC_PIN) _dc_pin = gpiozero.DigitalOutputDevice(DC_PIN)
@@ -110,7 +131,7 @@ def module_init() -> int:
_spi.open(_SPI_BUS, _SPI_DEVICE) _spi.open(_SPI_BUS, _SPI_DEVICE)
_spi.max_speed_hz = _SPI_HZ _spi.max_speed_hz = _SPI_HZ
_spi.mode = 0b00 _spi.mode = 0b00
except Exception as e: # noqa: BLE001 - alles hier wird zu DisplayUnavailable except hardware_errors as e:
module_exit() module_exit()
raise DisplayUnavailable(f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}") from e 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. 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 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 bei der in compose() verwendeten Schriftgroesse (PIL.ImageFont.load_default(), Textursprung
Werte (z.B. ein Produktname aus der Lampenerkennung) werden mit "..." gekuerzt, nie x=2) nicht umgebrochen werden muss - ueberlange Werte (z.B. ein Produktname aus der
umgebrochen (Spec: eine zu lange Zeile darf das Layout nicht sprengen). 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 Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus (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.""" Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
from __future__ import annotations from __future__ import annotations
MAX_COLS = 42 MAX_COLS = 40
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"} MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht # "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
+22 -6
View File
@@ -7,6 +7,12 @@ Description=ninlight E-Paper-Anzeige (Status der Migrations-/Rettungsstation)
# Migration. # Migration.
After=ninlight-station.service After=ninlight-station.service
Wants=ninlight-station.service Wants=ninlight-station.service
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort standen sie zuerst -
# Fixrunde 1, I1: systemd ignoriert sie dort mit "Unknown key name", die Vorgabe (5
# Starts/10s) greift dann bei RestartSec=5 nie, und genau das sollte die Deckelung unten
# eigentlich verhindern). ninlight-station.service (dieselbe Sektion) macht es richtig.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service] [Service]
Type=simple Type=simple
@@ -30,20 +36,30 @@ ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py
# so das Journal fluten koennte. # so das Journal fluten koennte.
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
NoNewPrivileges=yes NoNewPrivileges=yes
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
ReadOnlyPaths=/run/ninlight-station # KEIN ReadOnlyPaths=/run/ninlight-station hier (stand hier zuerst, Fixrunde 1, C3):
# server.py's Unit traegt RuntimeDirectory=ninlight-station, und "ninlight-station off"
# stoppt den Hauptdienst regulaer - dabei raeumt systemd dessen RuntimeDirectory auf,
# sofern nicht gerade ein Neustart lief (RuntimeDirectoryPreserve=restart deckt nur den
# Neustart-Fall ab, nicht den regulaeren Stop). Ein nicht mit "-" prefigierter Pfad in
# ReadOnlyPaths=/ReadWritePaths=/BindPaths=, der beim Start fehlt, laesst systemd die
# Namensraum-Einrichtung mit 226/NAMESPACE abbrechen - ausgerechnet dann, wenn die
# Anzeige "Station aus" zeigen soll, waere sie selbst nicht mehr startbar gewesen. Die
# Zeile war obendrein wirkungslos: ProtectSystem=strict macht "/" (und damit "/run",
# soweit nicht per ReadWritePaths= explizit freigegeben) bereits schreibgeschuetzt.
RestrictAddressFamilies=AF_UNIX AF_NETLINK RestrictAddressFamilies=AF_UNIX AF_NETLINK
MemoryMax=64M MemoryMax=64M
# Fuer /dev/spidev0.0 und die GPIO-Zeichenleitung (per lgpio/gpiozero) noetig - laeuft # Laeuft als root (wie der Hauptdienst) fuer /dev/spidev0.0 und die GPIO-Zeichenleitung -
# deshalb nicht mit DynamicUser=, sondern als root wie der Hauptdienst. # kein SupplementaryGroups=spi gpio hier (stand hier zuerst, Fixrunde 1, I2): auf reinem
SupplementaryGroups=spi gpio # Debian (im Unterschied zu Raspberry Pi OS) gibt es diese Gruppen oft gar nicht, und
# eine fehlende Gruppe ist fuer systemd ein harter Startfehler (216/GROUP) - ausgerechnet
# in der Unit, die nie stoeren soll, wenn etwas an der Anzeige fehlt. Als root sind die
# Gruppen fuer den Geraetezugriff ohnehin nicht noetig.
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+73
View File
@@ -1,8 +1,10 @@
import json
import sys import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display")) sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display"))
import display # noqa: E402
import render # noqa: E402 import render # noqa: E402
@@ -54,3 +56,74 @@ def test_armed_phase_is_visible():
lines = render.lines({"mode": "migration", "phase": "armed", "armed_target": "g95_cw", lines = render.lines({"mode": "migration", "phase": "armed", "armed_target": "g95_cw",
"served": 0, "recent": [], "ssid": "qconnex-firmware"}) "served": 0, "recent": [], "ssid": "qconnex-firmware"})
assert "FREIGEGEBEN" in "\n".join(lines) assert "FREIGEGEBEN" in "\n".join(lines)
# --- display.build_state() (Fixrunde 1, I3) ---------------------------------------
#
# Dieselbe Unterscheidung, die im CLI (tools/station/ninlight-station, status_cmd) zweimal
# nachgebessert werden musste: Phase/armed_target kommen aus control.json und sind nur im
# Migrationsmodus gueltig; ein control.json-Rest aus einer frueheren Migration darf im
# Rettungsmodus nicht als "FREIGEGEBEN" durchschlagen.
def test_control_json_is_ignored_outside_migration_mode(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery")
(tmp_path / "control.json").write_text(json.dumps({"phase": "armed", "armed_target": "g95_cw"}))
conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n")
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
assert state["mode"] == "recovery"
assert "phase" not in state
assert "armed_target" not in state
assert "FREIGEGEBEN" not in "\n".join(render.lines(state))
def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: False)
# Selbst ein noch so frisches/gueltiges status.json darf "off" nicht verhindern -
# "ninlight-station off" laesst status.json bewusst unangetastet liegen.
(tmp_path / "mode").write_text("migration")
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5}))
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x")
assert state == {"mode": "off"}
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
"""Der Modus-Vorrang liegt bei RUN_DIR/mode, nicht bei status.json's "mode"-Feld -
status.json kann veraltet sein (Fixrunde 1, Kleinigkeit: der Vorrang war zuerst
umgekehrt)."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("recovery")
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 1}))
conf_path = tmp_path / "station.conf"
conf_path.write_text("SSID='ninlight-recovery'\n")
state = display.build_state(run_dir=tmp_path, conf_path=conf_path, service_name="x")
assert state["mode"] == "recovery"
assert state["served"] == 1
def test_build_state_falls_back_to_recovery_without_mode_file(tmp_path, monkeypatch):
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x")
assert state["mode"] == "recovery"
def test_read_conf_accepts_export_prefix(tmp_path):
"""setup_pi.sh prueft beim Nachtragen fehlender Schluessel ausdruecklich auf ein
optionales "export "-Praefix (^(export[[:space:]]+)?KEY=) - eine bereits vorhandene
Konfigurationsdatei mit "export SSID=..." muss also genauso gelesen werden koennen."""
conf_path = tmp_path / "station.conf"
conf_path.write_text("export SSID='ninlight-recovery'\nexport MIGRATE_SSID='qconnex-firmware'\n")
values = display._read_conf(conf_path)
assert values["SSID"] == "ninlight-recovery"
assert values["MIGRATE_SSID"] == "qconnex-firmware"