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
+72 -14
View File
@@ -26,10 +26,14 @@ import button # noqa: E402
# --- Zustaende, von denen die Menueeintraege abhaengen -------------------------------
STATE_OFF = {"running": False, "mode": "recovery", "target": None}
STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None}
STATE_MIGRATION = {"running": True, "mode": "migration", "target": "gu10_rgb"}
STATE_MIGRATION_BLIND = {"running": True, "mode": "migration", "target": None}
STATE_OFF = {"running": False, "mode": "recovery", "target": None,
"imageset": "ohne-wlan"}
STATE_RECOVERY = {"running": True, "mode": "recovery", "target": None,
"imageset": "ohne-wlan"}
STATE_MIGRATION = {"running": True, "mode": "migration", "target": "gu10_rgb",
"imageset": "ohne-wlan"}
STATE_MIGRATION_BLIND = {"running": True, "mode": "migration", "target": None,
"imageset": "ohne-wlan"}
# --- Fake-Uhr, Taster-Doppel, Protokoll ---------------------------------------------
@@ -164,7 +168,8 @@ def _labels(state):
def test_menu_off_offers_starting_and_shutdown():
assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen",
"Migration: AUTOMATIK", "Herunterfahren"]
"Migration: AUTOMATIK", "Umschalten: mit WLAN",
"Herunterfahren"]
def test_the_listening_migration_comes_before_the_automatic_one():
@@ -175,21 +180,22 @@ def test_the_listening_migration_comes_before_the_automatic_one():
def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown():
assert _labels(STATE_RECOVERY) == ["Station aus", "Anzeige auffrischen",
"Herunterfahren"]
assert _labels(STATE_RECOVERY) == ["Station aus", "Umschalten: mit WLAN",
"Anzeige auffrischen", "Herunterfahren"]
def test_menu_in_migration_offers_the_detected_target():
assert _labels(STATE_MIGRATION) == ["Freigabe: gu10_rgb", "Station aus",
"Anzeige auffrischen", "Herunterfahren"]
"Umschalten: mit WLAN", "Anzeige auffrischen",
"Herunterfahren"]
def test_menu_in_migration_without_a_detected_target_offers_no_release():
"""Ohne last_seen gibt es nichts freizugeben - der Eintrag faellt ganz weg, statt
eine Freigabe ins Leere anzubieten (die das CLI ohnehin abweisen wuerde)."""
assert "Freigabe: " not in " ".join(_labels(STATE_MIGRATION_BLIND))
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Anzeige auffrischen",
"Herunterfahren"]
assert _labels(STATE_MIGRATION_BLIND) == ["Station aus", "Umschalten: mit WLAN",
"Anzeige auffrischen", "Herunterfahren"]
def test_only_the_refresh_entry_skips_the_confirmation():
@@ -231,7 +237,8 @@ def test_a_short_press_steps_to_the_next_entry():
def test_a_short_press_wraps_around_the_last_entry():
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0)], 7.0)
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0),
_short(6.0)], 8.0)
assert rec.views()[-1][1]["hero"] == "> Rettung starten"
@@ -257,7 +264,8 @@ def test_a_short_press_in_the_confirmation_cancels_and_returns_to_the_menu():
def test_the_refresh_entry_runs_without_a_confirmation():
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0, state=STATE_RECOVERY)
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0,
state=STATE_RECOVERY)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "display", "test"]]
assert "confirm" not in rec.kinds()
@@ -342,8 +350,8 @@ def test_a_blocking_action_does_not_break_the_hold_gesture():
def test_the_waiting_screen_is_written_before_the_slow_call():
rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 8.0, state=STATE_RECOVERY,
action_duration_s=4.0)
rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 9.0,
state=STATE_RECOVERY, action_duration_s=4.0)
kinds = [k for _t, k, _p in rec.events]
run_at = kinds.index("run")
assert rec.events[run_at - 1][2].get("view") == "busy"
@@ -493,3 +501,53 @@ def test_an_unreadable_station_state_keeps_the_menu_closed(caplog):
ui.update(0.0, True)
assert ui.update(0.2, False) is None
assert ui.view(0.3) == {"view": "idle", "active": False}
# --- Bildsatz-Umschalter im Menue ----------------------------------------------------
def test_the_image_set_entry_names_where_it_switches_to():
"""Waehrend das Menue offen ist, ersetzt es das Statusbild samt Fusszeile - der
Eintrag muss deshalb das ZIEL nennen, nicht den jetzigen Zustand."""
assert "Umschalten: mit WLAN" in _labels(STATE_OFF)
ohne = dict(STATE_OFF, imageset="mit-wlan")
assert "Umschalten: ohne WLAN" in _labels(ohne)
assert "Umschalten: mit WLAN" not in _labels(ohne)
def test_the_image_set_entry_switches_through_the_cli():
actions = button.CliActions(cli="/usr/local/bin/ninlight-station")
assert actions.argv_for(button.action_imageset("ohne-wlan")) == [
"/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]
assert actions.argv_for(button.action_imageset("mit-wlan")) == [
"/usr/local/bin/ninlight-station", "bildsatz", "ohne-wlan"]
def test_switching_to_wifi_credentials_asks_louder_than_switching_back():
loud = button.action_imageset("ohne-wlan")
back = button.action_imageset("mit-wlan")
assert loud.confirm and back.confirm
assert loud.confirm_title == "ACHTUNG"
assert back.confirm_title == "RUECKFRAGE"
assert "WLAN-Passwort" in " ".join(loud.confirm_details)
assert back.confirm_details is None
def test_the_image_set_entry_really_runs_the_switch(tmp_path):
"""Durch die echte Hauptschleife, nicht nur ueber menu_actions(): vierter Eintrag im
ausgeschalteten Zustand, Auswahl halten, Rueckfrage halten."""
rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0),
(5.0, 7.0), (8.0, 10.0)], 12.0)
assert rec.argvs() == [["/usr/local/bin/ninlight-station", "bildsatz", "mit-wlan"]]
def test_station_state_reads_the_same_switch_file_as_the_cli(tmp_path, monkeypatch):
monkeypatch.setattr(button, "_service_active", lambda service_name: False)
switch = tmp_path / "imageset"
state = lambda: button.station_state(run_dir=tmp_path, service_name="x",
imageset_path=switch)
assert state()["imageset"] == "ohne-wlan" # Datei fehlt -> sichere Vorgabe
switch.write_text("mit-wlan\n")
assert state()["imageset"] == "mit-wlan"
switch.write_text("quatsch")
assert state()["imageset"] == "ohne-wlan"