Files
ninlight-w600/tools/station/display/render.py
T
Michael Sauer eb9efa12f6 fix(station): Panel schlaeft nicht mehr nach jedem Bild, Breitenkuerzung nach Pixeln
Fixrunde 2 zu Task 6a: der sleep()-Einbau aus Fixrunde 1 hatte drei neue Critical
erzeugt. epd.sleep() schliesst ueber epdconfig.module_exit() die GPIO-Pins; ein
Teilaufbau direkt danach fasste bei V3 eine bereits geschlossene Pin-Referenz an
(AttributeError, ungefangen, Neustartschleife - binnen Sekunden nach dem Start, V3 ist
die Vorgaberevision) und war bei V2 nach Deep Sleep ohnehin keine belastbare Sequenz
(init(PART_UPDATE) allein setzt nicht die Register, die nur der FULL_UPDATE-Zweig setzt).
Ausserdem setzte der V3-Vollaufbau nie den Referenzpuffer, den ein Teilaufbau zum Diffen
braucht (displayPartBaseImage() statt display()).

Panel bekommt ein internes _awake-Flag: draw_partial() wertet sich selbst zu einem
Vollaufbau auf, wenn das Panel nicht wach ist (frisch erzeugt oder seit dem letzten
Aufbau geschlafen), unabhaengig von der Aufrufreihenfolge. sleep() wird nur noch nach
laengerer Ruhe (10 Minuten ohne Aenderung) gerufen, nicht mehr nach jedem Bild, und ist
selbst idempotent. Dazu: epdconfig faengt jetzt gpiozero.GPIOZeroError statt nur
GPIODeviceError (die PinError-Familie haengt nicht darunter), EnvironmentFile= in der
Unit bekommt ein "-"-Praefix, und die Zeilenkuerzung wandert von einer Zeichenzahl-Grenze
(ungueltig bei einer Proportionalschrift) zu einer echten Pixelbreitenmessung in
compose() - lines() bleibt dafuer ungekuerzt und weiterhin ohne Pillow testbar.
2026-09-13 21:24:42 +02:00

162 lines
7.2 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 PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. lines() liefert
UNGEKUERZTE Zeilen - die Breitenkuerzung (mit "..." statt Umbruch, Spec: eine zu lange
Zeile darf das Layout nicht sprengen) passiert erst in compose(), per _truncate_to_width()
gegen die tatsaechliche, schriftabhaengige Pixelbreite (PIL.ImageFont.load_default() ist
seit Pillow 10.1 eine proportionale TrueType-Schrift, keine feste Bitmapschrift - eine
Kuerzung nach Zeichenzahl (fruehere Fassung: MAX_COLS) ist dafuer keine gueltige
Breitengrenze: "W"*40 misst bei dieser Schrift fast doppelt so breit wie "i"*40, und
ausgerechnet grossbuchstabenlastige Werte - Produktnamen aus der Lampenerkennung wie
"KLYQA GU10 RGB CW/WW" - waeren trotz weniger Zeichen als die alte Grenze ueber die
Panelbreite hinausgelaufen (Fixrunde 2, MAX_COLS-Kleinigkeit war in Fixrunde 1 nicht
wirklich behoben). _truncate_to_width() selbst importiert kein Pillow (sie nimmt eine
Messfunktion entgegen, die der Aufrufer liefert) und bleibt damit wie lines() ohne Pillow
testbar; nur compose() verdrahtet sie mit einer echten PIL-Textmessung.
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
from typing import Callable
PANEL_WIDTH_PX = 250
PANEL_HEIGHT_PX = 122
TEXT_MARGIN_PX = 2
LINE_HEIGHT_PX = 14
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 _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 UNGEKUERZTE Textzeilen fuers Panel (siehe
Modul-Docstring, warum die Kuerzung hier bewusst nicht passiert). 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(f"Status unbekannt ({mode})" if mode else "Status unbekannt")
return out
out.append(MODE_LABELS[mode])
ssid = state.get("ssid")
if ssid:
out.append(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(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(str(product_name))
target = last_seen.get("target")
variant = last_seen.get("variant")
if target or variant:
out.append(f"{target or '?'} ({variant or '?'})")
lamp_version = last_seen.get("lamp_version")
if lamp_version:
out.append(f"FW: {lamp_version}")
served = state.get("served")
out.append(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(f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
f"{last.get('status') or '?'}")
return out
def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px: float) -> str:
"""Kuerzt `text` mit "…" am Ende, bis `measure(text)` nicht mehr breiter als
`max_width_px` ist - nie umgebrochen, immer von rechts gekuerzt.
Nimmt eine Messfunktion statt selbst Pillow zu importieren: so bleibt diese Funktion
(und damit render.py ausserhalb von compose()) ohne Pillow testbar, z.B. mit
`measure=len` fuer einen einfachen Zeichenzahl-Test, waehrend compose() unten eine
echte `ImageDraw.textlength()`-Messung durchreicht. Zeichenweise gekuerzt (nicht
wortweise): lines() liefert kurze Statuswerte, keinen Fliesstext - ein am Wortende
abgeschnittener Produktname ist hier kein Problem, ein zu breiter oder umgebrochener
waere eins."""
if measure(text) <= max_width_px:
return text
ellipsis = "…"
trimmed = text
while trimmed and measure(trimmed + ellipsis) > max_width_px:
trimmed = trimmed[:-1]
return (trimmed + ellipsis) if trimmed else ellipsis
def compose(state: dict):
"""Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus lines().
Importiert Pillow erst hier - siehe Modul-Docstring."""
from PIL import Image, ImageDraw, ImageFont
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
def measure(text: str) -> float:
return draw.textlength(text, font=font)
y = TEXT_MARGIN_PX
for line in lines(state):
if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX:
break
fitted = _truncate_to_width(line, measure=measure, max_width_px=max_width_px)
draw.text((TEXT_MARGIN_PX, y), fitted, font=font, fill=0)
y += LINE_HEIGHT_PX
return image