feat(station): Umschalter fuer Migrationsimages mit/ohne WLAN-Daten

Die WLAN-Zugangsdaten stecken zur Bauzeit in der Firmware, es gibt also
zwei Abzuege je Ziel. Die Station haelt jetzt beide vor - images/
migrate-nocreds (aus tools/build_nocreds.sh, Lampe startet im
Onboarding-AP) und images/migrate-wlan (aus build/, Lampe haengt sofort
im eigenen WLAN) - und der Bediener waehlt per Umschalter, welcher
ausgeliefert wird: "ninlight-station bildsatz" oder der Menueeintrag am
Taster.

Der Zustand liegt in /var/lib/ninlight-station/imageset, ueberlebt
Neustarts und steht dauerhaft in der Fusszeile des Panels - in jedem
Modus, auch bei ausgeschalteter Station: man soll vor dem Einschalten
sehen, was gilt.

Vorgabe und Rueckfallwert in JEDEM Zweifelsfall ist "ohne-wlan". Im
Automatikmodus wird jede Klyqa-Lampe in Reichweite migriert, auch
fremde; ein Abzug ohne Zugangsdaten ist dann ein harmloser Fehlgriff,
einer mit Zugangsdaten traegt das eigene WLAN-Passwort in ein fremdes
Geraet.

Fehlt der gewaehlte Satz (Verzeichnis weg oder leer), wird NICHTS
ausgeliefert - kein stilles Ausweichen auf den anderen Satz. Der Server
antwortet 404 mit Status "noimageset", CLI und Panel sagen "FEHLT".

server.py und die Stations-Unit muessen mit: die Auswahl der
ausgelieferten Datei ist genau die Aufgabe des Servers, und ein
Symlink-Tausch haette "gewaehlt, aber nicht vorhanden" von "dieses
Modell fehlt" nicht mehr unterscheidbar gemacht.
This commit is contained in:
Michael Sauer
2026-09-15 02:17:36 +02:00
parent 3642672c71
commit 300edafc3e
15 changed files with 1381 additions and 200 deletions
+46 -2
View File
@@ -97,6 +97,14 @@ RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
# Anzeigedienst nicht ueberdauern.
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"))
STATION_CLI = os.environ.get("NINLIGHT_STATION_BIN", "/usr/local/bin/ninlight-station")
# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" - dieselbe Datei, die
# das CLI schreibt, der Server liest und der Anzeigedienst auf dem Panel zeigt. Hier wird
# sie NUR GELESEN (fuer die Beschriftung des Menueeintrags); umgeschaltet wird wie jede
# andere Aktion ueber das CLI.
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
"/var/lib/ninlight-station/imageset"))
IMAGESET_NAMES = ("ohne-wlan", "mit-wlan")
IMAGESET_DEFAULT = "ohne-wlan"
STATION_SERVICE = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
# Das Herunterfahren geht NICHT ueber das CLI (das kennt es nicht) - es ist die einzige
# Aktion dieses Dienstes, die an systemd selbst geht.
@@ -178,6 +186,25 @@ def action_migrate_auto() -> Action:
"in Reichweite - auch fremde"])
def action_imageset(current: str) -> Action:
"""Der Umschalter zwischen den beiden Migrations-Bildsaetzen. Der Eintrag nennt, WOHIN
umgeschaltet wird, nicht was gerade gilt: waehrend das Menue offen ist, ersetzt es das
Statusbild samt Fusszeile - "Bildsatz: mit WLAN" waere dann nicht zu entscheiden.
Der Weg nach "mit-wlan" bekommt die deutlichere Rueckfrage, der Weg zurueck die
uebliche: nur die eine Richtung schreibt das WLAN-Passwort des Bedieners in jede
Lampe, die sie erwischt."""
target = "mit-wlan" if current != "mit-wlan" else "ohne-wlan"
if target == "mit-wlan":
return Action("Umschalten: mit WLAN", ("bildsatz", "mit-wlan"),
done_text="Bildsatz: mit WLAN",
confirm_title="ACHTUNG",
confirm_details=["Jede migrierte Lampe bekommt",
"dein WLAN-Passwort"])
return Action("Umschalten: ohne WLAN", ("bildsatz", "ohne-wlan"),
done_text="Bildsatz: ohne WLAN")
def action_cancel() -> Action:
"""Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die
Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss."""
@@ -190,6 +217,7 @@ def menu_actions(state: dict) -> list[Action]:
running = bool(state.get("running"))
mode = state.get("mode")
target = state.get("target")
imageset = state.get("imageset") or IMAGESET_DEFAULT
if not running:
return [
@@ -199,6 +227,9 @@ def menu_actions(state: dict) -> list[Action]:
# loslaesst, landet nicht im Automatikmodus.
action_migrate_listen(),
action_migrate_auto(),
# Der beste Zeitpunkt zum Umschalten ist VOR dem Einschalten - deshalb steht
# der Eintrag auch (und gerade) hier.
action_imageset(imageset),
action_shutdown(),
]
@@ -209,6 +240,7 @@ def menu_actions(state: dict) -> list[Action]:
items.append(Action("Freigabe: {}".format(target), ("arm", str(target)),
done_text="Freigegeben: {}".format(target)))
items.append(Action("Station aus", ("off",), done_text="Station aus"))
items.append(action_imageset(imageset))
# Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage.
items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False,
done_text="Anzeige aufgefrischt"))
@@ -268,7 +300,18 @@ def _service_active(service_name: str = STATION_SERVICE) -> bool:
return result.returncode == 0
def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE) -> dict:
def _read_imageset(path: Path = IMAGESET_FILE) -> str:
"""Der eingestellte Bildsatz, oder IMAGESET_DEFAULT bei fehlender/unlesbarer Datei
bzw. unbekanntem Inhalt - dieselbe Vorgabe wie in server.py und im CLI."""
try:
value = path.read_text().strip()
except OSError:
return IMAGESET_DEFAULT
return value if value in IMAGESET_NAMES else IMAGESET_DEFAULT
def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE,
imageset_path: Path = IMAGESET_FILE) -> dict:
"""Der Zustand, von dem die Menueeintraege abhaengen. Liest dieselben Quellen wie
display.build_state() - RUN_DIR/mode (nicht status.json's "mode"-Feld, siehe dort)
und status.json fuer das zuletzt erkannte Ziel - dupliziert aber keine Logik des
@@ -284,7 +327,8 @@ def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE)
last_seen = status.get("last_seen")
target = last_seen.get("target") if isinstance(last_seen, dict) else None
return {"running": running, "mode": mode,
"target": target if isinstance(target, str) and target else None}
"target": target if isinstance(target, str) and target else None,
"imageset": _read_imageset(imageset_path)}
# --- Bedienzustand in eine Datei schreiben ------------------------------------------