Compare commits

...
Author SHA1 Message Date
ChuckandClaude Opus 5.5 37d2280a15 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 <noreply@anthropic.com>
2026-10-15 18:06:48 -04:00
6 changed files with 170 additions and 17 deletions
+11
View File
@@ -76,6 +76,17 @@ to for one release are gone.
interface can no longer start or stop on-demand sessions or clear errors interface can no longer start or stop on-demand sessions or clear errors
on a running display (the mailbox used to carry them). 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 ## 3.8.7
Sports consolidation family 10: `src.common.sports_view_model`, the game Sports consolidation family 10: `src.common.sports_view_model`, the game
+5 -2
View File
@@ -21,7 +21,10 @@ Each plugin has a *Vegas participation*:
**`pause`:** **`pause`:**
- The scroll stops when the plugin's turn comes round - The scroll stops when the plugin's turn comes round
- The plugin draws the whole panel for its display duration, then the - 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 - Best for content that needs to be read in full, or alerts
**`exclude`:** **`exclude`:**
@@ -362,7 +365,7 @@ Vegas mode consists of four core components working together to provide smooth 1
**Main Loop:** **Main Loop:**
1. Check for interrupts (live priority, on-demand, config updates) 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 3. Otherwise, delegate to render pipeline for frame rendering
4. Sleep to maintain target FPS 4. Sleep to maintain target FPS
+1 -1
View File
@@ -328,7 +328,7 @@ Each plugin takes part in Vegas mode in one of three ways:
| Participation | What Vegas does | | Participation | What Vegas does |
|---|---| |---|---|
| `'scroll'` | The plugin's content (`get_vegas_content()`) scrolls by with everything else | | `'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 | | `'exclude'` | The plugin is left out of Vegas mode |
Declare the plugin's default in `manifest.json`: Declare the plugin's default in `manifest.json`:
+50 -11
View File
@@ -23,6 +23,7 @@ from typing import Optional, Dict, Any, FrozenSet, List, Callable, TYPE_CHECKING
from src import display_watchdog from src import display_watchdog
from src.common import render_gate from src.common import render_gate
from src.plugin_system.base_plugin import finite_seconds 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.config import VegasModeConfig
from src.vegas_mode.elements import LiveEpochs from src.vegas_mode.elements import LiveEpochs
from src.vegas_mode.plugin_adapter import PluginAdapter from src.vegas_mode.plugin_adapter import PluginAdapter
@@ -970,25 +971,38 @@ class VegasModeCoordinator:
# turn rather than stall the marquee. # turn rather than stall the marquee.
get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None) get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None)
plugin_lock = get_lock(plugin_id) if get_lock else None plugin_lock = get_lock(plugin_id) if get_lock else None
if plugin_lock is not None and not plugin_lock.acquire( if not self._draw_static(plugin, plugin_lock, self.STATIC_LOCK_TIMEOUT,
timeout=self.STATIC_LOCK_TIMEOUT): force_clear=True):
logger.info("Static pause skipped for %s: its update() is still running", logger.info("Static pause skipped for %s: its update() is still running",
plugin_id) plugin_id)
return True 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 # Wait for the plugin's display duration. Monotonic, like the
# iteration clock: an NTP step on an RTC-less Pi would otherwise # iteration clock: an NTP step on an RTC-less Pi would otherwise
# end the pause at once or stretch it by the correction. # end the pause at once or stretch it by the correction.
duration = self._static_pause_duration(plugin) duration = self._static_pause_duration(plugin)
start = time.monotonic() 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 # Check for interruptions
if self._should_stop: if self._should_stop:
logger.info("Static pause interrupted by stop request") logger.info("Static pause interrupted by stop request")
@@ -1003,9 +1017,8 @@ class VegasModeCoordinator:
logger.info("Static pause interrupted by the display controller") logger.info("Static pause interrupted by the display controller")
return False return False
# Sleep in small increments to remain responsive
time.sleep(0.1)
display_watchdog.beat() display_watchdog.beat()
time.sleep(max(0.0, min(next_draw, next_check) - time.monotonic()))
logger.info( logger.info(
"Static pause completed for %s after %.1fs", "Static pause completed for %s after %.1fs",
@@ -1021,6 +1034,32 @@ class VegasModeCoordinator:
return True 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: def _static_pause_duration(self, plugin: 'BasePlugin') -> float:
"""Seconds a static pause shows ``plugin``: its display duration, """Seconds a static pause shows ``plugin``: its display duration,
read the way the rotation reads it. read the way the rotation reads it.
+98 -1
View File
@@ -14,6 +14,7 @@ from collections import deque
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
from PIL import Image from PIL import Image
from src.plugin_system.base_plugin import VegasDisplayMode from src.plugin_system.base_plugin import VegasDisplayMode
@@ -233,7 +234,7 @@ class TestCoordinatorStaticPause:
plugin.display.side_effect = lambda **k: seen.append(lock.locked()) plugin.display.side_effect = lambda **k: seen.append(lock.locked())
coord = self._coord(plugin, lock) coord = self._coord(plugin, lock)
assert coord._handle_static_pause(plugin) is True assert coord._handle_static_pause(plugin) is True
assert seen == [True] assert seen and all(seen)
assert not lock.locked() assert not lock.locked()
def test_pause_is_skipped_while_update_holds_the_lock(self): def test_pause_is_skipped_while_update_holds_the_lock(self):
@@ -247,3 +248,99 @@ class TestCoordinatorStaticPause:
finally: finally:
lock.release() lock.release()
plugin.display.assert_not_called() 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
+5 -2
View File
@@ -19,7 +19,7 @@ import logging
import os import os
import threading import threading
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock, call
os.environ.setdefault("EMULATOR", "true") os.environ.setdefault("EMULATOR", "true")
@@ -62,6 +62,7 @@ def _plugin(duration, plugin_id='clock-simple'):
plugin = MagicMock() plugin = MagicMock()
plugin.plugin_id = plugin_id plugin.plugin_id = plugin_id
plugin.get_display_duration.return_value = duration plugin.get_display_duration.return_value = duration
plugin.needs_high_fps = False
return plugin return plugin
@@ -104,7 +105,9 @@ class TestPauseLength:
completed, lasted = _pause(_coord(plugin), plugin, clock) completed, lasted = _pause(_coord(plugin), plugin, clock)
assert completed is True assert completed is True
assert lasted == pytest.approx(30.0, abs=0.15) 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) @pytest.mark.parametrize('value', NOT_ABOVE_ZERO, ids=repr)
def test_a_number_not_above_zero_pauses_for_15s(self, clock, value): def test_a_number_not_above_zero_pauses_for_15s(self, clock, value):