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.
This commit is contained in:
Michael Sauer
2026-09-13 21:24:42 +02:00
parent 9b158c8c4f
commit eb9efa12f6
5 changed files with 325 additions and 71 deletions
+73 -27
View File
@@ -59,6 +59,19 @@ POLL_INTERVAL_S = 5
# 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
@@ -192,47 +205,74 @@ def _load_epd_module(revision: str):
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."""
(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() verwendet fuer BEIDE Revisionen displayPartBaseImage() statt display()
(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 den ersten Teilaufbau danach gegen
einen veralteten/undefinierten Puffer laufen lassen."""
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 clear(self) -> None:
def _full_init(self) -> None:
if self.revision == "V2":
self.epd.init(self.epd.FULL_UPDATE)
else:
self.epd.init()
def clear(self) -> None:
self._full_init()
self.epd.Clear(0xFF)
self._awake = True
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._full_init()
self.epd.displayPartBaseImage(buf)
else:
self.epd.init()
self.epd.display(buf)
self._awake = True
def draw_partial(self, image) -> None:
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
buf = self.epd.getbuffer(image)
if self.revision == "V2":
self.epd.init(self.epd.PART_UPDATE)
self.epd.displayPartial(buf)
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
def _test_image(revision: str):
@@ -277,30 +317,36 @@ def run_daemon(revision: str) -> None:
last_lines: list[str] | None = None
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
last_full = time.monotonic()
last_draw = 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:
mode_changed = mode != last_mode
stale = (now - last_full) >= FULL_REFRESH_INTERVAL_S
if current_lines != last_lines or mode_changed or stale:
image = render.compose(state)
if need_full:
if mode_changed or stale:
panel.draw_full(image)
last_full = now
else:
# Panel.draw_partial() wertet selbst zu einem Vollaufbau auf, falls das
# Panel seit dem letzten Aufbau geschlafen hat (siehe Panel-Klasse) -
# run_daemon() muss den Schlafzustand dafuer nicht selbst nachhalten.
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_draw = now
last_lines = current_lines
last_mode = mode
elif (now - last_draw) >= SLEEP_AFTER_IDLE_S:
# Nach laengerer Ruhe schlafen legen (I4: die Pixel bleiben - e-Paper ist
# bistabil - aber ohne dauerhafte Spannung altert das Panel deutlich
# weniger). Panel.sleep() ist idempotent, ein wiederholter Aufruf hier
# (solange weiterhin nichts passiert) ist also unschaedlich.
panel.sleep()
time.sleep(POLL_INTERVAL_S)
+7 -2
View File
@@ -117,10 +117,15 @@ def module_init() -> int:
# Nur Fehler, die tatsaechlich von einer fehlenden/blockierten Hardware kommen, werden
# zu DisplayUnavailable - alles andere (ein echter Programmierfehler an anderer
# Stelle) soll weiterhin als das erkennbar bleiben, was es ist, statt hier als
# "Panel fehlt" getarnt zu werden (Fixrunde 1, C2, zweiter Teil).
# "Panel fehlt" getarnt zu werden (Fixrunde 1, C2, zweiter Teil). gpiozero.GPIOZeroError
# (nicht nur GPIODeviceError, Fixrunde 2, N4): die PinError-Familie (PinInvalidPin,
# PinUnsupported, ... - z.B. bei falscher/fehlender Pin-Factory-Zuordnung) haengt
# direkt unter GPIOZeroError, nicht unter GPIODeviceError - per
# `gpiozero.PinInvalidPin.__mro__` nachgeprueft. BadPinFactory erbt zusaetzlich von
# ImportError und war dadurch schon abgedeckt.
hardware_errors: tuple[type[BaseException], ...] = (OSError, ImportError)
if gpiozero is not None:
hardware_errors = hardware_errors + (gpiozero.GPIODeviceError,)
hardware_errors = hardware_errors + (gpiozero.GPIOZeroError,)
try:
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
+62 -37
View File
@@ -5,13 +5,19 @@ tools/station/tests/test_render.py) - compose() importiert Pillow erst innerhalb
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).
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
@@ -22,7 +28,12 @@ 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
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
@@ -30,16 +41,6 @@ MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
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) -> "--:--"."""
@@ -49,7 +50,8 @@ def _hhmm(ts: object) -> str:
def lines(state: dict) -> list[str]:
"""Zerlegt den zusammengefuehrten Zustand in Textzeilen fuers Panel. Wirft nie -
"""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")
@@ -67,14 +69,14 @@ def lines(state: dict) -> list[str]:
# 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")
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(_cut(f"SSID: {ssid}"))
out.append(f"SSID: {ssid}")
if mode == "migration":
# Phase kommt aus control.json, nicht aus status.json (das schreibt der Server
@@ -84,7 +86,7 @@ def lines(state: dict) -> list[str]:
phase = state.get("phase")
if phase == "armed":
target = state.get("armed_target") or "?"
out.append(_cut(f"FREIGEGEBEN: {target}"))
out.append(f"FREIGEGEBEN: {target}")
elif phase in PHASE_LABELS:
out.append(PHASE_LABELS[phase])
@@ -93,44 +95,67 @@ def lines(state: dict) -> list[str]:
last_seen = {}
product_name = last_seen.get("product_name")
if product_name:
out.append(_cut(product_name))
out.append(str(product_name))
target = last_seen.get("target")
variant = last_seen.get("variant")
if target or variant:
out.append(_cut(f"{target or '?'} ({variant or '?'})"))
out.append(f"{target or '?'} ({variant or '?'})")
lamp_version = last_seen.get("lamp_version")
if lamp_version:
out.append(_cut(f"FW: {lamp_version}"))
out.append(f"FW: {lamp_version}")
served = state.get("served")
out.append(_cut(f"Ausgeliefert: {served if isinstance(served, int) else 0}"))
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(_cut(f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
f"{last.get('status') or '?'}"))
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 (250x122, 1-Bit) aus lines(). Importiert Pillow erst hier -
siehe Modul-Docstring."""
"""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
width, height = 250, 122
image = Image.new("1", (width, height), 255)
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
line_height = 14
y = 2
def measure(text: str) -> float:
return draw.textlength(text, font=font)
y = TEXT_MARGIN_PX
for line in lines(state):
if y > height - line_height:
if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX:
break
draw.text((2, y), line, font=font, fill=0)
y += line_height
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
+4 -1
View File
@@ -24,7 +24,10 @@ Type=simple
Environment=EPD_REVISION=V3
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
Environment=NINLIGHT_STATION_CONF=/etc/ninlight-station.conf
EnvironmentFile=/etc/ninlight-station.conf
# "-"-Praefix: ohne die Konfigurationsdatei soll die Anzeige trotzdem starten (mit den
# Environment=-Vorgaben oben) statt mit einem harten Fehler - "die Anzeige darf nirgends
# Voraussetzung sein" gilt auch fuer ihre eigene Konfiguration (Fixrunde 1/2, Hinweis).
-EnvironmentFile=/etc/ninlight-station.conf
ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py
# display.py faengt ein fehlendes/abgestuerztes Panel als DisplayUnavailable ab,
+178 -3
View File
@@ -5,6 +5,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "display"))
import display # noqa: E402
import epdconfig # noqa: E402
import render # noqa: E402
@@ -37,13 +38,62 @@ def test_recovery_mode_without_traffic():
assert "ninlight-recovery" in text
def test_long_product_name_is_truncated_not_wrapped():
"""Das Panel ist 250 px breit - eine zu lange Zeile darf das Layout nicht sprengen."""
def test_long_product_name_is_not_truncated_by_lines():
"""lines() kuerzt bewusst NICHT (mehr) - Breitenkuerzung braucht eine echte
Textmessung (schriftabhaengig, siehe render.py-Modul-Docstring/Fixrunde 2) und
passiert deshalb erst in compose(). lines() bleibt so ohne Pillow testbar und liefert
hier den vollen, ungekuerzten Produktnamen zurueck."""
lines = render.lines({"mode": "migration", "phase": "listen", "served": 0, "recent": [],
"ssid": "qconnex-firmware",
"last_seen": {"product_name": "X" * 90, "target": "e27_rgb",
"variant": "dev", "lamp_version": "V1"}})
assert all(len(l) <= render.MAX_COLS for l in lines)
assert "X" * 90 in lines
def test_truncate_to_width_shortens_and_appends_ellipsis():
"""_truncate_to_width() selbst importiert kein Pillow - eine einfache Zeichenzahl-
Messfunktion reicht, um die Kuerzungslogik ohne Hardware/Pillow zu pruefen."""
result = render._truncate_to_width("X" * 50, measure=len, max_width_px=10)
assert len(result) <= 10
assert result.endswith("…")
def test_truncate_to_width_leaves_short_text_untouched():
assert render._truncate_to_width("kurz", measure=len, max_width_px=10) == "kurz"
def test_compose_keeps_every_line_within_panel_width():
"""Regressionstest zu Fixrunde 2: eine Kuerzung nach Zeichenzahl (die fruehere
MAX_COLS-Fassung) ist bei der von compose() verwendeten Proportionalschrift
(PIL.ImageFont.load_default(), seit Pillow 10.1 eine TrueType-Schrift) keine
gueltige Breitengrenze - eine grossbuchstabenlastige Zeile wie ein Produktname aus
der Lampenerkennung ("KLYQA GU10 RGB CW/WW") ist pro Zeichen breiter als eine
gemischte Beispielzeile und lief trotz weniger Zeichen als die alte Grenze ueber die
Panelbreite hinaus. Pillow ist in dieser Testumgebung vorhanden (siehe Bericht); auf
dem Ziel-Pi kommt es erst per "apt install python3-pil" hinzu, genau wie fuer
compose() selbst."""
from PIL import Image, ImageDraw, ImageFont
state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [],
"ssid": "qconnex-firmware",
"last_seen": {"product_name": "KLYQA GU10 RGB CW/WW " + "W" * 40,
"target": "e27_rgb", "variant": "dev", "lamp_version": "V1"}}
# compose() muss uebrigens auch ohne Ausnahme durchlaufen - reine Rauchprobe.
image = render.compose(state)
assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX)
# Dieselbe Kuerzung, die compose() intern verwendet (echte Schrift/Messfunktion),
# muss jede Zeile unter die verfuegbare Panelbreite bringen.
dummy = Image.new("1", (1, 1))
draw = ImageDraw.Draw(dummy)
font = ImageFont.load_default()
max_width_px = render.PANEL_WIDTH_PX - 2 * render.TEXT_MARGIN_PX
for raw_line in render.lines(state):
fitted = render._truncate_to_width(
raw_line, measure=lambda s: draw.textlength(s, font=font),
max_width_px=max_width_px)
assert draw.textlength(fitted, font=font) <= max_width_px
def test_missing_fields_do_not_raise():
@@ -127,3 +177,128 @@ def test_read_conf_accepts_export_prefix(tmp_path):
assert values["SSID"] == "ninlight-recovery"
assert values["MIGRATE_SSID"] == "qconnex-firmware"
# --- Panel-Stubs fuer die V2/V3-Aufwach-/Schlafsequenz (Fixrunde 2, N1) --------------
#
# gpiozero/spidev sind auf der Entwicklungsmaschine nicht installiert (und selbst wenn
# sie es waeren, haengt hier kein echtes Panel) - diese Fakes implementieren nur genau
# die Teilmenge der Schnittstelle, die epdconfig.py und die vendored Panel-Treiber
# tatsaechlich aufrufen, damit Panel() end-to-end (ohne echte Hardware) durchlaufen kann.
class _FakeDigitalOutputDevice:
def __init__(self, pin):
self.pin = pin
self.value = False
def close(self):
pass
class _FakeDigitalInputDevice:
def __init__(self, pin, pull_up=False):
self.pin = pin
self.value = 0 # "idle", nie "busy" - sonst haengt ReadBusy() in einer Schleife
def close(self):
pass
class _FakeGpiozeroModule:
DigitalOutputDevice = _FakeDigitalOutputDevice
DigitalInputDevice = _FakeDigitalInputDevice
class GPIOZeroError(Exception):
pass
class _FakeSpiDev:
def __init__(self):
self.max_speed_hz = None
self.mode = None
def open(self, bus, device):
pass
def writebytes(self, data):
pass
def writebytes2(self, data):
pass
def close(self):
pass
class _FakeSpidevModule:
SpiDev = _FakeSpiDev
def _stub_hardware(monkeypatch):
"""Ersetzt epdconfig's Sicht auf gpiozero/spidev durch die Fakes oben, taeuscht ein
vorhandenes /dev/spidev0.0 vor, macht delay_ms() ein No-op (epd.sleep() wartet sonst
echte 2 Sekunden) und stellt sicher, dass epdconfig mit "nichts offen" in den Test
geht - epdconfig haelt seinen Zustand in Modulglobalen, die sonst zwischen Tests
durchsickern koennten."""
monkeypatch.setattr(epdconfig, "gpiozero", _FakeGpiozeroModule)
monkeypatch.setattr(epdconfig, "spidev", _FakeSpidevModule)
monkeypatch.setattr(epdconfig.os.path, "exists", lambda path: True)
monkeypatch.setattr(epdconfig, "delay_ms", lambda ms: None)
monkeypatch.setattr(epdconfig, "_spi", None)
monkeypatch.setattr(epdconfig, "_rst_pin", None)
monkeypatch.setattr(epdconfig, "_dc_pin", None)
monkeypatch.setattr(epdconfig, "_busy_pin", None)
def test_v3_partial_draw_after_sleep_does_not_crash(monkeypatch):
"""Regressionstest zu Fixrunde 2, N1 - genau der vom Reviewer nachgestellte Ablauf:
Panel("V3") -> clear -> draw_full -> sleep -> draw_partial. Vorher stuerzte das mit
AttributeError ab: epd.sleep() schliesst ueber epdconfig.module_exit() die
GPIO-Pins, und epd2in13_V3.displayPartial() fasst als Erstes die Reset-Leitung an -
von main()s except (DisplayUnavailable, ImportError) NICHT gefangen, also ein
echter Absturz (Exit 1, Neustartschleife). V3 ist die Vorgaberevision, und der
Vollaufbau-nach-jedem-Bild-Ansatz aus Fixrunde 1 erreichte diesen Pfad bei der
ersten Inhaltsaenderung ohne Moduswechsel - binnen Sekunden nach dem Start.
Mit dem Panel._awake-Tracking wertet draw_partial() sich nach sleep() selbst zu
einem Vollaufbau auf, statt die geschlossenen Pins anzufassen - dieser Test waere
mit der Fixrunde-1-Fassung von Panel fehlgeschlagen und besteht jetzt."""
_stub_hardware(monkeypatch)
panel = display.Panel("V3")
image = render.compose({"mode": "off"})
panel.clear()
panel.draw_full(image)
panel.sleep()
panel.draw_partial(image) # das ist der Schritt, der vorher abgestuerzt waere
def test_v2_partial_draw_after_sleep_does_not_crash(monkeypatch):
"""Dieselbe Absicherung fuer V2 (Fixrunde 2, N3): epd2in13_V2.sleep() ist Deep Sleep
Mode 2 (RAM/Register verworfen); ein init(PART_UPDATE) direkt danach setzt nur einen
Teil der fuer einen Teilaufbau noetigen Register (0x01/0x11/0x44/0x45/0x4E/0x4F setzt
ausschliesslich der FULL_UPDATE-Zweig) - keine Ausnahme, aber eine auf echter
Hardware unbrauchbare Sequenz. draw_partial() muss auch hier nach sleep() zu einem
vollwertigen FULL_UPDATE-Vollaufbau (inklusive displayPartBaseImage()) aufwerten."""
_stub_hardware(monkeypatch)
panel = display.Panel("V2")
image = render.compose({"mode": "off"})
panel.clear()
panel.draw_full(image)
panel.sleep()
panel.draw_partial(image)
def test_panel_sleep_is_idempotent(monkeypatch):
"""Ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft, darf nicht
versuchen, ueber die von module_exit() bereits geschlossenen Pins SPI-Befehle zu
schicken (derselbe AttributeError-Mechanismus wie N1, nur selbst herbeigefuehrt)."""
_stub_hardware(monkeypatch)
panel = display.Panel("V3")
panel.clear()
panel.sleep()
panel.sleep() # darf nicht erneut versuchen, epd.sleep() aufzurufen