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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user