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
+61 -13
View File
@@ -220,11 +220,16 @@ class Panel:
aufgewertet - unabhaengig davon, in welcher Reihenfolge draw_full()/draw_partial()/ aufgewertet - unabhaengig davon, in welcher Reihenfolge draw_full()/draw_partial()/
sleep() aufgerufen werden. sleep() aufgerufen werden.
draw_full() verwendet fuer BEIDE Revisionen displayPartBaseImage() statt display() draw_full() (und clear(), Fixrunde 3, F2) verwendet fuer BEIDE Revisionen
(Fixrunde 2, N2): das schreibt beide RAM-Puffer des Controllers (0x24 UND 0x26) und displayPartBaseImage() statt display()/Clear() (Fixrunde 2, N2): das schreibt beide
legt damit erst die Referenz an, gegen die ein nachfolgender Teilaufbau diffen kann - RAM-Puffer des Controllers (0x24 UND 0x26) und legt damit erst die Referenz an, gegen
display() allein haette nur 0x24 beschrieben und den ersten Teilaufbau danach gegen die ein nachfolgender Teilaufbau diffen kann - display() allein haette nur 0x24
einen veralteten/undefinierten Puffer laufen lassen.""" beschrieben (und Clear() ebenso - bei V2 ist das 0x26-Schreiben in Clear() sogar
auskommentiert) und den ersten Teilaufbau danach gegen einen veralteten/undefinierten
Puffer laufen lassen. self._awake wird deshalb nur dort auf True gesetzt, wo
tatsaechlich ein gueltiger Referenzpuffer entstanden ist - vorher (Fixrunde 2) setzte
clear() es, obwohl es nur epd.Clear() (kein 0x26) rief, und die im Klassen-Docstring
behauptete Invariante stimmte nach clear() nicht."""
def __init__(self, revision: str) -> None: def __init__(self, revision: str) -> None:
self.revision = revision self.revision = revision
@@ -241,9 +246,23 @@ class Panel:
else: else:
self.epd.init() self.epd.init()
def _blank_buffer(self) -> list[int]:
"""Ein reiner Weiss-Puffer in der Groesse, die epd.getbuffer() fuer ein
vollformatiges Bild liefern wuerde - dieselbe Formel wie in epd.Clear() selbst
(siehe epd2in13_V2.py/epd2in13_V3.py), nur ohne ueber Pillow ein echtes weisses
Bild zu erzeugen."""
width, height = self.epd.width, self.epd.height
linewidth = width // 8 if width % 8 == 0 else width // 8 + 1
return [0xFF] * (linewidth * height)
def clear(self) -> None: def clear(self) -> None:
self._full_init() self._full_init()
self.epd.Clear(0xFF) # displayPartBaseImage() statt epd.Clear() (Fixrunde 3, F2): dieselbe sichtbare
# Wirkung (Panel wird weiss), aber schreibt zusaetzlich den 0x26-Referenzpuffer,
# den ein nachfolgender draw_partial() braucht - epd.Clear() allein wuerde
# self._awake=True vortaeuschen, ohne die im Klassen-Docstring beschriebene
# Invariante wirklich herzustellen.
self.epd.displayPartBaseImage(self._blank_buffer())
self._awake = True self._awake = True
def draw_full(self, image) -> None: def draw_full(self, image) -> None:
@@ -252,17 +271,24 @@ class Panel:
self.epd.displayPartBaseImage(buf) self.epd.displayPartBaseImage(buf)
self._awake = True self._awake = True
def draw_partial(self, image) -> None: def draw_partial(self, image) -> bool:
"""Zeichnet teilweise - oder, falls das Panel nicht wach ist, vollstaendig.
Gibt zurueck, ob intern zu einem Vollaufbau aufgewertet wurde (Fixrunde 3, F4):
der Aufrufer muss das wie einen regulaeren Vollaufbau behandeln (z.B. fuer die
30-Minuten-Uhr in run_daemon()), sonst kommt der naechste dortige Vollaufbau zu
frueh und altert das Panel zusaetzlich, ohne dass ein Teilaufbau ueberhaupt
stattgefunden haette."""
if not self._awake: if not self._awake:
# Siehe Klassen-Docstring (Fixrunde 2, N1/N3): ein Teilaufbau direkt nach dem # Siehe Klassen-Docstring (Fixrunde 2, N1/N3): ein Teilaufbau direkt nach dem
# Schlafen (oder vor dem allerersten Vollaufbau) ist auf keiner Revision eine # Schlafen (oder vor dem allerersten Vollaufbau) ist auf keiner Revision eine
# gueltige Sequenz - stattdessen einen vollwertigen Vollaufbau ausfuehren. # gueltige Sequenz - stattdessen einen vollwertigen Vollaufbau ausfuehren.
self.draw_full(image) self.draw_full(image)
return return True
buf = self.epd.getbuffer(image) buf = self.epd.getbuffer(image)
if self.revision == "V2": if self.revision == "V2":
self.epd.init(self.epd.PART_UPDATE) self.epd.init(self.epd.PART_UPDATE)
self.epd.displayPartial(buf) self.epd.displayPartial(buf)
return False
def sleep(self) -> None: def sleep(self) -> None:
# Idempotent: ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft, # Idempotent: ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft,
@@ -318,6 +344,15 @@ def run_daemon(revision: str) -> None:
last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf last_mode: object = object() # garantiert ungleich jedem echten Modus beim ersten Lauf
last_full = time.monotonic() last_full = time.monotonic()
last_draw = time.monotonic() last_draw = time.monotonic()
# Zaehlt Teilaufbauten seit dem letzten Vollaufbau. FULL_REFRESH_INTERVAL_S existiert
# nur, um Geisterbilder aus AUFEINANDERFOLGENDEN Teilaufbauten auszugleichen - ohne
# mindestens einen Teilaufbau seit dem letzten Vollaufbau gibt es nichts auszugleichen,
# und ein Vollaufbau des UNVERAENDERTEN Bildes waere reine, nutzlose Alterung
# (Fixrunde 3, F3): ohne diese Absicherung entstand im eingeschwungenen Ruhezustand ein
# endloser 10-Minuten-schlafen/10-Minuten-wach-Zyklus (33% Bias-Anteil) rein aus der
# 30-Minuten-Uhr, obwohl sich der Inhalt nie aenderte - genau das, was I4 verhindern
# sollte.
partials_since_full = 0
while True: while True:
state = build_state() state = build_state()
@@ -326,18 +361,25 @@ def run_daemon(revision: str) -> None:
now = time.monotonic() now = time.monotonic()
mode_changed = mode != last_mode mode_changed = mode != last_mode
stale = (now - last_full) >= FULL_REFRESH_INTERVAL_S stale = partials_since_full > 0 and (now - last_full) >= FULL_REFRESH_INTERVAL_S
if current_lines != last_lines or mode_changed or stale: if current_lines != last_lines or mode_changed or stale:
image = render.compose(state) image = render.compose(state)
if mode_changed or stale: if mode_changed or stale:
panel.draw_full(image) panel.draw_full(image)
last_full = now last_full = now
partials_since_full = 0
else: else:
# Panel.draw_partial() wertet selbst zu einem Vollaufbau auf, falls das upgraded = panel.draw_partial(image)
# Panel seit dem letzten Aufbau geschlafen hat (siehe Panel-Klasse) - if upgraded:
# run_daemon() muss den Schlafzustand dafuer nicht selbst nachhalten. # Panel.draw_partial() hat selbst zu einem Vollaufbau aufgewertet
panel.draw_partial(image) # (Panel war nicht wach, siehe Panel-Klasse) - das zaehlt fuer die
# 30-Minuten-Uhr wie ein regulaerer Vollaufbau (Fixrunde 3, F4), sonst
# kaeme der naechste "stale"-Vollaufbau zu frueh.
last_full = now
partials_since_full = 0
else:
partials_since_full += 1
last_draw = now last_draw = now
last_lines = current_lines last_lines = current_lines
last_mode = mode last_mode = mode
@@ -347,6 +389,12 @@ def run_daemon(revision: str) -> None:
# weniger). Panel.sleep() ist idempotent, ein wiederholter Aufruf hier # weniger). Panel.sleep() ist idempotent, ein wiederholter Aufruf hier
# (solange weiterhin nichts passiert) ist also unschaedlich. # (solange weiterhin nichts passiert) ist also unschaedlich.
panel.sleep() panel.sleep()
# Nach dem Schlafen wertet Panel.draw_partial() den naechsten Aufbau ohnehin
# zu einem Vollaufbau auf - der 30-Minuten-Zaehler soll dieses "natuerliche"
# Zuruecksetzen nicht durch einen zusaetzlichen, inhaltslosen "stale"-
# Vollaufbau vorwegnehmen, falls vor dem Einschlafen schon Teilaufbauten
# stattgefunden hatten (Fixrunde 3, F3, zweiter Teil).
partials_since_full = 0
time.sleep(POLL_INTERVAL_S) time.sleep(POLL_INTERVAL_S)
+9 -1
View File
@@ -151,9 +151,17 @@ def compose(state: dict):
return draw.textlength(text, font=font) return draw.textlength(text, font=font)
y = TEXT_MARGIN_PX y = TEXT_MARGIN_PX
for line in lines(state): for raw_line in lines(state):
if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX: if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX:
break break
# draw.textlength() wirft ValueError("can't measure length of multiline text"),
# sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von
# main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3,
# F5). lines() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen
# (z.B. product_name) sind letztlich Daten von der Lampe/dem Netzwerk, kein
# Wert, den lines() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
# nur ein zerstoertes Layout, kein sinnvoller Inhalt.
line = raw_line.replace("\n", " ")
fitted = _truncate_to_width(line, measure=measure, max_width_px=max_width_px) fitted = _truncate_to_width(line, measure=measure, max_width_px=max_width_px)
draw.text((TEXT_MARGIN_PX, y), fitted, font=font, fill=0) draw.text((TEXT_MARGIN_PX, y), fitted, font=font, fill=0)
y += LINE_HEIGHT_PX y += LINE_HEIGHT_PX
+9 -4
View File
@@ -24,10 +24,15 @@ Type=simple
Environment=EPD_REVISION=V3 Environment=EPD_REVISION=V3
Environment=NINLIGHT_RUN_DIR=/run/ninlight-station Environment=NINLIGHT_RUN_DIR=/run/ninlight-station
Environment=NINLIGHT_STATION_CONF=/etc/ninlight-station.conf Environment=NINLIGHT_STATION_CONF=/etc/ninlight-station.conf
# "-"-Praefix: ohne die Konfigurationsdatei soll die Anzeige trotzdem starten (mit den # "-"-Praefix VOR dem Dateipfad, nicht vor dem Direktivennamen (Fixrunde 3, F1 - das war
# Environment=-Vorgaben oben) statt mit einem harten Fehler - "die Anzeige darf nirgends # in Fixrunde 2 falsch herum: "-EnvironmentFile=..." ist kein gueltiger Schluesselname,
# Voraussetzung sein" gilt auch fuer ihre eigene Konfiguration (Fixrunde 1/2, Hinweis). # systemd verwirft die Zeile komplett mit "Unknown key name", und die Konfigurationsdatei
-EnvironmentFile=/etc/ninlight-station.conf # wurde dadurch ueberhaupt nicht mehr gelesen - Environment=EPD_REVISION=V3 oben haette
# IMMER gewonnen, unabhaengig davon, was in /etc/ninlight-station.conf steht). Mit dem
# Praefix am richtigen Platz gilt: ohne die Konfigurationsdatei startet die Anzeige
# trotzdem (mit den Environment=-Vorgaben oben) statt mit einem harten Fehler - "die
# Anzeige darf nirgends Voraussetzung sein" gilt auch fuer ihre eigene Konfiguration.
EnvironmentFile=-/etc/ninlight-station.conf
ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py ExecStart=/usr/bin/python3 /opt/ninlight-station/display/display.py
# display.py faengt ein fehlendes/abgestuerztes Panel als DisplayUnavailable ab, # display.py faengt ein fehlendes/abgestuerztes Panel als DisplayUnavailable ab,
+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" assert render._truncate_to_width("kurz", measure=len, max_width_px=10) == "kurz"
def test_compose_keeps_every_line_within_panel_width(): def test_compose_does_not_draw_into_the_last_column_for_long_lines():
"""Regressionstest zu Fixrunde 2: eine Kuerzung nach Zeichenzahl (die fruehere """Regressionstest zu Fixrunde 3: der Reviewer hat per Mutation belegt, dass eine
MAX_COLS-Fassung) ist bei der von compose() verwendeten Proportionalschrift deaktivierte Kuerzung in compose() (fitted = line) die vorherige Testsuite NICHT
(PIL.ImageFont.load_default(), seit Pillow 10.1 eine TrueType-Schrift) keine aufgehalten haette, weil der alte Test die Kuerzung selbst nachbaute (mit derselben
gueltige Breitengrenze - eine grossbuchstabenlastige Zeile wie ein Produktname aus _truncate_to_width()-Funktion, die auch geprueft werden sollte) statt das von
der Lampenerkennung ("KLYQA GU10 RGB CW/WW") ist pro Zeichen breiter als eine compose() tatsaechlich GEZEICHNETE BILD zu inspizieren.
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
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": [], state = {"mode": "migration", "phase": "listen", "served": 0, "recent": [],
"ssid": "qconnex-firmware", "ssid": "qconnex-firmware",
"last_seen": {"product_name": "KLYQA GU10 RGB CW/WW " + "W" * 40, "last_seen": {"product_name": "W" * 200, "target": "e27_rgb",
"target": "e27_rgb", "variant": "dev", "lamp_version": "V1"}} "variant": "dev", "lamp_version": "V1"}}
# compose() muss uebrigens auch ohne Ausnahme durchlaufen - reine Rauchprobe.
image = render.compose(state) 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), black_in_last_column = any(pixels[last_col, y] == 0 for y in range(render.PANEL_HEIGHT_PX))
# muss jede Zeile unter die verfuegbare Panelbreite bringen. assert not black_in_last_column, "Text erreicht die letzte Spalte - Kuerzung wirkt nicht"
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(): 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): def test_v2_partial_draw_after_sleep_does_not_crash(monkeypatch):
"""Dieselbe Absicherung fuer V2 (Fixrunde 2, N3): epd2in13_V2.sleep() ist Deep Sleep """Dieselbe Rauchprobe fuer V2 wie oben fuer V3 (Fixrunde 2, N3) - dass hier NICHTS
Mode 2 (RAM/Register verworfen); ein init(PART_UPDATE) direkt danach setzt nur einen crasht, ist aber nur die halbe Absicherung: ob dabei auch die RICHTIGEN Register
Teil der fuer einen Teilaufbau noetigen Register (0x01/0x11/0x44/0x45/0x4E/0x4F setzt gesetzt werden, prueft erst der staerkere Test unten
ausschliesslich der FULL_UPDATE-Zweig) - keine Ausnahme, aber eine auf echter (test_v2_wake_after_sleep_sets_full_update_registers) mit einem Kommando-Rekorder.
Hardware unbrauchbare Sequenz. draw_partial() muss auch hier nach sleep() zu einem Der Reviewer hat zu Recht angemerkt, dass eine Fassung dieses Tests ohne jede
vollwertigen FULL_UPDATE-Vollaufbau (inklusive displayPartBaseImage()) aufwerten.""" Zusicherung nichts ueber die Registerlage aussagt - deshalb hier wenigstens die
beobachtbaren Panel-Zustandsuebergaenge pruefen."""
_stub_hardware(monkeypatch) _stub_hardware(monkeypatch)
panel = display.Panel("V2") panel = display.Panel("V2")
@@ -289,16 +283,170 @@ def test_v2_partial_draw_after_sleep_does_not_crash(monkeypatch):
panel.clear() panel.clear()
panel.draw_full(image) panel.draw_full(image)
panel.sleep() 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): def test_panel_sleep_is_idempotent(monkeypatch):
"""Ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft, darf nicht """Ein zweiter sleep()-Aufruf, waehrend das Panel schon schlaeft, darf nicht
versuchen, ueber die von module_exit() bereits geschlossenen Pins SPI-Befehle zu 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) _stub_hardware(monkeypatch)
panel = display.Panel("V3") panel = display.Panel("V3")
panel.clear() panel.clear()
panel.sleep() panel.sleep()
assert panel._awake is False
panel.sleep() # darf nicht erneut versuchen, epd.sleep() aufzurufen 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 == []