Fixrunde 1, Befund 3: RUN_DIR/mode ueberlebt nur einen "systemctl restart" (RuntimeDirectoryPreserve=restart) - "ninlight-station off" fuehrt aber einen echten "systemctl stop" aus, der das RuntimeDirectory leert. build_state() fiel danach unbemerkt auf "recovery" zurueck, auch wenn tatsaechlich eine Migration anstand - die Fusszeile haette dann "in Ordnung" gezeigt, obwohl der Migrationssatz leer ist. Ein Docstring behauptete ausdruecklich das Gegenteil. _read_mode_file() liest jetzt eine neue, dauerhafte LAST_MODE_FILE (unter /var/lib/ninlight-station, wie der Bildsatz-Umschalter selbst) statt RUN_DIR/mode; write_mode() im CLI schreibt beide. Ausserdem: _read_imageset() faengt jetzt auch UnicodeDecodeError ab (Fixrunde 1, Kleinigkeit). Ein neuer Test bildet den echten Ablauf nach (Modus schreiben, RUN_DIR wie bei einem echten Stop leeren, pruefen dass der Modus trotzdem stimmt) - der alte Test fuer den Rettungs-Bildsatz liess die Modusdatei ganz weg und pruefte damit nur den zufaellig gleichen Rueckfallwert, nicht die Wirklichkeit.
852 lines
44 KiB
Python
852 lines
44 KiB
Python
#!/usr/bin/env python3
|
|
"""Daemon fuer die E-Paper-Anzeige der ninlight-Station: alle 5 s status.json,
|
|
control.json und die Konfigurationsdatei lesen, daraus einen Text zeichnen und - nur bei
|
|
Aenderung - aufs Panel bringen.
|
|
|
|
Panel: Waveshare 2,13" (250x122 px), SPI. Ob das verbaute Panel Revision V2 oder V3 ist,
|
|
laesst sich am Geraet selbst nicht auslesen - die Revision kommt deshalb aus der
|
|
Umgebungsvariable EPD_REVISION (Vorgabe "V3", von setup_pi.sh in
|
|
/etc/ninlight-station.conf geschrieben und von der systemd-Unit als Environment=
|
|
weitergereicht).
|
|
|
|
So bestimmst du die richtige Revision empirisch: erst
|
|
EPD_REVISION=V3 python3 display.py --test
|
|
laufen lassen und das Panel ansehen. Zeigt es ein sauberes, seitenrichtiges Testbild ohne
|
|
Geisterbilder/Streifen, ist V3 richtig. Zeigt es nichts, ein invertiertes/verschobenes Bild
|
|
oder haengt beim SWRESET (ReadBusy() kehrt nie zurueck), mit
|
|
EPD_REVISION=V2 python3 display.py --test
|
|
wiederholen. Zwischen den beiden Versuchen kurz die Stromversorgung des Panels trennen
|
|
(--clear reicht nicht immer, falsche Revision kann das Panel in einem Zwischenzustand
|
|
lassen).
|
|
|
|
Die Anzeige ist nirgends Voraussetzung fuer die Migration: fehlt das Panel, SPI oder eine
|
|
Bibliothek (gpiozero/spidev/Pillow), faengt main() das als DisplayUnavailable bzw.
|
|
ImportError ab, protokolliert GENAU EINE Zeile und beendet sich mit sys.exit(0) - nicht mit
|
|
einem Fehler, sonst wuerde systemd (Restart=on-failure) den Dienst endlos neu starten und
|
|
das Journal fluten. Ein Display-Fehler darf niemals eine Migration verhindern.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
# Flacher Import wie bei server.py/products.py: dieses Verzeichnis ist kein Package,
|
|
# display.py kann aber von ueberall aus gestartet werden (die systemd-Unit ruft es mit
|
|
# vollem Pfad auf).
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
import render # noqa: E402
|
|
from epdconfig import DisplayUnavailable # noqa: E402
|
|
|
|
log = logging.getLogger("ninlight-display")
|
|
|
|
# Dieselben Umgebungsvariablen wie tools/station/ninlight-station verwendet, damit Tests
|
|
# und Handbetrieb ohne echtes /run bzw. /etc auskommen.
|
|
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"))
|
|
# Der Umschalter "Migrationsimages mit oder ohne WLAN-Zugangsdaten" (siehe
|
|
# ninlight-station, bildsatz_cmd, und server.py:read_image_set). NUR GELESEN, und
|
|
# absichtlich aus derselben Datei wie der Server - nicht aus status.json: der Zustand
|
|
# muss auch dann auf dem Panel stehen, wenn die Station AUS ist. Genau davor soll er
|
|
# schuetzen ("man soll vor jedem Einschalten sehen, was gilt"), und ausgeschaltet
|
|
# schreibt server.py nichts mehr.
|
|
IMAGESET_FILE = Path(os.environ.get("NINLIGHT_IMAGESET_FILE",
|
|
"/var/lib/ninlight-station/imageset"))
|
|
# Der Modus, den "recovery on"/"migrate on" zuletzt angefordert haben - fuer die
|
|
# Fusszeile GENAUSO dauerhaft gebraucht wie der Bildsatz-Umschalter oben, aus demselben
|
|
# Grund und nach demselben Muster (eigene Datei unter /var/lib, NICHT unter RUN_DIR).
|
|
#
|
|
# Fixrunde 1, Befund 3: RUN_DIR/mode taugt dafuer NICHT. Die Unit setzt
|
|
# RuntimeDirectoryPreserve=restart - das erhaelt /run/ninlight-station nur ueber einen
|
|
# "systemctl restart" hinweg (das, was "recovery on"/"migrate on" fuer einen bereits
|
|
# laufenden Dienst ausloesen). "ninlight-station off" aber STOPPT den Dienst
|
|
# (systemctl stop), und GENAU DANN raeumt systemd das RuntimeDirectory leer, "restart"
|
|
# deckt einen echten Stop nicht ab. Ein frueherer Docstring hier behauptete das
|
|
# Gegenteil ("die Datei ueberlebt das Abschalten") - das war schlicht falsch, und der
|
|
# Fehler war unauffaellig, weil eine fehlende Datei auf "recovery" faellt: genau der
|
|
# Modus, den viele Tests ohnehin erwarteten, ohne dass die Faelligkeit der Datei
|
|
# je geprueft wurde. Die Folge am Geraet waere die falsche Beruhigung, gegen die die
|
|
# Fusszeile ueberhaupt gebaut ist - "in Ordnung" vor einer geplanten Migration, obwohl
|
|
# der Migrationssatz leer ist, weil das Panel mangels Datei den Rettungssatz geprueft
|
|
# haette.
|
|
#
|
|
# write_mode() im CLI schreibt deshalb NEBEN der ephemeren RUN_DIR/mode (die
|
|
# ausschliesslich server.py's naechsten Start betrifft) auch hierher - dieselbe
|
|
# Zeichenkette, zwei Ablagen fuer zwei verschiedene Leser mit verschiedenen
|
|
# Haltbarkeitsanforderungen.
|
|
LAST_MODE_FILE = Path(os.environ.get("NINLIGHT_LAST_MODE_FILE",
|
|
"/var/lib/ninlight-station/mode"))
|
|
# Dieselben Namen und dieselbe Vorgabe wie in server.py und im CLI - im Zweifel der
|
|
# Satz OHNE Zugangsdaten (dort steht die Begruendung).
|
|
IMAGESET_DIRS = {"ohne-wlan": "migrate-nocreds", "mit-wlan": "migrate-wlan"}
|
|
IMAGESET_DEFAULT = "ohne-wlan"
|
|
# Derselbe Umschalter gilt jetzt auch fuer die RETTUNG (server.py: rescue_dir()) - mit
|
|
# eigenen Verzeichnissen, denn Rettungs- und Migrationsabzuege sind verschieden hergestellt
|
|
# (siehe server.py fuer die Begruendung). Welches Paar am Panel zu pruefen ist, haengt vom
|
|
# Modus ab: _read_imageset() bekommt ihn deshalb als Parameter statt fest verdrahtet.
|
|
RESCUE_IMAGESET_DIRS = {"ohne-wlan": "rescue-nocreds", "mit-wlan": "rescue-wlan"}
|
|
|
|
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
|
|
# Geisterbilder stehen).
|
|
FULL_REFRESH_INTERVAL_S = 30 * 60
|
|
|
|
# Panel erst schlafen legen, wenn seit dem letzten Bildaufbau schon eine Weile nichts
|
|
# mehr passiert ist - NICHT nach jedem einzelnen Bild (Fixrunde 2, N1/N2/N3: das liess
|
|
# V3 beim naechsten Teilaufbau abstuerzen und machte V2s Deep-Sleep-Aufwachsequenz
|
|
# unbrauchbar, siehe Panel-Klasse). Migrationsereignisse kommen oft in kurzen Buendeln
|
|
# (mehrere Lampen kurz nacheinander) - das Panel dafuer jedesmal schlafen zu legen und
|
|
# wieder aufzuwecken waere nur zusaetzlicher Verschleiss ohne Nutzen fuer die
|
|
# Bias-Alterung, die I4 eigentlich vermeiden soll. 10 Minuten ohne Aenderung sind lang
|
|
# genug, dass ein Buendel zusammenhaengender Ereignisse das Panel nicht staendig
|
|
# schlafen und wieder aufwachen laesst, aber kurz genug, dass es zwischen zwei
|
|
# tatsaechlich seltenen Ereignissen (z.B. stundenlanger Rettungsmodus ohne Anfrage)
|
|
# nicht dauerhaft unter Bias steht.
|
|
SLEEP_AFTER_IDLE_S = 10 * 60
|
|
|
|
|
|
def _read_json(path: Path) -> dict:
|
|
"""Geparster Inhalt von `path`, oder {} wenn er fehlt/unlesbar/kaputt/kein Objekt
|
|
ist - status.json und control.json koennen jederzeit in genau diesem Zustand sein
|
|
(frisch aufgesetzter Pi, Schreibfehler mitten im Schreiben)."""
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def _read_conf(path: Path) -> dict[str, str]:
|
|
"""Minimaler Parser fuer /etc/ninlight-station.conf: KEY='VALUE'-Zeilen, wie
|
|
setup_pi.sh sie schreibt, optional mit einem "export "-Praefix (setup_pi.sh prueft
|
|
beim Nachtragen fehlender Schluessel ausdruecklich auf "^(export[[:space:]]+)?KEY=" -
|
|
ein bereits vorhandenes "export SSID=..." muss also erkannt werden, nicht nur die
|
|
unpraefigierte Form). Kein echtes Shell-Sourcing (das CLI braucht das fuer $- und
|
|
`-Escaping in Passwoertern, siehe dessen Kommentar - display.py liest hier nur
|
|
SSID/MIGRATE_SSID, reine Bezeichner ohne Sonderzeichen)."""
|
|
values: dict[str, str] = {}
|
|
try:
|
|
text = path.read_text()
|
|
except OSError:
|
|
return values
|
|
for raw_line in text.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
if line.startswith("export ") or line.startswith("export\t"):
|
|
line = line[len("export"):].lstrip()
|
|
key, _, value = line.partition("=")
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
value = value[1:-1]
|
|
if key:
|
|
values[key] = value
|
|
return values
|
|
|
|
|
|
def _read_autooff_minutes(run_dir: Path, now: float | None = None):
|
|
"""Restzeit des optionalen Zeitschalters in ganzen Minuten (aufgerundet), oder None,
|
|
wenn gerade keiner laeuft.
|
|
|
|
Quelle ist RUN_DIR/autooff.json, die "ninlight-station --for/renew" schreibt und
|
|
"hold"/"off" wieder loescht (siehe dort). Ein Zeitschalter, den man am Panel nicht
|
|
sieht, waere schlimmer als keiner: man glaubt, die Station laeuft, und sie ist
|
|
laengst aus.
|
|
|
|
"deadline" ist eine Unix-Zeit (der Zeitschalter selbst haengt an systemds monotoner
|
|
Uhr, die Anzeige rechnet in Wanduhrzeit) - eine Zeitverstellung verschiebt also die
|
|
angezeigte Restzeit, nicht das tatsaechliche Abschalten. Rundet auf: solange noch
|
|
eine Sekunde uebrig ist, steht dort "1 min" und nicht "0 min"."""
|
|
data = _read_json(run_dir / "autooff.json")
|
|
deadline = data.get("deadline")
|
|
if isinstance(deadline, bool) or not isinstance(deadline, (int, float)):
|
|
return None
|
|
remaining_s = deadline - (time.time() if now is None else now)
|
|
if remaining_s <= 0:
|
|
# Der Termin ist da (oder der Lauf verschiebt gerade) - "0 min" ist hier die
|
|
# ehrliche Auskunft, nicht das Verschwinden der Zeile.
|
|
return 0
|
|
whole, rest = divmod(remaining_s, 60)
|
|
return int(whole) + (1 if rest else 0)
|
|
|
|
|
|
# Lebenszeichen: ein kleiner Punkt, der erscheint, sobald irgendeine Anfrage hereinkommt
|
|
# (auch eine erfolglose - "notfound"/"listen"/"unknown"/"noimageset" zaehlen genauso wie
|
|
# eine erfolgreiche Auslieferung), und nach ACTIVITY_WINDOW_S Sekunden von selbst wieder
|
|
# verschwindet. Der Wunsch dahinter (Rueckmeldung eines Bedieners, 2026-09-23): bisher war
|
|
# nur am Servierzaehler ("Served: N") zu sehen, ob es geklappt HAT - bis dahin war offen,
|
|
# ob ueberhaupt jemand anklopft. server.py schreibt den Zeitpunkt der letzten Anfrage
|
|
# jeder Art schon nach status.json (Station.last_activity); hier wird daraus nur noch ein
|
|
# Ja/Nein fuer "gerade eben".
|
|
#
|
|
# Bewusst KEIN eigener Zeittakt/Timer: das Panel pollt status.json ohnehin schon alle
|
|
# POLL_INTERVAL_S Sekunden, und render.blocks() haengt den Punkt an eine bestehende
|
|
# Textzeile an (siehe dort) - er faehrt also ueber genau denselben Textvergleich
|
|
# (RefreshPlanner.decide()) mit, der auch jede andere Aenderung erkennt. Ein Erscheinen
|
|
# und ein Verschwinden kosten damit je einen gewoehnlichen Teilaufbau, keinen eigenen
|
|
# Mechanismus und kein zusaetzliches Wachhalten des E-Papers - fuegt sich also in die
|
|
# vorhandene, fuer E-Paper sparsame Taktung ein, statt eine eigene, potenziell haeufigere
|
|
# aufzumachen.
|
|
ACTIVITY_WINDOW_S = 15
|
|
|
|
|
|
def _recent_request(status: dict, now: float | None = None) -> bool:
|
|
"""War die letzte Anfrage (jeder Art) hoechstens ACTIVITY_WINDOW_S Sekunden her?
|
|
|
|
"last_request_ts" fehlt bei einer aelteren server.py-Fassung oder wenn noch nie eine
|
|
Anfrage einging - dann gilt False, kein Punkt erfunden. Ein Zeitstempel aus der
|
|
Zukunft (Uhr verstellt, oder Server- und Anzeigerechner-Uhr laufen leicht auseinander)
|
|
zaehlt ebenfalls als "nicht mehr aktuell" statt als potenziell dauerhaft aktiv."""
|
|
ts = status.get("last_request_ts")
|
|
if not isinstance(ts, str):
|
|
return False
|
|
try:
|
|
parsed = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
except ValueError:
|
|
return False
|
|
now_s = time.time() if now is None else now
|
|
age_s = now_s - parsed.timestamp()
|
|
return 0 <= age_s <= ACTIVITY_WINDOW_S
|
|
|
|
|
|
def _read_mode_file(path: Path) -> str:
|
|
"""Zuletzt angeforderter Modus laut `path`. Fehlt die Datei, ist sie unlesbar
|
|
(inklusive ungueltigem UTF-8 - eine halb geschriebene/beschaedigte Datei ist nicht
|
|
nur per fehlendem Inhalt kaputt), oder ist ihr Inhalt kein bekannter Modus, gilt
|
|
"recovery" - derselbe Vorgabewert wie in server.py:resolve_mode(). Wirft nie: eine
|
|
kaputte Datei darf weder das Panel noch "off" aufhalten.
|
|
|
|
`path` ist bewusst eine Datei, kein Verzeichnis (anders als frueher, wo hier
|
|
`run_dir / "mode"` gelesen wurde) - siehe LAST_MODE_FILE oben, warum die Fusszeile
|
|
NICHT mehr aus RUN_DIR liest."""
|
|
try:
|
|
value = path.read_text().strip()
|
|
except (OSError, UnicodeDecodeError):
|
|
return "recovery"
|
|
return value if value in ("recovery", "migration") else "recovery"
|
|
|
|
|
|
# Nur beim ersten Mal protokollieren, dass systemctl selbst nicht erreichbar war (siehe
|
|
# _service_active) - das passiert bei jedem der Aufrufe alle 5 s erneut, und eine Zeile
|
|
# alle 5 s wuerde das Journal fluten, obwohl es sich nie aendert (Fixrunde 1, Kleinigkeit).
|
|
_service_active_fallback_logged = False
|
|
|
|
|
|
def _service_active(service_name: str) -> bool:
|
|
"""Laeuft der Hauptdienst (server.py) gerade? "ninlight-station off" stoppt ihn und
|
|
faehrt den Zugangspunkt herunter, laesst status.json dabei aber unangetastet liegen -
|
|
ein reines Lesen von status.json wuerde nach "off" also faelschlich weiter den letzten
|
|
Modus vor dem Abschalten zeigen. Deshalb ist die Dienstaktivitaet selbst (nicht das
|
|
Vorhandensein von status.json) die Quelle fuer den Anzeigemodus "off"."""
|
|
global _service_active_fallback_logged
|
|
try:
|
|
result = subprocess.run(
|
|
["systemctl", "is-active", "--quiet", service_name],
|
|
timeout=5, check=False)
|
|
return result.returncode == 0
|
|
except (OSError, subprocess.SubprocessError):
|
|
# systemctl selbst nicht erreichbar (z.B. Handlauf ohne systemd) - dann anhand
|
|
# der Statusdatei entscheiden statt "off" zu behaupten, ohne es zu wissen.
|
|
if not _service_active_fallback_logged:
|
|
log.warning("systemctl nicht erreichbar - Anzeige raet den Modus anhand von "
|
|
"RUN_DIR/mode statt der echten Dienstaktivitaet")
|
|
_service_active_fallback_logged = True
|
|
return True
|
|
|
|
|
|
def _read_imageset(imageset_path: Path, conf: dict[str, str], mode: str) -> tuple[str, bool]:
|
|
"""(Name des eingestellten Bildsatzes, liegt der fuer `mode` PASSENDE Satz ueberhaupt
|
|
auf dem Pi?).
|
|
|
|
Fehlt die Datei, ist sie unlesbar oder steht etwas Unbekanntes darin, gilt
|
|
IMAGESET_DEFAULT - dieselbe Vorgabe wie in server.py und im CLI, sonst wuerde das
|
|
Panel etwas anderes behaupten als der Server tut.
|
|
|
|
`ok` ist False, wenn der GEWAEHLTE Satz fuer den GERADE GELTENDEN Modus nicht auf dem
|
|
Pi liegt - nach derselben Regel wie server.py:migrate_set_present()/
|
|
rescue_set_present(): Verzeichnis vorhanden UND mindestens ein Image darin. Welches
|
|
Verzeichnispaar das ist (migrate-*/rescue-*), entscheidet `mode` - Rettung und
|
|
Migration liegen seit der Erweiterung des Umschalters auf die Rettung in getrennten
|
|
Verzeichnissen (server.py begruendet das). Eine falsche Zuordnung hier wuerde das
|
|
Panel in GENAU DEM Modus luegen lassen, fuer den es gerade steht: "ok", obwohl die
|
|
Rettung nichts ausliefern kann, oder "FEHLT", obwohl sie es koennte.
|
|
|
|
Ohne bekanntes IMAGE_DIR (Konfigurationsdatei nicht lesbar) wird nichts behauptet:
|
|
dann gilt "ok", denn eine unbelegte Warnung ist schlechter als keine."""
|
|
try:
|
|
value = imageset_path.read_text().strip()
|
|
except (OSError, UnicodeDecodeError):
|
|
value = ""
|
|
name = value if value in IMAGESET_DIRS else IMAGESET_DEFAULT
|
|
image_dir = conf.get("IMAGE_DIR")
|
|
if not image_dir:
|
|
return name, True
|
|
dirs = RESCUE_IMAGESET_DIRS if mode == "recovery" else IMAGESET_DIRS
|
|
set_dir = Path(image_dir) / dirs[name]
|
|
try:
|
|
return name, any(p.name.endswith(".img") for p in set_dir.iterdir())
|
|
except OSError:
|
|
return name, False
|
|
|
|
|
|
def local_ip() -> str:
|
|
"""Die Adresse, unter der der Pi im Heimnetz erreichbar ist, oder "" wenn keine da ist.
|
|
|
|
Ueber einen UDP-Socket, der nur verbunden und nie benutzt wird: das erzeugt keinen
|
|
Datenverkehr und fragt kein DNS, liefert aber genau die Adresse, die der Kernel fuer
|
|
die Standardroute waehlen wuerde - also die, ueber die der Bediener tatsaechlich per
|
|
SSH hereinkommt. Ein Aufruf von "hostname -I" waere die naheliegende Alternative,
|
|
listet aber ALLE Adressen (inklusive der des Zugangspunkts und IPv6) und braucht einen
|
|
Unterprozess in einer Schleife, die alle 5 s laeuft.
|
|
|
|
Wirft nie: ohne Netz gibt es keine Route, dann liefert der Aufruf "" und die Anzeige
|
|
sagt das - eine Ausnahme aus der Adressermittlung darf das Panel nicht aufhalten."""
|
|
import socket
|
|
|
|
sock = None
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
# Die Adresse wird nie angesprochen; sie waehlt nur die Route aus. Bewusst eine
|
|
# dokumentierte Testadresse (RFC 5737) statt eines echten Ziels wie 8.8.8.8 -
|
|
# es soll auch dann nichts Fremdes anklingen, wenn jemand mitliest.
|
|
sock.connect(("192.0.2.1", 9))
|
|
return str(sock.getsockname()[0])
|
|
except OSError:
|
|
return ""
|
|
finally:
|
|
if sock is not None:
|
|
sock.close()
|
|
|
|
|
|
def build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
|
|
service_name: str = SERVICE_NAME,
|
|
imageset_path: Path = IMAGESET_FILE,
|
|
last_mode_path: Path = LAST_MODE_FILE) -> dict:
|
|
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
|
|
|
|
Modus kommt vorrangig aus `last_mode_path` (Vorgabe "recovery", wie
|
|
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
|
|
zuletzt geschrieben haben, dauerhaft (siehe LAST_MODE_FILE oben - NICHT die ephemere
|
|
RUN_DIR/mode, die server.py fuer den naechsten Start liest) und unabhaengig davon
|
|
verfuegbar, ob/wann der Serverprozess seit dem letzten Start schon einmal
|
|
status.json geschrieben hat. status.json selbst liefert nur served/recent/
|
|
last_seen; sein "mode"-Feld wird bewusst NICHT als Modusquelle verwendet
|
|
(Fixrunde 1: die urspruengliche Fassung hatte den Vorrang umgekehrt).
|
|
|
|
Phase/armed_target kommen aus control.json, nur im Migrationsmodus massgeblich - der
|
|
Rettungsmodus ignoriert control.json ebenso wie server.py das tut. "off" ueberschreibt
|
|
alles, wenn der Hauptdienst gar nicht laeuft.
|
|
|
|
Der Bildsatz ("imageset"/"imageset_ok") kommt in JEDEN Zustand, auch in "off" und in
|
|
den Rettungsmodus: er ist ein dauerhafter Umschalter, und man soll ihn VOR dem
|
|
Einschalten sehen - eine Einstellung von vorletzter Woche, die erst beim Blaettern im
|
|
Menue auffaellt, hat genau den Zweck verfehlt.
|
|
|
|
Ob "imageset_ok" den Rettungs- oder den Migrations-Bildsatz prueft, richtet sich nach
|
|
dem MODUS, in dem die Station beim naechsten Einschalten anlaufen wuerde - also
|
|
`last_mode_path`, nicht danach, ob sie gerade laeuft. Deshalb wird der Modus HIER,
|
|
vor der "ist der Dienst aktiv"-Abfrage, gelesen: er ist auch im Zustand "off"
|
|
verfuegbar (die Datei ist dauerhaft, siehe LAST_MODE_FILE) und genau dort soll die
|
|
Vorschau ja gelten - "off" zeigt ja noch keinen Modus im engeren Sinn, aber der
|
|
naechste Start faehrt in genau diesen Modus, und die Fusszeile soll VORHER zeigen,
|
|
ob dessen Bildsatz bereitliegt.
|
|
|
|
"activity" (nur in Recovery/Migration, siehe _recent_request()) sagt, ob die letzte
|
|
Anfrage jeder Art hoechstens ACTIVITY_WINDOW_S Sekunden her ist - das Lebenszeichen,
|
|
das render.blocks() an die Servierzeile anhaengt."""
|
|
conf = _read_conf(conf_path)
|
|
mode = _read_mode_file(last_mode_path)
|
|
imageset, imageset_ok = _read_imageset(imageset_path, conf, mode)
|
|
|
|
if not _service_active(service_name):
|
|
# Die IP-Zeile gibt es NUR hier: ist die Station aus, ist der Pi ein gewoehnlicher
|
|
# Rechner im Heimnetz, und die einzige Auskunft, die man dann am Panel braucht,
|
|
# ist die Adresse, unter der man per SSH drankommt. Im Betrieb waere sie sinnlos
|
|
# (wlan0 traegt dann 192.168.8.1, das steht ohnehin auf jedem Statusbild) und
|
|
# wuerde den knappen Platz fuer Betriebsauskuenfte wegnehmen.
|
|
return {"mode": "off", "imageset": imageset, "imageset_ok": imageset_ok,
|
|
"ip": local_ip()}
|
|
|
|
status = _read_json(run_dir / "status.json")
|
|
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent"),
|
|
"imageset": imageset, "imageset_ok": imageset_ok,
|
|
"activity": _recent_request(status)}
|
|
|
|
if mode == "recovery":
|
|
state["ssid"] = conf.get("SSID")
|
|
else: # migration
|
|
state["ssid"] = conf.get("MIGRATE_SSID")
|
|
state["last_seen"] = status.get("last_seen")
|
|
control = _read_json(run_dir / "control.json")
|
|
phase = control.get("phase")
|
|
state["phase"] = phase if phase in ("auto", "listen", "armed") else "auto"
|
|
state["armed_target"] = control.get("armed_target")
|
|
|
|
# Der Zeitschalter ist optional und gilt in beiden Modi gleichermassen - ohne ihn
|
|
# bleibt das Feld weg und render.blocks() zeichnet keine Zeile dafuer.
|
|
autooff_minutes = _read_autooff_minutes(run_dir)
|
|
if autooff_minutes is not None:
|
|
state["autooff_minutes"] = autooff_minutes
|
|
|
|
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
|
|
moeglichen Importe findet."""
|
|
if revision == "V2":
|
|
import epd2in13_V2 as epd_module
|
|
elif revision == "V3":
|
|
import epd2in13_V3 as epd_module
|
|
else:
|
|
raise DisplayUnavailable(
|
|
f"unbekannte EPD_REVISION '{revision}' - erwartet 'V2' oder 'V3'")
|
|
return epd_module
|
|
|
|
|
|
class Panel:
|
|
"""Kapselt die V2/V3-Unterschiede der Waveshare-Treiber hinter einer einheitlichen
|
|
Schnittstelle (Fixrunde 1, C1): epd2in13_V2.init() braucht ein Pflichtargument
|
|
(FULL_UPDATE/PART_UPDATE), epd2in13_V3.init() braucht keins.
|
|
|
|
Verfolgt zusaetzlich selbst, ob das Panel gerade "wach" ist (Register initialisiert,
|
|
Referenzpuffer fuer Teilaufbauten bekannt) oder zuletzt sleep() gerufen wurde
|
|
(Fixrunde 2, N1/N3): epd.sleep() schickt das Panel in einen Zustand, aus dem heraus
|
|
KEINE Revision direkt einen Teilaufbau vertraegt - V2s sleep() ist Deep Sleep Mode 2
|
|
(RAM/Register verworfen; ein nachfolgendes init(PART_UPDATE) setzt nur einen Teil der
|
|
Register, die ein Teilaufbau braucht - 0x01/0x11/0x44/0x45/0x4E/0x4F setzt
|
|
ausschliesslich der FULL_UPDATE-Zweig), V3s sleep() ruft epdconfig.module_exit() und
|
|
schliesst damit die GPIO-Pins, die epd2in13_V3.displayPartial() als Erstes anfasst
|
|
(roher AttributeError auf ein geschlossenes Pin-Objekt). Ein Teilaufbau nach dem
|
|
Schlafen wird deshalb hier, nicht beim Aufrufer, transparent zu einem Vollaufbau
|
|
aufgewertet - unabhaengig davon, in welcher Reihenfolge draw_full()/draw_partial()/
|
|
sleep() aufgerufen werden.
|
|
|
|
draw_full() (und clear(), Fixrunde 3, F2) verwendet fuer BEIDE Revisionen
|
|
displayPartBaseImage() statt display()/Clear() (Fixrunde 2, N2): das schreibt beide
|
|
RAM-Puffer des Controllers (0x24 UND 0x26) und legt damit erst die Referenz an, gegen
|
|
die ein nachfolgender Teilaufbau diffen kann - display() allein haette nur 0x24
|
|
beschrieben (und Clear() ebenso - bei V2 ist das 0x26-Schreiben in Clear() sogar
|
|
auskommentiert) und den ersten Teilaufbau danach gegen einen veralteten/undefinierten
|
|
Puffer laufen lassen. self._awake wird deshalb nur dort auf True gesetzt, wo
|
|
tatsaechlich ein gueltiger Referenzpuffer entstanden ist - vorher (Fixrunde 2) setzte
|
|
clear() es, obwohl es nur epd.Clear() (kein 0x26) rief, und die im Klassen-Docstring
|
|
behauptete Invariante stimmte nach clear() nicht."""
|
|
|
|
def __init__(self, revision: str) -> None:
|
|
self.revision = revision
|
|
self._epd_module = _load_epd_module(revision)
|
|
self.epd = self._epd_module.EPD()
|
|
# Vor dem ersten clear()/draw_full() ist nichts initialisiert - siehe Klassen-
|
|
# Docstring, warum draw_partial() das genauso wie ein sleep() davor behandeln
|
|
# muss (beides: kein gueltiger Referenzpuffer, keine vollstaendige Registerlage).
|
|
self._awake = False
|
|
|
|
def _full_init(self) -> None:
|
|
if self.revision == "V2":
|
|
self.epd.init(self.epd.FULL_UPDATE)
|
|
else:
|
|
self.epd.init()
|
|
|
|
def _blank_buffer(self) -> list[int]:
|
|
"""Ein reiner Weiss-Puffer in der Groesse, die epd.getbuffer() fuer ein
|
|
vollformatiges Bild liefern wuerde - dieselbe Formel wie in epd.Clear() selbst
|
|
(siehe epd2in13_V2.py/epd2in13_V3.py), nur ohne ueber Pillow ein echtes weisses
|
|
Bild zu erzeugen."""
|
|
width, height = self.epd.width, self.epd.height
|
|
linewidth = width // 8 if width % 8 == 0 else width // 8 + 1
|
|
return [0xFF] * (linewidth * height)
|
|
|
|
def clear(self) -> None:
|
|
self._full_init()
|
|
# displayPartBaseImage() statt epd.Clear() (Fixrunde 3, F2): dieselbe sichtbare
|
|
# Wirkung (Panel wird weiss), aber schreibt zusaetzlich den 0x26-Referenzpuffer,
|
|
# den ein nachfolgender draw_partial() braucht - epd.Clear() allein wuerde
|
|
# self._awake=True vortaeuschen, ohne die im Klassen-Docstring beschriebene
|
|
# Invariante wirklich herzustellen.
|
|
self.epd.displayPartBaseImage(self._blank_buffer())
|
|
self._awake = True
|
|
|
|
def draw_full(self, image) -> None:
|
|
buf = self.epd.getbuffer(image)
|
|
self._full_init()
|
|
self.epd.displayPartBaseImage(buf)
|
|
self._awake = True
|
|
|
|
def draw_partial(self, image) -> bool:
|
|
"""Zeichnet teilweise - oder, falls das Panel nicht wach ist, vollstaendig.
|
|
Gibt zurueck, ob intern zu einem Vollaufbau aufgewertet wurde (Fixrunde 3, F4):
|
|
der Aufrufer muss das wie einen regulaeren Vollaufbau behandeln (z.B. fuer die
|
|
30-Minuten-Uhr in run_daemon()), sonst kommt der naechste dortige Vollaufbau zu
|
|
frueh und altert das Panel zusaetzlich, ohne dass ein Teilaufbau ueberhaupt
|
|
stattgefunden haette."""
|
|
if not self._awake:
|
|
# Siehe Klassen-Docstring (Fixrunde 2, N1/N3): ein Teilaufbau direkt nach dem
|
|
# Schlafen (oder vor dem allerersten Vollaufbau) ist auf keiner Revision eine
|
|
# gueltige Sequenz - stattdessen einen vollwertigen Vollaufbau ausfuehren.
|
|
self.draw_full(image)
|
|
return True
|
|
buf = self.epd.getbuffer(image)
|
|
if self.revision == "V2":
|
|
self.epd.init(self.epd.PART_UPDATE)
|
|
self.epd.displayPartial(buf)
|
|
return False
|
|
|
|
def sleep(self) -> None:
|
|
# Idempotent: ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft,
|
|
# wuerde sonst versuchen, ueber bereits von module_exit() geschlossene GPIO-Pins
|
|
# SPI-Befehle zu schicken (derselbe AttributeError-Mechanismus wie N1, nur
|
|
# selbst herbeigefuehrt statt durch einen Teilaufbau).
|
|
if not self._awake:
|
|
return
|
|
self.epd.sleep()
|
|
self._awake = False
|
|
|
|
|
|
class RefreshPlanner:
|
|
"""Die gesamte Zeitsteuerung der Anzeige - wann voll aufgebaut, wann teilweise, wann
|
|
geschlafen wird - als reine Zustandsmaschine ohne Schleife, ohne time.sleep() und ohne
|
|
Hardware.
|
|
|
|
Vorher steckte diese Logik direkt in run_daemon()s Endlosschleife und war dadurch
|
|
ungetestet (Fixrunde 4): drei Korrekturen aus Fixrunde 3 (die stale-Bedingung, die
|
|
Rueckmeldung einer intern aufgewerteten Teilaktualisierung, die Zeilenumbruchbehandlung
|
|
in compose()) liessen sich per Mutation aendern, ohne dass ein einziger Test rot wurde.
|
|
Hier laesst sich stattdessen ein kompletter Tagesverlauf in Millisekunden durchspielen
|
|
(siehe test_render.py, _run_schedule()).
|
|
|
|
Benutzung: pro Runde einmal decide() fragen, die zurueckgegebene Aktion ausfuehren und
|
|
das Ergebnis per record_draw()/record_sleep() zurueckmelden. decide() selbst aendert
|
|
nichts - der Aufrufer meldet, was tatsaechlich passiert ist (insbesondere, ob
|
|
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat).
|
|
|
|
Aktionen:
|
|
"full" - kompletter Bildaufbau (Moduswechsel oder faelliger Ghosting-Ausgleich)
|
|
"partial" - Teilaktualisierung (Inhalt hat sich geaendert)
|
|
"sleep" - Panel schlafen legen (laenger nichts passiert)
|
|
"idle" - nichts tun
|
|
"""
|
|
|
|
_NO_MODE = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
|
|
|
def __init__(self, now: float,
|
|
full_refresh_interval_s: float | None = None,
|
|
sleep_after_idle_s: float | None = None) -> None:
|
|
# Bewusst None statt der Modulkonstanten als Vorgabewert (Fixrunde 5): ein
|
|
# Default-Argument wird zur Importzeit ausgewertet, ein spaeteres
|
|
# monkeypatch.setattr(display, "FULL_REFRESH_INTERVAL_S", ...) haette dann keine
|
|
# Wirkung mehr auf neu gebaute Planer - die alte Schleife las die Konstanten pro
|
|
# Runde. Die Properties unten lesen sie weiterhin bei jeder Entscheidung.
|
|
self._full_refresh_interval_s = full_refresh_interval_s
|
|
self._sleep_after_idle_s = sleep_after_idle_s
|
|
self.last_lines: list[str] | None = None
|
|
self.last_mode: object = self._NO_MODE
|
|
self.last_full = now
|
|
self.last_draw = now
|
|
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. full_refresh_interval_s
|
|
# existiert nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten
|
|
# auszugleichen - ohne mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt
|
|
# es nichts auszugleichen, und ein Vollaufbau des UNVERAENDERTEN Bildes waere
|
|
# reine, nutzlose Alterung (Fixrunde 3, F3): ohne diese Absicherung entstand im
|
|
# eingeschwungenen Ruhezustand ein endloser 10-Minuten-schlafen/10-Minuten-wach-
|
|
# Zyklus (33% Bias-Anteil) rein aus der 30-Minuten-Uhr, obwohl sich der Inhalt nie
|
|
# aenderte.
|
|
#
|
|
# Der Zaehler wird beim Einschlafen BEWUSST NICHT zurueckgesetzt (Fixrunde 4, N1):
|
|
# das tat die Fassung aus Fixrunde 3 und liess den faelligen Ghosting-Ausgleich
|
|
# damit dauerhaft ausfallen - nach einem Buendel Teilaufbauten (z.B. 30 Lampen in
|
|
# 2,5 Minuten) schlief das Panel nach 10 Minuten mit dem entstandenen Geisterbild
|
|
# ein und zeigte genau dieses Bild stundenlang weiter, weil stale wegen
|
|
# partials_since_full == 0 nie mehr feuern konnte. So bleibt der Ausgleich
|
|
# ausstehend und wird zum 30-Minuten-Termin nachgeholt (das weckt das Panel einmal
|
|
# kurz auf; danach ist der Zaehler 0 und der eingeschwungene Ruhezustand bleibt
|
|
# wie in F3 gewuenscht bei 3% Wachanteil). Die Alternative - vor dem Einschlafen
|
|
# einmal draw_full() ziehen - kostet denselben einen Vollaufbau, aber JEDES Mal,
|
|
# wenn ein Buendel endet, auch wenn kurz darauf ohnehin neue Inhalte kommen (die
|
|
# das Panel nach dem Schlafen sowieso ueber draw_partial()s Aufwertung voll
|
|
# aufbauen). Diese Variante altert das Panel im Betriebsfall also mehr, nicht
|
|
# weniger, und der Ausgleich hier kommt spaetestens zum ohnehin vorgesehenen
|
|
# Termin.
|
|
self.partials_since_full = 0
|
|
self.asleep = False
|
|
|
|
@property
|
|
def full_refresh_interval_s(self) -> float:
|
|
return (FULL_REFRESH_INTERVAL_S if self._full_refresh_interval_s is None
|
|
else self._full_refresh_interval_s)
|
|
|
|
@property
|
|
def sleep_after_idle_s(self) -> float:
|
|
return (SLEEP_AFTER_IDLE_S if self._sleep_after_idle_s is None
|
|
else self._sleep_after_idle_s)
|
|
|
|
def decide(self, now: float, lines: list[str], mode: object) -> str:
|
|
"""Was jetzt zu tun ist. Aendert nichts - das Ergebnis wird per record_*()
|
|
zurueckgemeldet."""
|
|
mode_changed = mode != self.last_mode
|
|
stale = (self.partials_since_full > 0
|
|
and (now - self.last_full) >= self.full_refresh_interval_s)
|
|
|
|
if lines != self.last_lines or mode_changed or stale:
|
|
return "full" if (mode_changed or stale) else "partial"
|
|
if not self.asleep and (now - self.last_draw) >= self.sleep_after_idle_s:
|
|
# Nach laengerer Ruhe schlafen legen (Fixrunde 1, I4: die Pixel bleiben -
|
|
# e-Paper ist bistabil - aber ohne dauerhafte Spannung altert das Panel
|
|
# deutlich weniger).
|
|
return "sleep"
|
|
return "idle"
|
|
|
|
def record_draw(self, now: float, lines: list[str], mode: object,
|
|
was_full: bool) -> None:
|
|
"""`was_full` ist True fuer einen regulaeren Vollaufbau UND dann, wenn
|
|
Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat (Fixrunde 3,
|
|
F4) - sonst kaeme der naechste "stale"-Vollaufbau zu frueh und altert das Panel
|
|
zusaetzlich, obwohl gerade erst vollstaendig aufgebaut wurde."""
|
|
self.last_lines = lines
|
|
self.last_mode = mode
|
|
self.last_draw = now
|
|
self.asleep = False
|
|
if was_full:
|
|
self.last_full = now
|
|
self.partials_since_full = 0
|
|
else:
|
|
self.partials_since_full += 1
|
|
|
|
def record_sleep(self) -> None:
|
|
"""Kein `now`-Parameter (Fixrunde 5): das Einschlafen verschiebt weder die
|
|
30-Minuten-Uhr noch last_draw - es merkt sich nur, dass ein weiteres
|
|
panel.sleep() nichts mehr zu tun haette."""
|
|
self.asleep = True
|
|
|
|
|
|
def _test_image(revision: str):
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
width, height = 250, 122
|
|
image = Image.new("1", (width, height), 255)
|
|
draw = ImageDraw.Draw(image)
|
|
draw.rectangle((0, 0, width - 1, height - 1), outline=0)
|
|
draw.text((4, 4), "ninlight-display Test", font=ImageFont.load_default(), fill=0)
|
|
draw.text((4, 20), f"EPD_REVISION={revision}", font=ImageFont.load_default(), fill=0)
|
|
draw.line((4, 40, width - 4, 40), fill=0)
|
|
# Vier Kaestchen, abwechselnd gefuellt/leer - hilft, Kontrast/Inversion am
|
|
# angeschlossenen Panel auf einen Blick zu beurteilen.
|
|
for i in range(4):
|
|
x0 = 4 + i * 20
|
|
draw.rectangle((x0, 50, x0 + 16, 90), outline=0, fill=0 if i % 2 == 0 else 255)
|
|
return image
|
|
|
|
|
|
def run_test(revision: str) -> None:
|
|
"""Zeichnet ein Testbild und beendet sich - siehe Modul-Docstring, wie man damit V2
|
|
von V3 unterscheidet."""
|
|
panel = Panel(revision)
|
|
panel.clear()
|
|
panel.draw_full(_test_image(revision))
|
|
panel.sleep()
|
|
log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
|
|
|
|
|
|
def run_final(revision: str, kind: str) -> None:
|
|
"""Zeichnet den Abschlussbildschirm ("poweroff"/"reboot") und legt das Panel schlafen.
|
|
|
|
Das ist das Bild, das nach dem Herunterfahren DAUERHAFT stehen bleibt - E-Paper ist
|
|
bistabil, es haelt sein Bild auch ohne Strom. Bewusst ein VOLLAUFBAU: ein Teilaufbau
|
|
liesse Reste des vorigen Bildes stehen, und dieses eine Bild ueberdauert alle anderen.
|
|
|
|
Aufgerufen wird das ausschliesslich ueber "ninlight-station display final <kind>" aus
|
|
einer systemd-Unit heraus, und zwar ERST, nachdem der Anzeigedienst gestoppt ist - er
|
|
haelt sonst die GPIO-/SPI-Leitungen ("lgpio.error: 'GPIO busy'"). Diese Uebergabe
|
|
macht das CLI (display_final()), nicht diese Funktion."""
|
|
panel = Panel(revision)
|
|
panel.clear()
|
|
panel.draw_full(render.compose({"mode": "final", "final": kind}))
|
|
panel.sleep()
|
|
log.info("Abschlussbild gezeichnet (%s, EPD_REVISION=%s)", kind, revision)
|
|
|
|
|
|
def run_clear(revision: str) -> None:
|
|
panel = Panel(revision)
|
|
panel.clear()
|
|
panel.sleep()
|
|
log.info("Panel geleert (EPD_REVISION=%s)", revision)
|
|
|
|
|
|
def _safe_read_state(read_state, fallback: dict) -> dict:
|
|
"""Ruft `read_state()` auf und faengt JEDE Ausnahme ab - nicht nur die, die die
|
|
einzelnen build_state()-Bausteine schon selbst abfangen (siehe _read_json() etc.
|
|
oben). run_daemon() laeuft als Endlosschleife, und main() fängt darum nur
|
|
(DisplayUnavailable, ImportError) ab; ein einzelner, unvorhergesehener Fehler beim
|
|
Erheben wuerde diese Schleife sonst beenden - und weil E-Paper sein zuletzt
|
|
gezeichnetes Bild dauerhaft behaelt, saehe das Panel einfach so aus, als sei nichts
|
|
passiert, nur eben fuer immer eingefroren. `fallback` (der vorherige Zustand, oder
|
|
beim allerersten Aufruf ein neutraler Platzhalter) gilt dann fuer diesen einen Takt;
|
|
beim naechsten wird es erneut versucht."""
|
|
try:
|
|
return read_state()
|
|
except Exception as e: # noqa: BLE001 - bewusst pauschal, siehe Docstring
|
|
log.warning("Zustand konnte nicht gelesen werden, vorheriger Stand bleibt: %s", e)
|
|
return fallback
|
|
|
|
|
|
def run_daemon(revision: str, clock=time, panel: "Panel | None" = None,
|
|
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.
|
|
|
|
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 = _safe_read_state(read_state, {"mode": None})
|
|
last_state_poll = clock.monotonic()
|
|
|
|
while True:
|
|
now = clock.monotonic()
|
|
if now - last_state_poll >= POLL_INTERVAL_S:
|
|
# Jeder einzelne Baustein von build_state() faengt seine eigenen I/O-Fehler
|
|
# schon ab (siehe _read_json/_read_conf/_service_active/local_ip oben) - aber
|
|
# main() faengt um run_daemon() herum nur (DisplayUnavailable, ImportError)
|
|
# ab, nicht Exception im Allgemeinen. Ein einzelner unerwarteter Fehler beim
|
|
# Erheben (z.B. ein kaputtes RUN_DIR mitten in einem Neustart) wuerde diese
|
|
# Endlosschleife sonst ganz beenden - und E-Paper behaelt sein zuletzt
|
|
# gezeichnetes Bild fuer immer (siehe Modul-Docstring): die Anzeige wuerde
|
|
# dann OHNE jede Fehlermeldung am Geraet einfach stehen bleiben, ununterscheidbar
|
|
# von einem echten, andauernden Zustand. read_state() bleibt deshalb hier
|
|
# abgesichert - ein einzelner Fehlschlag ueberspringt genau diese Erhebung
|
|
# (der vorherige Zustand bleibt stehen) statt den ganzen Dienst zu beenden;
|
|
# beim naechsten Takt in POLL_INTERVAL_S wird es erneut versucht.
|
|
base_state = _safe_read_state(read_state, base_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")
|
|
|
|
action = planner.decide(now, current_lines, mode)
|
|
if action == "full":
|
|
panel.draw_full(render.compose(state))
|
|
planner.record_draw(now, current_lines, mode, was_full=True)
|
|
elif action == "partial":
|
|
# Panel.draw_partial() wertet sich selbst zu einem Vollaufbau auf, wenn das
|
|
# Panel nicht wach ist (siehe Panel-Klasse) - das meldet es hier zurueck.
|
|
upgraded = panel.draw_partial(render.compose(state))
|
|
planner.record_draw(now, current_lines, mode, was_full=upgraded)
|
|
elif action == "sleep":
|
|
panel.sleep()
|
|
planner.record_sleep()
|
|
|
|
clock.sleep(UI_POLL_INTERVAL_S)
|
|
|
|
|
|
def main() -> None:
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
|
|
parser = argparse.ArgumentParser(description="ninlight E-Paper-Anzeige")
|
|
group = parser.add_mutually_exclusive_group()
|
|
group.add_argument("--test", action="store_true", help="Testbild zeichnen und beenden")
|
|
group.add_argument("--clear", action="store_true", help="Panel leeren und beenden")
|
|
group.add_argument("--final", choices=sorted(render.FINAL_LABELS),
|
|
help="Abschlussbild zeichnen und beenden (beim Herunterfahren "
|
|
"bzw. Neustart; die Texte unterscheiden sich)")
|
|
args = parser.parse_args()
|
|
|
|
revision = os.environ.get("EPD_REVISION", "V3")
|
|
|
|
try:
|
|
if args.test:
|
|
run_test(revision)
|
|
elif args.clear:
|
|
run_clear(revision)
|
|
elif args.final:
|
|
run_final(revision, args.final)
|
|
else:
|
|
run_daemon(revision)
|
|
except (DisplayUnavailable, ImportError) as e:
|
|
# Genau eine Zeile, dann sys.exit(0) - siehe Modul-Docstring. Das deckt sowohl
|
|
# Hardwareprobleme (DisplayUnavailable: kein Panel/SPI) als auch eine fehlende
|
|
# Bibliothek ab - allen voran Pillow, das laut Spec auf dem Ziel-Pi absichtlich
|
|
# erst per "apt install python3-pil" nachkommt (Fixrunde 1, C4): render.compose()
|
|
# importiert Pillow erst hier, ein fehlendes Paket zeigt sich also erst als
|
|
# ModuleNotFoundError (eine ImportError-Unterklasse) beim ersten Zeichnen, nicht
|
|
# schon beim Start. Restart=on-failure (statt always) in der Unit greift hier
|
|
# nicht, weil 0 kein Fehlschlag ist; ein spaeter angestecktes Panel bzw.
|
|
# nachinstalliertes Paket braucht also einen manuellen/zeitgesteuerten Neustart
|
|
# der Unit, nicht eine Neustartschleife.
|
|
log.info("E-Paper-Anzeige nicht verfuegbar, beende ohne Fehler: %s", e)
|
|
sys.exit(0)
|
|
except KeyboardInterrupt:
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|