diff --git a/src/display_controller.py b/src/display_controller.py index 900b952a..55e2ed61 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -398,6 +398,12 @@ class DisplayController: check_interval=10 # Check every 10 frames (~80ms at 125 FPS) ) + # Set up plugin update tick to keep data fresh during Vegas mode + self.vegas_coordinator.set_update_tick( + self._tick_plugin_updates_for_vegas, + interval=1.0 + ) + logger.info("Vegas mode coordinator initialized") except Exception as e: @@ -434,6 +440,38 @@ class DisplayController: return False + def _tick_plugin_updates_for_vegas(self): + """ + Run scheduled plugin updates and return IDs of plugins that were updated. + + Called periodically by the Vegas coordinator to keep plugin data fresh + during Vegas mode. Returns a list of plugin IDs whose data changed so + Vegas can refresh their content in the scroll. + + Returns: + List of updated plugin IDs, or None if no updates occurred + """ + if not self.plugin_manager or not hasattr(self.plugin_manager, 'plugin_last_update'): + self._tick_plugin_updates() + return None + + # Snapshot update timestamps before ticking + old_times = dict(self.plugin_manager.plugin_last_update) + + # Run the scheduled updates + self._tick_plugin_updates() + + # Detect which plugins were actually updated + updated = [] + for plugin_id, new_time in self.plugin_manager.plugin_last_update.items(): + if new_time > old_times.get(plugin_id, 0.0): + updated.append(plugin_id) + + if updated: + logger.info("Vegas update tick: %d plugin(s) updated: %s", len(updated), updated) + + return updated or None + def _check_schedule(self): """Check if display should be active based on schedule.""" # Get fresh config from config_service to support hot-reload diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index b15e0a25..20892612 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -90,6 +90,14 @@ class VegasModeCoordinator: self._interrupt_check: Optional[Callable[[], bool]] = None self._interrupt_check_interval: int = 10 # Check every N frames + # Plugin update tick for keeping data fresh during Vegas mode + self._update_tick: Optional[Callable[[], Optional[List[str]]]] = None + self._update_tick_interval: float = 1.0 # Tick every 1 second + self._update_thread: Optional[threading.Thread] = None + self._update_results: Optional[List[str]] = None + self._update_results_lock = threading.Lock() + self._last_update_tick_time: float = 0.0 + # Config update tracking self._config_version = 0 self._pending_config_update = False @@ -158,6 +166,25 @@ class VegasModeCoordinator: self._interrupt_check = checker self._interrupt_check_interval = max(1, check_interval) + def set_update_tick( + self, + callback: Callable[[], Optional[List[str]]], + interval: float = 1.0 + ) -> None: + """ + Set the callback for periodic plugin update ticking during Vegas mode. + + This keeps plugin data fresh while the Vegas render loop is running. + The callback should run scheduled plugin updates and return a list of + plugin IDs that were actually updated, or None/empty if no updates occurred. + + Args: + callback: Callable that returns list of updated plugin IDs or None + interval: Seconds between update tick calls (default 1.0) + """ + self._update_tick = callback + self._update_tick_interval = max(0.5, interval) + def start(self) -> bool: """ Start Vegas mode operation. @@ -210,6 +237,9 @@ class VegasModeCoordinator: self.stats['total_runtime_seconds'] += time.time() - self._start_time self._start_time = None + # Wait for in-flight background update before tearing down state + self._drain_update_thread() + # Cleanup components self.render_pipeline.reset() self.stream_manager.reset() @@ -305,71 +335,83 @@ class VegasModeCoordinator: last_fps_log_time = start_time fps_frame_count = 0 + self._last_update_tick_time = start_time + logger.info("Starting Vegas iteration for %.1fs", duration) - while True: - # Check for STATIC mode plugin that should pause scroll - static_plugin = self._check_static_plugin_trigger() - if static_plugin: - if not self._handle_static_pause(static_plugin): - # Static pause was interrupted - return False - # After static pause, skip this segment and continue - self.stream_manager.get_next_segment() # Consume the segment - continue - - # Run frame - if not self.run_frame(): - # Check why we stopped - with self._state_lock: - if self._should_stop: - return False - if self._is_paused: - # Paused for live priority - let caller handle + try: + while True: + # Check for STATIC mode plugin that should pause scroll + static_plugin = self._check_static_plugin_trigger() + if static_plugin: + if not self._handle_static_pause(static_plugin): + # Static pause was interrupted return False + # After static pause, skip this segment and continue + self.stream_manager.get_next_segment() # Consume the segment + continue - # Sleep for frame interval - time.sleep(frame_interval) + # Run frame + if not self.run_frame(): + # Check why we stopped + with self._state_lock: + if self._should_stop: + return False + if self._is_paused: + # Paused for live priority - let caller handle + return False - # Increment frame count and check for interrupt periodically - frame_count += 1 - fps_frame_count += 1 + # Sleep for frame interval + time.sleep(frame_interval) - # Periodic FPS logging - current_time = time.time() - if current_time - last_fps_log_time >= fps_log_interval: - fps = fps_frame_count / (current_time - last_fps_log_time) - logger.info( - "Vegas FPS: %.1f (target: %d, frames: %d)", - fps, self.vegas_config.target_fps, fps_frame_count - ) - last_fps_log_time = current_time - fps_frame_count = 0 + # Increment frame count and check for interrupt periodically + frame_count += 1 + fps_frame_count += 1 - if (self._interrupt_check and - frame_count % self._interrupt_check_interval == 0): - try: - if self._interrupt_check(): - logger.debug( - "Vegas interrupted by callback after %d frames", - frame_count - ) - return False - except Exception: - # Log but don't let interrupt check errors stop Vegas - logger.exception("Interrupt check failed") + # Periodic FPS logging + current_time = time.time() + if current_time - last_fps_log_time >= fps_log_interval: + fps = fps_frame_count / (current_time - last_fps_log_time) + logger.info( + "Vegas FPS: %.1f (target: %d, frames: %d)", + fps, self.vegas_config.target_fps, fps_frame_count + ) + last_fps_log_time = current_time + fps_frame_count = 0 - # Check elapsed time - elapsed = time.time() - start_time - if elapsed >= duration: - break + # Periodic plugin update tick to keep data fresh (non-blocking) + self._drive_background_updates() - # Check for cycle completion - if self.render_pipeline.is_cycle_complete(): - break + if (self._interrupt_check and + frame_count % self._interrupt_check_interval == 0): + try: + if self._interrupt_check(): + logger.debug( + "Vegas interrupted by callback after %d frames", + frame_count + ) + return False + except Exception: + # Log but don't let interrupt check errors stop Vegas + logger.exception("Interrupt check failed") - logger.info("Vegas iteration completed after %.1fs", time.time() - start_time) - return True + # Check elapsed time + elapsed = time.time() - start_time + if elapsed >= duration: + break + + # Check for cycle completion + if self.render_pipeline.is_cycle_complete(): + break + + logger.info("Vegas iteration completed after %.1fs", time.time() - start_time) + return True + + finally: + # Ensure background update thread finishes before the main loop + # resumes its own _tick_plugin_updates() calls, preventing concurrent + # run_scheduled_updates() execution. + self._drain_update_thread() def _check_live_priority(self) -> bool: """ @@ -458,6 +500,71 @@ class VegasModeCoordinator: if self._pending_config is None: self._pending_config_update = False + def _run_update_tick_background(self) -> None: + """Run the plugin update tick in a background thread. + + Stores results for the render loop to pick up on its next iteration, + so the scroll never blocks on API calls. + """ + try: + updated_plugins = self._update_tick() + if updated_plugins: + with self._update_results_lock: + # Accumulate rather than replace to avoid losing notifications + # if a previous result hasn't been picked up yet + if self._update_results is None: + self._update_results = updated_plugins + else: + self._update_results.extend(updated_plugins) + except Exception: + logger.exception("Background plugin update tick failed") + + def _drain_update_thread(self, timeout: float = 2.0) -> None: + """Wait for any in-flight background update thread to finish. + + Called when transitioning out of Vegas mode so the main-loop + ``_tick_plugin_updates`` call doesn't race with a still-running + background thread. + """ + if self._update_thread is not None and self._update_thread.is_alive(): + self._update_thread.join(timeout=timeout) + if self._update_thread.is_alive(): + logger.warning( + "Background update thread did not finish within %.1fs", timeout + ) + + def _drive_background_updates(self) -> None: + """Collect finished background update results and launch new ticks. + + Safe to call from both the main render loop and the static-pause + wait loop so that plugin data stays fresh regardless of which + code path is active. + """ + # 1. Collect results from a previously completed background update + with self._update_results_lock: + ready_results = self._update_results + self._update_results = None + if ready_results: + for pid in ready_results: + self.mark_plugin_updated(pid) + + # 2. Kick off a new background update if interval elapsed and none running + current_time = time.time() + if (self._update_tick and + current_time - self._last_update_tick_time >= self._update_tick_interval): + thread_alive = ( + self._update_thread is not None + and self._update_thread.is_alive() + ) + if not thread_alive: + self._last_update_tick_time = current_time + self._update_thread = threading.Thread( + target=self._run_update_tick_background, + daemon=True, + name="vegas-update-tick", + ) + self._update_thread.start() + def mark_plugin_updated(self, plugin_id: str) -> None: """ Notify that a plugin's data has been updated. @@ -576,6 +683,9 @@ class VegasModeCoordinator: logger.info("Static pause interrupted by live priority") return False + # Keep plugin data fresh during static pause + self._drive_background_updates() + # Sleep in small increments to remain responsive time.sleep(0.1) diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index 1399479a..8f05a18e 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -408,7 +408,10 @@ class PluginAdapter: original_image = self.display_manager.image.copy() logger.info("[%s] Fallback: saved original display state", plugin_id) - # Ensure plugin has fresh data before capturing + # Lightweight in-memory data refresh before capturing. + # Full update() is intentionally skipped here — the background + # update tick in the Vegas coordinator handles periodic API + # refreshes so we don't block the content-fetch thread. has_update_data = hasattr(plugin, 'update_data') logger.info("[%s] Fallback: has update_data=%s", plugin_id, has_update_data) if has_update_data: @@ -582,6 +585,28 @@ class PluginAdapter: else: self._content_cache.clear() + def invalidate_plugin_scroll_cache(self, plugin: 'BasePlugin', plugin_id: str) -> None: + """ + Clear a plugin's scroll_helper cache so Vegas re-fetches fresh visuals. + + Uses scroll_helper.clear_cache() to reset all cached state (cached_image, + cached_array, total_scroll_width, scroll_position, etc.) — not just the + image. Without this, plugins that use scroll_helper (stocks, news, + odds-ticker, etc.) would keep serving stale scroll images even after + their data refreshes. + + Args: + plugin: Plugin instance + plugin_id: Plugin identifier + """ + scroll_helper = getattr(plugin, 'scroll_helper', None) + if scroll_helper is None: + return + + if getattr(scroll_helper, 'cached_image', None) is not None: + scroll_helper.clear_cache() + logger.debug("[%s] Cleared scroll_helper cache", plugin_id) + def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str: """ Get the type of content a plugin provides. diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index c63f7295..17efc357 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -217,6 +217,15 @@ class StreamManager: refreshed_segments = {} for plugin_id in updated_plugins: self.plugin_adapter.invalidate_cache(plugin_id) + + # Clear the plugin's scroll_helper cache so the visual is rebuilt + # from fresh data (affects stocks, news, odds-ticker, etc.) + plugin = None + if hasattr(self.plugin_manager, 'plugins'): + plugin = self.plugin_manager.plugins.get(plugin_id) + if plugin: + self.plugin_adapter.invalidate_plugin_scroll_cache(plugin, plugin_id) + segment = self._fetch_plugin_content(plugin_id) if segment: refreshed_segments[plugin_id] = segment