fix(station): Panel liest den Modus aus einer dauerhaften Datei, nicht aus RUN_DIR

Fixrunde 1, Befund 3: RUN_DIR/mode ueberlebt nur einen "systemctl restart"
(RuntimeDirectoryPreserve=restart) - "ninlight-station off" fuehrt aber einen
echten "systemctl stop" aus, der das RuntimeDirectory leert. build_state()
fiel danach unbemerkt auf "recovery" zurueck, auch wenn tatsaechlich eine
Migration anstand - die Fusszeile haette dann "in Ordnung" gezeigt, obwohl der
Migrationssatz leer ist. Ein Docstring behauptete ausdruecklich das Gegenteil.

_read_mode_file() liest jetzt eine neue, dauerhafte LAST_MODE_FILE (unter
/var/lib/ninlight-station, wie der Bildsatz-Umschalter selbst) statt
RUN_DIR/mode; write_mode() im CLI schreibt beide. Ausserdem: _read_imageset()
faengt jetzt auch UnicodeDecodeError ab (Fixrunde 1, Kleinigkeit).

Ein neuer Test bildet den echten Ablauf nach (Modus schreiben, RUN_DIR wie bei
einem echten Stop leeren, pruefen dass der Modus trotzdem stimmt) - der alte
Test fuer den Rettungs-Bildsatz liess die Modusdatei ganz weg und pruefte
damit nur den zufaellig gleichen Rueckfallwert, nicht die Wirklichkeit.
This commit is contained in:
Michael Sauer
2026-09-23 21:59:09 +02:00
parent e67fc53b20
commit 1562a7983a
2 changed files with 132 additions and 52 deletions
+53 -20
View File
@@ -67,6 +67,30 @@ UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"
# schreibt server.py nichts mehr.
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
"/var/lib/ninlight-station/imageset"))
# Der Modus, den "recovery on"/"migrate on" zuletzt angefordert haben - fuer die
# Fusszeile GENAUSO dauerhaft gebraucht wie der Bildsatz-Umschalter oben, aus demselben
# Grund und nach demselben Muster (eigene Datei unter /var/lib, NICHT unter RUN_DIR).
#
# Fixrunde 1, Befund 3: RUN_DIR/mode taugt dafuer NICHT. Die Unit setzt
# RuntimeDirectoryPreserve=restart - das erhaelt /run/ninlight-station nur ueber einen
# "systemctl restart" hinweg (das, was "recovery on"/"migrate on" fuer einen bereits
# laufenden Dienst ausloesen). "ninlight-station off" aber STOPPT den Dienst
# (systemctl stop), und GENAU DANN raeumt systemd das RuntimeDirectory leer, "restart"
# deckt einen echten Stop nicht ab. Ein frueherer Docstring hier behauptete das
# Gegenteil ("die Datei ueberlebt das Abschalten") - das war schlicht falsch, und der
# Fehler war unauffaellig, weil eine fehlende Datei auf "recovery" faellt: genau der
# Modus, den viele Tests ohnehin erwarteten, ohne dass die Faelligkeit der Datei
# je geprueft wurde. Die Folge am Geraet waere die falsche Beruhigung, gegen die die
# Fusszeile ueberhaupt gebaut ist - "in Ordnung" vor einer geplanten Migration, obwohl
# der Migrationssatz leer ist, weil das Panel mangels Datei den Rettungssatz geprueft
# haette.
#
# write_mode() im CLI schreibt deshalb NEBEN der ephemeren RUN_DIR/mode (die
# ausschliesslich server.py's naechsten Start betrifft) auch hierher - dieselbe
# Zeichenkette, zwei Ablagen fuer zwei verschiedene Leser mit verschiedenen
# Haltbarkeitsanforderungen.
LAST_MODE_FILE = Path(os.environ.get("NINLIGHT_LAST_MODE_FILE",
"/var/lib/ninlight-station/mode"))
# Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der
# Satz OHNE Zugangsdaten (dort steht die Begruendung).
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
@@ -215,13 +239,19 @@ def _recent_request(status: dict, now: float | None = None) -> bool:
return 0 <= age_s <= ACTIVITY_WINDOW_S
def _read_mode_file(run_dir: Path) -> str:
"""Modus, in dem der Dienst laut RUN_DIR/mode tatsaechlich gestartet wurde. Fehlt die
Datei oder ist ihr Inhalt kein bekannter Modus, gilt "recovery" - derselbe Vorgabewert
wie in server.py:resolve_mode()."""
def _read_mode_file(path: Path) -> str:
"""Zuletzt angeforderter Modus laut `path`. Fehlt die Datei, ist sie unlesbar
(inklusive ungueltigem UTF-8 - eine halb geschriebene/beschaedigte Datei ist nicht
nur per fehlendem Inhalt kaputt), oder ist ihr Inhalt kein bekannter Modus, gilt
"recovery" - derselbe Vorgabewert wie in server.py:resolve_mode(). Wirft nie: eine
kaputte Datei darf weder das Panel noch "off" aufhalten.
`path` ist bewusst eine Datei, kein Verzeichnis (anders als frueher, wo hier
`run_dir / "mode"` gelesen wurde) - siehe LAST_MODE_FILE oben, warum die Fusszeile
NICHT mehr aus RUN_DIR liest."""
try:
value = (run_dir / "mode").read_text().strip()
except OSError:
value = path.read_text().strip()
except (OSError, UnicodeDecodeError):
return "recovery"
return value if value in ("recovery", "migration") else "recovery"
@@ -275,7 +305,7 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tupl
dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
try:
value = imageset_path.read_text().strip()
except OSError:
except (OSError, UnicodeDecodeError):
value = ""
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
image_dir = conf.get("IMAGE_DIR")
@@ -320,16 +350,18 @@ def local_ip() -> str:
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
service_name: str = SERVICE_NAME,
imageset_path: Path = IMAGESET_FILE) -> dict:
imageset_path: Path = IMAGESET_FILE,
last_mode_path: Path = LAST_MODE_FILE) -> dict:
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie
Modus kommt vorrangig aus `last_mode_path` (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).
zuletzt geschrieben haben, dauerhaft (siehe LAST_MODE_FILE oben - NICHT die ephemere
RUN_DIR/mode, die server.py fuer den naechsten Start liest) 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
@@ -342,17 +374,18 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
Ob "imageset_ok" den Rettungs- oder den Migrations-Bildsatz prueft, richtet sich nach
dem MODUS, in dem die Station beim naechsten Einschalten anlaufen wuerde - also
RUN_DIR/mode, nicht danach, ob sie gerade laeuft. Deshalb wird der Modus HIER, vor der
"ist der Dienst aktiv"-Abfrage, gelesen: er ist auch im Zustand "off" verfuegbar (die
Datei ueberlebt das Abschalten) und genau dort soll die Vorschau ja gelten - "off"
zeigt ja=noch keinen Modus im engeren Sinn, aber der naechste Start faehrt in genau
diesen Modus, und die Fusszeile soll VORHER zeigen, ob dessen Bildsatz bereitliegt.
`last_mode_path`, nicht danach, ob sie gerade laeuft. Deshalb wird der Modus HIER,
vor der "ist der Dienst aktiv"-Abfrage, gelesen: er ist auch im Zustand "off"
verfuegbar (die Datei ist dauerhaft, siehe LAST_MODE_FILE) und genau dort soll die
Vorschau ja gelten - "off" zeigt ja noch keinen Modus im engeren Sinn, aber der
naechste Start faehrt in genau diesen Modus, und die Fusszeile soll VORHER zeigen,
ob dessen Bildsatz bereitliegt.
"activity" (nur in Recovery/Migration, siehe _recent_request()) sagt, ob die letzte
Anfrage jeder Art hoechstens ACTIVITY_WINDOW_S Sekunden her ist - das Lebenszeichen,
das render.blocks() an die Servierzeile anhaengt."""
conf = _read_conf(conf_path)
mode = _read_mode_file(run_dir)
mode = _read_mode_file(last_mode_path)
imageset, imageset_ok = _read_imageset(imageset_path, conf, mode)
if not _service_active(service_name):