diff --git a/tools/station/display/display.py b/tools/station/display/display.py index 2e89a1d..b64117f 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -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.epd.displayPartBaseImage(buf) - else: - self.epd.init() - self.epd.display(buf) + self._full_init() + self.epd.displayPartBaseImage(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) diff --git a/tools/station/display/epdconfig.py b/tools/station/display/epdconfig.py index 1f7b4b6..875aff7 100644 --- a/tools/station/display/epdconfig.py +++ b/tools/station/display/epdconfig.py @@ -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) diff --git a/tools/station/display/render.py b/tools/station/display/render.py index ab0c999..c0f3b10 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -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 diff --git a/tools/station/ninlight-display.service b/tools/station/ninlight-display.service index 154b1af..f18decf 100644 --- a/tools/station/ninlight-display.service +++ b/tools/station/ninlight-display.service @@ -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, diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index e67efd2..7d9cfe3 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -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