mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
The preview snapshot (/api/v3/display/current, the web UI's live preview) is written only from update_display(), at most once per write interval. A changed frame pushed inside that interval was skipped and left for the next update_display() -- which a screen that draws its card once and then holds it never makes. Soccer's recent/upcoming cards skip redundant redraws. After an on-demand start, the card is pushed a few milliseconds after the start's clear wrote a black frame, so the throttle skipped it and the preview stayed black for the whole 15 s screen while the panel showed the card. The next screen looked fine because its first push came after the interval had passed. DisplayManager now records a skipped changed frame as owed, and the render loop (_display_once) calls write_owed_snapshot() after each frame, which writes it once the interval has passed. The cadence is unchanged, an unchanged frame is never re-encoded, and nothing runs when no frame is owed. Golden run-loop traces are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
5.5 KiB
Python
163 lines
5.5 KiB
Python
"""A frame the preview throttle skipped still reaches the snapshot.
|
|
|
|
The preview snapshot (/api/v3/display/current, the web UI's live preview) is
|
|
only written from update_display(), at most once per write interval. A screen
|
|
that draws its card once and then holds it -- soccer's recent/upcoming cards
|
|
skip redundant redraws -- pushes exactly one frame. When that push lands inside
|
|
the interval, e.g. a few milliseconds after the on-demand start's clear wrote a
|
|
black frame, the throttle skips it and nothing ever writes it: on ledpi the
|
|
preview stayed black for soccer's whole 15 s screen while the panel showed the
|
|
card, and the next screen "rendered immediately".
|
|
|
|
Runs the real DisplayManager on the emulator, like test_display_dirty_tracking.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import types
|
|
|
|
os.environ["EMULATOR"] = "true"
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def dm(tmp_path_factory):
|
|
from src.display_manager import DisplayManager
|
|
DisplayManager._instance = None
|
|
manager = DisplayManager({
|
|
"display": {
|
|
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
|
"parallel": 1, "brightness": 90},
|
|
"runtime": {"gpio_slowdown": 0},
|
|
},
|
|
}, suppress_test_pattern=True)
|
|
manager._snapshot_path = str(
|
|
tmp_path_factory.mktemp("owed_snapshot") / "led_matrix_preview.png")
|
|
yield manager
|
|
DisplayManager._instance = None
|
|
|
|
|
|
@pytest.fixture
|
|
def viewer(dm, monkeypatch, tmp_path):
|
|
"""A preview is open (1 s write interval); fresh snapshot bookkeeping."""
|
|
monkeypatch.setattr(dm, "_viewer_is_fresh", lambda now: True)
|
|
dm._viewer_was_fresh = True
|
|
dm._snapshot_path = str(tmp_path / "snap.png")
|
|
dm._last_snapshot_ts = 0.0
|
|
dm._last_snapshot_touch_ts = 0.0
|
|
dm._last_snapshot_digest = None
|
|
dm._saved_snapshot_digest = None
|
|
dm._snapshot_owed = False
|
|
dm.set_scrolling_state(False)
|
|
return dm
|
|
|
|
|
|
def _lit(path):
|
|
with Image.open(path) as img:
|
|
return sum(1 for p in img.convert("RGB").getdata() if max(p) > 20)
|
|
|
|
|
|
def _age_last_write(dm, seconds=2.0):
|
|
"""As if `seconds` had passed since the last snapshot write."""
|
|
dm._last_snapshot_ts -= seconds
|
|
dm._last_snapshot_touch_ts -= seconds
|
|
|
|
|
|
def _clear_then_draw_card(dm):
|
|
"""The on-demand start's clear, then the card a few ms later."""
|
|
dm.clear()
|
|
dm.update_display() # black frame: written
|
|
assert _lit(dm._snapshot_path) == 0
|
|
dm.draw.rectangle([4, 4, 40, 20], fill=(255, 255, 0))
|
|
dm.update_display() # the card: inside the interval
|
|
|
|
|
|
def _controller(dm):
|
|
from src import display_controller as dc_module
|
|
controller = dc_module.DisplayController.__new__(dc_module.DisplayController)
|
|
controller.plugin_manager = None
|
|
controller.display_manager = dm
|
|
return controller
|
|
|
|
|
|
class _HoldingPlugin:
|
|
"""Already showing its card: display() returns True and draws nothing."""
|
|
|
|
plugin_id = "holding"
|
|
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def display(self, display_mode=None, force_clear=False):
|
|
self.calls += 1
|
|
return True
|
|
|
|
|
|
def test_a_held_card_reaches_the_preview_on_the_next_frame(viewer):
|
|
dm = viewer
|
|
_clear_then_draw_card(dm)
|
|
assert _lit(dm._snapshot_path) == 0 # the throttle skipped the card
|
|
|
|
controller = _controller(dm)
|
|
plugin = _HoldingPlugin()
|
|
_age_last_write(dm)
|
|
# The render loop's next frame: the plugin draws nothing and makes no
|
|
# update_display() call, as soccer's switch cards do.
|
|
assert controller._display_once(plugin, "soccer_eng.1_recent", True) is True
|
|
assert plugin.calls == 1
|
|
assert _lit(dm._snapshot_path) > 0
|
|
|
|
|
|
def test_the_owed_write_still_waits_out_the_interval(viewer, monkeypatch):
|
|
dm = viewer
|
|
_clear_then_draw_card(dm)
|
|
saves = []
|
|
monkeypatch.setattr(dm, "_save_snapshot", lambda image: saves.append(image))
|
|
dm.write_owed_snapshot() # still inside the interval
|
|
assert saves == []
|
|
_age_last_write(dm)
|
|
dm.write_owed_snapshot()
|
|
assert len(saves) == 1
|
|
# Written: nothing is owed, so later frames do no work and the unchanged
|
|
# frame is not encoded again.
|
|
assert dm._snapshot_owed is False
|
|
_age_last_write(dm)
|
|
dm.write_owed_snapshot()
|
|
assert len(saves) == 1
|
|
|
|
|
|
def test_nothing_owed_after_a_frame_that_was_written(viewer, monkeypatch):
|
|
dm = viewer
|
|
dm.draw.rectangle([0, 0, 8, 8], fill=(0, 255, 0))
|
|
dm.update_display() # due: written at once
|
|
assert dm._snapshot_owed is False
|
|
calls = []
|
|
monkeypatch.setattr(dm, "_write_snapshot_if_due",
|
|
lambda *a, **k: calls.append(a))
|
|
dm.write_owed_snapshot()
|
|
assert calls == []
|
|
|
|
|
|
def test_an_unchanged_frame_inside_the_interval_is_not_owed(viewer):
|
|
dm = viewer
|
|
dm.draw.rectangle([0, 0, 8, 8], fill=(0, 0, 255))
|
|
dm.update_display()
|
|
dm.update_display() # same frame, inside the interval
|
|
assert dm._snapshot_owed is False
|
|
|
|
|
|
def test_a_controller_without_the_hook_still_draws():
|
|
"""Controllers built without a display manager (tests) are unaffected."""
|
|
from src import display_controller as dc_module
|
|
controller = dc_module.DisplayController.__new__(dc_module.DisplayController)
|
|
controller.plugin_manager = None
|
|
plugin = _HoldingPlugin()
|
|
assert controller._display_once(plugin, "x", True) is True
|
|
controller.display_manager = types.SimpleNamespace()
|
|
assert controller._display_once(plugin, "x", True) is True
|
|
assert plugin.calls == 2
|