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