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
+162 -4
View File
@@ -861,9 +861,11 @@ def test_a_switched_off_station_shows_no_timer(tmp_path, monkeypatch):
{"minutes": 30, "deadline": time.time() + 600, "deferrals": 0}))
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x")
service_name="x",
imageset_path=tmp_path / "imageset")
assert state == {"mode": "off"}
assert state["mode"] == "off"
assert "autooff_minutes" not in state
assert not any("Auto-Aus" in line for line in render.lines(state))
@@ -897,9 +899,13 @@ def test_build_state_off_when_service_not_active(tmp_path, monkeypatch):
(tmp_path / "status.json").write_text(json.dumps({"mode": "migration", "served": 5}))
state = display.build_state(run_dir=tmp_path, conf_path=tmp_path / "station.conf",
service_name="x")
service_name="x",
imageset_path=tmp_path / "imageset")
assert state == {"mode": "off"}
# "off" und sonst NICHTS aus status.json - nur der Bildsatz, der auch im
# ausgeschalteten Zustand gilt (und gerade dann gesehen werden soll).
assert state["mode"] == "off"
assert set(state) == {"mode", "imageset", "imageset_ok"}
def test_build_state_mode_comes_from_mode_file_not_status_json(tmp_path, monkeypatch):
@@ -1707,3 +1713,155 @@ def test_the_expensive_station_state_is_still_only_read_every_five_seconds():
_drive_daemon(state_at, duration_s=10.0)
assert calls == [0.0, 5.0, 10.0]
# --- Bildsatz-Umschalter in der Fusszeile -------------------------------------------
#
# Zusage: welcher Migrations-Bildsatz gilt, steht DAUERHAFT auf dem Panel - nicht nur im
# Menue. Der Zustand ueberlebt Neustarts und kann von vorletzter Woche stammen; wer ihn
# erst beim Blaettern im Menue saehe, hat womoeglich schon zwanzig Lampen mit den eigenen
# WLAN-Zugangsdaten geflasht.
def _flat(rows):
"""_band_pixels() liefert Zeilen-Tupel - hier interessieren die einzelnen Pixel."""
return [px for row in rows for px in row]
def _imageset_line(state):
texts = [text for text, role in render.blocks(state) if role == render.ROLE_FOOTER]
assert len(texts) <= 1, texts
return texts[0] if texts else None
def test_the_image_set_is_shown_in_every_status_screen():
for state in ({"mode": "off"},
{"mode": "recovery", "ssid": "ninlight-recovery"},
dict(_migration_state()),
{"mode": None}):
for name in ("ohne-wlan", "mit-wlan"):
s = dict(state, imageset=name, imageset_ok=True)
line = _imageset_line(s)
assert line == render.IMAGESET_LABELS[name], (state.get("mode"), name)
def test_the_two_image_sets_read_differently():
"""Die beiden Zustaende muessen sich am Panel auf einen Blick unterscheiden - eine
Fusszeile, die nur "Bildsatz gesetzt" sagt, waere wertlos."""
assert render.IMAGESET_LABELS["ohne-wlan"] != render.IMAGESET_LABELS["mit-wlan"]
assert "WLAN" in render.IMAGESET_LABELS["mit-wlan"]
def test_a_missing_image_set_says_so_on_the_panel():
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
line = _imageset_line(state)
assert line is not None and "FEHLT" in line
def test_without_an_image_set_field_nothing_is_claimed():
"""Ein Zustand aus einer aelteren display.py-Fassung bekommt keine erfundene
Fusszeile."""
assert _imageset_line({"mode": "off"}) is None
def test_the_image_set_line_is_actually_drawn_at_the_bottom():
"""Nicht nur in blocks(): auf dem Panel, unterhalb aller Beiwerkzeilen und ueber dem
unteren Rand."""
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=True)
footer_top = (render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
- render.FOOTER_BAND_HEIGHT_PX)
with_footer = render.compose(state)
without = render.compose({k: v for k, v in state.items()
if k not in ("imageset", "imageset_ok")})
bottom = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
band = _band_pixels(with_footer, footer_top, bottom)
assert 0 in _flat(band), "im Fussband ist gar keine Tinte"
assert band != _band_pixels(without, footer_top, bottom), \
"Fussband sieht mit und ohne Bildsatz gleich aus"
def test_the_image_set_line_keeps_the_bottom_margin_free():
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=False)
image = render.compose(state)
margin = _flat(_band_pixels(image, render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX,
render.PANEL_HEIGHT_PX))
assert set(margin) <= {255}, "der untere Rand ist nicht mehr frei"
def test_the_image_set_line_survives_the_fullest_state():
"""Im vollsten Zustand faellt eine Beiwerkzeile heraus - aber nie die Fusszeile:
sie steht an festem Platz und nicht im Zeilenfluss."""
state = dict(_migration_state(), imageset="mit-wlan", imageset_ok=True,
autooff_minutes=17)
assert _imageset_line(state) is not None
# ... und der Zeilenfluss endet wirklich vor dem Fussband.
image = render.compose(state)
footer_top = (render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
- render.FOOTER_BAND_HEIGHT_PX)
# Die Zeile direkt ueber dem Trennstrich darf ihn nicht beruehren.
assert set(_flat(_band_pixels(image, footer_top - 1, footer_top))) <= {255}
def test_build_state_reads_the_image_set_switch(tmp_path, monkeypatch):
"""Dieselbe Datei, die das CLI schreibt und server.py liest - kein zweiter Weg."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
(tmp_path / "mode").write_text("migration")
images = tmp_path / "images"
(images / "migrate-nocreds").mkdir(parents=True)
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
(images / "migrate-wlan").mkdir()
(images / "migrate-wlan" / "g95_cw_dev.img").write_bytes(b"IMG")
conf = tmp_path / "station.conf"
conf.write_text(f"MIGRATE_SSID='qconnex-firmware'\nIMAGE_DIR='{images}'\n")
switch = tmp_path / "imageset"
def state():
return display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)
# Ohne Datei: die sichere Vorgabe.
assert state()["imageset"] == "ohne-wlan"
switch.write_text("mit-wlan\n")
assert state()["imageset"] == "mit-wlan"
# Unbekannter Inhalt darf NIE auf den teureren Satz zeigen.
switch.write_text("irgendwas")
assert state()["imageset"] == "ohne-wlan"
def test_build_state_reports_a_missing_image_set(tmp_path, monkeypatch):
""""Vorhanden" heisst: Verzeichnis da UND mindestens ein Image darin - dieselbe Regel
wie in server.py, sonst sagt das Panel "in Ordnung" und die Lampe bekommt nichts."""
monkeypatch.setattr(display, "_service_active", lambda service_name: True)
images = tmp_path / "images"
(images / "migrate-nocreds").mkdir(parents=True)
(images / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
conf = tmp_path / "station.conf"
conf.write_text(f"IMAGE_DIR='{images}'\n")
switch = tmp_path / "imageset"
switch.write_text("ohne-wlan")
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)["imageset_ok"] is True
switch.write_text("mit-wlan") # migrate-wlan/ gibt es nicht
state = display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)
assert state["imageset_ok"] is False
assert "FEHLT" in _imageset_line(state)
# Und ein LEERES Verzeichnis zaehlt genauso als fehlend.
(images / "migrate-wlan").mkdir()
assert display.build_state(run_dir=tmp_path, conf_path=conf, service_name="x",
imageset_path=switch)["imageset_ok"] is False
def test_the_display_and_the_server_agree_on_the_set_names():
"""Zwei Prozesse, eine Wahrheit: waeren die Namen oder die Vorgabe verschieden,
behauptete das Panel etwas anderes, als der Server tut."""
import importlib.util
spec = importlib.util.spec_from_file_location(
"server_for_names", Path(__file__).resolve().parents[1] / "server.py")
server = importlib.util.module_from_spec(spec)
spec.loader.exec_module(server)
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET