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
+111 -11
View File
@@ -62,10 +62,54 @@ def trailer_version(path: Path) -> str:
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
# --- Bildsatz: mit oder ohne einkompilierte WLAN-Zugangsdaten -----------------------
#
# Die WLAN-Zugangsdaten stecken zur BAUZEIT in der Firmware (config.local.cmake). Es gibt
# deshalb zwei Abzuege je Ziel, und beide liegen auf dem Pi:
#
# "ohne-wlan" -> images/migrate-nocreds/ aus tools/build_nocreds.sh: die migrierte
# Lampe startet im eigenen Onboarding-Zugangspunkt (ninlight-<mac4>)
# und wartet, bis ihr jemand ein WLAN nennt.
# "mit-wlan" -> images/migrate-wlan/ aus build/: die Lampe haengt sofort im WLAN
# des Bedieners.
#
# Welcher ausgeliefert wird, entscheidet ein UMSCHALTER (ninlight-station bildsatz bzw.
# der Menueeintrag am Taster), nicht eine Abfrage je Migration: sein Zustand bleibt, bis
# ihn jemand aendert, und er steht dauerhaft auf dem Panel.
#
# Vorgabe ist "ohne-wlan", und zwar in JEDEM Zweifelsfall (Datei fehlt, unlesbar,
# unbekannter Inhalt). Begruendung: im Automatikmodus wird jede alte Klyqa-Lampe in
# Funkreichweite migriert, auch fremde. Ein Abzug OHNE Zugangsdaten ist dann ein
# aergerlicher, aber harmloser Fehlgriff - die Lampe steht im Onboarding-AP und wartet.
# Ein Abzug MIT Zugangsdaten traegt das WLAN-Passwort des Bedieners unwiderruflich in
# ein fremdes Geraet. Der teurere Fehler darf nie der sein, der ohne Zutun passiert.
IMAGE_SET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
DEFAULT_IMAGE_SET = "ohne-wlan"
def read_image_set(path: Path | None) -> str:
"""Der eingestellte Bildsatz, oder DEFAULT_IMAGE_SET, wenn die Datei fehlt, unlesbar
ist oder etwas Unbekanntes enthaelt. Wirft nie - eine kaputte Umschalterdatei darf
den Server nicht abschiessen, und sie darf vor allem nicht auf den teureren der
beiden Saetze zeigen (siehe IMAGE_SET_DIRS)."""
if path is None:
return DEFAULT_IMAGE_SET
try:
value = path.read_text().strip()
except OSError:
return DEFAULT_IMAGE_SET
return value if value in IMAGE_SET_DIRS else DEFAULT_IMAGE_SET
class Station:
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None,
mode: str = "recovery", control_path: Path | None = None) -> None:
mode: str = "recovery", control_path: Path | None = None,
imageset_path: Path | None = None) -> None:
self.image_dir = image_dir
# Bei JEDER Anfrage neu gelesen, nicht einmal beim Start: der Umschalter soll
# wirken, ohne den Dienst neu zu starten - ein Neustart mitten in einer
# laufenden Auslieferung waere genau das, was die Station nicht tun darf.
self.imageset_path = imageset_path
self.state_path = state_path
self.stall_after = stall_after
self.mode = mode
@@ -77,6 +121,8 @@ class Station:
# wurde - das ist es, was die Anzeige gross darstellt (Spec 5.2).
self.last_seen: dict | None = None
self._manifest_mtime: float | None = None
# Zu welchem Bildsatz der Cache gehoert - siehe _load_manifest().
self._manifest_path: Path | None = None
# Der geparste Inhalt von manifest.json (oder {}, wenn sie fehlt/kaputt/falsch
# geformt ist) statt nur einer einzelnen Version - seit Fixrunde 2 (N2) traegt
# jeder Eintrag seine eigene Version, image_version() muss also den ganzen
@@ -105,24 +151,52 @@ class Station:
def image_path(self, target: str) -> Path:
return self.image_dir / f"ninlight_{target}_ota.img"
def image_set(self) -> str:
"""Der gerade eingestellte Bildsatz ("ohne-wlan"/"mit-wlan")."""
return read_image_set(self.imageset_path)
def migrate_dir(self) -> Path:
"""Das Verzeichnis des EINGESTELLTEN Bildsatzes. Es kann fehlen (der Satz wurde
nie ausgeliefert) - dann wird NICHTS ausgeliefert, siehe Handler: auf den anderen
Satz auszuweichen waere genau der Fehlgriff, gegen den der Umschalter da ist."""
return self.image_dir / IMAGE_SET_DIRS[self.image_set()]
def migrate_image_path(self, target: str, variant: str) -> Path:
return self.image_dir / "migrate" / f"{target}_{variant}.img"
return self.migrate_dir() / f"{target}_{variant}.img"
def migrate_set_present(self) -> bool:
"""Liegt der eingestellte Bildsatz ueberhaupt auf dem Pi? "Vorhanden" heisst: das
Verzeichnis gibt es UND es enthaelt mindestens ein Image. Ein leeres Verzeichnis
zaehlt bewusst als nicht vorhanden - es entsteht genauso leicht (angelegt, aber nie
bestueckt) und unterscheidet sich fuer den Bediener in nichts; das Panel und das
CLI beurteilen es nach derselben Regel, sonst behaupten drei Stellen dreierlei."""
try:
return any(p.name.endswith(".img") for p in self.migrate_dir().iterdir())
except OSError:
return False
def _load_manifest(self) -> dict:
"""Geparster Inhalt von <image_dir>/migrate/manifest.json, neu eingelesen bei
jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie unlesbar/kaputtes
JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis {} - dieselbe
Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit eine falsch
geformte, aber syntaktisch gueltige Datei nicht mit einem AttributeError den
Server abschiesst."""
path = self.image_dir / "migrate" / "manifest.json"
"""Geparster Inhalt von manifest.json IM EINGESTELLTEN Bildsatz, neu eingelesen
bei jeder Aenderung der Datei (mtime-Vergleich). Fehlt sie, ist sie
unlesbar/kaputtes JSON, oder ist ihr Wurzelelement kein Objekt, ist das Ergebnis
{} - dieselbe Formpruefung wie beim Merge in deploy.sh (Fixrunde 2, N1), damit
eine falsch geformte, aber syntaktisch gueltige Datei nicht mit einem
AttributeError den Server abschiesst."""
path = self.migrate_dir() / "manifest.json"
try:
mtime = path.stat().st_mtime
except OSError:
self._manifest_mtime = None
self._manifest_path = path
self._manifest_cache = {}
return self._manifest_cache
if mtime != self._manifest_mtime:
# Der Pfad gehoert mit in den Vergleich: nach einem Umschalten zeigt er auf eine
# ANDERE Datei, die zufaellig dieselbe mtime tragen kann (beide entstehen im
# selben Deploy, oft in derselben Sekunde). Ohne ihn behielte der Cache die
# Versionen des vorigen Satzes - und die stuenden dann in status.json und am
# Panel neben einem Image, das gar nicht von dort kommt.
if mtime != self._manifest_mtime or path != getattr(self, "_manifest_path", None):
self._manifest_path = path
try:
data = json.loads(path.read_text())
except (OSError, ValueError):
@@ -428,6 +502,19 @@ class Handler(BaseHTTPRequestHandler):
return
path = self.station.migrate_image_path(target, variant)
if not self.station.migrate_set_present():
# Der EINGESTELLTE Bildsatz liegt gar nicht auf dem Pi. Hier wird bewusst
# nichts ausgeliefert und auch nicht auf den anderen Satz ausgewichen: der
# Umschalter existiert genau dafuer, dass niemand versehentlich Lampen mit
# den eigenen WLAN-Zugangsdaten flasht. Ein eigener Status, damit der
# Bediener am Panel den Unterschied zu "dieses Modell fehlt" sieht.
if phase == "armed":
self.station.restore_armed(target)
self.station.record_migration(ip, target, variant, pid, lamp_version, name,
version, 0, "noimageset", variant_source)
self.reject(404, "selected image set not available")
self.log(404, 0, started)
return
if not path.is_file():
if phase == "armed":
# claim_delivery() hat die Freigabe schon verbraucht, bevor wir wussten,
@@ -552,6 +639,10 @@ def main(argv: list[str] | None = None) -> int:
"Vorgabe bei fehlender/kaputter Datei ist 'recovery'")
p.add_argument("--control", type=Path, default=Path("/run/ninlight-station/control.json"),
help="Steuerdatei fuer Phase/Freigabe im Migrationsmodus")
p.add_argument("--imageset-file", type=Path, default=None,
help="Datei mit 'ohne-wlan' oder 'mit-wlan' (Umschalter des "
"Migrations-Bildsatzes); Vorgabe bei fehlender/kaputter Datei "
"ist 'ohne-wlan'")
p.add_argument("--stall-after", type=int, default=None,
help="test aid: stop sending after N bytes but keep the connection open")
a = p.parse_args(argv)
@@ -561,13 +652,22 @@ def main(argv: list[str] | None = None) -> int:
return 2
mode = resolve_mode(a.mode, a.mode_file)
station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control)
station = Station(a.image_dir, a.state, a.stall_after, mode=mode, control_path=a.control,
imageset_path=a.imageset_file)
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
httpd.daemon_threads = True
httpd.station = station # type: ignore[attr-defined]
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img"))
print(f"{now_iso()} listening on {a.bind}:{a.port}, mode={mode}, {len(images)} image(s): "
f"{', '.join(images) or 'none'}", flush=True)
if mode == "migration":
# Nur im Migrationsmodus: der Bildsatz betrifft ausschliesslich migrate/-Images.
# Im Journal, weil "warum kam das Image mit meinem WLAN?" sonst nirgends
# nachschlagbar waere - das Panel zeigt nur den JETZIGEN Zustand.
selected = station.image_set()
present = "vorhanden" if station.migrate_set_present() else "FEHLT"
print(f"{now_iso()} image set: {selected} ({station.migrate_dir()}, {present})",
flush=True)
if a.stall_after is not None:
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
try: