Fixrunde 1 zu Task 6a: epd2in13_V2.init() braucht ein Pflichtargument und dessen displayPartial() setzt selbst keine LUT (neue Panel-Klasse in display.py kapselt das statt es zu verstreuen); epdconfig.module_init() war nicht wiedereintrittsfaehig und ein zu breites except tarnte GPIOPinInUse als "Panel fehlt" (jetzt idempotent, gezielter except); ninlight-display.service hatte ReadOnlyPaths=/run/ninlight-station (verhindert den Start, wenn die Station regulaer aus ist), StartLimit* in der falschen Sektion und SupplementaryGroups=spi gpio (harter Fehler auf reinem Debian ohne diese Gruppen); ein fehlendes Pillow fuehrte zu ModuleNotFoundError statt zum vorgesehenen stillen Exit 0. Dazu: Panel schlaeft nach jedem Zeichnen (Alterung), Modus-Vorrang liegt jetzt bei RUN_DIR/mode statt bei status.json, export-Praefix in der Konfigurationsdatei wird erkannt, MAX_COLS auf das mit Pillow nachgemessene Mass korrigiert, und build_state() hat jetzt eigene Tests.
345 lines
15 KiB
Python
345 lines
15 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
|
|
|
|
|
|
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) und dessen displayPartial() setzt selbst keine LUT - vorher
|
|
muessen init(PART_UPDATE) und (fuer den allerersten Teilaufbau nach einem Vollaufbau)
|
|
displayPartBaseImage() gelaufen sein, sonst zeichnet das Panel mit der falschen
|
|
Wellenform oder gegen einen veralteten Referenzpuffer. epd2in13_V3.init() braucht
|
|
dagegen kein Argument und dessen displayPartial() setzt seine Teil-LUT selbst bei
|
|
jedem Aufruf - dort reicht ein einfaches init() vor jedem Vollaufbau."""
|
|
|
|
def __init__(self, revision: str) -> None:
|
|
self.revision = revision
|
|
self._epd_module = _load_epd_module(revision)
|
|
self.epd = self._epd_module.EPD()
|
|
|
|
def clear(self) -> None:
|
|
if self.revision == "V2":
|
|
self.epd.init(self.epd.FULL_UPDATE)
|
|
else:
|
|
self.epd.init()
|
|
self.epd.Clear(0xFF)
|
|
|
|
def draw_full(self, image) -> None:
|
|
buf = self.epd.getbuffer(image)
|
|
if self.revision == "V2":
|
|
self.epd.init(self.epd.FULL_UPDATE)
|
|
# displayPartBaseImage() zeichnet UND schreibt gleichzeitig beide RAM-Puffer
|
|
# des Controllers - genau das, was ein nachfolgender Teilaufbau als
|
|
# Referenzpuffer braucht. display() allein wuerde nur einen Puffer setzen und
|
|
# den ersten Teilaufbau danach gegen einen veralteten Referenzpuffer diffen
|
|
# lassen (Fixrunde 1, C1).
|
|
self.epd.displayPartBaseImage(buf)
|
|
else:
|
|
self.epd.init()
|
|
self.epd.display(buf)
|
|
|
|
def draw_partial(self, image) -> None:
|
|
buf = self.epd.getbuffer(image)
|
|
if self.revision == "V2":
|
|
self.epd.init(self.epd.PART_UPDATE)
|
|
self.epd.displayPartial(buf)
|
|
|
|
def sleep(self) -> None:
|
|
self.epd.sleep()
|
|
|
|
|
|
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:
|
|
panel = Panel(revision)
|
|
panel.clear()
|
|
|
|
last_lines: list[str] | None = None
|
|
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
|
|
last_full = time.monotonic()
|
|
|
|
while True:
|
|
state = build_state()
|
|
current_lines = render.lines(state)
|
|
mode = state.get("mode")
|
|
now = time.monotonic()
|
|
need_full = (mode != last_mode) or (now - last_full >= FULL_REFRESH_INTERVAL_S)
|
|
|
|
if current_lines != last_lines or need_full:
|
|
image = render.compose(state)
|
|
if need_full:
|
|
panel.draw_full(image)
|
|
last_full = now
|
|
else:
|
|
panel.draw_partial(image)
|
|
# Panel nach jedem Bildaufbau schlafen legen: die Pixel bleiben (e-Paper ist
|
|
# bistabil), aber ohne dauerhafte Spannung altert das Panel deutlich weniger -
|
|
# Waveshare warnt fuer diese Serie ausdruecklich davor, es dauerhaft unter
|
|
# Bias zu lassen (Fixrunde 1, I4). epd.sleep() gibt auch GPIO/SPI frei
|
|
# (epdconfig.module_exit()); der naechste Aufbau ruft ohnehin wieder init()
|
|
# und damit module_init() auf, das das sauber neu aufsetzt.
|
|
panel.sleep()
|
|
last_lines = current_lines
|
|
last_mode = mode
|
|
|
|
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()
|