feat(station): Bedienung ueber einen Taster mit Panel-Rueckmeldung

Ein Taster auf GPIO23 (active low, 50 ms entprellt) bedient die Station am
Geraet: kurz oeffnet das Menue bzw. blaettert, gehalten waehlt (1,5 s),
bricht ab (3 s, feuert sofort) und faehrt herunter (10 s, nur ueber die
sichtbare Abbruchstufe hinweg). Menue schliesst nach 20 s, Rueckfrage
verfaellt nach 10 s als Abbruch; jede veraendernde Auswahl bekommt eine
Rueckfrage.

Noetig, weil "on"/"migrate on" wlan0 zum Zugangspunkt macht und damit jede
SSH-Sitzung abreisst - bisher blieb dort nur warten oder Stecker ziehen.

"Migration starten" startet ueber das Menue IMMER mit --listen: der
Migrations-AP hat ein oeffentlich bekanntes Passwort, ein Tastendruck im
Automatikmodus wuerde jede alte Klyqa-Lampe in Reichweite ungefragt
migrieren.

Der Tasterdienst fasst den SPI-Bus nicht an (sonst "GPIO busy"), sondern
schreibt seinen Bedienzustand atomar mit 0644 nach
/run/ninlight-button/ui.json - bewusst nicht ins RuntimeDirectory der
Hauptunit, das systemd beim "off" aufraeumt. Der Anzeigedienst liest die
Datei jede halbe Sekunde (der teure build_state()-Fork bleibt beim
5-Sekunden-Takt) und zeichnet sie als Teilaufbau, kostet dem Panel also
keinen Vollaufbau je Tastendruck.

Fehlt gpiozero oder der Taster, gibt es genau eine Protokollzeile und
sys.exit(0) - dieselbe Zusage wie bei der Anzeige.

Ausliefern/Einrichten folgt in einem eigenen Durchgang.
This commit is contained in:
Michael Sauer
2026-09-14 21:50:37 +02:00
parent f0f6894bc9
commit 82a3942554
7 changed files with 1378 additions and 19 deletions
+120 -3
View File
@@ -1402,14 +1402,20 @@ class _RecordingPanel:
self.awake_s += end_t - self._awake_since
def _drive_daemon(state_at, duration_s: float) -> _RecordingPanel:
def _drive_daemon(state_at, duration_s: float, ui_at=None) -> _RecordingPanel:
"""Laesst das echte display.run_daemon() `duration_s` simulierte Sekunden laufen.
`state_at(t)` liefert den Zustand, den build_state() zum Zeitpunkt t liefern wuerde."""
`state_at(t)` liefert den Zustand, den build_state() zum Zeitpunkt t liefern wuerde,
`ui_at(t)` den Bedienzustand, den read_ui() dort liefern wuerde (Vorgabe: keiner -
dann verhaelt sich die Schleife wie ohne Taster)."""
clock = _FakeClock(duration_s)
panel = _RecordingPanel(clock)
if ui_at is None:
def ui_at(t):
return {}
try:
display.run_daemon("V3", clock=clock, panel=panel,
read_state=lambda: state_at(clock.monotonic()))
read_state=lambda: state_at(clock.monotonic()),
read_ui=lambda: ui_at(clock.monotonic()))
except _StopDaemon:
pass
panel._finish(clock.monotonic())
@@ -1570,3 +1576,114 @@ def test_planner_draws_fully_on_mode_change():
planner.record_draw(0.0, ["RETTUNG"], "recovery", was_full=True)
assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"
# --- Kopplung an den Tasterdienst ----------------------------------------------------
#
# Der Tasterdienst (tools/station/button/button.py) fasst den SPI-Bus NICHT an - zwei
# Prozesse auf denselben GPIO-/SPI-Leitungen ergeben "lgpio.error: 'GPIO busy'". Er
# schreibt stattdessen seinen Bedienzustand in eine Datei, die hier gelesen und gezeichnet
# wird. Geprueft wird deshalb beides: dass ein Bedienzustand das Statusbild ersetzt, und
# dass die ECHTE Schleife ihn schnell genug aufgreift, um bedienbar zu sein.
_MENU_UI = {"view": "menu", "title": "MENUE", "hero": "> Station aus",
"details": ["Anzeige auffrischen", "Herunterfahren"], "active": True}
def test_the_ui_file_lives_outside_the_stations_runtime_directory():
"""Nicht in /run/ninlight-station: systemd raeumt dieses RuntimeDirectory beim
regulaeren Stop der Hauptunit auf - also ausgerechnet im Zustand "Station aus", in dem
das Menue "Rettung starten"/"Migration starten" anbietet."""
assert str(display.UI_FILE) == "/run/ninlight-button/ui.json"
assert not str(display.UI_FILE).startswith("/run/ninlight-station/")
def test_a_running_operation_replaces_the_status_screen():
lines = render.lines({"mode": "recovery", "ssid": "ninlight-recovery", "served": 7,
"recent": [], "ui": _MENU_UI})
assert lines == ["MENUE", "> Station aus", "Anzeige auffrischen", "Herunterfahren"]
assert not any("ninlight-recovery" in l for l in lines)
def test_the_menu_uses_the_same_roles_as_the_status_screen():
"""Kopfbalken, grosse Zeile, Beiwerk - damit Schriftgroessen, Breitenkuerzung und
unterer Rand dieselben bleiben wie im Statusbild."""
roles = [role for _text, role in render.blocks({"mode": "recovery", "ui": _MENU_UI})]
assert roles[:2] == [render.ROLE_HEADER, render.ROLE_HERO]
assert set(roles[2:]) <= {render.ROLE_DETAIL}
def test_the_menu_is_actually_drawn_on_the_panel():
image = render.compose({"mode": "recovery", "ssid": "ninlight-recovery",
"ui": _MENU_UI})
reference = render.compose({"mode": "recovery", "ssid": "ninlight-recovery"})
assert list(image.getdata()) != list(reference.getdata())
assert _ink_box(image) is not None
def test_a_broken_ui_file_does_not_break_the_display():
"""Die Datei kommt aus einem fremden Prozess, der abgestuerzt, aelter oder neuer sein
kann - genau wie status.json darf sie die Anzeige nie zum Absturz bringen."""
lines = render.lines({"mode": "recovery", "ui": {"view": "menu", "title": 17,
"hero": None, "details": "nope"}})
assert lines == ["Bedienung"]
render.compose({"mode": "recovery", "ui": {"view": "menu"}})
def test_read_ui_ignores_a_missing_broken_or_idle_file(tmp_path):
path = tmp_path / "ui.json"
assert display.read_ui(path) == {}
path.write_text("{kein json")
assert display.read_ui(path) == {}
path.write_text(json.dumps({"view": "idle", "active": False}))
assert display.read_ui(path) == {}
# Auch ein Menue, das sich selbst nicht mehr als laufend meldet, zaehlt nicht - so
# kann ein stehengebliebener Bedienzustand das Statusbild nicht dauerhaft verdecken.
path.write_text(json.dumps({"view": "menu", "active": False}))
assert display.read_ui(path) == {}
path.write_text(json.dumps(_MENU_UI))
assert display.read_ui(path)["hero"] == "> Station aus"
def test_the_daemon_shows_an_operation_within_half_a_second():
"""Solange eine Bedienung laeuft, muss die Anzeige schneller nachsehen als in ihrem
ueblichen 5-Sekunden-Takt - sonst ist das Menue unbenutzbar."""
panel = _drive_daemon(lambda t: {"mode": "recovery", "served": 0, "recent": []},
duration_s=20.0,
ui_at=lambda t: _MENU_UI if t >= 3.0 else {})
drawn = [t for t, name in panel.calls if name in ("partial", "full", "wake-full")
and t >= 3.0]
assert drawn, "das Menue wurde ueberhaupt nicht gezeichnet"
assert drawn[0] <= 3.5
def test_a_keypress_costs_the_panel_no_full_refresh():
"""Alterungsschutz: Vollaufbauten sind teuer, und das Panel soll nicht bei jedem
Tastendruck einen bekommen. Der Modus bleibt ueber eine Bedienung hinweg derselbe,
also kommt jeder Bedienschritt als Teilaufbau."""
views = [dict(_MENU_UI, hero="> Eintrag {}".format(i)) for i in range(4)]
def ui_at(t):
if t < 3.0:
return {}
return views[min(int((t - 3.0) // 2), len(views) - 1)]
panel = _drive_daemon(lambda t: {"mode": "recovery", "served": 0, "recent": []},
duration_s=12.0, ui_at=ui_at)
assert len(_times(panel, "partial")) >= 4
# Genau einer: der allererste Bildaufbau nach dem Start.
assert _times(panel, "full") == [0.0]
def test_the_expensive_station_state_is_still_only_read_every_five_seconds():
"""Der schnelle Takt gilt NUR fuer die kleine Bedienzustandsdatei. build_state()
forkt fuer "systemctl is-active" - das zehnmal so oft zu tun waere auf einem
ARMv6-Kern spuerbar und voellig unnoetig."""
calls = []
def state_at(t):
calls.append(t)
return {"mode": "recovery", "served": 0, "recent": []}
_drive_daemon(state_at, duration_s=10.0)
assert calls == [0.0, 5.0, 10.0]