fix(display): live games take over within a second, and straight from Vegas (#713)

A game that goes live takes over within about a second (_check_live_takeover in the frame loops and the dwell sleep, throttled to 1 s, never during on-demand, scheduled-off, live_in_ticker or an already-live screen); an interrupted Vegas iteration switches straight to the game; has_live_content() is asked once per plugin per scan. Goldens: live_priority, vegas.

Merged with #712: after an interrupted Vegas iteration the WiFi-notice check runs before the live switch (WiFi outranks live). Adds test/test_run_loop_wifi_and_live.py, pinning that a notice and a game arriving during the same screen (1 Hz, 125 Hz, Vegas) show the notice first, then the game, and neither while scheduled off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 15:59:14 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6c6394a1a7
commit 77e0ea91ae
7 changed files with 492 additions and 27 deletions
+86 -6
View File
@@ -1145,9 +1145,10 @@ class DisplayController:
Also services pending changes (see _service_pending_changes), and
returns early when one of them changes what the panel should show --
an on-demand start or stop, the display schedule turning the panel
on or off, or a WiFi notice arriving -- so the caller can act on it
instead of finishing a dwell that could be a minute long (sixty
seconds while scheduled off).
on or off, a WiFi notice arriving, or a live game taking over
(_check_live_takeover) -- so the caller can act on it instead of
finishing a dwell that could be a minute long (sixty seconds while
scheduled off).
"""
if duration <= 0:
return
@@ -1173,6 +1174,7 @@ class DisplayController:
display_watchdog.watchdog.beat()
self._tick_plugin_updates()
self._service_pending_changes()
self._check_live_takeover()
if (self.current_display_mode != mode
or self.is_display_active != display_active
or self.on_demand_active != on_demand
@@ -2252,15 +2254,22 @@ class DisplayController:
preserving order. A plugin registered under several mode keys (the
sports plugins register one per league) contributes each live mode once.
"""
self._last_live_scan = time.monotonic()
live = []
seen = set()
# Asked once per plugin per scan, not once per mode key: a scoreboard
# registered under several modes computes the same answer each time.
is_live: Dict[int, bool] = {}
for mode_name, plugin_instance in self.plugin_modes.items():
if not (hasattr(plugin_instance, 'has_live_priority')
and hasattr(plugin_instance, 'has_live_content')):
continue
try:
if not (plugin_instance.has_live_priority()
and plugin_instance.has_live_content()):
key = id(plugin_instance)
if key not in is_live:
is_live[key] = bool(plugin_instance.has_live_priority()
and plugin_instance.has_live_content())
if not is_live[key]:
continue
resolved = []
if hasattr(plugin_instance, 'get_live_modes'):
@@ -2275,6 +2284,7 @@ class DisplayController:
live.append(m)
except Exception as e:
logger.warning("Error checking live priority for %s: %s", mode_name, e)
self._last_live_modes = tuple(live)
return live
def _vegas_keeps_live_in_ticker(self) -> bool:
@@ -2311,6 +2321,58 @@ class DisplayController:
return self.current_display_mode
return live_modes[0]
#: Shortest gap between live-priority scans made mid-screen. A scan asks
#: every live-priority plugin has_live_content(), which the scoreboards
#: compute by filtering their game lists. Once a second matches the 1 Hz
#: frame loop and is a quarter of the rate Vegas already polls at.
LIVE_TAKEOVER_INTERVAL = 1.0
#: Class-level defaults for controllers built without __init__ (tests).
#: When the last live-priority scan of any kind ran (_collect_live_modes).
_last_live_scan: Optional[float] = None
#: What that scan found.
_last_live_modes: Tuple[str, ...] = ()
#: A mid-screen takeover chose current_display_mode and it has not been
#: shown yet, so the next pass must not advance the live round-robin past it.
_live_takeover_unshown: bool = False
def _check_live_takeover(self) -> None:
"""Hand the panel to a live game that started while a screen runs.
Called from the frame loops and the dwell sleep. Live priority used
to be checked only between screens, so a game that went live during
a 30 s screen waited for it to end. This switches current_display_mode
to the live mode, which ends the screen the way any other mode change
does. Throttled to LIVE_TAKEOVER_INTERVAL since the last scan of any
kind. Nothing happens while an on-demand session is active, while
the panel is scheduled off, while Vegas keeps live content in its
ticker, or when the screen showing is already a live mode.
A live screen is not rescanned at all: live priority put it there,
and live games take turns between screens, not mid-screen.
"""
if self.current_display_mode in self._last_live_modes:
return
last = self._last_live_scan
if last is not None and time.monotonic() - last < self.LIVE_TAKEOVER_INTERVAL:
return
if self.on_demand_active or not self.is_display_active:
return
try:
coordinator = getattr(self, 'vegas_coordinator', None)
if (coordinator is not None and coordinator.is_enabled
and self._vegas_keeps_live_in_ticker()):
return
live_modes = self._collect_live_modes()
if not live_modes or self.current_display_mode in live_modes:
return
self._apply_live_priority(live_modes[0])
self._live_takeover_unshown = True
except Exception: # pylint: disable=broad-except
# Called from inside the frame loops; a failure here must not
# take the display loop down with it.
logger.exception("Error checking for a live-priority takeover")
# -- Pieces of run() --------------------------------------------------
# Extracted from run() unchanged, as the first step of restructuring it
# into an Arbiter / ScreenRunner / Sources (docs/RUN_LOOP_REDESIGN.md).
@@ -2984,11 +3046,15 @@ class DisplayController:
# Skipped when the ticker is keeping live content: switching
# the rotation underneath Vegas would move current_mode_index
# and stash a resume point for a takeover that never happens.
# After a mid-screen takeover (_check_live_takeover) the live
# mode is already chosen but not shown yet: don't advance past it.
if (not self.on_demand_active
and not (self._is_vegas_mode_active()
and self._vegas_keeps_live_in_ticker())):
live_priority_mode = self._check_live_priority(advance=True)
live_priority_mode = self._check_live_priority(
advance=not self._live_takeover_unshown)
self._apply_live_priority(live_priority_mode)
self._live_takeover_unshown = False
# Vegas scroll mode - continuous ticker across all plugins
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
@@ -3016,7 +3082,19 @@ class DisplayController:
# It yielded for a WiFi notice: the next
# pass shows it, not a rotation screen
# that would outlast a short notice.
# Checked before live content: WiFi
# outranks live, and step 7 of a later
# pass switches to the game.
continue
# Live content stopped the ticker: switch to
# the game now. Step 7 ran before the game
# went live, so without this a rotation screen
# showed first and the game a screen later.
if (not self.on_demand_active
and not self._vegas_keeps_live_in_ticker()):
live_mode = self._check_live_priority(advance=True)
if live_mode:
self._apply_live_priority(live_mode)
except Exception:
logger.exception("Vegas mode error")
# Fall through to normal rotation on error
@@ -3169,6 +3247,7 @@ class DisplayController:
self._tick_plugin_updates()
# Throttled: one clock compare between passes.
self._service_pending_changes()
self._check_live_takeover()
# Pace to the frame deadline rather than sleeping a flat
# interval on top of the work. display() has already
@@ -3250,6 +3329,7 @@ class DisplayController:
self._send_follower_frame(manager_to_display)
self._service_pending_changes()
self._check_live_takeover()
if (self.current_display_mode != active_mode
or not self.is_display_active
or self._wifi_notice_pending()):