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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user