#!/usr/bin/env python3 """Tasterdienst der ninlight-Station: EIN Taster auf GPIO23 (active low, interner Pull-up), Rueckmeldung ueber das E-Paper. Wozu das gut ist: "ninlight-station on"/"migrate on" macht wlan0 zum Zugangspunkt und reisst damit JEDE SSH-Sitzung ueber WLAN ab. Genau in diesem Zustand hatte der Bediener am Geraet bisher keinerlei Eingriff - es blieb warten oder Stecker ziehen, und Letzteres hat an diesem Pi schon eine SD-Karte gekostet. Der Taster ist der Ausweg: Abbruch (Station aus) und, ueber eine sichtbare Zwischenstufe, ein sauberes Herunterfahren. Bedienung (ein Taster, zwei Zusammenhaenge - die Bedeutung haengt daran, ob das Menue offen ist): kurz (< 1 s) gehalten Ruhe Menue oeffnen 3 s -> Abbruch, weiter 10 s -> Herunterfahren Menue offen naechster Eintrag 1,5 s -> Eintrag auswaehlen Rueckfrage abbrechen 1,5 s -> bestaetigen Der Abbruch feuert BEI 3 s, nicht erst beim Loslassen: der Bediener soll die Wirkung am Panel sehen. Haelt er weiter, laeuft dort ein Countdown bis 10 s (Herunterfahren); Loslassen bricht diese zweite Stufe jederzeit ab. Das Herunterfahren ist damit nur ueber eine sichtbare Zustandsaenderung hinweg erreichbar - nicht aus Versehen. DER TASTERDIENST FASST DEN SPI-BUS NICHT AN. Zwei Prozesse auf denselben GPIO-/SPI- Leitungen enden in "lgpio.error: 'GPIO busy'" (am Geraet passiert, als "ninlight-station display test" neben dem laufenden Anzeigedienst lief). Deshalb schreibt dieser Dienst nur seinen Bedienzustand nach UI_FILE, und der Anzeigedienst liest ihn und zeichnet (siehe display.read_ui()/render.blocks()). Alle veraendernden Aktionen laufen ueber das vorhandene CLI (/usr/local/bin/ ninlight-station), nie ueber nachgebaute Logik: nur das CLI kennt die Profilumschaltung, die Nachpruefungen ("es darf nie mehr als ein Stationsprofil aktiv sein") und den Zeitschalter. SICHERHEITSREGEL: "Migration starten" startet ueber das Menue IMMER in der Horchphase ("migrate on --listen"), nie automatisch. Der Migrations-Zugangspunkt hat ein oeffentlich bekanntes Passwort (es steht in der alten Klyqa-Firmware); ein Tastendruck, der ihn im Automatikmodus hochfaehrt, wuerde jede alte Klyqa-Lampe in Funkreichweite ungefragt migrieren, auch fremde. Die bequeme Geste muss die sichere sein - wer den Automatikmodus will, nimmt die Kommandozeile, dort ist die Absicht eindeutig. Der Taster ist NIRGENDS Voraussetzung: fehlt gpiozero, fehlt der Taster oder ist GPIO23 belegt, schreibt main() GENAU EINE Protokollzeile und beendet sich mit sys.exit(0) - dieselbe Zusage (und derselbe Grund: Restart=on-failure darf daraus keine Neustartschleife machen) wie bei der Anzeige, siehe display.py's Modul-Docstring. Und ein Fehler am Taster darf niemals eine laufende Migration stoeren oder das Ein-/Ausschalten aufhalten - deshalb faengt jeder Schreib- und Ausfuehrungspfad hier seine Fehler selbst ab. """ from __future__ import annotations import json import logging import math import os import subprocess import sys import time from pathlib import Path log = logging.getLogger("ninlight-button") # --- Zeiten und Pfade --------------------------------------------------------------- BUTTON_PIN = int(os.environ.get("NINLIGHT_BUTTON_PIN", "23")) # 50 ms statt der naheliegenden 20: ein sehr kurzer Tipper kam am Geraet durch eine # 20-ms-Entprellung noch durch und oeffnete ungewollt das Menue. Die Entprellung macht # gpiozero selbst (bounce_time), nicht diese Schleife - siehe open_button(). DEBOUNCE_S = 0.05 # Abtastrate der Hauptschleife. Deutlich feiner als die Entprellung, damit eine Flanke # nicht erst eine halbe Entprellzeit spaeter gesehen wird; ein Durchlauf ohne Aenderung # kostet nur einen gpiozero-Registerzugriff und einen Woerterbuchvergleich (UiWriter # schreibt nur bei Aenderung). TICK_S = 0.02 SHORT_PRESS_MAX_S = 1.0 SELECT_HOLD_S = 1.5 # Menue: Eintrag waehlen / Rueckfrage: bestaetigen CANCEL_HOLD_S = 3.0 # Ruhe: Abbruch (Station aus) SHUTDOWN_HOLD_S = 10.0 # Ruhe: Herunterfahren (nur ueber die Abbruchstufe hinweg) MENU_TIMEOUT_S = 20.0 # ohne Eingabe zurueck aufs Statusbild CONFIRM_TIMEOUT_S = 10.0 # Rueckfrage verfaellt als Abbruch RESULT_S = 4.0 # wie lange die Rueckmeldung einer Aktion stehen bleibt # Dieselben Umgebungsvariablen wie ninlight-station/display.py, damit Tests und # Handbetrieb ohne echtes /run bzw. /etc auskommen. RUN_DIR = Path(os.environ.get("NINLIGHT_RUN_DIR", "/run/ninlight-station")) # BEWUSST NICHT /run/ninlight-station/ui.json (das war der Vorschlag): dieses Verzeichnis # ist das RuntimeDirectory der Hauptunit, und systemd raeumt es beim regulaeren Stop von # ninlight-station auf - also ausgerechnet im Zustand "Station aus", in dem das Menue am # dringendsten gebraucht wird (dort liegen "Rettung starten"/"Migration starten"). Die # Datei liegt deshalb im eigenen RuntimeDirectory dieses Dienstes; sie verschwindet damit # genau dann, wenn dieser Dienst endet - und ein stehengebliebenes Menue kann den # Anzeigedienst nicht ueberdauern. UI_FILE = Path(os.environ.get("NINLIGHT_UI_FILE", "/run/ninlight-button/ui.json")) STATION_CLI = os.environ.get("NINLIGHT_STATION_BIN", "/usr/local/bin/ninlight-station") STATION_SERVICE = os.environ.get("NINLIGHT_STATION_SERVICE", "ninlight-station") # Das Herunterfahren geht NICHT ueber das CLI (das kennt es nicht) - es ist die einzige # Aktion dieses Dienstes, die an systemd selbst geht. SHUTDOWN_ARGV = ("systemctl", "poweroff") # Obergrenze fuer einen CLI-Aufruf. "recovery on"/"migrate on" schalten NetworkManager- # Profile um und pruefen danach nach; das dauert Sekunden, nicht Minuten. Ohne Deckel # wuerde ein haengender NetworkManager die Bedienung dauerhaft einfrieren - mit ihm # bleibt der Taster spaetestens danach wieder bedienbar. ACTION_TIMEOUT_S = 120 class ButtonUnavailable(Exception): """Kein Taster da (keine Bibliothek, kein Pin, Pin belegt) - main() macht daraus genau eine Protokollzeile und sys.exit(0).""" # --- Aktionen ----------------------------------------------------------------------- class Action: """Was ein Menueeintrag bzw. die Haltegeste ausloest. `confirm` = braucht eine Rueckfrage. Zusage: JEDE veraendernde Auswahl bekommt eine ("Anzeige auffrischen" veraendert nichts und braucht keine).""" def __init__(self, label: str, argv=(), kind: str = "cli", confirm: bool = True, done_text: str | None = None) -> None: self.label = label self.argv = tuple(argv) self.kind = kind self.confirm = confirm self.done_text = done_text or label def __repr__(self) -> str: # nur fuer Testausgaben return "Action({!r}, {!r}, kind={!r})".format(self.label, self.argv, self.kind) def action_shutdown() -> Action: return Action("Herunterfahren", kind="shutdown", confirm=True, done_text="Faehrt herunter") def action_cancel() -> Action: """Die Haltegeste in Ruhe: Station aus. Bewusst ohne Rueckfrage - sie IST die Rueckfrage (3 s halten), und sie ist der Notausgang, der ohne Umweg wirken muss.""" return Action("Station aus", ("off",), confirm=False, done_text="Station aus") def menu_actions(state: dict) -> list[Action]: """Die Menueeintraege fuer den gegebenen Stationszustand - nur das, was gerade sinnvoll ist. Reine Funktion ueber dem von station_state() gelesenen Zustand.""" running = bool(state.get("running")) mode = state.get("mode") target = state.get("target") if not running: return [ Action("Rettung starten", ("recovery", "on"), done_text="Rettung laeuft"), # IMMER --listen (siehe Modul-Docstring, Sicherheitsregel). Wer den # Automatikmodus will, nimmt die Kommandozeile. Action("Migration starten", ("migrate", "on", "--listen"), done_text="Migration: horcht"), action_shutdown(), ] items: list[Action] = [] if mode == "migration" and target: # Nur wenn last_seen wirklich ein Ziel kennt - ohne erkannte Lampe gibt es # nichts freizugeben, und "arm" ohne Ziel wuerde das CLI ohnehin abweisen. items.append(Action("Freigabe: {}".format(target), ("arm", str(target)), done_text="Freigegeben: {}".format(target))) items.append(Action("Station aus", ("off",), done_text="Station aus")) # Veraendert nichts am Betrieb - einzige Auswahl ohne Rueckfrage. items.append(Action("Anzeige auffrischen", ("display", "test"), confirm=False, done_text="Anzeige aufgefrischt")) items.append(action_shutdown()) return items class CliActions: """Fuehrt eine Action aus - ueber das CLI bzw. systemctl, nie durch Nachbauen deren Logik. Gibt zurueck, ob es geklappt hat; wirft nie: ein Fehler hier darf weder den Tasterdienst beenden noch eine laufende Migration stoeren.""" def __init__(self, cli: str = STATION_CLI, shutdown_argv=SHUTDOWN_ARGV, runner=subprocess.run) -> None: self.cli = cli self.shutdown_argv = tuple(shutdown_argv) self.runner = runner def argv_for(self, action: Action) -> list[str]: if action.kind == "shutdown": return list(self.shutdown_argv) return [self.cli] + list(action.argv) def run(self, action: Action) -> bool: argv = self.argv_for(action) log.info("Taster fuehrt aus: %s", " ".join(argv)) try: result = self.runner(argv, check=False, timeout=ACTION_TIMEOUT_S) except (OSError, subprocess.SubprocessError) as e: log.warning("Aktion '%s' fehlgeschlagen: %s", action.label, e) return False rc = getattr(result, "returncode", 0) if rc != 0: log.warning("Aktion '%s' endete mit Rueckgabewert %s", action.label, rc) return rc == 0 # --- Stationszustand (nur lesen) ---------------------------------------------------- def _read_json(path: Path) -> dict: try: data = json.loads(path.read_text()) except (OSError, ValueError): return {} return data if isinstance(data, dict) else {} def _service_active(service_name: str = STATION_SERVICE) -> bool: try: result = subprocess.run(["systemctl", "is-active", "--quiet", service_name], timeout=5, check=False) except (OSError, subprocess.SubprocessError): # Ohne systemd (Handlauf) lieber "laeuft" annehmen als das Menue mit # "Rettung starten" anzubieten, waehrend die Station in Wahrheit laeuft. return True return result.returncode == 0 def station_state(run_dir: Path = RUN_DIR, service_name: str = STATION_SERVICE) -> dict: """Der Zustand, von dem die Menueeintraege abhaengen. Liest dieselben Quellen wie display.build_state() - RUN_DIR/mode (nicht status.json's "mode"-Feld, siehe dort) und status.json fuer das zuletzt erkannte Ziel - dupliziert aber keine Logik des CLI: geschaltet wird ausschliesslich ueber das CLI.""" running = _service_active(service_name) try: mode = (run_dir / "mode").read_text().strip() except OSError: mode = "recovery" if mode not in ("recovery", "migration"): mode = "recovery" status = _read_json(run_dir / "status.json") last_seen = status.get("last_seen") target = last_seen.get("target") if isinstance(last_seen, dict) else None return {"running": running, "mode": mode, "target": target if isinstance(target, str) and target else None} # --- Bedienzustand in eine Datei schreiben ------------------------------------------ class UiWriter: """Schreibt den Bedienzustand atomar mit 0644 nach UI_FILE - dasselbe Muster und dieselben Rechte wie status.json/control.json (Temp-Datei im selben Verzeichnis, Rechte setzen, dann umbenennen). Beides sind Muster, deren Verletzung in diesem Projekt schon mehrfach Fehler verursacht hat: ohne 0644 kann der Anzeigedienst die Datei nicht lesen, ohne das Umbenennen sieht er sie halb geschrieben. Schreibt nur bei Aenderung (die Schleife laeuft alle TICK_S) und wirft nie.""" def __init__(self, path: Path = UI_FILE) -> None: self.path = Path(path) self._last: dict | None = None self._failed_logged = False def write(self, view: dict) -> bool: """True, wenn tatsaechlich geschrieben wurde.""" if view == self._last: return False payload = json.dumps(view, ensure_ascii=False, sort_keys=True) tmp = self.path.with_name("{}.tmp-{}".format(self.path.name, os.getpid())) try: self.path.parent.mkdir(parents=True, exist_ok=True) tmp.write_text(payload) os.chmod(tmp, 0o644) os.replace(tmp, self.path) except OSError as e: # Nur einmal protokollieren: die Schleife kaeme sonst alle TICK_S wieder # hierher und wuerde das Journal fluten. Der Taster bleibt trotzdem # bedienbar, nur die Rueckmeldung am Panel fehlt. if not self._failed_logged: log.warning("Bedienzustand konnte nicht geschrieben werden (%s): %s", self.path, e) self._failed_logged = True try: tmp.unlink() except OSError: pass return False self._last = dict(view) return True # --- Die Zustandsfuehrung ----------------------------------------------------------- def _countdown(total_s: float, held_s: float) -> int: """Ganze Sekunden bis `total_s`, aufgerundet und nie negativ - "noch 6" solange auch nur eine Zehntelsekunde davon uebrig ist.""" return max(0, int(math.ceil(total_s - held_s))) class ButtonUI: """Die gesamte Bedienlogik - welcher Druck was bedeutet, welche Zeitschwelle was ausloest, was auf dem Panel steht - als reine Zustandsmaschine ohne Schleife, ohne time.sleep() und ohne GPIO. Dasselbe Muster wie display.RefreshPlanner, aus demselben Grund: so laesst sich ein kompletter Bedienverlauf in Millisekunden durchspielen. Benutzung pro Runde: update(now, pressed) -> Action oder None; eine zurueckgegebene Action fuehrt der Aufrufer aus und meldet das Ergebnis per done() zurueck. view(now) liefert jederzeit den Bedienzustand fuer die Anzeige. `pressed` ist der ENTPRELLTE Pegel (gpiozero, bounce_time), nicht eine Flanke - alle Schwellen rechnen deshalb gegen den Zeitpunkt der letzten steigenden Flanke. Das ist auch der Grund, warum ein blockierender CLI-Aufruf mitten in einer Haltegeste nichts kaputtmacht: nach der Rueckkehr wird derselbe Pegel wieder gegen dieselbe Uhr gemessen.""" def __init__(self, now: float = 0.0, read_state=station_state) -> None: self._read_state = read_state self._pressed = False self._press_t = now # Diese Haltegeste hat schon etwas ausgeloest - ein spaeteres Loslassen darf # dann nicht zusaetzlich als "kurzer Druck" gelten. self._consumed = False # 0 = nichts, 1 = Abbruch gefeuert, zweite Stufe (Herunterfahren) laeuft. self._hold_stage = 0 # In welchem Zusammenhang diese Haltegeste BEGONNEN hat ("idle", "menu", # "confirm"). Die Haltegeste wird danach beurteilt, NICHT nach dem inzwischen # erreichten Bildschirm: waehlt man im Menue einen Eintrag (1,5 s), steht danach # "Bitte warten"/die Rueckfrage - haelt der Bediener einfach weiter, duerfte das # nicht plotzlich als die Ruhe-Haltegeste gelten und bei 3 s die Station # abschalten. Genau das tat eine fruehere Fassung. self._press_screen = "idle" self.screen = "idle" self.items: list[Action] = [] self.index = 0 self.pending: Action | None = None self.deadline: float | None = None self.busy: Action | None = None self.result: tuple[str, bool] | None = None self.result_until: float | None = None # -- Eingabe --------------------------------------------------------------------- def update(self, now: float, pressed: bool) -> Action | None: action = None if pressed and not self._pressed: self._pressed = True self._press_t = now self._consumed = False self._press_screen = self.screen if self.screen in ("menu", "confirm") else "idle" elif pressed and self._pressed: action = self._held(now) elif self._pressed and not pressed: self._pressed = False held = now - self._press_t if not self._consumed and held < SHORT_PRESS_MAX_S: action = self._short(now) # Loslassen bricht die zweite Stufe (Herunterfahren) jederzeit ab. self._hold_stage = 0 self._expire(now) else: self._expire(now) if action is not None: self.busy = action self.screen = "busy" self.pending = None self.deadline = None return action def _short(self, now: float) -> Action | None: self.result = None self.result_until = None if self.screen == "menu": if self.items: self.index = (self.index + 1) % len(self.items) self.deadline = now + MENU_TIMEOUT_S return None if self.screen == "confirm": # Abbrechen - zurueck ins Menue, nicht aufs Statusbild: der Bediener ist # noch am Bedienen und will meist einen anderen Eintrag. self.pending = None self.screen = "menu" self.deadline = now + MENU_TIMEOUT_S return None # Ruhe (auch waehrend eine Rueckmeldung steht): Menue oeffnen. self._open_menu(now) return None def _open_menu(self, now: float) -> None: try: state = self._read_state() except Exception as e: # noqa: BLE001 - siehe Modul-Docstring: nie stoeren log.warning("Stationszustand nicht lesbar, Menue bleibt zu: %s", e) return self.items = menu_actions(state) if not self.items: return self.index = 0 self.screen = "menu" self.deadline = now + MENU_TIMEOUT_S def _held(self, now: float) -> Action | None: held = now - self._press_t # Nach dem Zusammenhang BEIM DRUCK beurteilt, nicht nach dem aktuellen # Bildschirm - siehe _press_screen. if self._press_screen == "menu": if held >= SELECT_HOLD_S and not self._consumed and self.items: self._consumed = True return self._select(now, self.items[self.index]) return None if self._press_screen == "confirm": if held >= SELECT_HOLD_S and not self._consumed: self._consumed = True return self.pending return None # Ruhe (oder eine stehende Rueckmeldung/ein gerade gelaufener Abbruch): # zweistufige Haltegeste. if self._hold_stage == 1 and held >= SHUTDOWN_HOLD_S and not self._consumed: self._consumed = True return action_shutdown() if self._hold_stage == 0 and held >= CANCEL_HOLD_S: # Feuert BEI 3 s, nicht erst beim Loslassen - und setzt _consumed NICHT: # die zweite Stufe muss aus derselben Geste noch erreichbar bleiben. self._hold_stage = 1 self.result = None self.result_until = None return action_cancel() return None def _select(self, now: float, action: Action) -> Action | None: if not action.confirm: return action self.pending = action self.screen = "confirm" self.deadline = now + CONFIRM_TIMEOUT_S return None def _expire(self, now: float) -> None: if self.result_until is not None and now >= self.result_until: self.result = None self.result_until = None if self.deadline is None or now < self.deadline: return if self.screen == "menu": # Ein Geraet, das im Menue stehenbleibt, soll nicht aussehen, als sei es # beschaeftigt - zurueck aufs Statusbild. self.screen = "idle" self.items = [] self.deadline = None elif self.screen == "confirm": # Verfaellt als ABBRUCH - eine Rueckfrage, die von selbst zustimmt, waere # das Gegenteil einer Rueckfrage. self.pending = None self.screen = "idle" self.items = [] self.deadline = None def done(self, now: float, action: Action, ok: bool) -> None: """Ergebnis einer ausgefuehrten Aktion - setzt die Rueckmeldung fuers Panel.""" self.busy = None self.screen = "idle" self.items = [] self.pending = None self.deadline = None self.result = (action.done_text if ok else "{}: Fehler".format(action.label), ok) self.result_until = now + RESULT_S # -- Ausgabe --------------------------------------------------------------------- def view(self, now: float) -> dict: """Der Bedienzustand, wie ihn der Anzeigedienst zeichnet. "active" sagt ihm, dass gerade eine Bedienung laeuft (er sieht dann schneller nach).""" if (self._pressed and self._press_screen == "idle" and self.screen in ("idle", "busy")): held = now - self._press_t if self._hold_stage == 1: return _view("hold", "STATION AUS", "Herunterfahren in {} s".format( _countdown(SHUTDOWN_HOLD_S, held)), ["Weiter halten - Loslassen bricht ab"]) if held >= SHORT_PRESS_MAX_S: return _view("hold", "HALTEN", "Abbruch in {} s".format(_countdown(CANCEL_HOLD_S, held)), ["Weiter halten - Loslassen bricht ab"]) if self.screen == "menu" and self.items: selected = self.items[self.index] rest = self.items[self.index + 1:] + self.items[:self.index] return _view("menu", "MENUE", "> " + selected.label, [a.label for a in rest] + ["kurz: weiter, halten: waehlen"]) if self.screen == "confirm" and self.pending is not None: return _view("confirm", "RUECKFRAGE", self.pending.label, ["halten (1,5 s): ja", "kurz: nein"]) if self.screen == "busy" and self.busy is not None: return _view("busy", "BITTE WARTEN", self.busy.label, []) if self.result is not None: text, ok = self.result return _view("result", "FERTIG" if ok else "FEHLER", text, []) return {"view": "idle", "active": False} def _view(view: str, title: str, hero: str, details: list[str]) -> dict: return {"view": view, "title": title, "hero": hero, "details": list(details), "active": True} # --- Hardware und Hauptschleife ----------------------------------------------------- def open_button(pin: int = BUTTON_PIN): """Der einzige Hardwarezugriff dieses Dienstes: ein Eingangspin. KEIN SPI, kein Zeichenpin - siehe Modul-Docstring ("GPIO busy"). Entprellt gpiozero selbst (bounce_time): ein Tipper, der kuerzer ist als DEBOUNCE_S, kommt gar nicht erst als Druck an.""" try: import gpiozero except ImportError as e: raise ButtonUnavailable("gpiozero fehlt ({})".format(e)) try: return gpiozero.Button(pin, pull_up=True, bounce_time=DEBOUNCE_S) except Exception as e: # noqa: BLE001 # Bewusst breit: ein belegter Pin kommt je nach Pin-Factory als # gpiozero.GPIOPinInUse, als roher lgpio.error ('GPIO busy') oder als OSError # zurueck - keine gemeinsame Oberklasse, die hier greifen wuerde. Alles davon # heisst dasselbe: kein Taster, also lautlos ohne Fehler beenden, statt eine # Neustartschleife auszuloesen. raise ButtonUnavailable("GPIO{} nicht verfuegbar ({})".format(pin, e)) def run_daemon(button=None, ui: "ButtonUI | None" = None, writer: "UiWriter | None" = None, actions: "CliActions | None" = None, clock=time, read_state=station_state) -> None: """Endlosschleife: Pegel lesen, ButtonUI fragen, eine zurueckgegebene Aktion ausfuehren, Ergebnis zurueckmelden, Bedienzustand schreiben. `button`, `ui`, `writer`, `actions` und `clock` sind einschleusbar, damit die VERDRAHTUNG hier selbst pruefbar ist und nicht nur eine Kopie davon (display.py hatte genau diesen Fehler: die Tests trieben eine nachgebaute Schleife, und Mutationen an den echten Zeilen blieben gruen). Im Betrieb bleiben die Vorgaben. Die Reihenfolge ist wesentlich: der Bedienzustand wird VOR dem (sekundenlangen) CLI-Aufruf geschrieben, sonst steht auf dem Panel bis zu dessen Ende noch das Menue und der Bediener haelt das Geraet fuer eingefroren.""" if button is None: button = open_button() if writer is None: writer = UiWriter() if actions is None: actions = CliActions() if ui is None: ui = ButtonUI(clock.monotonic(), read_state=read_state) log.info("Taster bereit (GPIO%s), Bedienzustand in %s", BUTTON_PIN, writer.path) writer.write(ui.view(clock.monotonic())) while True: now = clock.monotonic() action = ui.update(now, bool(button.is_pressed)) if action is not None: writer.write(ui.view(now)) ok = actions.run(action) ui.done(clock.monotonic(), action, ok) writer.write(ui.view(clock.monotonic())) clock.sleep(TICK_S) def main() -> None: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") try: run_daemon() except ButtonUnavailable as e: # GENAU EINE Zeile, dann sys.exit(0) - siehe Modul-Docstring. Kein Fehler, damit # Restart=on-failure daraus keine Neustartschleife macht und das Journal flutet. log.info("Taster nicht verfuegbar, beende ohne Fehler: %s", e) sys.exit(0) except KeyboardInterrupt: sys.exit(0) if __name__ == "__main__": main()