From 9b158c8c4f6215f490f62a202faa81f984cafb9e Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 13 Sep 2026 21:07:01 +0200 Subject: [PATCH] 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. --- tools/station/display/display.py | 176 ++++++++++++++++--------- tools/station/display/epdconfig.py | 27 +++- tools/station/display/render.py | 11 +- tools/station/ninlight-display.service | 28 +++- tools/station/tests/test_render.py | 73 ++++++++++ 5 files changed, 243 insertions(+), 72 deletions(-) diff --git a/tools/station/display/display.py b/tools/station/display/display.py index 8db5677..2e89a1d 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -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: diff --git a/tools/station/display/epdconfig.py b/tools/station/display/epdconfig.py index 8eb93b8..1f7b4b6 100644 --- a/tools/station/display/epdconfig.py +++ b/tools/station/display/epdconfig.py @@ -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 diff --git a/tools/station/display/render.py b/tools/station/display/render.py index c89c00e..ab0c999 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -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 diff --git a/tools/station/ninlight-display.service b/tools/station/ninlight-display.service index 91c0bac..154b1af 100644 --- a/tools/station/ninlight-display.service +++ b/tools/station/ninlight-display.service @@ -7,6 +7,12 @@ Description=ninlight E-Paper-Anzeige (Status der Migrations-/Rettungsstation) # Migration. After=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] Type=simple @@ -30,20 +36,30 @@ ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py # so das Journal fluten koennte. Restart=on-failure RestartSec=5 -StartLimitIntervalSec=60 -StartLimitBurst=5 NoNewPrivileges=yes ProtectSystem=strict ProtectHome=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 MemoryMax=64M -# Fuer /dev/spidev0.0 und die GPIO-Zeichenleitung (per lgpio/gpiozero) noetig - laeuft -# deshalb nicht mit DynamicUser=, sondern als root wie der Hauptdienst. -SupplementaryGroups=spi gpio +# Laeuft als root (wie der Hauptdienst) fuer /dev/spidev0.0 und die GPIO-Zeichenleitung - +# kein SupplementaryGroups=spi gpio hier (stand hier zuerst, Fixrunde 1, I2): auf reinem +# 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] WantedBy=multi-user.target diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index 7ca1f01..e67efd2 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -1,8 +1,10 @@ +import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display")) +import display # 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", "served": 0, "recent": [], "ssid": "qconnex-firmware"}) 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"