From b8c7febb8fb49fd66d1d9dd4aafeb418d321dabb Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 13 Sep 2026 22:04:39 +0200 Subject: [PATCH] fix(station): Ghosting-Ausgleich nach Buendel, Zeitsteuerung testbar Der Reset von partials_since_full beim Einschlafen liess den faelligen Ausgleichs-Vollaufbau dauerhaft ausfallen: nach einem Buendel Teilaufbauten schlief das Panel mit dem Geisterbild ein und zeigte es stundenlang weiter, weil stale nie mehr feuern konnte. Reset ersatzlos gestrichen - der Ausgleich bleibt ausstehend und wird zum 30-Minuten-Termin nachgeholt; der in Fixrunde 3 beseitigte Bias-Zyklus kommt dadurch nicht zurueck. Die gesamte Zeitsteuerung steckt jetzt in RefreshPlanner statt in run_daemon()s Endlosschleife und ist ohne Schleife, ohne time.sleep() und ohne Hardware pruefbar. Neue Tests spielen sechs Stunden Betrieb durch (statischer Inhalt, Aenderung alle 3/9/11 Minuten, Buendel-dann-Ruhe) und schliessen die drei vom Reviewer per Mutation belegten Luecken aus Fixrunde 3. --- tools/station/display/display.py | 162 +++++++++++++++------- tools/station/tests/test_render.py | 208 +++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 48 deletions(-) diff --git a/tools/station/display/display.py b/tools/station/display/display.py index 89cb103..83eb9e0 100644 --- a/tools/station/display/display.py +++ b/tools/station/display/display.py @@ -301,6 +301,104 @@ class Panel: self._awake = False +class RefreshPlanner: + """Die gesamte Zeitsteuerung der Anzeige - wann voll aufgebaut, wann teilweise, wann + geschlafen wird - als reine Zustandsmaschine ohne Schleife, ohne time.sleep() und ohne + Hardware. + + Vorher steckte diese Logik direkt in run_daemon()s Endlosschleife und war dadurch + ungetestet (Fixrunde 4): drei Korrekturen aus Fixrunde 3 (die stale-Bedingung, die + Rueckmeldung einer intern aufgewerteten Teilaktualisierung, die Zeilenumbruchbehandlung + in compose()) liessen sich per Mutation aendern, ohne dass ein einziger Test rot wurde. + Hier laesst sich stattdessen ein kompletter Tagesverlauf in Millisekunden durchspielen + (siehe test_render.py, _run_schedule()). + + Benutzung: pro Runde einmal decide() fragen, die zurueckgegebene Aktion ausfuehren und + das Ergebnis per record_draw()/record_sleep() zurueckmelden. decide() selbst aendert + nichts - der Aufrufer meldet, was tatsaechlich passiert ist (insbesondere, ob + Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat). + + Aktionen: + "full" - kompletter Bildaufbau (Moduswechsel oder faelliger Ghosting-Ausgleich) + "partial" - Teilaktualisierung (Inhalt hat sich geaendert) + "sleep" - Panel schlafen legen (laenger nichts passiert) + "idle" - nichts tun + """ + + _NO_MODE = object() # garantiert ungleich jedem echten Modus beim ersten Lauf + + def __init__(self, now: float, + full_refresh_interval_s: float = FULL_REFRESH_INTERVAL_S, + sleep_after_idle_s: float = SLEEP_AFTER_IDLE_S) -> None: + self.full_refresh_interval_s = full_refresh_interval_s + self.sleep_after_idle_s = sleep_after_idle_s + self.last_lines: list[str] | None = None + self.last_mode: object = self._NO_MODE + self.last_full = now + self.last_draw = now + # 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. + # + # Der Zaehler wird beim Einschlafen BEWUSST NICHT zurueckgesetzt (Fixrunde 4, N1): + # das tat die Fassung aus Fixrunde 3 und liess den faelligen Ghosting-Ausgleich + # damit dauerhaft ausfallen - nach einem Buendel Teilaufbauten (z.B. 30 Lampen in + # 2,5 Minuten) schlief das Panel nach 10 Minuten mit dem entstandenen Geisterbild + # ein und zeigte genau dieses Bild stundenlang weiter, weil stale wegen + # partials_since_full == 0 nie mehr feuern konnte. So bleibt der Ausgleich + # ausstehend und wird zum 30-Minuten-Termin nachgeholt (das weckt das Panel einmal + # kurz auf; danach ist der Zaehler 0 und der eingeschwungene Ruhezustand bleibt + # wie in F3 gewuenscht bei 3% Wachanteil). Die Alternative - vor dem Einschlafen + # einmal draw_full() ziehen - kostet denselben einen Vollaufbau, aber JEDES Mal, + # wenn ein Buendel endet, auch wenn kurz darauf ohnehin neue Inhalte kommen (die + # das Panel nach dem Schlafen sowieso ueber draw_partial()s Aufwertung voll + # aufbauen). Diese Variante altert das Panel im Betriebsfall also mehr, nicht + # weniger, und der Ausgleich hier kommt spaetestens zum ohnehin vorgesehenen + # Termin. + self.partials_since_full = 0 + self.asleep = False + + def decide(self, now: float, lines: list[str], mode: object) -> str: + """Was jetzt zu tun ist. Aendert nichts - das Ergebnis wird per record_*() + zurueckgemeldet.""" + mode_changed = mode != self.last_mode + stale = (self.partials_since_full > 0 + and (now - self.last_full) >= self.full_refresh_interval_s) + + if lines != self.last_lines or mode_changed or stale: + return "full" if (mode_changed or stale) else "partial" + if not self.asleep and (now - self.last_draw) >= self.sleep_after_idle_s: + # Nach laengerer Ruhe schlafen legen (Fixrunde 1, I4: die Pixel bleiben - + # e-Paper ist bistabil - aber ohne dauerhafte Spannung altert das Panel + # deutlich weniger). + return "sleep" + return "idle" + + def record_draw(self, now: float, lines: list[str], mode: object, + was_full: bool) -> None: + """`was_full` ist True fuer einen regulaeren Vollaufbau UND dann, wenn + Panel.draw_partial() sich selbst zu einem Vollaufbau aufgewertet hat (Fixrunde 3, + F4) - sonst kaeme der naechste "stale"-Vollaufbau zu frueh und altert das Panel + zusaetzlich, obwohl gerade erst vollstaendig aufgebaut wurde.""" + self.last_lines = lines + self.last_mode = mode + self.last_draw = now + self.asleep = False + if was_full: + self.last_full = now + self.partials_since_full = 0 + else: + self.partials_since_full += 1 + + def record_sleep(self, now: float) -> None: + self.asleep = True + + def _test_image(revision: str): from PIL import Image, ImageDraw, ImageFont @@ -337,22 +435,13 @@ def run_clear(revision: str) -> None: def run_daemon(revision: str) -> None: + """Endlosschleife: alle POLL_INTERVAL_S Sekunden den Zustand einlesen und die von + RefreshPlanner entschiedene Aktion ausfuehren. Hier steht bewusst KEINE Zeitlogik mehr + (Fixrunde 4) - die steckt vollstaendig in RefreshPlanner und ist dort ohne Schleife, + ohne time.sleep() und ohne Hardware pruefbar.""" panel = Panel(revision) panel.clear() - - 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() - # 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 + planner = RefreshPlanner(time.monotonic()) while True: state = build_state() @@ -360,41 +449,18 @@ def run_daemon(revision: str) -> None: mode = state.get("mode") now = time.monotonic() - mode_changed = mode != last_mode - 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: - 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 - 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. + action = planner.decide(now, current_lines, mode) + if action == "full": + panel.draw_full(render.compose(state)) + planner.record_draw(now, current_lines, mode, was_full=True) + elif action == "partial": + # Panel.draw_partial() wertet sich selbst zu einem Vollaufbau auf, wenn das + # Panel nicht wach ist (siehe Panel-Klasse) - das meldet es hier zurueck. + upgraded = panel.draw_partial(render.compose(state)) + planner.record_draw(now, current_lines, mode, was_full=upgraded) + elif action == "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 + planner.record_sleep(now) time.sleep(POLL_INTERVAL_S) diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index 811e196..e38eaf5 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -450,3 +450,211 @@ def test_panel_sleep_sends_no_commands_when_already_asleep(monkeypatch): panel.sleep() # bereits asleep - darf keine weiteren Kommandos senden assert recorder.commands == [] + + +def test_draw_partial_reports_whether_it_upgraded_itself(monkeypatch): + """Fixrunde 3, F4 - vom Reviewer per Mutation als ungetestet belegt: gibt + draw_partial() nach dem Aufwerten `False` statt `True` zurueck, blieben alle 21 Tests + gruen. Der Rueckgabewert ist aber die einzige Information, an der RefreshPlanner + erkennt, dass die 30-Minuten-Uhr neu beginnt - ohne ihn kaeme der naechste + Ghosting-Ausgleich zu frueh und alterte das Panel ohne Not.""" + _install_recorder(monkeypatch) + panel = display.Panel("V3") + image = render.compose({"mode": "off"}) + + panel.clear() + assert panel.draw_partial(image) is False, "waches Panel: kein Vollaufbau noetig" + + panel.sleep() + assert panel.draw_partial(image) is True, ( + "nach sleep() wertet draw_partial() intern zu einem Vollaufbau auf - das muss es " + "auch zurueckmelden") + assert panel.draw_partial(image) is False, "danach ist das Panel wieder wach" + + +def test_compose_survives_a_newline_inside_a_value(): + """Fixrunde 3, F5 - ebenfalls per Mutation als ungetestet belegt (`line = raw_line` + liess alle Tests gruen): draw.textlength() wirft ValueError("can't measure length of + multiline text"), sobald eine Zeile ein "\\n" enthaelt. product_name kommt aus der + Lampenerkennung, ist also letztlich Netzwerkdatum - und ein ValueError hier faengt + main()s `except (DisplayUnavailable, ImportError)` NICHT ab, das waere ein echter + Absturz des Dienstes.""" + state = {"mode": "migration", "phase": "auto", "served": 0, "recent": [], + "last_seen": {"product_name": "Klyqa\nGU10", "target": "gu10_rgb", + "variant": "prod", "lamp_version": "V1"}} + + image = render.compose(state) # vor F5: ValueError + + assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX) + + +# --- RefreshPlanner: die Zeitsteuerung ohne Schleife durchspielen (Fixrunde 4) --------- +# +# run_daemon() hatte null Testabdeckung - die gesamte Zeitsteuerung steckte in einer +# Endlosschleife. Sie liegt jetzt in display.RefreshPlanner und laesst sich hier als +# Zeitreihe durchspielen: pro Poll-Runde decide() fragen, die Aktion "ausfuehren" (das +# Panel-Verhalten wird dabei nur so weit nachgebildet, wie es die Entscheidung beeinflusst: +# ein Teilaufbau auf einem schlafenden Panel wertet sich selbst zu einem Vollaufbau auf, +# siehe Panel.draw_partial()) und das Ergebnis zurueckmelden - genau die Reihenfolge, die +# run_daemon() ebenfalls verwendet. + +SIX_HOURS_S = 6 * 60 * 60 + + +def _run_schedule(content_at, duration_s, mode="recovery", + poll=display.POLL_INTERVAL_S, planner=None): + """Spielt `duration_s` Sekunden Anzeigebetrieb durch. `content_at(t)` liefert die + Zeilen, die build_state()/render.lines() zum Zeitpunkt t liefern wuerden. + + Gibt (Protokoll, Wachsekunden) zurueck; Protokoll ist eine Liste von (t, Aktion).""" + planner = planner if planner is not None else display.RefreshPlanner(0.0) + log: list[tuple[float, str]] = [] + awake = True # run_daemon() ruft direkt nach dem Start panel.clear() + awake_s = 0.0 + t = 0.0 + while t <= duration_s: + lines = content_at(t) + action = planner.decide(t, lines, mode) + if action == "full": + planner.record_draw(t, lines, mode, was_full=True) + awake = True + elif action == "partial": + # Panel.draw_partial() wertet auf einem schlafenden Panel selbst zu einem + # Vollaufbau auf und meldet das zurueck (F4, siehe Test oben) - im Protokoll + # als "wake-full" unterschieden, weil so ein Aufbau das Panel eben NICHT nur + # teilweise auffrischt. + upgraded = not awake + planner.record_draw(t, lines, mode, was_full=upgraded) + awake = True + if upgraded: + action = "wake-full" + elif action == "sleep": + planner.record_sleep(t) + awake = False + log.append((t, action)) + if awake: + awake_s += poll + t += poll + return log, awake_s + + +def _times(log, action): + return [t for t, a in log if a == action] + + +def _changing_every(period_s): + """Inhalt, der sich alle `period_s` Sekunden aendert (und sonst gleich bleibt).""" + return lambda t: ["RETTUNG", f"Ausgeliefert: {int(t // period_s)}"] + + +def test_static_content_never_triggers_another_full_refresh(monkeypatch): + """Fixrunde 3, F3 - vom Reviewer per Mutation als ungetestet belegt: nimmt man die + Bedingung `partials_since_full > 0` aus `stale` heraus, bleiben alle 21 Tests gruen. + Genau diese Bedingung verhindert aber den 33-%-Bias-Zyklus: bei UNVERAENDERTEM Inhalt + gibt es keine Geisterbilder aus Teilaufbauten auszugleichen, ein Vollaufbau alle 30 + Minuten waere reine Alterung - und weckt das Panel jedesmal fuer weitere 10 Minuten + auf.""" + log, awake_s = _run_schedule(lambda t: ["RETTUNG", "Ausgeliefert: 0"], SIX_HOURS_S) + + assert _times(log, "full") == [0.0], ( + f"nur der Erstaufbau darf voll sein, tatsaechlich: {_times(log, 'full')}") + assert _times(log, "partial") == [] + assert _times(log, "sleep") == [600.0], "genau einmal einschlafen, dann Ruhe" + assert awake_s <= 700, f"Panel war {awake_s} s wach - erwartet nur die ersten 10 min" + + +def test_change_every_3_minutes_stays_awake_and_compensates_every_30_minutes(): + log, _ = _run_schedule(_changing_every(3 * 60), SIX_HOURS_S) + + fulls = _times(log, "full") + assert _times(log, "sleep") == [], "alle 3 min Verkehr - das Panel darf nicht schlafen" + assert len(_times(log, "partial")) > 100, "die Aenderungen muessen Teilaufbauten sein" + assert len(fulls) >= 10, f"Ghosting-Ausgleich alle 30 min fehlt: {fulls}" + gaps = [b - a for a, b in zip(fulls, fulls[1:])] + assert all(gap >= display.FULL_REFRESH_INTERVAL_S for gap in gaps), ( + f"Vollaufbauten zu dicht: {fulls}") + + +def test_change_every_9_minutes_still_never_sleeps(): + """9 Minuten liegen knapp unter SLEEP_AFTER_IDLE_S (10 min) - das Panel bleibt wach, + die Teilaufbauten sammeln sich, und die 30-Minuten-Uhr gleicht sie aus.""" + log, _ = _run_schedule(_changing_every(9 * 60), SIX_HOURS_S) + + fulls = _times(log, "full") + assert _times(log, "sleep") == [] + assert _times(log, "partial"), "Aenderungen bei wachem Panel sind Teilaufbauten" + assert len(fulls) >= 5, f"Ghosting-Ausgleich fehlt: {fulls}" + gaps = [b - a for a, b in zip(fulls, fulls[1:])] + assert all(gap >= display.FULL_REFRESH_INTERVAL_S for gap in gaps) + + +def test_change_every_11_minutes_sleeps_between_and_wakes_with_a_full_refresh(): + """11 Minuten liegen knapp ueber SLEEP_AFTER_IDLE_S: das Panel schlaeft zwischen zwei + Aenderungen ein, und der naechste Aufbau wird von Panel.draw_partial() selbst zu einem + Vollaufbau aufgewertet - es darf also gar kein Teilaufbau mehr entstehen, und die + 30-Minuten-Uhr darf nie zusaetzlich feuern (jeder Aufbau IST schon ein Vollaufbau).""" + log, _ = _run_schedule(_changing_every(11 * 60), SIX_HOURS_S) + + assert _times(log, "partial") == [], ( + "nach dem Schlafen wertet das Panel jeden Aufbau selbst zu einem Vollaufbau auf") + assert len(_times(log, "wake-full")) == SIX_HOURS_S // (11 * 60), ( + f"pro Aenderung ein Aufbau erwartet: {_times(log, 'wake-full')}") + assert len(_times(log, "sleep")) >= 15, "zwischen den Aenderungen muss geschlafen werden" + # Nur der Erstaufbau ist ein regulaerer Vollaufbau: jeder weitere Aufbau IST wegen der + # Aufwertung schon vollstaendig, ein zusaetzlicher "stale"-Vollaufbau waere ein Zeichen, + # dass die Aufwertung nicht als Vollaufbau verbucht wurde (F4). + assert _times(log, "full") == [0.0], f"unerwartete Vollaufbauten: {_times(log, 'full')}" + + +def test_burst_then_quiet_still_gets_its_ghosting_compensation(): + """Fixrunde 4, N1 - der Dauerzustand, den jemand am laengsten ansieht: ein Buendel von + 30 Teilaufbauten in 2,5 Minuten (Migration mehrerer Lampen), danach stundenlang Ruhe. + + Die Fassung aus Fixrunde 3 setzte partials_since_full beim Einschlafen auf 0; das + Panel schlief 10 Minuten nach dem Buendel mit dem entstandenen Geisterbild ein, stale + konnte danach nie mehr feuern, und genau dieses Bild stand die restlichen rund 5 h 50 + min unveraendert da. Der faellige Ausgleich muss stattdessen nachgeholt werden - und + das Panel danach wieder zur Ruhe kommen.""" + def content_at(t): + # 30 Aenderungen im 5-Sekunden-Takt (t = 5 .. 150), danach unveraendert. + step = min(int(t // display.POLL_INTERVAL_S), 30) + return ["MIGRATION", f"Ausgeliefert: {step}"] + + log, awake_s = _run_schedule(content_at, SIX_HOURS_S, mode="migration") + + assert len(_times(log, "partial")) == 30, ( + f"das Buendel muss aus Teilaufbauten bestehen: {_times(log, 'partial')}") + + fulls = _times(log, "full") + assert len(fulls) == 2, f"Erstaufbau + genau ein Ausgleich erwartet, war: {fulls}" + ausgleich = fulls[1] + assert display.FULL_REFRESH_INTERVAL_S <= ausgleich <= 35 * 60, ( + f"Ghosting-Ausgleich faellt aus oder kommt zu spaet (t={ausgleich})") + + # ... und danach wirklich Ruhe: einmal nach dem Buendel, einmal nach dem Ausgleich. + assert len(_times(log, "sleep")) == 2, f"unruhiger Schlaf: {_times(log, 'sleep')}" + assert awake_s <= 30 * 60, ( + f"Panel war {awake_s} s von {SIX_HOURS_S} s wach - der Ausgleich darf nicht in " + "einen Wachzyklus ausarten") + + +def test_planner_counts_an_upgraded_partial_as_a_full_refresh(): + """Direkt an der Zustandsmaschine (F4): meldet der Aufrufer eine von + Panel.draw_partial() selbst aufgewertete Teilaktualisierung als Vollaufbau, beginnt die + 30-Minuten-Uhr neu und es gibt nichts mehr auszugleichen.""" + planner = display.RefreshPlanner(0.0) + planner.record_draw(0.0, ["a"], "recovery", was_full=True) + planner.record_draw(10.0, ["b"], "recovery", was_full=False) + assert planner.partials_since_full == 1 + + planner.record_draw(20.0, ["c"], "recovery", was_full=True) # aufgewerteter Teilaufbau + + assert planner.partials_since_full == 0 + assert planner.decide(20.0 + display.FULL_REFRESH_INTERVAL_S, ["c"], "recovery") != "full" + + +def test_planner_draws_fully_on_mode_change(): + planner = display.RefreshPlanner(0.0) + planner.record_draw(0.0, ["RETTUNG"], "recovery", was_full=True) + + assert planner.decide(5.0, ["MIGRATION"], "migration") == "full"