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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user