Files
ninlight-w600/tools/station/display/render.py
T
Michael Sauer 9b158c8c4f fix(station): V2/V3-Panelkapselung, wiedereintrittsfaehiges module_init, Unit-Fixes
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.
2026-09-13 21:07:01 +02:00

137 lines
5.6 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 (PIL.ImageFont.load_default(), Textursprung
x=2) 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 Wert 40 ist mit genau dieser Schrift nachgemessen
(draw.textbbox(): 40 Zeichen reichen bis x=243, 42 bis x=255 - beim 250 px breiten Panel
waere das "..." selbst abgeschnitten gewesen, siehe Fixrunde 1, Kleinigkeit).
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 = 40
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