Files
ninlight-w600/tools/station/display/display.py
T
Michael Sauer b8c7febb8f fix(station): Ghosting-Ausgleich nach Buendel, Zeitsteuerung testbar
Der Reset von partials_since_full beim Einschlafen liess den faelligen
Ausgleichs-Vollaufbau dauerhaft ausfallen: nach einem Buendel Teilaufbauten
schlief das Panel mit dem Geisterbild ein und zeigte es stundenlang weiter,
weil stale nie mehr feuern konnte. Reset ersatzlos gestrichen - der Ausgleich
bleibt ausstehend und wird zum 30-Minuten-Termin nachgeholt; der in Fixrunde 3
beseitigte Bias-Zyklus kommt dadurch nicht zurueck.

Die gesamte Zeitsteuerung steckt jetzt in RefreshPlanner statt in run_daemon()s
Endlosschleife und ist ohne Schleife, ohne time.sleep() und ohne Hardware
pruefbar. Neue Tests spielen sechs Stunden Betrieb durch (statischer Inhalt,
Aenderung alle 3/9/11 Minuten, Buendel-dann-Ruhe) und schliessen die drei vom
Reviewer per Mutation belegten Luecken aus Fixrunde 3.
2026-09-13 22:04:39 +02:00

505 lines
24 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 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")
POLL_INTERVAL_S = 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_mode_file(run_dir: Path) -> str:
"""Modus, in dem der Dienst laut RUN_DIR/mode tatsaechlich gestartet wurde. Fehlt die
Datei oder ist ihr Inhalt kein bekannter Modus, gilt "recovery" - derselbe Vorgabewert
wie in server.py:resolve_mode()."""
try:
value = (run_dir / "mode").read_text().strip()
except OSError:
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 build_state(run_dir: Path = RUN_DIR, conf_path: Path = CONF_PATH,
service_name: str = SERVICE_NAME) -> dict:
"""Baut den zusammengefuehrten Zustand, den render.lines()/compose() erwarten.
Modus kommt vorrangig aus RUN_DIR/mode (Vorgabe "recovery", wie
server.py:resolve_mode()) - das ist das, was "ninlight-station on"/"migrate on"
zuletzt geschrieben haben 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."""
if not _service_active(service_name):
return {"mode": "off"}
mode = _read_mode_file(run_dir)
status = _read_json(run_dir / "status.json")
conf = _read_conf(conf_path)
state: dict = {"mode": mode, "served": status.get("served"), "recent": status.get("recent")}
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")
return state
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 = FULL_REFRESH_INTERVAL_S,
sleep_after_idle_s: float = SLEEP_AFTER_IDLE_S) -> None:
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
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, now: float) -> None:
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_clear(revision: str) -> None:
panel = Panel(revision)
panel.clear()
panel.sleep()
log.info("Panel geleert (EPD_REVISION=%s)", revision)
def run_daemon(revision: str) -> 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."""
panel = Panel(revision)
panel.clear()
planner = RefreshPlanner(time.monotonic())
while True:
state = build_state()
current_lines = render.lines(state)
mode = state.get("mode")
now = time.monotonic()
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(now)
time.sleep(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")
args = parser.parse_args()
revision = os.environ.get("EPD_REVISION", "V3")
try:
if args.test:
run_test(revision)
elif args.clear:
run_clear(revision)
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()