From 3642672c710df38df72e95460788dcb3e6b44ca2 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Tue, 15 Sep 2026 01:20:42 +0200 Subject: [PATCH] feat(station): Automatikmodus als eigener Menueeintrag am Taster Das Tastermenue bot bisher nur "Migration starten" an, fest verdrahtet auf die Horchphase; den Automatikmodus gab es nur ueber die Kommandozeile - also ausgerechnet dort nicht, wo man ihn braucht, denn "on" macht wlan0 zum Zugangspunkt und reisst jede SSH-Sitzung ab. Jetzt zwei getrennte, namentlich unterscheidbare Eintraege: "Migration: horchen" (--listen) zuerst, danach "Migration: AUTOMATIK" (ohne --listen). Der Automatikeintrag bekommt eine andere, deutlichere Rueckfrage ("ACHTUNG" statt "RUECKFRAGE", plus zwei Warnzeilen), weil der Migrations-Zugangspunkt ein oeffentlich bekanntes Passwort hat und im Automatikmodus jede alte Klyqa-Lampe in Funkreichweite ungefragt migriert wird, auch fremde. Die Bedienhinweise stehen weiterhin und zuletzt. --- tools/station/README.md | 13 +++-- tools/station/button/button.py | 78 ++++++++++++++++++++++++------ tools/station/tests/test_button.py | 52 ++++++++++++++++---- 3 files changed, 115 insertions(+), 28 deletions(-) diff --git a/tools/station/README.md b/tools/station/README.md index 77d3563..2651c25 100644 --- a/tools/station/README.md +++ b/tools/station/README.md @@ -43,11 +43,14 @@ Countdown bis zum Herunterfahren, den Loslassen jederzeit abbricht. Das Menü sc Zustand ab (Station aus / Rettung läuft / Migration läuft); jede verändernde Auswahl bekommt eine Rückfrage. -**„Migration starten" startet über das Menü immer in der Horchphase** (`--listen`), nie -automatisch: der Migrations-Zugangspunkt hat ein öffentlich bekanntes Passwort, und ein -Tastendruck, der ihn im Automatikmodus hochfährt, würde jede alte Klyqa-Lampe in -Funkreichweite ungefragt migrieren, auch fremde. Wer den Automatikmodus will, nimmt die -Kommandozeile. +**Die beiden Migrationsarten sind getrennte Einträge.** „Migration: horchen" +(`migrate on --listen`) steht zuerst und ist der naheliegende: der Zugangspunkt steht, +aber es geht nichts raus, bis du eine erkannte Lampe freigibst. „Migration: AUTOMATIK" +(`migrate on`) folgt danach und bekommt eine **andere, deutlichere Rückfrage** — der +Migrations-Zugangspunkt hat ein öffentlich bekanntes Passwort (es steht in der alten +Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite +ungefragt migriert, auch fremde. Die Rückfrage benennt genau das, statt nur „Wirklich?" +zu fragen. Der Taster ist nirgends Voraussetzung: fehlt `gpiozero` oder der Taster, protokolliert der Dienst eine Zeile und beendet sich ohne Fehler. Er fasst den SPI-Bus **nicht** an (zwei diff --git a/tools/station/button/button.py b/tools/station/button/button.py index 6d06ec3..3e89565 100644 --- a/tools/station/button/button.py +++ b/tools/station/button/button.py @@ -32,12 +32,17 @@ ninlight-station), nie ueber nachgebaute Logik: nur das CLI kennt die Profilumsc die Nachpruefungen ("es darf nie mehr als ein Stationsprofil aktiv sein") und den Zeitschalter. -SICHERHEITSREGEL: "Migration starten" startet ueber das Menue IMMER in der Horchphase -("migrate on --listen"), nie automatisch. Der Migrations-Zugangspunkt hat ein oeffentlich -bekanntes Passwort (es steht in der alten Klyqa-Firmware); ein Tastendruck, der ihn im -Automatikmodus hochfaehrt, wuerde jede alte Klyqa-Lampe in Funkreichweite ungefragt -migrieren, auch fremde. Die bequeme Geste muss die sichere sein - wer den Automatikmodus -will, nimmt die Kommandozeile, dort ist die Absicht eindeutig. +SICHERHEITSREGEL: die beiden Migrationseintraege sind GETRENNT und namentlich +unterscheidbar. "Migration: horchen" ruft "migrate on --listen" - der Zugangspunkt steht, +aber es geht nichts raus, bis der Bediener eine erkannte Lampe freigibt; das ist der +erste und naheliegende Eintrag. "Migration: AUTOMATIK" ruft "migrate on" ohne "--listen" +und bekommt eine ANDERE, deutlichere Rueckfrage (siehe action_migrate_auto()): der +Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten +Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite +ungefragt migriert, auch fremde. Die bequeme Geste bleibt damit die sichere, ohne dass +der Automatikmodus - fuer eine ganze Kiste eigener Lampen am Stueck - nur noch ueber die +Kommandozeile erreichbar waere, die im Zugangspunktbetrieb gerade nicht zur Verfuegung +steht. Der Taster ist NIRGENDS Voraussetzung: fehlt gpiozero, fehlt der Taster oder ist GPIO23 belegt, schreibt main() GENAU EINE Protokollzeile und beendet sich mit sys.exit(0) - @@ -115,15 +120,26 @@ class Action: """Was ein Menueeintrag bzw. die Haltegeste ausloest. `confirm` = braucht eine Rueckfrage. Zusage: JEDE veraendernde Auswahl bekommt eine - ("Anzeige auffrischen" veraendert nichts und braucht keine).""" + ("Anzeige auffrischen" veraendert nichts und braucht keine). + + `confirm_title`/`confirm_details` machen die Rueckfrage bei Bedarf DEUTLICHER als die + uebliche. Die Vorgabe ("RUECKFRAGE" + die zwei Bedienhinweise) reicht fuer eine + Auswahl, deren Tragweite im Eintragsnamen steht ("Station aus", "Herunterfahren"). + Sie reicht NICHT fuer den Automatikmodus: was dort passiert - jede alte Klyqa-Lampe + in Funkreichweite wird ungefragt migriert, auch fremde - steht in keinem + Eintragsnamen, und ein blosses "Wirklich?" fragt nach etwas, das der Bediener in + diesem Moment gar nicht vor Augen hat.""" def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True, - done_text: str | None = None) -> None: + done_text: str | None = None, confirm_title: str = "RUECKFRAGE", + confirm_details=None) -> None: self.label = label self.argv = tuple(argv) self.kind = kind self.confirm = confirm self.done_text = done_text or label + self.confirm_title = confirm_title + self.confirm_details = list(confirm_details) if confirm_details else None def __repr__(self) -> str: # nur fuer Testausgaben return "Action({!r}, {!r}, kind={!r})".format(self.label, self.argv, self.kind) @@ -134,6 +150,34 @@ def action_shutdown() -> Action: done_text="Faehrt herunter") +def action_migrate_listen() -> Action: + """Der naheliegende Weg in die Migration: Zugangspunkt an, aber es geht NICHTS raus, + bis der Bediener eine erkannte Lampe freigibt ("arm"). Deshalb steht dieser Eintrag + vor dem Automatikmodus und traegt die uebliche Rueckfrage.""" + return Action("Migration: horchen", ("migrate", "on", "--listen"), + done_text="Migration: horcht") + + +def action_migrate_auto() -> Action: + """Der Automatikmodus - fuer die eigene Kiste Lampen am Stueck, ohne jede einzeln + freizugeben. Er steht hier, weil das Verabreden ueber die Kommandozeile genau dann + nicht geht, wenn man ihn braucht: "on" macht wlan0 zum Zugangspunkt und reisst jede + SSH-Sitzung ueber WLAN ab. + + Seine Rueckfrage ist ABSICHTLICH eine andere als die der uebrigen Eintraege. Der + Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten + Klyqa-Firmware), und im Automatikmodus wird jede alte Klyqa-Lampe in Funkreichweite + ungefragt migriert - auch die des Nachbarn, der davon nichts weiss und dessen Lampe + danach nicht mehr mit seiner App spricht. Ein "Wirklich?" fragt nach etwas, das der + Bediener in diesem Moment gar nicht vor Augen hat; deshalb steht die Folge in der + Rueckfrage selbst.""" + return Action("Migration: AUTOMATIK", ("migrate", "on"), + done_text="Migration: automatisch", + confirm_title="ACHTUNG", + confirm_details=["Flasht JEDE alte Klyqa-Lampe", + "in Reichweite - auch fremde"]) + + 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.""" @@ -150,10 +194,11 @@ def menu_actions(state: dict) -> list[Action]: if not running: return [ Action("Rettung starten", ("recovery", "on"), done_text="Rettung laeuft"), - # IMMER --listen (siehe Modul-Docstring, Sicherheitsregel). Wer den - # Automatikmodus will, nimmt die Kommandozeile. - Action("Migration starten", ("migrate", "on", "--listen"), - done_text="Migration: horcht"), + # Reihenfolge ist Absicht: der horchende Modus zuerst, weil er der + # naheliegende und der harmlose ist - wer nur blaettert und irgendwo + # loslaesst, landet nicht im Automatikmodus. + action_migrate_listen(), + action_migrate_auto(), action_shutdown(), ] @@ -486,8 +531,13 @@ class ButtonUI: return _view("menu", "MENUE", "> " + selected.label, [a.label for a in rest] + ["kurz: weiter, halten: waehlen"]) if self.screen == "confirm" and self.pending is not None: - return _view("confirm", "RUECKFRAGE", self.pending.label, - ["halten (1,5 s): ja", "kurz: nein"]) + # Die Bedienhinweise stehen IMMER und IMMER ZULETZT - eine Rueckfrage, bei + # der man raten muss, wie man ablehnt, ist keine. Eine eigene Warnung des + # Eintrags (confirm_details) kommt davor, damit sie zuerst gelesen wird. + details = list(self.pending.confirm_details or []) + details += ["halten (1,5 s): ja", "kurz: nein"] + return _view("confirm", self.pending.confirm_title, self.pending.label, + details) if self.screen == "busy" and self.busy is not None: return _view("busy", "BITTE WARTEN", self.busy.label, []) if self.result is not None: diff --git a/tools/station/tests/test_button.py b/tools/station/tests/test_button.py index 1322c5b..aa80edc 100644 --- a/tools/station/tests/test_button.py +++ b/tools/station/tests/test_button.py @@ -163,7 +163,15 @@ def _labels(state): def test_menu_off_offers_starting_and_shutdown(): - assert _labels(STATE_OFF) == ["Rettung starten", "Migration starten", "Herunterfahren"] + assert _labels(STATE_OFF) == ["Rettung starten", "Migration: horchen", + "Migration: AUTOMATIK", "Herunterfahren"] + + +def test_the_listening_migration_comes_before_the_automatic_one(): + """Der horchende Modus ist der erste und naheliegende Eintrag - wer nur blaettert und + irgendwo loslaesst, soll nicht im Automatikmodus landen.""" + labels = _labels(STATE_OFF) + assert labels.index("Migration: horchen") < labels.index("Migration: AUTOMATIK") def test_menu_in_recovery_offers_stopping_refreshing_and_shutdown(): @@ -219,11 +227,11 @@ def test_a_short_press_opens_the_menu(): def test_a_short_press_steps_to_the_next_entry(): rec = _drive([_short(1.0), _short(2.0)], 4.0) assert rec.argvs() == [] - assert rec.views()[-1][1]["hero"] == "> Migration starten" + assert rec.views()[-1][1]["hero"] == "> Migration: horchen" def test_a_short_press_wraps_around_the_last_entry(): - rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0)], 6.0) + rec = _drive([_short(1.0), _short(2.0), _short(3.0), _short(4.0), _short(5.0)], 7.0) assert rec.views()[-1][1]["hero"] == "> Rettung starten" @@ -254,15 +262,41 @@ def test_the_refresh_entry_runs_without_a_confirmation(): assert "confirm" not in rec.kinds() -def test_starting_the_migration_always_listens(): - """Sicherheitsregel, nicht verhandelbar: der Migrations-Zugangspunkt hat ein - oeffentlich bekanntes Passwort - ein Tastendruck, der ihn im Automatikmodus - hochfaehrt, wuerde jede alte Klyqa-Lampe in Funkreichweite ungefragt migrieren, auch - fremde.""" +def test_the_listening_entry_calls_the_cli_with_listen(): + """Der zweite Eintrag ("Migration: horchen") ruft das CLI MIT --listen: der + Zugangspunkt steht, es geht aber nichts raus, bis jemand eine erkannte Lampe + freigibt.""" rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0), (6.0, 8.0)], 10.0) assert rec.argvs() == [["/usr/local/bin/ninlight-station", "migrate", "on", "--listen"]] - assert "--listen" in rec.argvs()[0] + + +def test_the_automatic_entry_calls_the_cli_without_listen(): + """Der dritte Eintrag ("Migration: AUTOMATIK") ruft dasselbe CLI OHNE --listen - das + ist der ganze Unterschied zwischen den beiden Eintraegen, und er entscheidet, ob + fremde Lampen ungefragt migriert werden.""" + rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0), (7.0, 9.0)], 11.0) + assert rec.argvs() == [["/usr/local/bin/ninlight-station", "migrate", "on"]] + assert "--listen" not in rec.argvs()[0] + + +def test_the_automatic_entry_asks_a_louder_question_than_the_others(): + """Zusage: die Rueckfrage des Automatikmodus benennt die Folge, statt nur "Wirklich?" + zu fragen - und sie unterscheidet sich sichtbar von der der uebrigen Eintraege.""" + rec = _drive([_short(1.0), _short(2.0), _short(3.0), (4.0, 6.0)], 8.0) + _t, view = rec.first_view("confirm") + assert view["title"] == "ACHTUNG" + assert view["hero"] == "Migration: AUTOMATIK" + text = " ".join(view["details"]).lower() + assert "jede" in text and "fremde" in text + # Die Bedienhinweise bleiben trotzdem stehen, und zwar zuletzt. + assert view["details"][-2:] == ["halten (1,5 s): ja", "kurz: nein"] + + # Gegenprobe am horchenden Eintrag: uebliche Rueckfrage, keine Warnung. + rec = _drive([_short(1.0), _short(2.0), (3.0, 5.0)], 7.0) + _t, plain = rec.first_view("confirm") + assert plain["title"] == "RUECKFRAGE" + assert plain["details"] == ["halten (1,5 s): ja", "kurz: nein"] def test_the_cancel_fires_at_three_seconds_while_the_button_is_still_held():