From 37d2280a155e42e055d9edebb965969dbd6554ea Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:05:55 -0400 Subject: [PATCH] fix(vegas): a static pause redraws its plugin for the whole pause The pause called display(force_clear=True) once and slept out the duration, so a plugin that rotates its screens or animates (Fantasy Blitz's playlist) was frozen on its first frame. The pause now calls display() again at the rotation's rate: once a second, or the high-FPS interval for a plugin that declares needs_high_fps (or scrolls). Only the first call clears. A redraw that finds update() holding the plugin lock is skipped; the pause carries on. Interrupt checks and the watchdog beat still run every 0.1 s; a raise still ends the pause. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++ docs/ADVANCED_FEATURES.md | 7 +- docs/PLUGIN_API_REFERENCE.md | 2 +- src/vegas_mode/coordinator.py | 61 ++++++++++++--- test/test_vegas_static_mode.py | 99 +++++++++++++++++++++++- test/test_vegas_static_pause_duration.py | 7 +- 6 files changed, 170 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe02195..1f4933b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,17 @@ to for one release are gone. interface can no longer start or stop on-demand sessions or clear errors on a running display (the mailbox used to carry them). +### Fixes + +- Vegas: a `pause` (STATIC) turn redraws its plugin for the whole pause, + once a second, or at the high-FPS rate for a plugin that declares + `needs_high_fps` (or scrolls) -- the rate the rotation uses. The pause used + to call `display()` once and sleep out the duration, so a plugin that + rotates its screens or animates was frozen on its first frame. The first + draw still passes `force_clear=True`; later ones pass nothing. A redraw + that finds the plugin's `update()` holding its lock is skipped; the pause + carries on. + ## 3.8.7 Sports consolidation family 10: `src.common.sports_view_model`, the game diff --git a/docs/ADVANCED_FEATURES.md b/docs/ADVANCED_FEATURES.md index 69137c0b..70723580 100644 --- a/docs/ADVANCED_FEATURES.md +++ b/docs/ADVANCED_FEATURES.md @@ -21,7 +21,10 @@ Each plugin has a *Vegas participation*: **`pause`:** - The scroll stops when the plugin's turn comes round - The plugin draws the whole panel for its display duration, then the - scroll resumes + scroll resumes. `display()` is called again through the pause at the + rotation's rate (once a second, or the high-FPS rate for a plugin that + declares `needs_high_fps`), so a plugin can rotate screens or animate in + it; only the first call passes `force_clear=True` - Best for content that needs to be read in full, or alerts **`exclude`:** @@ -362,7 +365,7 @@ Vegas mode consists of four core components working together to provide smooth 1 **Main Loop:** 1. Check for interrupts (live priority, on-demand, config updates) -2. If static pause active, wait for duration +2. If a static pause is due, redraw that plugin for its duration 3. Otherwise, delegate to render pipeline for frame rendering 4. Sleep to maintain target FPS diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index 884dfb0f..86f447ba 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -328,7 +328,7 @@ Each plugin takes part in Vegas mode in one of three ways: | Participation | What Vegas does | |---|---| | `'scroll'` | The plugin's content (`get_vegas_content()`) scrolls by with everything else | -| `'pause'` | The scroll stops when the plugin's turn comes round; its `display()` draws it full screen for `get_display_duration()` seconds, then the scroll resumes | +| `'pause'` | The scroll stops when the plugin's turn comes round; its `display()` draws it full screen for `get_display_duration()` seconds, then the scroll resumes. `display()` is called again through the pause, as in the rotation: once a second, or at the high-FPS rate if the plugin declares `needs_high_fps`; the first call passes `force_clear=True` | | `'exclude'` | The plugin is left out of Vegas mode | Declare the plugin's default in `manifest.json`: diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index bcdb3794..3ba5b65c 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -23,6 +23,7 @@ from typing import Optional, Dict, Any, FrozenSet, List, Callable, TYPE_CHECKING from src import display_watchdog from src.common import render_gate from src.plugin_system.base_plugin import finite_seconds +from src.screen_runner import HIGH_FPS_INTERVAL, STATIC_INTERVAL from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.elements import LiveEpochs from src.vegas_mode.plugin_adapter import PluginAdapter @@ -970,25 +971,38 @@ class VegasModeCoordinator: # turn rather than stall the marquee. get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None) plugin_lock = get_lock(plugin_id) if get_lock else None - if plugin_lock is not None and not plugin_lock.acquire( - timeout=self.STATIC_LOCK_TIMEOUT): + if not self._draw_static(plugin, plugin_lock, self.STATIC_LOCK_TIMEOUT, + force_clear=True): logger.info("Static pause skipped for %s: its update() is still running", plugin_id) return True - try: - plugin.display(force_clear=True) - finally: - if plugin_lock is not None: - plugin_lock.release() - self.display_manager.update_display() # Wait for the plugin's display duration. Monotonic, like the # iteration clock: an NTP step on an RTC-less Pi would otherwise # end the pause at once or stretch it by the correction. duration = self._static_pause_duration(plugin) start = time.monotonic() + # Redraw for the whole pause at the rotation's own rate. One + # display() used to be all a pause got, so a plugin that rotates + # its screens or animates was frozen on its first frame. + interval = (HIGH_FPS_INTERVAL if self._static_needs_high_fps(plugin) + else STATIC_INTERVAL) + next_draw = start + interval + next_check = start + + while (now := time.monotonic()) - start < duration: + if now >= next_draw: + next_draw = max(next_draw, now) + interval + # No wait for the lock: update() holding it costs this + # frame only, and the next one tries again. + self._draw_static(plugin, plugin_lock, 0) + if now < next_check: + time.sleep(max(0.0, min(next_draw, next_check) - time.monotonic())) + continue + # Every 0.1 s, however fast the redraws: the live-priority + # check asks the plugins. + next_check = now + 0.1 - while time.monotonic() - start < duration: # Check for interruptions if self._should_stop: logger.info("Static pause interrupted by stop request") @@ -1003,9 +1017,8 @@ class VegasModeCoordinator: logger.info("Static pause interrupted by the display controller") return False - # Sleep in small increments to remain responsive - time.sleep(0.1) display_watchdog.beat() + time.sleep(max(0.0, min(next_draw, next_check) - time.monotonic())) logger.info( "Static pause completed for %s after %.1fs", @@ -1021,6 +1034,32 @@ class VegasModeCoordinator: return True + def _draw_static(self, plugin: 'BasePlugin', plugin_lock: Any, + timeout: float, **kwargs: Any) -> bool: + """One display() of a static pause, under the plugin's lock like + every other display() call: without it this could draw while the + update worker is inside the plugin's update(). False when update() + held the lock past ``timeout`` and nothing was drawn.""" + if plugin_lock is not None and not plugin_lock.acquire(timeout=timeout): + return False + try: + plugin.display(**kwargs) + finally: + if plugin_lock is not None: + plugin_lock.release() + self.display_manager.update_display() + return True + + @staticmethod + def _static_needs_high_fps(plugin: 'BasePlugin') -> bool: + """Whether the rotation would run ``plugin`` in its high-FPS loop: + DisplayController._needs_high_fps's rule, without its static-image + special case (static-image declares needs_high_fps).""" + declared = getattr(plugin, 'needs_high_fps', None) + if declared is None: + declared = getattr(plugin, 'enable_scrolling', False) + return bool(declared) + def _static_pause_duration(self, plugin: 'BasePlugin') -> float: """Seconds a static pause shows ``plugin``: its display duration, read the way the rotation reads it. diff --git a/test/test_vegas_static_mode.py b/test/test_vegas_static_mode.py index f8da3382..23746eb5 100644 --- a/test/test_vegas_static_mode.py +++ b/test/test_vegas_static_mode.py @@ -14,6 +14,7 @@ from collections import deque from types import SimpleNamespace from unittest.mock import MagicMock +import pytest from PIL import Image from src.plugin_system.base_plugin import VegasDisplayMode @@ -233,7 +234,7 @@ class TestCoordinatorStaticPause: plugin.display.side_effect = lambda **k: seen.append(lock.locked()) coord = self._coord(plugin, lock) assert coord._handle_static_pause(plugin) is True - assert seen == [True] + assert seen and all(seen) assert not lock.locked() def test_pause_is_skipped_while_update_holds_the_lock(self): @@ -247,3 +248,99 @@ class TestCoordinatorStaticPause: finally: lock.release() plugin.display.assert_not_called() + + +class FakeClock: + """time.monotonic/time.sleep for the pause loop: sleeping moves the clock.""" + + def __init__(self): + self.now = 0.0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + assert seconds >= 0 + self.now += seconds + if self.now > 3600: + raise RuntimeError("the static pause never ended") + + +class TestStaticPauseRedraws: + """A pause redraws its plugin for as long as it lasts. + + It used to call display() once and sleep out the duration, so a plugin + that rotates its screens or animates (Fantasy Blitz's playlist, say) was + frozen on its first frame for the whole pause. + """ + + def _run(self, monkeypatch, duration=10.0, high_fps=False, interrupt_at=None, + lock=None): + from src.vegas_mode import coordinator + clock = FakeClock() + monkeypatch.setattr(coordinator, 'time', clock) + plugin = MagicMock() + plugin.plugin_id = 'clock' + plugin.needs_high_fps = high_fps + plugin.get_display_duration.return_value = duration + calls = [] + plugin.display.side_effect = lambda **k: calls.append((clock.now, k)) + coord = TestCoordinatorStaticPause()._coord(plugin, lock) + if interrupt_at is not None: + coord._interrupt_check = lambda: clock.now >= interrupt_at + return coord._handle_static_pause(plugin), calls, clock + + def test_redraws_once_a_second_for_the_whole_pause(self, monkeypatch): + done, calls, clock = self._run(monkeypatch, duration=10.0) + assert done is True + assert len(calls) == 10 + assert [round(t, 6) for t, _ in calls] == [float(i) for i in range(10)] + assert 10.0 <= clock.now < 10.2 + + def test_only_the_first_draw_clears(self, monkeypatch): + _, calls, _ = self._run(monkeypatch, duration=3.0) + assert calls[0][1] == {'force_clear': True} + assert all(kwargs == {} for _, kwargs in calls[1:]) + + def test_a_high_fps_plugin_redraws_at_the_high_fps_rate(self, monkeypatch): + from src.screen_runner import HIGH_FPS_INTERVAL + _, calls, _ = self._run(monkeypatch, duration=1.0, high_fps=True) + assert len(calls) == pytest.approx(1.0 / HIGH_FPS_INTERVAL, abs=2) + + def test_an_interrupt_still_ends_the_pause(self, monkeypatch): + done, calls, clock = self._run(monkeypatch, duration=30.0, interrupt_at=2.5) + assert done is False + assert clock.now < 2.7 + assert len(calls) == 3 + + def test_a_held_lock_skips_one_redraw_not_the_pause(self, monkeypatch): + lock = threading.Lock() + real_acquire = lock.acquire + held = {'count': 0} + + class FlakyLock: + def acquire(self, timeout=-1): + held['count'] += 1 + # update() holds the lock at the third draw only. + return False if held['count'] == 3 else real_acquire(timeout=timeout) + + def release(self): + lock.release() + + done, calls, _ = self._run(monkeypatch, duration=5.0, lock=FlakyLock()) + assert done is True + assert [round(t) for t, _ in calls] == [0, 1, 3, 4] + + def test_a_raise_from_a_redraw_ends_the_pause(self, monkeypatch): + from src.vegas_mode import coordinator + clock = FakeClock() + monkeypatch.setattr(coordinator, 'time', clock) + plugin = MagicMock() + plugin.plugin_id = 'clock' + plugin.needs_high_fps = False + plugin.get_display_duration.return_value = 30.0 + plugin.display.side_effect = [None, RuntimeError('boom')] + coord = TestCoordinatorStaticPause()._coord(plugin) + assert coord._handle_static_pause(plugin) is False + assert clock.now < 1.2 + assert coord._static_pause_active is False diff --git a/test/test_vegas_static_pause_duration.py b/test/test_vegas_static_pause_duration.py index 957e45f7..021e71bf 100644 --- a/test/test_vegas_static_pause_duration.py +++ b/test/test_vegas_static_pause_duration.py @@ -19,7 +19,7 @@ import logging import os import threading from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call os.environ.setdefault("EMULATOR", "true") @@ -62,6 +62,7 @@ def _plugin(duration, plugin_id='clock-simple'): plugin = MagicMock() plugin.plugin_id = plugin_id plugin.get_display_duration.return_value = duration + plugin.needs_high_fps = False return plugin @@ -104,7 +105,9 @@ class TestPauseLength: completed, lasted = _pause(_coord(plugin), plugin, clock) assert completed is True assert lasted == pytest.approx(30.0, abs=0.15) - plugin.display.assert_called_once_with(force_clear=True) + # Redrawn once a second; only the first draw clears. + assert plugin.display.call_args_list[0] == call(force_clear=True) + assert plugin.display.call_count == 30 @pytest.mark.parametrize('value', NOT_ABOVE_ZERO, ids=repr) def test_a_number_not_above_zero_pauses_for_15s(self, clock, value):