fix(station): EnvironmentFile-Syntax, Bias-Zyklus, echte Panel-Tests

Fixrunde 3 zu Task 6a. EnvironmentFile=-/etc/ninlight-station.conf statt
-EnvironmentFile= (Fixrunde 2 hatte das Praefix vor den Direktivennamen statt
vor den Dateipfad gesetzt - systemd verwarf die Zeile komplett, die
Konfigurationsdatei wurde ueberhaupt nicht mehr gelesen). clear() stellte die
in Panel dokumentierte Awake-Invariante nicht her (kein 0x26-Referenzpuffer)
und verwendet jetzt wie draw_full() displayPartBaseImage(). Die 30-Minuten-
Vollaufbau-Uhr feuerte bisher auch ohne vorherige Teilaufbauten und erzeugte
im eingeschwungenen Ruhezustand einen endlosen Schlaf-/Wach-Zyklus mit 33
Prozent Bias-Anteil - sie greift jetzt nur noch, wenn seit dem letzten
Vollaufbau tatsaechlich Teilaufbauten stattfanden, und der Zaehler wird beim
Einschlafen zurueckgesetzt. draw_partial() meldet zurueck, ob es intern zu
einem Vollaufbau aufgewertet hat, damit die 30-Minuten-Uhr korrekt bleibt.
compose() ersetzt eingebettete Zeilenumbrueche vor der Breitenmessung.

Dazu ein Kommando-Rekorder fuer die Tests, der die tatsaechlich gesendete
SPI-Byte-Folge protokolliert: die vorherigen Tests fuer den Referenzpuffer
(N2) und die Registerfolge nach dem Schlafen (N3) pruefften nur "keine
Ausnahme" und blieben laut Reviewer-Mutation gruen, wenn die eigentliche
Fehlerursache wieder eingefuehrt wurde. Die neuen Tests pruefen die
gesendeten Kommandos direkt und wurden gegen genau diese Mutationen
verifiziert.
This commit is contained in:
Michael Sauer
2026-09-13 21:46:59 +02:00
parent eb9efa12f6
commit 38c70ac5ce
4 changed files with 261 additions and 52 deletions
+182 -34
View File
@@ -62,38 +62,31 @@ 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
def test_compose_does_not_draw_into_the_last_column_for_long_lines():
"""Regressionstest zu Fixrunde 3: der Reviewer hat per Mutation belegt, dass eine
deaktivierte Kuerzung in compose() (fitted = line) die vorherige Testsuite NICHT
aufgehalten haette, weil der alte Test die Kuerzung selbst nachbaute (mit derselben
_truncate_to_width()-Funktion, die auch geprueft werden sollte) statt das von
compose() tatsaechlich GEZEICHNETE BILD zu inspizieren.
Dieser Test prueft stattdessen die Pixel direkt: bei korrekt wirkender Kuerzung
bleibt die letzte Panel-Spalte (x = PANEL_WIDTH_PX - 1) fuer eine absichtlich sehr
lange Zeile weiss, weil die Kuerzung auf PANEL_WIDTH_PX - 2*TEXT_MARGIN_PX zielt
(empirisch mit echtem Pillow bestaetigt: die rechte Kante liegt bei ca. Spalte 237,
deutlich vor Spalte 249). Mit deaktivierter Kuerzung fuellt der zeichenweise laengere
Text die Zeile bis zum Rand - ebenfalls empirisch bestaetigt (die letzte Spalte
enthaelt dann einen schwarzen Pixel)."""
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"}}
"last_seen": {"product_name": "W" * 200, "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)
pixels = image.load()
last_col = render.PANEL_WIDTH_PX - 1
# 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
black_in_last_column = any(pixels[last_col, y] == 0 for y in range(render.PANEL_HEIGHT_PX))
assert not black_in_last_column, "Text erreicht die letzte Spalte - Kuerzung wirkt nicht"
def test_missing_fields_do_not_raise():
@@ -275,12 +268,13 @@ def test_v3_partial_draw_after_sleep_does_not_crash(monkeypatch):
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."""
"""Dieselbe Rauchprobe fuer V2 wie oben fuer V3 (Fixrunde 2, N3) - dass hier NICHTS
crasht, ist aber nur die halbe Absicherung: ob dabei auch die RICHTIGEN Register
gesetzt werden, prueft erst der staerkere Test unten
(test_v2_wake_after_sleep_sets_full_update_registers) mit einem Kommando-Rekorder.
Der Reviewer hat zu Recht angemerkt, dass eine Fassung dieses Tests ohne jede
Zusicherung nichts ueber die Registerlage aussagt - deshalb hier wenigstens die
beobachtbaren Panel-Zustandsuebergaenge pruefen."""
_stub_hardware(monkeypatch)
panel = display.Panel("V2")
@@ -289,16 +283,170 @@ def test_v2_partial_draw_after_sleep_does_not_crash(monkeypatch):
panel.clear()
panel.draw_full(image)
panel.sleep()
panel.draw_partial(image)
assert panel._awake is False # Vorbedingung fuer den eigentlich interessanten Schritt
panel.draw_partial(image) # das ist der Schritt, der bei falscher Sequenz froeren wuerde
assert panel._awake is True
assert epdconfig._spi is not None # Panel hat sich nach dem Schlafen neu initialisiert
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)."""
schicken (derselbe AttributeError-Mechanismus wie N1, nur selbst herbeigefuehrt).
Die staerkere Fassung unten (test_panel_sleep_sends_no_commands_when_already_asleep)
prueft das zusaetzlich direkt an der gesendeten Kommandofolge."""
_stub_hardware(monkeypatch)
panel = display.Panel("V3")
panel.clear()
panel.sleep()
assert panel._awake is False
panel.sleep() # darf nicht erneut versuchen, epd.sleep() aufzurufen
assert panel._awake is False
# --- Kommando-Rekorder: Registerzustand nach echten Bytes pruefen, nicht nur "kein Absturz"
#
# Der Reviewer hat per Mutation gezeigt, dass die Stubs oben (_stub_hardware) N2/N3 nicht
# wirklich absichern: displayPartBaseImage(buf) durch display(buf) ersetzen, oder die
# V2-Registerfolge nach sleep() kaputt machen, liess alle bisherigen Tests gruen. Ein
# stiller Registerzustandsfehler wirft keine Ausnahme - die einzige Art, ihn in Software
# statt erst am Panel selbst zu fassen, ist die tatsaechlich gesendete Byte-Folge zu
# protokollieren. Ersetzt dafuer epdconfig's Funktionen direkt (nicht gpiozero/spidev
# darunter) und wertet aus, welche Bytes waehrend DC=0 (Kommando) vs. DC=1 (Daten)
# gesendet wurden - siehe send_command()/send_data() in den vendored Treibern.
class _RecordingHardware:
def __init__(self):
self.commands: list[int] = []
self._dc = 0
self._busy = 0
def digital_write(self, pin, value):
if pin == epdconfig.DC_PIN:
self._dc = value
# RST_PIN/CS_PIN sind fuer die Kommandofolge nicht relevant.
def digital_read(self, pin):
return self._busy
def delay_ms(self, ms):
pass
def spi_writebyte(self, data):
if self._dc == 0:
self.commands.extend(data)
def spi_writebyte2(self, data):
pass # Nutzdaten (Bildpuffer/LUT) - fuer die Kommandofolge nicht relevant.
def module_init(self):
return 0
def module_exit(self):
pass
def _install_recorder(monkeypatch) -> _RecordingHardware:
recorder = _RecordingHardware()
monkeypatch.setattr(epdconfig, "digital_write", recorder.digital_write)
monkeypatch.setattr(epdconfig, "digital_read", recorder.digital_read)
monkeypatch.setattr(epdconfig, "delay_ms", recorder.delay_ms)
monkeypatch.setattr(epdconfig, "spi_writebyte", recorder.spi_writebyte)
monkeypatch.setattr(epdconfig, "spi_writebyte2", recorder.spi_writebyte2)
monkeypatch.setattr(epdconfig, "module_init", recorder.module_init)
monkeypatch.setattr(epdconfig, "module_exit", recorder.module_exit)
return recorder
def test_v3_full_refresh_writes_reference_buffer(monkeypatch):
"""Regressionstest zu Fixrunde 2, N2 - mit einer echten Zusicherung (Fixrunde 3): ein
Vollaufbau muss bei V3 sowohl RAM 0x24 als auch 0x26 beschreiben
(displayPartBaseImage() statt display()), sonst arbeitet der naechste Teilaufbau
gegen einen undefinierten Referenzpuffer. Der Reviewer hat per Mutation
(displayPartBaseImage(buf) -> display(buf)) gezeigt, dass die vorherige Testsuite das
nicht bemerkt haette - dieser Test waere mit dieser Mutation rot."""
recorder = _install_recorder(monkeypatch)
panel = display.Panel("V3")
image = render.compose({"mode": "off"})
panel.clear()
recorder.commands.clear() # nur der eigentliche Vollaufbau interessiert hier
panel.draw_full(image)
assert 0x24 in recorder.commands, f"0x24 (aktuelles RAM) fehlt: {recorder.commands}"
assert 0x26 in recorder.commands, f"0x26 (Referenzpuffer) fehlt: {recorder.commands}"
assert recorder.commands.index(0x26) > recorder.commands.index(0x24)
def test_v2_wake_after_sleep_sets_full_update_registers(monkeypatch):
"""Regressionstest zu Fixrunde 2, N3 - mit einer echten Zusicherung (Fixrunde 3,
statt der vorherigen Fassung ganz ohne): nach sleep() muss der naechste Aufbau
(draw_partial(), das sich wegen Panel._awake selbst zu einem Vollaufbau aufwertet)
die Register senden, die NUR der FULL_UPDATE-Zweig von epd2in13_V2.init() setzt
(0x01 Driver output control, 0x11 Data entry mode, 0x44/0x45 RAM-Fenster, 0x4E/0x4F
RAM-Zaehler), und danach sowohl 0x24 als auch 0x26 beschreiben. Dieser Fehler wirft
keine Ausnahme - er ist nur an der gesendeten Byte-Folge erkennbar, sonst erst am
Panel selbst, wo er von einem Verdrahtungsfehler nicht zu unterscheiden waere."""
recorder = _install_recorder(monkeypatch)
panel = display.Panel("V2")
image = render.compose({"mode": "off"})
panel.clear()
panel.draw_full(image)
panel.sleep()
recorder.commands.clear() # nur die Kommandofolge NACH dem Schlafen interessiert hier
panel.draw_partial(image)
required = [0x01, 0x11, 0x44, 0x45, 0x4E, 0x4F]
positions = [recorder.commands.index(cmd) for cmd in required
if cmd in recorder.commands]
assert len(positions) == len(required), (
f"FULL_UPDATE-Register fehlen nach sleep(): {recorder.commands}")
assert positions == sorted(positions), (
f"FULL_UPDATE-Register nicht in der erwarteten Reihenfolge: {recorder.commands}")
assert 0x24 in recorder.commands and 0x26 in recorder.commands, (
f"0x24/0x26 (Referenzpuffer) fehlen: {recorder.commands}")
idx_24 = recorder.commands.index(0x24)
idx_26 = recorder.commands.index(0x26)
assert idx_24 > positions[-1], "0x24 vor den FULL_UPDATE-Registern geschrieben"
assert idx_26 > idx_24, "0x26 (Referenzpuffer) fehlt oder kommt vor 0x24"
def test_panel_clear_establishes_reference_buffer(monkeypatch):
"""Regressionstest zu Fixrunde 3, F2: clear() setzte vorher self._awake=True, ohne
einen 0x26-Referenzpuffer zu schreiben (epd.Clear() schreibt nur 0x24, bei V2 ist das
0x26-Schreiben in Clear() sogar auskommentiert) - ein draw_partial() direkt nach
clear() liefe gegen denselben undefinierten Puffer, den N2 eigentlich beheben
sollte. clear() verwendet jetzt wie draw_full() displayPartBaseImage()."""
recorder = _install_recorder(monkeypatch)
panel = display.Panel("V3")
panel.clear()
assert 0x24 in recorder.commands
assert 0x26 in recorder.commands
assert panel._awake is True
def test_panel_sleep_sends_no_commands_when_already_asleep(monkeypatch):
"""Staerkere Fassung von test_panel_sleep_is_idempotent (Fixrunde 3): ein zweiter
sleep()-Aufruf darf NICHT nochmal versuchen, ueber die schon geschlossenen Pins
SPI-Befehle zu schicken - hier direkt an der aufgezeichneten Kommandofolge geprueft
statt nur daran, dass keine Ausnahme fliegt."""
recorder = _install_recorder(monkeypatch)
panel = display.Panel("V3")
panel.clear()
panel.sleep()
recorder.commands.clear()
panel.sleep() # bereits asleep - darf keine weiteren Kommandos senden
assert recorder.commands == []