feat(station): Panel zeigt Rettungs-Bildsatz, Lebenszeichen und klarere LAN-Zeile

display.py/_read_imageset() prueft jetzt das zum Modus passende Verzeichnispaar
(rescue-* im Rettungsmodus, migrate-* in der Migration) - vorher pruefte die
Fusszeile immer nur den Migrationssatz, auch im Rettungsmodus.

render.py haengt einen kleinen Punkt an die Servierzeile, solange
display.py:_recent_request() eine Anfrage der letzten ACTIVITY_WINDOW_S
Sekunden meldet - faehrt ueber den vorhandenen Textvergleich mit, kein
zusaetzlicher Zeichentakt fuer das E-Paper.

Aus-Zustand: "no network" hiess fuer einen Bediener wie "der Migrations-/
Rettungs-Zugangspunkt ist aus" - gemeint ist das Gegenteil, das Heimnetz.
Jetzt "LAN: <ip>" bzw. "LAN: not connected".

run_daemon() faengt jetzt jeden Fehler beim Erheben des Zustands ab
(_safe_read_state) statt die Schleife zu beenden - sonst friert das Panel
nach einem einzigen unerwarteten Lesefehler fuer immer auf dem letzten Bild
ein, ohne dass das am Geraet zu erkennen waere.
This commit is contained in:
Michael Sauer
2026-09-23 21:22:42 +02:00
parent ac8dfb1077
commit 6d0f79a6ac
3 changed files with 303 additions and 23 deletions
+109 -15
View File
@@ -34,6 +34,7 @@ import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
# Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package,
@@ -70,6 +71,11 @@ IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
# Satz OHNE Zugangsdaten (dort steht die Begruendung).
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
IMAGESET_DEFAULT = "ohne-wlan"
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (server.py: rescue_dir()) - mit
# eigenen Verzeichnissen, denn Rettungs- und Migrationsabzuege sind verschieden hergestellt
# (siehe server.py fuer die Begruendung). Welches Paar am Panel zu pruefen ist, haengt vom
# Modus ab: _read_imageset() bekommt ihn deshalb als Parameter statt fest verdrahtet.
RESCUE_IMAGESET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
POLL_INTERVAL_S = 5
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck
@@ -170,6 +176,45 @@ def _read_autooff_minutes(run_dir: Path, now: float | None = None):
return int(whole) + (1 if rest else 0)
# Lebenszeichen: ein kleiner Punkt, der erscheint, sobald irgendeine Anfrage hereinkommt
# (auch eine erfolglose - "notfound"/"listen"/"unknown"/"noimageset" zaehlen genauso wie
# eine erfolgreiche Auslieferung), und nach ACTIVITY_WINDOW_S Sekunden von selbst wieder
# verschwindet. Der Wunsch dahinter (Rueckmeldung eines Bedieners, 2026-09-23): bisher war
# nur am Servierzaehler ("Served: N") zu sehen, ob es geklappt HAT - bis dahin war offen,
# ob ueberhaupt jemand anklopft. server.py schreibt den Zeitpunkt der letzten Anfrage
# jeder Art schon nach status.json (Station.last_activity); hier wird daraus nur noch ein
# Ja/Nein fuer "gerade eben".
#
# Bewusst KEIN eigener Zeittakt/Timer: das Panel pollt status.json ohnehin schon alle
# POLL_INTERVAL_S Sekunden, und render.blocks() haengt den Punkt an eine bestehende
# Textzeile an (siehe dort) - er faehrt also ueber genau denselben Textvergleich
# (RefreshPlanner.decide()) mit, der auch jede andere Aenderung erkennt. Ein Erscheinen
# und ein Verschwinden kosten damit je einen gewoehnlichen Teilaufbau, keinen eigenen
# Mechanismus und kein zusaetzliches Wachhalten des E-Papers - fuegt sich also in die
# vorhandene, fuer E-Paper sparsame Taktung ein, statt eine eigene, potenziell haeufigere
# aufzumachen.
ACTIVITY_WINDOW_S = 15
def _recent_request(status: dict, now: float | None = None) -> bool:
"""War die letzte Anfrage (jeder Art) hoechstens ACTIVITY_WINDOW_S Sekunden her?
"last_request_ts" fehlt bei einer aelteren server.py-Fassung oder wenn noch nie eine
Anfrage einging - dann gilt False, kein Punkt erfunden. Ein Zeitstempel aus der
Zukunft (Uhr verstellt, oder Server- und Anzeigerechner-Uhr laufen leicht auseinander)
zaehlt ebenfalls als "nicht mehr aktuell" statt als potenziell dauerhaft aktiv."""
ts = status.get("last_request_ts")
if not isinstance(ts, str):
return False
try:
parsed = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
except ValueError:
return False
now_s = time.time() if now is None else now
age_s = now_s - parsed.timestamp()
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
@@ -209,19 +254,25 @@ def _service_active(service_name: str) -> bool:
return True
def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool]:
"""(Name des eingestellten Bildsatzes, liegt er ueberhaupt auf dem Pi?).
def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tuple[str, bool]:
"""(Name des eingestellten Bildsatzes, liegt der fuer `mode` PASSENDE Satz ueberhaupt
auf dem Pi?).
Fehlt die Datei, ist sie unlesbar oder steht etwas Unbekanntes darin, gilt
IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das
Panel etwas anderes behaupten als der Server tut.
`ok` ist False, wenn der GEWAEHLTE Satz nicht auf dem Pi liegt - nach derselben
Regel wie server.py:migrate_set_present(): Verzeichnis vorhanden UND mindestens ein
Image darin. Dann liefert server.py nichts aus (Status "noimageset"), und genau das
muss am Panel stehen, statt dass der Bediener vor einer Lampe steht, die nichts
bekommt. Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts
behauptet: dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
`ok` ist False, wenn der GEWAEHLTE Satz fuer den GERADE GELTENDEN Modus nicht auf dem
Pi liegt - nach derselben Regel wie server.py:migrate_set_present()/
rescue_set_present(): Verzeichnis vorhanden UND mindestens ein Image darin. Welches
Verzeichnispaar das ist (migrate-*/rescue-*), entscheidet `mode` - Rettung und
Migration liegen seit der Erweiterung des Umschalters auf die Rettung in getrennten
Verzeichnissen (server.py begruendet das). Eine falsche Zuordnung hier wuerde das
Panel in GENAU DEM Modus luegen lassen, fuer den es gerade steht: "ok", obwohl die
Rettung nichts ausliefern kann, oder "FEHLT", obwohl sie es koennte.
Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts behauptet:
dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
try:
value = imageset_path.read_text().strip()
except OSError:
@@ -230,7 +281,8 @@ def _read_imageset(imageset_path: Path, conf: dict[str, str]) -> tuple[str, bool
image_dir = conf.get("IMAGE_DIR")
if not image_dir:
return name, True
set_dir = Path(image_dir) / IMAGESET_DIRS[name]
dirs = RESCUE_IMAGESET_DIRS if mode == "recovery" else IMAGESET_DIRS
set_dir = Path(image_dir) / dirs[name]
try:
return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
except OSError:
@@ -286,9 +338,22 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in
den Rettungsmodus: er ist ein dauerhafter Umschalter, und man soll ihn VOR dem
Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im
Menue auffaellt, hat genau den Zweck verfehlt."""
Menue auffaellt, hat genau den Zweck verfehlt.
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.
"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)
imageset, imageset_ok = _read_imageset(imageset_path, conf)
mode = _read_mode_file(run_dir)
imageset, imageset_ok = _read_imageset(imageset_path, conf, mode)
if not _service_active(service_name):
# Die IP-Zeile gibt es NUR hier: ist die Station aus, ist der Pi ein gewoehnlicher
@@ -299,10 +364,10 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
"ip": local_ip()}
mode = _read_mode_file(run_dir)
status = _read_json(run_dir / "status.json")
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
"imageset": imageset, "imageset_ok": imageset_ok}
"imageset": imageset, "imageset_ok": imageset_ok,
"activity": _recent_request(status)}
if mode == "recovery":
state["ssid"] = conf.get("SSID")
@@ -620,6 +685,23 @@ def run_clear(revision: str) -> None:
log.info("Panel geleert (EPD_REVISION=%s)", revision)
def _safe_read_state(read_state, fallback: dict) -> dict:
"""Ruft `read_state()` auf und faengt JEDE Ausnahme ab - nicht nur die, die die
einzelnen build_state()-Bausteine schon selbst abfangen (siehe _read_json() etc.
oben). run_daemon() laeuft als Endlosschleife, und main() fängt darum nur
(DisplayUnavailable, ImportError) ab; ein einzelner, unvorhergesehener Fehler beim
Erheben wuerde diese Schleife sonst beenden - und weil E-Paper sein zuletzt
gezeichnetes Bild dauerhaft behaelt, saehe das Panel einfach so aus, als sei nichts
passiert, nur eben fuer immer eingefroren. `fallback` (der vorherige Zustand, oder
beim allerersten Aufruf ein neutraler Platzhalter) gilt dann fuer diesen einen Takt;
beim naechsten wird es erneut versucht."""
try:
return read_state()
except Exception as e: # noqa: BLE001 - bewusst pauschal, siehe Docstring
log.warning("Zustand konnte nicht gelesen werden, vorheriger Stand bleibt: %s", e)
return fallback
def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
read_state=build_state, read_ui=read_ui) -> None:
"""Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle
@@ -647,13 +729,25 @@ def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
panel = Panel(revision)
panel.clear()
planner = RefreshPlanner(clock.monotonic())
base_state = read_state()
base_state = _safe_read_state(read_state, {"mode": None})
last_state_poll = clock.monotonic()
while True:
now = clock.monotonic()
if now - last_state_poll >= POLL_INTERVAL_S:
base_state = read_state()
# Jeder einzelne Baustein von build_state() faengt seine eigenen I/O-Fehler
# schon ab (siehe _read_json/_read_conf/_service_active/local_ip oben) - aber
# main() faengt um run_daemon() herum nur (DisplayUnavailable, ImportError)
# ab, nicht Exception im Allgemeinen. Ein einzelner unerwarteter Fehler beim
# Erheben (z.B. ein kaputtes RUN_DIR mitten in einem Neustart) wuerde diese
# Endlosschleife sonst ganz beenden - und E-Paper behaelt sein zuletzt
# gezeichnetes Bild fuer immer (siehe Modul-Docstring): die Anzeige wuerde
# dann OHNE jede Fehlermeldung am Geraet einfach stehen bleiben, ununterscheidbar
# von einem echten, andauernden Zustand. read_state() bleibt deshalb hier
# abgesichert - ein einzelner Fehlschlag ueberspringt genau diese Erhebung
# (der vorherige Zustand bleibt stehen) statt den ganzen Dienst zu beenden;
# beim naechsten Takt in POLL_INTERVAL_S wird es erneut versucht.
base_state = _safe_read_state(read_state, base_state)
last_state_poll = now
ui = read_ui()
if ui: