Files
ninlight-w600/tools/station/display/render.py
T
Michael Sauer 4a029a5d1c feat(station): E-Paper-Anzeige fuer Modus, Lampe und Auslieferungen
render.lines() zerlegt den zusammengefuehrten Zustand (status.json + control.json +
SSID aus der Konfigurationsdatei) in Textzeilen, ohne Pillow und ohne Hardware zu
brauchen - render.compose() importiert Pillow erst innerhalb der Funktion. epdconfig.py
ist eine eigene GPIO/SPI-Schicht auf gpiozero/spidev, weil RPi.GPIO auf Debian 13 mit
Python 3.13 nicht mehr traegt; epd2in13_V2.py/epd2in13_V3.py sind unveraendert aus dem
Waveshare-Repository uebernommen (siehe HERKUNFT.md), nur der epdconfig-Import zeigt auf
unser Modul. display.py laeuft als eigener systemd-Dienst (Wants=, Restart=on-failure),
fehlendes Panel/SPI/Bibliothek beendet ihn sauber mit sys.exit(0) statt einer
Neustartschleife - eine Migration darf davon nie abhaengen.

Verdrahtung in setup_pi.sh/deploy.sh/ninlight-station folgt in einem eigenen Durchgang.
2026-09-13 20:44:02 +02:00

134 lines
5.3 KiB
Python

"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station.
lines() ist bewusst ohne Pillow und ohne Hardwarezugriff importierbar und testbar (siehe
tools/station/tests/test_render.py) - compose() importiert Pillow erst innerhalb der
Funktion, damit ein fehlendes PIL (das erst per "apt install python3-pil" auf den Pi
kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht.
Das Panel ist 250x122 Pixel, quer verwendet. MAX_COLS begrenzt jede Textzeile so, dass sie
bei der in compose() verwendeten Schriftgroesse nicht umgebrochen werden muss - ueberlange
Werte (z.B. ein Produktname aus der Lampenerkennung) werden mit "..." gekuerzt, nie
umgebrochen (Spec: eine zu lange Zeile darf das Layout nicht sprengen).
Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus
/etc/ninlight-station.conf. display.py liest beides und reicht ein gemeinsames dict
herein. Jedes Feld wird ueber .get() mit Vorgabewert gelesen: status.json,
control.json und die Konfigurationsdatei koennen jederzeit fehlen, halb geschrieben oder
aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
from __future__ import annotations
MAX_COLS = 42
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
# gelistet.
PHASE_LABELS = {"auto": "AUTO", "listen": "HORCHT"}
def _cut(text: object) -> str:
"""Kuerzt auf MAX_COLS Zeichen, mit "..." am Ende statt Umbruch."""
text = str(text)
if len(text) <= MAX_COLS:
return text
if MAX_COLS <= 1:
return text[:MAX_COLS]
return text[: MAX_COLS - 1] + "…"
def _hhmm(ts: object) -> str:
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
aelteren Serverfassung ohne Zeitstempel) -> "--:--"."""
if isinstance(ts, str) and len(ts) >= 16 and ts[10:11] == "T":
return ts[11:16]
return "--:--"
def lines(state: dict) -> list[str]:
"""Zerlegt den zusammengefuehrten Zustand in Textzeilen fuers Panel. Wirft nie -
fehlende/falsch geformte Felder werden ausgelassen statt eine Ausnahme zu werfen."""
state = state if isinstance(state, dict) else {}
mode = state.get("mode")
out: list[str] = []
if mode == "off":
# Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
# gerade nicht aktiv).
out.append("Station aus")
return out
if mode not in ("recovery", "migration"):
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
# unterstellen.
out.append(_cut(f"Status unbekannt ({mode})") if mode else "Status unbekannt")
return out
out.append(MODE_LABELS[mode])
ssid = state.get("ssid")
if ssid:
out.append(_cut(f"SSID: {ssid}"))
if mode == "migration":
# Phase kommt aus control.json, nicht aus status.json (das schreibt der Server
# nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als
# Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine
# Phase, deshalb dieser Block nur hier drin.
phase = state.get("phase")
if phase == "armed":
target = state.get("armed_target") or "?"
out.append(_cut(f"FREIGEGEBEN: {target}"))
elif phase in PHASE_LABELS:
out.append(PHASE_LABELS[phase])
last_seen = state.get("last_seen")
if not isinstance(last_seen, dict):
last_seen = {}
product_name = last_seen.get("product_name")
if product_name:
out.append(_cut(product_name))
target = last_seen.get("target")
variant = last_seen.get("variant")
if target or variant:
out.append(_cut(f"{target or '?'} ({variant or '?'})"))
lamp_version = last_seen.get("lamp_version")
if lamp_version:
out.append(_cut(f"FW: {lamp_version}"))
served = state.get("served")
out.append(_cut(f"Ausgeliefert: {served if isinstance(served, int) else 0}"))
recent = state.get("recent")
if isinstance(recent, list) and recent:
last = recent[-1]
if isinstance(last, dict):
out.append(_cut(f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
f"{last.get('status') or '?'}"))
return out
def compose(state: dict):
"""Baut das Panel-Bild (250x122, 1-Bit) aus lines(). Importiert Pillow erst hier -
siehe Modul-Docstring."""
from PIL import Image, ImageDraw, ImageFont
width, height = 250, 122
image = Image.new("1", (width, height), 255)
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
line_height = 14
y = 2
for line in lines(state):
if y > height - line_height:
break
draw.text((2, y), line, font=font, fill=0)
y += line_height
return image