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
+75
View File
@@ -0,0 +1,75 @@
[Unit]
Description=ninlight Taster (Bedienung der Station ueber GPIO23)
# Wie beim Anzeigedienst nur After=, NICHT Requires=/BindsTo=/Wants=: ein fehlender oder
# abgestuerzter Taster darf server.py niemals mitreissen, und server.py muss starten
# koennen, auch wenn dieser Dienst fehlt oder deaktiviert ist. KEIN
# Wants=ninlight-station.service - das wuerde die absichtlich deaktivierte Station bei
# jedem Boot mit hochziehen (Fund der Hardware-Abnahme, ausfuehrlich in
# ninlight-display.service begruendet).
After=ninlight-station.service
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort werden sie mit
# "Unknown key name" ignoriert, und die Deckelung unten greift dann nie).
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
Environment=NINLIGHT_UI_FILE=/run/ninlight-button/ui.json
# "-"-Praefix VOR dem Dateipfad (nicht vor dem Schluesselnamen - so herum ist es ein
# ungueltiger Schluessel und systemd verwirft die Zeile ganz): ohne die
# Konfigurationsdatei startet der Taster trotzdem.
EnvironmentFile=-/etc/ninlight-station.conf
# Eigenes RuntimeDirectory, aus zwei Gruenden:
#
# 1. Hier liegt ui.json. Es darf NICHT in /run/ninlight-station liegen (dem
# RuntimeDirectory der Hauptunit): systemd raeumt das beim regulaeren Stop von
# ninlight-station auf - also ausgerechnet im Zustand "Station aus", in dem das Menue
# ("Rettung starten"/"Migration starten") am dringendsten gebraucht wird. So
# verschwindet ui.json genau dann, wenn dieser Dienst endet, und ein
# stehengebliebenes Menue kann ihn nicht ueberdauern.
# 2. lgpio (gpiozero's bevorzugte Pin-Factory) legt beim Start "$HOME/.lgd-nfy0" an.
# Unter systemd ist HOME per Vorgabe LEER und das Arbeitsverzeichnis "/" - der Pfad
# wird "//.lgd-nfy0" und scheitert. Am Geraet fuer den Anzeigedienst nachgemessen,
# siehe dort; derselbe Stolperstein trifft jeden gpiozero-Dienst.
RuntimeDirectory=ninlight-button
RuntimeDirectoryMode=0755
WorkingDirectory=/run/ninlight-button
Environment=HOME=/run/ninlight-button
ExecStart=/usr/bin/python3 /opt/ninlight-station/button/button.py
# button.py faengt einen fehlenden Taster (keine Bibliothek, kein Pin, Pin belegt) als
# ButtonUnavailable ab, protokolliert eine Zeile und beendet sich mit sys.exit(0) - das
# ist nie ein "Fehlschlag" im systemd-Sinne und loest kein Restart= aus. on-failure
# (nicht always) deckelt damit genau das Richtige: echte Abstuerze werden neu versucht,
# ein dauerhaft fehlender Taster nicht.
Restart=on-failure
RestartSec=5
# BEWUSST KEIN NoNewPrivileges=/ProtectSystem=strict/ProtectHome= hier - anders als beim
# Anzeigedienst, und das ist kein Versehen:
#
# Dieser Dienst ruft /usr/local/bin/ninlight-station auf, und dessen einziger Zweck ist
# es, NetworkManager-Profile umzuschalten, Units zu starten/stoppen und nach
# /run/ninlight-station zu schreiben - alles ueber "sudo". Kindprozesse erben den
# Namensraum und das no_new_privs-Flag dieser Unit: mit ProtectSystem=strict waere
# /run/ninlight-station fuer das CLI schreibgeschuetzt (mode/control.json liessen sich
# nicht mehr schreiben), und no_new_privs laesst kein setuid-Binary mehr zu (sudo). Eine
# Absicherung, die genau die Aktionen unmoeglich macht, wegen derer es den Dienst gibt,
# ist keine. Die Angriffsflaeche ist stattdessen eng gehalten: EIN Eingangspin, kein
# Netzwerk, keine Eingabe ausser der Taste selbst, und ausgefuehrt wird ausschliesslich
# die feste Liste von CLI-Unterbefehlen aus button.py.
MemoryMax=64M
# Laeuft als root (wie Haupt- und Anzeigedienst) fuer GPIO23 und fuer das CLI - kein
# SupplementaryGroups=gpio: auf reinem Debian gibt es die Gruppe oft gar nicht, und eine
# fehlende Gruppe ist fuer systemd ein harter Startfehler (216/GROUP), ausgerechnet in
# einer Unit, die nie stoeren darf.
# KEIN Zugriff auf SPI und keine Zeichenleitung: der Tasterdienst fasst den Panel-Bus
# nicht an (zwei Prozesse darauf ergeben "lgpio.error: 'GPIO busy'"). Er schreibt seinen
# Bedienzustand nach /run/ninlight-button/ui.json, der Anzeigedienst liest ihn und
# zeichnet.
[Install]
WantedBy=multi-user.target