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.
This commit is contained in:
Michael Sauer
2026-09-15 01:20:42 +02:00
parent 2fe2f7d9f4
commit 3642672c71
3 changed files with 115 additions and 28 deletions
+8 -5
View File
@@ -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
+64 -14
View File
@@ -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:
+43 -9
View File
@@ -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():