mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-16 11:59:13 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37d2280a15 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user