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()/
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."""
draw_full() (und clear(), Fixrunde 3, F2) verwendet fuer BEIDE Revisionen
displayPartBaseImage() statt display()/Clear() (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 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:
self.revision = revision
@@ -241,9 +246,23 @@ class Panel:
else:
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:
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
def draw_full(self, image) -> None:
@@ -252,17 +271,24 @@ class Panel:
self.epd.displayPartBaseImage(buf)
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:
# 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
return True
buf = self.epd.getbuffer(image)
if self.revision == "V2":
self.epd.init(self.epd.PART_UPDATE)
self.epd.displayPartial(buf)
return False
def sleep(self) -> None:
# 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_full = 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:
state = build_state()
@@ -326,18 +361,25 @@ def run_daemon(revision: str) -> None:
now = time.monotonic()
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:
image = render.compose(state)
if mode_changed or stale:
panel.draw_full(image)
last_full = now
partials_since_full = 0
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)
upgraded = panel.draw_partial(image)
if upgraded:
# Panel.draw_partial() hat selbst zu einem Vollaufbau aufgewertet
# (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_lines = current_lines
last_mode = mode
@@ -347,6 +389,12 @@ def run_daemon(revision: str) -> None:
# weniger). Panel.sleep() ist idempotent, ein wiederholter Aufruf hier
# (solange weiterhin nichts passiert) ist also unschaedlich.
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)
+9 -1
View File
@@ -151,9 +151,17 @@ def compose(state: dict):
return draw.textlength(text, font=font)
y = TEXT_MARGIN_PX
for line in lines(state):
for raw_line in lines(state):
if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX:
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)
draw.text((TEXT_MARGIN_PX, y), fitted, font=font, fill=0)
y += LINE_HEIGHT_PX