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.
505 lines
24 KiB
Python
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()
|