diff --git a/tools/station/display/display.py b/tools/station/display/display.py index b64117f..89cb103 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -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) diff --git a/tools/station/display/render.py b/tools/station/display/render.py index c0f3b10..6af297f 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -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 diff --git a/tools/station/ninlight-display.service b/tools/station/ninlight-display.service index f18decf..0c81a5a 100644 --- a/tools/station/ninlight-display.service +++ b/tools/station/ninlight-display.service @@ -24,10 +24,15 @@ Type=simple Environment=EPD_REVISION=V3 Environment=NINLIGHT_RUN_DIR=/run/ninlight-station Environment=NINLIGHT_STATION_CONF=/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 +# "-"-Praefix VOR dem Dateipfad, nicht vor dem Direktivennamen (Fixrunde 3, F1 - das war +# in Fixrunde 2 falsch herum: "-EnvironmentFile=..." ist kein gueltiger Schluesselname, +# systemd verwirft die Zeile komplett mit "Unknown key name", und die Konfigurationsdatei +# 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 # 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 7d9cfe3..811e196 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -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 == []