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
+40
View File
@@ -186,6 +186,16 @@ def blocks(state: dict) -> list[tuple[str, str]]:
Bild - die Rolle ist ein reiner Bezeichner, die zugehoerigen Schriftgroessen kennt
ausschliesslich compose()."""
state = state if isinstance(state, dict) else {}
# Laeuft gerade eine Bedienung am Taster, ERSETZT sie das Statusbild komplett - eine
# Bedienung, die neben dem Statusbild um Platz kaempft, waere auf 250x122 unlesbar,
# und in diesem Moment ist der Bedienzustand die einzige Auskunft, auf die der
# Bediener wartet. display.read_ui() liefert {} (und damit kein "ui"), sobald nichts
# laeuft - dann geht es unveraendert weiter wie zuvor.
ui = state.get("ui")
if isinstance(ui, dict) and ui.get("view") not in (None, "", "idle"):
return _ui_blocks(ui)
mode = state.get("mode")
out: list[tuple[str, str]] = []
@@ -311,6 +321,36 @@ def blocks(state: dict) -> list[tuple[str, str]]:
return out
def _ui_blocks(ui: dict) -> list[tuple[str, str]]:
"""Der Bedienzustand des Tasterdienstes in Zeilen mit Rolle. Bewusst DUMM: welcher
Text bei welcher Geste erscheint, entscheidet allein der Tasterdienst (dort ist es
ohne Hardware pruefbar) - hier wird er nur noch den vorhandenen Rollen zugeordnet,
damit Kopfbalken, Schriftgroessen, Breitenkuerzung und unterer Rand dieselben bleiben
wie im Statusbild.
Jedes Feld wird auf seinen Typ geprueft: die Datei kommt aus einem anderen Prozess
(der aelter, neuer oder halb geschrieben sein kann) - genau wie status.json darf sie
die Anzeige nie zum Absturz bringen."""
out: list[tuple[str, str]] = []
title = ui.get("title")
if isinstance(title, str) and title:
out.append((title, ROLE_HEADER))
hero = ui.get("hero")
if isinstance(hero, str) and hero:
out.append((hero, ROLE_HERO))
details = ui.get("details")
if isinstance(details, list):
for detail in details:
if isinstance(detail, str) and detail:
out.append((detail, ROLE_DETAIL))
if not out:
# Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
# ein leeres Panel zu zeigen, das nach "haengt" aussieht.
out.append(("Bedienung", ROLE_BIG))
return out
def lines(state: dict) -> list[str]:
"""Nur die Texte aus blocks(), ohne Rollen - unveraenderte Schnittstelle fuer
display.run_daemon(), das damit erkennt, ob sich der Inhalt geaendert hat (die Rolle