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
+68 -16
View File
@@ -51,8 +51,27 @@ log = logging.getLogger("ninlight-display")
RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station"))
CONF_PATH = Path(os.environ.get("NINLIGHT_STATION_CONF", "/etc/ninlight-station.conf"))
SERVICE_NAME = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station")
# Bedienzustand des Tasterdienstes (tools/station/button/button.py). NUR GELESEN: der
# Tasterdienst fasst den SPI-Bus nicht an und dieser Dienst kein GPIO23 - zwei Prozesse
# auf denselben Leitungen enden in "lgpio.error: 'GPIO busy'". Die Datei liegt im
# RuntimeDirectory DES TASTERDIENSTES (nicht in RUN_DIR): RUN_DIR ist das
# RuntimeDirectory der Hauptunit und verschwindet beim regulaeren "off" - also
# ausgerechnet dann, wenn das Menue "Rettung starten"/"Migration starten" anbietet.
UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json"))
POLL_INTERVAL_S = 5
# Solange eine Bedienung laeuft, waere ein 5-Sekunden-Takt unbenutzbar (ein Tastendruck
# bis zu 5 s ohne Reaktion). Die Schleife tickt deshalb in diesem Takt und sieht bei
# JEDEM Tick in der Bedienzustandsdatei nach - auch im Ruhezustand, sonst waere
# ausgerechnet der erste Druck (der das Menue oeffnet) der langsamste. Das kostet eine
# kleine Dateilesung je halber Sekunde; der teure Teil des Zustands (build_state() mit
# seinem "systemctl is-active"-Fork) bleibt beim 5-Sekunden-Takt.
#
# Am Panel gemessen kommt zu dieser halben Sekunde noch der Teilaufbau (rund 0,3 s) -
# also grob 0,5-0,8 s je Druck. Das ist spuerbar, aber bedienbar: die Geste ist
# "druecken und hinsehen", nicht "tippen wie auf einer Tastatur", und die Haltegesten
# (1,5/3/10 s) liegen alle weit ueber dieser Verzoegerung.
UI_POLL_INTERVAL_S = 0.5
# E-Paper altert mit jedem Bildaufbau - ein Vollaufbau nur bei Moduswechsel und
# mindestens alle 30 Minuten, sonst Teilaktualisierung (siehe Modul-Docstring der
# Panel-Treiber: haeufige Teilaktualisierungen ohne gelegentlichen Vollaufbau lassen
@@ -220,6 +239,21 @@ def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
return state
def read_ui(ui_path: Path = UI_FILE) -> dict:
"""Der Bedienzustand des Tasterdienstes, oder {} wenn gerade keine Bedienung laeuft
(Datei fehlt, ist unlesbar, halb geschrieben, aus einer aelteren Fassung oder meldet
ausdruecklich "idle").
Jedes Feld wird defensiv gelesen: die Datei kommt aus einem FREMDEN Prozess, der
abgestuerzt, aelter oder neuer sein kann - ein Fehler hier darf die Anzeige nicht
aufhalten und schon gar nicht eine Migration stoeren."""
data = _read_json(ui_path)
view = data.get("view")
if not data.get("active") or not isinstance(view, str) or view == "idle":
return {}
return data
def _load_epd_module(revision: str):
"""Laedt den passenden Panel-Treiber. Nicht ueber ein Dict aus Modulnamen, damit ein
statischer Import-Checker (und ein Mensch, der `grep -n epd2in13` tippt) beide
@@ -485,30 +519,48 @@ def run_clear(revision: str) -> None:
def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
read_state=build_state) -> None:
"""Endlosschleife: alle POLL_INTERVAL_S Sekunden den Zustand einlesen und die von
RefreshPlanner entschiedene Aktion ausfuehren. Hier steht bewusst KEINE Zeitlogik mehr
(Fixrunde 4) - die steckt vollstaendig in RefreshPlanner und ist dort ohne Schleife,
ohne time.sleep() und ohne Hardware pruefbar.
read_state=build_state, read_ui=read_ui) -> None:
"""Endlosschleife: alle UI_POLL_INTERVAL_S Sekunden den Bedienzustand und alle
POLL_INTERVAL_S Sekunden den Stationszustand einlesen und die von RefreshPlanner
entschiedene Aktion ausfuehren. Hier steht bewusst KEINE Zeitlogik zur Panelalterung
mehr (Fixrunde 4) - die steckt vollstaendig in RefreshPlanner und ist dort ohne
Schleife, ohne time.sleep() und ohne Hardware pruefbar.
`clock` (braucht monotonic() und sleep()), `panel` und `read_state` sind einschleusbar,
damit die VERDRAHTUNG hier - welche Aktion welche Panel-Methode ruft, dass die
Selbst-Aufwertung von draw_partial() zurueckgemeldet wird, dass das Einschlafen
zurueckgemeldet wird, und die Reihenfolge decide -> ausfuehren -> zurueckmelden -
selbst pruefbar ist und nicht nur eine Kopie davon (Fixrunde 5, N2: alle drei
Mutationen an genau diesen Zeilen blieben vorher gruen, weil der Test eine nachgebaute
Schleife statt dieser hier trieb). Im Betrieb bleiben die Vorgaben: echte Uhr, echtes
Panel, echtes build_state()."""
Die zwei Takte: der teure Teil (build_state() forkt fuer "systemctl is-active")
bleibt beim 5-Sekunden-Takt, der billige (eine kleine Datei lesen) laeuft in
UI_POLL_INTERVAL_S, damit eine Bedienung am Taster ueberhaupt benutzbar ist. Ein
laufender Bedienzustand ERSETZT das Statusbild (render.blocks() zweigt dafuer ganz
vorne ab); der Modus bleibt derselbe, sodass das Bild ueber Teilaufbauten kommt und
ein Tastendruck dem Panel keinen Vollaufbau kostet.
`clock` (braucht monotonic() und sleep()), `panel`, `read_state` und `read_ui` sind
einschleusbar, damit die VERDRAHTUNG hier - welche Aktion welche Panel-Methode ruft,
dass die Selbst-Aufwertung von draw_partial() zurueckgemeldet wird, dass das
Einschlafen zurueckgemeldet wird, und die Reihenfolge decide -> ausfuehren ->
zurueckmelden - selbst pruefbar ist und nicht nur eine Kopie davon (Fixrunde 5, N2:
alle drei Mutationen an genau diesen Zeilen blieben vorher gruen, weil der Test eine
nachgebaute Schleife statt dieser hier trieb). Im Betrieb bleiben die Vorgaben: echte
Uhr, echtes Panel, echtes build_state()/read_ui()."""
if panel is None:
panel = Panel(revision)
panel.clear()
planner = RefreshPlanner(clock.monotonic())
base_state = read_state()
last_state_poll = clock.monotonic()
while True:
state = read_state()
now = clock.monotonic()
if now - last_state_poll >= POLL_INTERVAL_S:
base_state = read_state()
last_state_poll = now
ui = read_ui()
if ui:
state = dict(base_state)
state["ui"] = ui
else:
state = base_state
current_lines = render.lines(state)
mode = state.get("mode")
now = clock.monotonic()
action = planner.decide(now, current_lines, mode)
if action == "full":
@@ -523,7 +575,7 @@ def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
panel.sleep()
planner.record_sleep()
clock.sleep(POLL_INTERVAL_S)
clock.sleep(UI_POLL_INTERVAL_S)
def main() -> None: