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