diff --git a/CHANGELOG.md b/CHANGELOG.md index d581dd30..761676fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,14 @@ accepts both, but the store flags the old spelling as deprecated - Stats the server can't read show `--`. - New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages. +- Display and Vegas: + - Vegas `max_cycle_duration` defaults to 240 s when unset, as documented (it was 600 s). The Vegas defaults are now defined once. + - The display controller stops Vegas mode on shutdown. + - Startup validation warnings are logged once, not twice. + - Vegas logs one INFO line per plugin-list refresh. + - `run.py -d` shows `display_manager` debug output. + - Removed: the Vegas staging buffer that was never filled (`swap_buffers()`, and `staging_count` / `current_index` in `get_buffer_status()`), unread `ContentSegment` fields, and `geometry.find_blank_cut()`. + - The web service (`ledmatrix-web`) logs through `src.logging_config` like the display service, so `journalctl -p err -u ledmatrix-web` works. Successful GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of diff --git a/run.py b/run.py index a1400c68..b7b1f52a 100755 --- a/run.py +++ b/run.py @@ -31,8 +31,6 @@ if args.emulator: print("Using pygame/RGBMatrixEmulator for display") print("Press ESC to exit\n") -# Project directory already added above - # Debug output (only in debug mode or emulator mode) debug_mode = args.debug or args.emulator or os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true' if debug_mode: diff --git a/src/common/scroll_helper.py b/src/common/scroll_helper.py index 73c6e1e8..fe76942a 100644 --- a/src/common/scroll_helper.py +++ b/src/common/scroll_helper.py @@ -238,20 +238,9 @@ class ScrollHelper: self.cached_image = full_image # Convert to numpy array for fast operations self.cached_array = np.array(full_image) - - # Use actual image width instead of calculated width to ensure accuracy - # This fixes cases where width calculation doesn't match actual positioning actual_image_width = full_image.width self.total_scroll_width = actual_image_width - # Log if there's a mismatch (indicating a bug in width calculation) - if actual_image_width != total_width: - self.logger.warning( - "Width calculation mismatch: calculated=%dpx, actual=%dpx (diff=%dpx). " - "Using actual width for scroll calculations.", - total_width, actual_image_width, abs(actual_image_width - total_width) - ) - self.scroll_position = 0.0 self.total_distance_scrolled = 0.0 self.scroll_complete = False @@ -339,10 +328,8 @@ class ScrollHelper: # gained. This is what the one visibly smooth scroller on the # hardware (the stock ticker) was already doing by virtue of never # enabling frame-based mode. - if self.scroll_delay > 0: - pixels_per_second = self.scroll_speed / self.scroll_delay - else: - pixels_per_second = self.scroll_speed * 100.0 + # set_scroll_delay clamps scroll_delay to at least 0.001. + pixels_per_second = self.scroll_speed / self.scroll_delay pixels_to_move = pixels_per_second * delta_time self.last_step_time = current_time else: @@ -353,11 +340,11 @@ class ScrollHelper: self.scroll_position += pixels_to_move self.total_distance_scrolled += pixels_to_move - # Calculate required total distance: total_scroll_width only. - # The image already includes display_width pixels of blank padding at the start - # (added by create_scrolling_image), so once scroll_position reaches - # total_scroll_width the last card has fully scrolled off the left edge. - # Adding display_width here would cause 1-2 extra wrap-arounds on wide chains. + # One pass is total_scroll_width. With the default lead_gap the strip + # starts with display_width of blank, so by then the last item has + # fully left the panel; a caller passing a smaller lead_gap (Vegas) + # decides for itself where its cycle ends. Adding display_width here + # caused 1-2 extra wrap-arounds on wide chains. required_total_distance = self.total_scroll_width # Guard: zero-width content has nothing to scroll — keep position at 0 and skip @@ -414,7 +401,6 @@ class ScrollHelper: and current_time - self.last_progress_log_time >= self.progress_log_interval ): elapsed_time = current_time - (self.scroll_start_time or current_time) - # The image already includes display_width padding, so we only need total_scroll_width required_total_distance = self.total_scroll_width # Progress telemetry, emitted every few seconds for the whole of # every scroll. It says how far along a marquee is, which is what @@ -461,10 +447,8 @@ class ScrollHelper: """ Linear blend between the frames at ``start_x`` and ``start_x + 1``. - Implemented with numpy rather than scipy.ndimage.shift: scipy is not - installed on the target devices, and the old scipy-based sub-pixel path - was dead code -- get_visible_portion never consulted the flag. The scipy - import was removed with it; installing scipy has no effect. + Implemented with numpy rather than scipy.ndimage.shift, which is not + installed on the target devices. Args: start_x: Left column of the earlier of the two frames @@ -571,22 +555,16 @@ class ScrollHelper: return self.min_duration try: - # Calculate total scroll distance needed - # The image already includes display_width padding at the start, so we need - # to scroll total_scroll_width pixels to show all content, plus display_width - # more pixels to ensure the last content scrolls completely off the screen + # The strip's width plus one more screen, so the duration covers + # the last item leaving the panel even when the strip has less + # than display_width of lead-in blank (lead_gap). total_scroll_distance = self.total_scroll_width + self.display_width # Calculate effective pixels per second based on scrolling mode if self.frame_based_scrolling: - # Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame - # Effective pixels per second = pixels per frame / seconds per frame - if self.scroll_delay > 0: - pixels_per_second = self.scroll_speed / self.scroll_delay - else: - # Fallback if scroll_delay is invalid - pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default - self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay) + # Frame-based mode: scroll_speed is pixels per scroll_delay + # seconds, and set_scroll_delay keeps scroll_delay >= 0.001. + pixels_per_second = self.scroll_speed / self.scroll_delay scroll_mode_str = "frame-based" else: # Time-based mode: scroll_speed is already pixels per second @@ -1072,7 +1050,6 @@ class ScrollHelper: Returns: Dictionary with scroll state information """ - # The image already includes display_width padding, so we only need total_scroll_width required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0 return { 'scroll_position': self.scroll_position, diff --git a/src/display_controller.py b/src/display_controller.py index 42201c73..3f6222bf 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -22,6 +22,7 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls import time import os +import inspect import json import threading import types @@ -31,7 +32,6 @@ from datetime import datetime from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module import pytz -# Core system imports only - all functionality now handled via plugins from src.display_manager import DisplayManager from src.config_manager import ConfigManager from src.config_service import ConfigService @@ -39,6 +39,7 @@ from src.cache_manager import CacheManager from src.font_manager import FontManager from src.logging_config import get_logger from src.common.sync_manager import DisplaySyncManager, SyncRole +from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL # Get logger with consistent configuration logger = get_logger(__name__) @@ -57,11 +58,25 @@ _INITIAL_UPDATE_BUDGET_SECONDS = 20.0 # slot it never had a chance to use. _MIN_INITIAL_UPDATE_TIMEOUT_SECONDS = 2.0 -# Vegas mode import (lazy loaded to avoid circular imports) -_vegas_mode_imported = False -VegasModeCoordinator = None DEFAULT_DYNAMIC_DURATION_CAP = 180.0 +# Follower dead reckoning (the follower branch of DisplayController.run()). +# A leader position further off than this fraction of the strip is a cycle +# reset, and is snapped to rather than corrected toward. +_FOLLOWER_SNAP_FRACTION = 0.5 +# Strip width assumed, in screens, before the follower has the leader's image. +_FOLLOWER_FALLBACK_STRIP_SCREENS = 4 +# Drift beyond this many pixels is corrected by _FOLLOWER_DRIFT_GAIN of the +# error per tick; smaller drift by _FOLLOWER_NEAR_GAIN, so UDP jitter does not +# show as the scroll twitching. +_FOLLOWER_DRIFT_PX = 10 +_FOLLOWER_DRIFT_GAIN = 0.20 +_FOLLOWER_NEAR_GAIN = 0.05 +# Follower render period, and how late a frame may run before the pacing grid +# is restarted rather than caught up. +_FOLLOWER_FRAME_INTERVAL = 1.0 / 60 +_FOLLOWER_DEADLINE_SLIP = 0.1 + class DisplayController: """ Top-level controller that owns the LED display run loop. @@ -92,7 +107,7 @@ class DisplayController: config_manager=config_manager, enable_hot_reload=enable_hot_reload ) - self.config_manager = config_manager # Keep for backward compatibility + self.config_manager = config_manager self.config = self.config_service.get_config() self.cache_manager = CacheManager() # The web interface's /api/v3/errors/* read what this publishes. @@ -100,24 +115,20 @@ class DisplayController: start_error_snapshot_publisher(self.cache_manager) logger.info("Config loaded in %.3f seconds (hot-reload: %s)", time.time() - start_time, enable_hot_reload) - # Validate startup configuration + # Validate startup configuration. Errors are logged, not fatal. The + # plugin checks need the plugin manager and run once it exists. try: from src.startup_validator import StartupValidator validator = StartupValidator(self.config_manager, cache_manager=self.cache_manager) is_valid, errors, warnings = validator.validate_all() - - if warnings: - for warning in warnings: - logger.warning(f"Startup validation warning: {warning}") - + for warning in warnings: + logger.warning("Startup validation warning: %s", warning) if not is_valid: - error_msg = "Startup validation failed:\n" + "\n".join(f" - {e}" for e in errors) - logger.error(error_msg) - # For now, log errors but continue - can be made stricter later - # validator.raise_on_errors() # Uncomment to fail fast on errors + logger.error("Startup validation failed:\n%s", + "\n".join(f" - {e}" for e in errors)) except Exception as e: - logger.warning(f"Startup validation could not be completed: {e}") + logger.warning("Startup validation could not be completed: %s", e) # Automatic updates need their health-check units, and this is the # one root process running project code, so it installs them while @@ -158,29 +169,28 @@ class DisplayController: _real_update() self.display_manager.update_display = _follower_gated_update - # Note: _on_new_cycle is NOT registered here. The leader now sends - # its actual scroll image via TCP at each new_cycle, so the follower - # adopts that image directly via set_on_scroll_image(). Registering - # _on_new_cycle would trigger a local rebuild that overwrites the - # leader's just-received image with a different locally-built one. + # No new_cycle handler is registered: the leader sends its scroll + # image over TCP at each new cycle and the follower adopts it (see + # set_on_scroll_image in _initialize_vegas_mode). A local rebuild + # would overwrite that image with a different, locally built one. + + # Follower render-loop state (see the follower branch of run()). + self._follower_dr_last_t: Optional[float] = None # perf_counter of last tick + self._follower_local_x: Optional[float] = None # dead-reckoned scroll_x + # Set on a cycle reset; holds the last frame until the leader's new + # scroll image arrives. + self._follower_pending_new_image = False + self._follower_last_frame = None + self._follower_deadline: Optional[float] = None + # Leader: time.time() of the last follower frame sent. + self._last_follower_send = 0.0 # Initialize Font Manager font_time = time.time() self.font_manager = FontManager(self.config) logger.info("FontManager initialized in %.3f seconds", time.time() - font_time) - - # Initialize display modes - all functionality now handled via plugins - init_time = time.time() - - # All other functionality handled via plugins - logger.info("Display modes initialized in %.3f seconds", time.time() - init_time) - + self.force_change = False - - # All sports and content managers now handled via plugins - logger.info("All sports and content managers now handled via plugin system") - - # List of available display modes - now handled entirely by plugins self.available_modes = [] # Initialize Plugin System @@ -296,22 +306,20 @@ class DisplayController: except Exception as e: logger.warning("Could not enable plugin health/resource monitoring: %s", e) - # Validate plugins after plugin manager is created + # Only the plugin checks: validate_all() above has run the rest, + # and running it again logged every config warning twice. try: from src.startup_validator import StartupValidator validator = StartupValidator(self.config_manager, self.plugin_manager, cache_manager=self.cache_manager) - is_valid, errors, warnings = validator.validate_all() - - if warnings: - for warning in warnings: - logger.warning(f"Plugin validation warning: {warning}") - - if not is_valid: - error_msg = "Plugin validation failed:\n" + "\n".join(f" - {e}" for e in errors) - logger.error(error_msg) + validator._validate_plugins() + for warning in validator.warnings: + logger.warning("Plugin validation warning: %s", warning) + if validator.errors: + logger.error("Plugin validation failed:\n%s", + "\n".join(f" - {e}" for e in validator.errors)) except Exception as e: - logger.warning(f"Plugin validation could not be completed: {e}") + logger.warning("Plugin validation could not be completed: %s", e) # Discover plugins discovered_plugins = self.plugin_manager.discover_plugins() @@ -369,7 +377,7 @@ class DisplayController: if result['success']: plugin_id = result['plugin_id'] - logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)", + logger.info("Loaded plugin %s in %.3f seconds (%d/%d)", plugin_id, result['load_time'], loaded_count, enabled_count) # Register the loaded plugin's modes, config subscription @@ -382,7 +390,7 @@ class DisplayController: logger.info("Progress: %d%% (%d/%d plugins, %.1fs elapsed)", progress_pct, loaded_count, enabled_count, elapsed) else: - logger.warning("✗ Failed to load plugin %s: %s", + logger.warning("Failed to load plugin %s: %s", result['plugin_id'], result['error']) # Log disabled plugins @@ -414,6 +422,8 @@ class DisplayController: # Display rotation state self.current_mode_index = 0 self.current_display_mode = None + # Last mode written to the display_current_state cache key. + self._last_published_mode: Optional[str] = None self.global_dynamic_config = ( self.config.get("display", {}).get("dynamic_duration", {}) or {} ) @@ -426,11 +436,12 @@ class DisplayController: # Schedule management self.is_display_active = True - self._was_display_active = True # Track previous state for schedule change detection + # The previous schedule result, so only transitions log at INFO. + self._was_display_active = True - # --- Opt #2: cached config values --- - # Avoids chained dict.get() with temporary {} defaults on every hot path call. - # Refreshed via _refresh_config_cache() on every hot-reload. + # Config values read on hot paths, cached so they are not re-read + # from the config dict every frame. _refresh_config_cache() updates + # them on every hot reload. self._normal_brightness: int = ( self.config.get('display', {}).get('hardware', {}).get('brightness', 90) ) @@ -441,12 +452,11 @@ class DisplayController: self.is_dimmed = False self._was_dimmed = False - # --- Opt #3: schedule minute-gate --- - # Both _check_schedule and _check_dim_schedule re-evaluated at most once per - # clock minute. Storing the (hour, minute) tuple that was last evaluated lets - # the methods skip all timezone / strptime work within the same minute. - # Reset to None on config change so the next call re-evaluates immediately. - self._tz = None # pytz timezone, lazily built from config + # _check_schedule and _check_dim_schedule re-evaluate at most once per + # clock minute: each stores the (hour, minute) it last evaluated and + # skips the strptime and comparison work until the minute changes. + # Reset to None on config change so the next call re-evaluates. + self._tz = None # pytz timezone, built lazily by _timezone() self._schedule_checked_minute: Optional[tuple] = None self._dim_checked_minute: Optional[tuple] = None self._cached_target_brightness: int = self._normal_brightness @@ -476,8 +486,6 @@ class DisplayController: def _initialize_vegas_mode(self): """Initialize Vegas mode coordinator if enabled.""" - global _vegas_mode_imported, VegasModeCoordinator - vegas_config = self.config.get('display', {}).get('vegas_scroll', {}) if not vegas_config.get('enabled', False): logger.debug("Vegas mode disabled in config") @@ -488,15 +496,7 @@ class DisplayController: return try: - # Lazy import to avoid circular imports - if not _vegas_mode_imported: - try: - from src.vegas_mode import VegasModeCoordinator as VMC - VegasModeCoordinator = VMC - _vegas_mode_imported = True - except ImportError: - logger.exception("Failed to import Vegas mode module") - return + from src.vegas_mode import VegasModeCoordinator self.vegas_coordinator = VegasModeCoordinator( config=self.config, @@ -537,7 +537,7 @@ class DisplayController: # cached_array so both Pis have pixel-identical images. import numpy as _np def _on_leader_scroll_image(image): - vc = getattr(self, 'vegas_coordinator', None) + vc = self.vegas_coordinator if vc and vc.render_pipeline: rp = vc.render_pipeline arr = _np.asarray(image.convert("RGB"), dtype=_np.uint8) @@ -556,15 +556,14 @@ class DisplayController: # the follower doesn't have to wait for the next new_cycle event. # Polls until the image is ready (Vegas may still be composing on startup). def _on_follower_connected(): - import time as _t for _ in range(300): # up to 30s - vc = getattr(self, 'vegas_coordinator', None) + vc = self.vegas_coordinator if vc and vc.render_pipeline: img = vc.render_pipeline.scroll_helper.cached_image if img is not None: self.sync_manager.send_scroll_image(img) return - _t.sleep(0.1) + time.sleep(0.1) logger.warning("Sync: no scroll image available to push to new follower") self.sync_manager.set_on_follower_connected(_on_follower_connected) @@ -617,25 +616,8 @@ class DisplayController: return False - def _check_schedule(self): - """Check if display should be active based on schedule.""" - schedule_config = self.config.get('schedule', {}) - - # If schedule config doesn't exist or is empty, default to always active - if not schedule_config: - self.is_display_active = True - self._was_display_active = True # Track previous state for schedule change detection - return - - # Check if schedule is explicitly disabled - # Default to True (schedule enabled) if 'enabled' key is missing for backward compatibility - if 'enabled' in schedule_config and not schedule_config.get('enabled', True): - self.is_display_active = True - self._was_display_active = True # Track previous state for schedule change detection - logger.debug("Schedule is disabled - display always active") - return - - # Lazily build the timezone object once; reuse on every subsequent call. + def _timezone(self): + """The configured timezone, built once and cached until config changes.""" if self._tz is None: timezone_str = self.config.get('timezone', 'UTC') try: @@ -643,8 +625,33 @@ class DisplayController: except pytz.UnknownTimeZoneError: logger.warning("Unknown timezone '%s', using UTC", timezone_str) self._tz = pytz.UTC + return self._tz - current_time = datetime.now(self._tz) + @staticmethod + def _in_window(start, end, now) -> bool: + """Whether ``now`` is within [start, end], a window that may span midnight.""" + if start <= end: + return start <= now <= end + return now >= start or now <= end + + def _check_schedule(self): + """Check if display should be active based on schedule.""" + schedule_config = self.config.get('schedule', {}) + + # No schedule configured: always active. + if not schedule_config: + self.is_display_active = True + self._was_display_active = True + return + + # A schedule without an 'enabled' key counts as enabled. + if 'enabled' in schedule_config and not schedule_config.get('enabled', True): + self.is_display_active = True + self._was_display_active = True + logger.debug("Schedule is disabled - display always active") + return + + current_time = datetime.now(self._timezone()) # Gate: schedule state can only change on a minute boundary, so skip # all the strptime / comparison work if we already evaluated this minute. current_minute_key = (current_time.hour, current_time.minute) @@ -677,14 +684,12 @@ class DisplayController: use_per_day = True else: logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day) - + if use_per_day: - # Use per-day schedule day_config = days_config[current_day] - - # Check if this day is enabled + if not day_config.get('enabled', True): - was_active = getattr(self, '_was_display_active', True) + was_active = self._was_display_active self.is_display_active = False if was_active: logger.info("Schedule activated: Display is now INACTIVE (%s is disabled in schedule). Display will be blanked.", current_day) @@ -692,56 +697,43 @@ class DisplayController: logger.debug("Display inactive - %s is disabled in schedule", current_day) self._was_display_active = self.is_display_active return - + start_time_str = day_config.get('start_time', '07:00') end_time_str = day_config.get('end_time', '23:00') schedule_type = f"per-day ({current_day})" else: - # Use global schedule start_time_str = schedule_config.get('start_time', '07:00') end_time_str = schedule_config.get('end_time', '23:00') schedule_type = "global" - + try: start_time = datetime.strptime(start_time_str, '%H:%M').time() end_time = datetime.strptime(end_time_str, '%H:%M').time() - - if start_time <= end_time: - # Normal case: start and end on same day - self.is_display_active = start_time <= current_time_only <= end_time - else: - # Overnight case: start and end on different days - self.is_display_active = current_time_only >= start_time or current_time_only <= end_time - - # Track previous state to detect changes - was_active = getattr(self, '_was_display_active', True) - - # Log schedule state changes + self.is_display_active = self._in_window(start_time, end_time, current_time_only) + + was_active = self._was_display_active if not self.is_display_active: if was_active: - # State changed from active to inactive - schedule kicked in - logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.", + logger.info("Schedule activated: Display is now INACTIVE (outside %s schedule window %s - %s). Display will be blanked.", schedule_type, start_time_str, end_time_str) else: - logger.debug("Display inactive - outside %s schedule window (%s - %s)", + logger.debug("Display inactive - outside %s schedule window (%s - %s)", schedule_type, start_time_str, end_time_str) else: if not was_active: - # State changed from inactive to active - logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)", + logger.info("Schedule activated: Display is now ACTIVE (within %s schedule window %s - %s)", schedule_type, start_time_str, end_time_str) else: - logger.debug("Display active - within %s schedule window (%s - %s)", + logger.debug("Display active - within %s schedule window (%s - %s)", schedule_type, start_time_str, end_time_str) - - # Store current state for next check + self._was_display_active = self.is_display_active - + except ValueError as e: logger.warning("Invalid schedule format for %s schedule: %s (start: %s, end: %s). Defaulting to active.", schedule_type, e, start_time_str, end_time_str) self.is_display_active = True - self._was_display_active = True # Track previous state for schedule change detection + self._was_display_active = True def _check_dim_schedule(self) -> int: """ @@ -751,7 +743,6 @@ class DisplayController: Target brightness level (dim_brightness if in dim period, normal brightness otherwise) """ - # Opt #2: use cached brightness rather than re-traversing config dict normal_brightness = self._normal_brightness # If display is OFF via schedule, don't process dim schedule @@ -766,16 +757,8 @@ class DisplayController: self.is_dimmed = False return normal_brightness - # Opt #3: lazily build timezone; gate full re-parse to once per clock minute - if self._tz is None: - timezone_str = self.config.get('timezone', 'UTC') - try: - self._tz = pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str) - self._tz = pytz.UTC - - current_time = datetime.now(self._tz) + # Re-evaluated at most once per clock minute, like _check_schedule. + current_time = datetime.now(self._timezone()) current_minute_key = (current_time.hour, current_time.minute) if current_minute_key == self._dim_checked_minute: return self._cached_target_brightness @@ -806,15 +789,7 @@ class DisplayController: start_time = datetime.strptime(start_time_str, '%H:%M').time() end_time = datetime.strptime(end_time_str, '%H:%M').time() - # Determine if currently in dim period - if start_time <= end_time: - # Same-day schedule (e.g., 10:00 to 18:00) - in_dim_period = start_time <= current_time_only <= end_time - else: - # Overnight schedule (e.g., 20:00 to 07:00) - in_dim_period = current_time_only >= start_time or current_time_only <= end_time - - if in_dim_period: + if self._in_window(start_time, end_time, current_time_only): self.is_dimmed = True target_brightness = dim_config.get('dim_brightness', 30) else: @@ -872,38 +847,19 @@ class DisplayController: # running. deferred.append(plugin_id) continue - # Check circuit breaker before attempting update - if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - if self.plugin_manager.health_tracker.should_skip_plugin(plugin_id): - logger.debug(f"Skipping update for plugin {plugin_id} due to circuit breaker") - continue - - # Use PluginExecutor if available for safe execution - if hasattr(self.plugin_manager, 'plugin_executor'): - # The remaining budget is the timeout, so the pass cannot - # run past its deadline. Bounding the loop alone did not do - # it: the last plugin to start could still block for the - # executor's full 30s, which turned a 20s budget into a 31.8s - # pass on the rig. - success = self.plugin_manager.plugin_executor.execute_update( - plugin_instance, plugin_id, timeout=update_timeout) - if success and hasattr(self.plugin_manager, 'plugin_last_update'): - self.plugin_manager.plugin_last_update[plugin_id] = time.time() - else: - # Fallback to direct call - try: - if hasattr(plugin_instance, 'update'): - plugin_instance.update() - if hasattr(self.plugin_manager, 'plugin_last_update'): - self.plugin_manager.plugin_last_update[plugin_id] = time.time() - # Record success - if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - self.plugin_manager.health_tracker.record_success(plugin_id) - except Exception as exc: # pylint: disable=broad-except - logger.exception("Error updating plugin %s", plugin_id) - # Record failure - if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - self.plugin_manager.health_tracker.record_failure(plugin_id, exc) + health_tracker = self.plugin_manager.health_tracker + if health_tracker is not None and health_tracker.should_skip_plugin(plugin_id): + logger.debug("Skipping update for plugin %s due to circuit breaker", plugin_id) + continue + + # The remaining budget is the timeout, so the pass cannot run past + # its deadline. Bounding the loop alone did not do it: the last + # plugin to start could still block for the executor's full 30s, + # which turned a 20s budget into a 31.8s pass on the rig. + success = self.plugin_manager.plugin_executor.execute_update( + plugin_instance, plugin_id, timeout=update_timeout) + if success: + self.plugin_manager.plugin_last_update[plugin_id] = time.time() if deferred: logger.info( @@ -913,29 +869,25 @@ class DisplayController: def _tick_plugin_updates_for_vegas(self) -> None: """Run scheduled plugin updates and tell Vegas mode which plugins - actually got fresh data, so it can hot-swap them into the scroll + actually got fresh data, so it can refresh them in the scroll without waiting for a full cycle to complete. - Used as the Vegas coordinator's update callback instead of the plain - _tick_plugin_updates() so that a live score change is reflected in - the ticker within a few seconds rather than at the next cycle - boundary (which, depending on min/max_cycle_duration, can be - minutes away). Restores wiring that PR #299 added and PR #330's - sync-mode refactor inadvertently dropped: coordinator.mark_plugin_updated() - has been unreachable dead code since. + This is the Vegas coordinator's update callback, used instead of the + plain _tick_plugin_updates() so that a live score change reaches the + ticker within seconds rather than at the next cycle boundary (which, + depending on min/max_cycle_duration, can be minutes away). - Delegates the before/after plugin_last_update snapshot to - PluginManager.run_scheduled_updates_with_changes() so the snapshot, - update pass, and diff are lock-protected against this callback's own - background update-tick thread racing the main render loop. + PluginManager.run_scheduled_updates_with_changes() takes the + before/after plugin_last_update snapshot, so the snapshot, update + pass and diff are locked against the main render loop's own update + tick running concurrently on the other thread. """ - if not self.plugin_manager or not hasattr(self.plugin_manager, "run_scheduled_updates_with_changes"): - self._tick_plugin_updates() + if not self.plugin_manager: return updated = self.plugin_manager.run_scheduled_updates_with_changes() - vc = getattr(self, "vegas_coordinator", None) + vc = self.vegas_coordinator if vc is None: return @@ -948,29 +900,27 @@ class DisplayController: logger.exception("Error marking plugin %s updated for Vegas", plugin_id) def _tick_plugin_updates(self): - """Run scheduled plugin updates if the plugin manager supports them.""" + """Run any plugin updates that are due.""" if not self.plugin_manager: return - - if hasattr(self.plugin_manager, "run_scheduled_updates"): - try: - self.plugin_manager.run_scheduled_updates() - except Exception: # pylint: disable=broad-except - logger.exception("Error running scheduled plugin updates") + try: + self.plugin_manager.run_scheduled_updates() + except Exception: # pylint: disable=broad-except + logger.exception("Error running scheduled plugin updates") @contextmanager def _display_lock_or_skip(self, plugin_id): """Try-lock guard keeping a plugin's display() off its in-flight update(). Yields True when display may run (lock held, released on exit) or - when no lock support exists (older plugin manager). Yields False when - the plugin's update() is currently executing on the background + when there is no plugin manager or plugin id to lock on. Yields False + when the plugin's update() is currently executing on the background worker — the caller should treat the frame as displayed (the panel holds the last pushed frame) rather than as a plugin failure, so a mid-update skip never advances the rotation. """ pm = self.plugin_manager - if not pm or not hasattr(pm, 'get_plugin_lock') or not plugin_id: + if not pm or not plugin_id: yield True return lock = pm.get_plugin_lock(plugin_id) @@ -982,7 +932,51 @@ class DisplayController: finally: lock.release() - _FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate + def _display_once(self, plugin, mode: str, accepts_display_mode: bool, + force_clear: bool = False): + """Call ``plugin.display()`` directly for one frame of a render loop. + + Frames after a screen's first dispatch come through here rather than + PluginExecutor: the executor spawns a thread per call, which at the + high-FPS loop's frame rate would cost more than the advisory timeout + it buys -- and that timeout cannot cancel a hung plugin anyway (see + execute_with_timeout). The first dispatch in run() still goes through + the executor, so failures there are caught and recorded. + + Args: + plugin: The plugin to draw. + mode: The display mode, passed on when the plugin accepts + ``display_mode`` so plugins with several modes stay on it. + accepts_display_mode: Whether display() takes ``display_mode``. + force_clear: Passed through to display(). + + Returns: + display()'s result, or True when the frame was skipped because + the plugin's update() holds its lock (the panel keeps the last + frame; that is not a failure). + """ + with self._display_lock_or_skip(getattr(plugin, 'plugin_id', None)) as can_display: + if not can_display: + return True + if accepts_display_mode: + return plugin.display(display_mode=mode, force_clear=force_clear) + return plugin.display(force_clear=force_clear) + + def _health_tracker(self): + """The plugin circuit breaker, or None when it is not enabled.""" + if self.plugin_manager is None: + return None + return self.plugin_manager.health_tracker + + def _follower_sign(self) -> int: + """-1 when the follower panel sits left of the leader, +1 when right. + + The follower shows the strip at the leader's position plus + ``sign * width``: content that has already scrolled off the leader's + left edge when it is on the left (the default). + """ + position = self.config.get("sync", {}).get("follower_position", "left") + return -1 if position == "left" else 1 def _send_follower_frame(self, plugin_instance) -> None: """Leader: generate and send the follower's portion of the current frame. @@ -994,17 +988,13 @@ class DisplayController: """ if not (self.sync_manager and self.sync_manager.role == SyncRole.LEADER): return - # Throttle to ~90fps via _FOLLOWER_SEND_INTERVAL — raw RGB bytes, no encode/decode now = time.time() - if now - getattr(self, '_last_follower_send', 0) < self._FOLLOWER_SEND_INTERVAL: + if now - self._last_follower_send < SYNC_SEND_INTERVAL: return self._last_follower_send = now follower_frame = None - width = self.display_manager.width - sync_cfg = self.config.get("sync", {}) - sign = -1 if sync_cfg.get("follower_position", "left") == "left" else 1 - offset = sign * width + offset = self._follower_sign() * self.display_manager.width # 1. Explicit hook — plugin opted in with get_offset_frame() try: @@ -1206,7 +1196,7 @@ class DisplayController: def _publish_current_mode_state_if_changed(self) -> None: """Publish current mode state only when it actually changed, to avoid writing to the shared cache on every render tick.""" - if self.current_display_mode != getattr(self, '_last_published_mode', None): + if self.current_display_mode != self._last_published_mode: self._publish_current_mode_state() def _publish_on_demand_state(self) -> None: @@ -1232,9 +1222,20 @@ class DisplayController: def _set_on_demand_error(self, message: str) -> None: """Set on-demand state to error and publish.""" + self._reset_on_demand_fields() self.on_demand_status = 'error' self.on_demand_last_error = message self.on_demand_last_event = None + self.rotation_resume_index = None + self._publish_on_demand_state() + + def _reset_on_demand_fields(self) -> None: + """Clear the on-demand session: no plugin, no modes, no expiry. + + Leaves status, last error/event and rotation_resume_index to the + caller, which is what differs between ending a session and failing + to start one. + """ self.on_demand_active = False self.on_demand_mode = None self.on_demand_modes = [] @@ -1244,8 +1245,19 @@ class DisplayController: self.on_demand_requested_at = None self.on_demand_expires_at = None self.on_demand_pinned = False - self.rotation_resume_index = None self.on_demand_schedule_override = False + + def _advance_on_demand(self) -> None: + """Move an active on-demand session to its next mode and publish it. + + The caller checks that on_demand_modes is non-empty. + """ + self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) + next_mode = self.on_demand_modes[self.on_demand_mode_index] + logger.info("Rotating to next on-demand mode: %s (index %d/%d)", + next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) + self.current_display_mode = next_mode + self.force_change = True self._publish_on_demand_state() #: Shortest gap between mailbox disk reads. This is called after every @@ -1679,7 +1691,7 @@ class DisplayController: ordered_modes = self._apply_on_demand_pin(ordered_modes, resolved_mode, pinned) self.on_demand_active = True - self.on_demand_mode = resolved_mode # Keep for backward compatibility + self.on_demand_mode = resolved_mode self.on_demand_modes = ordered_modes self.on_demand_mode_index = 0 self.on_demand_plugin_id = resolved_plugin_id @@ -1731,20 +1743,11 @@ class DisplayController: self._publish_on_demand_state() return - self.on_demand_active = False - self.on_demand_mode = None - self.on_demand_modes = [] - self.on_demand_mode_index = 0 - self.on_demand_plugin_id = None - self.on_demand_duration = None - self.on_demand_requested_at = None - self.on_demand_expires_at = None - self.on_demand_pinned = False + self._reset_on_demand_fields() self.on_demand_status = 'idle' self.on_demand_last_error = None self.on_demand_last_event = reason or 'cleared' - self.on_demand_schedule_override = False - + # Clear on-demand configuration from cache self.cache_manager.clear_cache('display_on_demand_config') @@ -1764,7 +1767,7 @@ class DisplayController: self.rotation_resume_index = None self.force_change = True - logger.info("✓ ON-DEMAND MODE CLEARED (reason=%s), resuming normal rotation to mode: %s", + logger.info("On-demand mode cleared (reason=%s), resuming normal rotation to mode: %s", reason, self.current_display_mode) self._publish_on_demand_state() @@ -1882,7 +1885,7 @@ class DisplayController: def _vegas_keeps_live_in_ticker(self) -> bool: """Whether live content should stay in the ticker instead of preempting it.""" - coordinator = getattr(self, 'vegas_coordinator', None) + coordinator = self.vegas_coordinator config = getattr(coordinator, 'vegas_config', None) return bool(getattr(config, 'live_in_ticker', False)) @@ -1993,78 +1996,77 @@ class DisplayController: # Plugin update() threads still run (via _tick_plugin_updates above) so # data is fresh when we return to standalone if the leader goes offline. if self.sync_manager.is_follower_active(): - # Dead-reckoning follower render: - # Advance local position at configured speed each tick; snap or - # gently correct toward received scroll_x to absorb UDP jitter. - _now_dr = time.perf_counter() - _dt = _now_dr - getattr(self, '_follower_dr_last_t', _now_dr) - self._follower_dr_last_t = _now_dr + # Dead-reckoning follower render: advance the local + # position at the configured speed each tick, then snap or + # nudge it toward the leader's scroll_x to absorb UDP + # jitter. + now_dr = time.perf_counter() + last_t = self._follower_dr_last_t + dt = now_dr - last_t if last_t is not None else 0.0 + self._follower_dr_last_t = now_dr - vc = getattr(self, 'vegas_coordinator', None) + vc = self.vegas_coordinator rp = vc.render_pipeline if (vc and vc.render_pipeline) else None width = self.display_manager.width - # Opt #2: use pre-cached scroll speed (constant for the run) - vegas_speed = self._scroll_speed - local_x = getattr(self, '_follower_local_x', None) + local_x = self._follower_local_x if local_x is None: local_x = float(width) # safe start (past pre-roll guard) - local_x += vegas_speed * _dt + local_x += self._scroll_speed * dt - # Pull latest position from leader (may be None if no packet yet) + # Latest position from the leader (None until a packet arrives) scroll_x = self.sync_manager.get_latest_scroll_x() if scroll_x is not None: diff = scroll_x - local_x total_w = ( rp.scroll_helper.total_scroll_width if rp and rp.scroll_helper.total_scroll_width - else width * 4 + else width * _FOLLOWER_FALLBACK_STRIP_SCREENS ) - if abs(diff) > total_w * 0.5: - # Large jump → cycle reset, snap immediately + if abs(diff) > total_w * _FOLLOWER_SNAP_FRACTION: + # A jump that large is a cycle reset: snap. local_x = float(scroll_x) self._follower_pending_new_image = True - elif abs(diff) > 10: - # Moderate drift → 20% correction per tick - local_x += diff * 0.20 + elif abs(diff) > _FOLLOWER_DRIFT_PX: + local_x += diff * _FOLLOWER_DRIFT_GAIN else: - # Near → gentle 5% correction - local_x += diff * 0.05 + local_x += diff * _FOLLOWER_NEAR_GAIN self._follower_local_x = local_x if rp and rp.scroll_helper.cached_image is not None: - sync_cfg = self.config.get("sync", {}) - sign = -1 if sync_cfg.get("follower_position", "left") == "left" else 1 # Hold last frame until TCP image arrives after cycle reset - if not getattr(self, "_follower_pending_new_image", False): - if local_x >= width: - rp.scroll_helper.scroll_position = local_x + sign * width - frame = rp.scroll_helper.get_visible_portion() - if frame is not None: - self._follower_last_frame = frame + if not self._follower_pending_new_image and local_x >= width: + rp.scroll_helper.scroll_position = ( + local_x + self._follower_sign() * width) + frame = rp.scroll_helper.get_visible_portion() + if frame is not None: + self._follower_last_frame = frame elif scroll_x is None: # Fallback: pixel frame before first scroll_x arrives frame = self.sync_manager.get_latest_frame() if frame is not None: self._follower_last_frame = frame - display_frame = getattr(self, '_follower_last_frame', None) - if display_frame is not None: - self.display_manager.image = display_frame + if self._follower_last_frame is not None: + self.display_manager.image = self._follower_last_frame self.display_manager._sync_render_allowed = True self.display_manager.update_display() self.display_manager._sync_render_allowed = False - # Precision deadline timer — keeps render at exactly 60fps - _deadline = getattr(self, '_follower_deadline', None) - _now = time.perf_counter() - if _deadline is None or _now > _deadline + 0.1: - _deadline = _now - _deadline += 1.0 / 60 - self._follower_deadline = _deadline - _sleep = _deadline - time.perf_counter() - if _sleep > 0: - time.sleep(_sleep) + + # Pace to a fixed deadline grid rather than sleeping a + # flat interval, so render time doesn't lower the rate. + # After a stall longer than _FOLLOWER_DEADLINE_SLIP the + # grid restarts instead of rendering a burst to catch up. + now = time.perf_counter() + deadline = self._follower_deadline + if deadline is None or now > deadline + _FOLLOWER_DEADLINE_SLIP: + deadline = now + deadline += _FOLLOWER_FRAME_INTERVAL + self._follower_deadline = deadline + remaining = deadline - time.perf_counter() + if remaining > 0: + time.sleep(remaining) continue # Process any deferred updates that may have accumulated @@ -2163,13 +2165,12 @@ class DisplayController: if hasattr(plugin_instance, 'display'): # Check plugin health before attempting to display plugin_id = getattr(plugin_instance, 'plugin_id', active_mode) - should_skip = False - if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - should_skip = self.plugin_manager.health_tracker.should_skip_plugin(plugin_id) - if should_skip: - logger.info("Skipping plugin %s due to circuit breaker (mode: %s)", plugin_id, active_mode) - - if not should_skip: + health_tracker = self._health_tracker() + should_skip = (health_tracker is not None + and health_tracker.should_skip_plugin(plugin_id)) + if should_skip: + logger.info("Skipping plugin %s due to circuit breaker (mode: %s)", plugin_id, active_mode) + else: manager_to_display = plugin_instance logger.debug(f"Found plugin manager for mode {active_mode}: {type(plugin_instance).__name__}") else: @@ -2177,168 +2178,138 @@ class DisplayController: else: logger.warning(f"Mode {active_mode} not found in plugin_modes (available: {list(self.plugin_modes.keys())})") - # Display the current mode - display_result = True # Default to True for backward compatibility - display_failed_due_to_exception = False # Track if False was due to exception vs no content + # Display the current mode. A plugin that returns nothing + # (None) counts as having displayed. + display_result = True + # Whether a False result came from an exception rather than + # the plugin having no content. + display_failed_due_to_exception = False if not manager_to_display: logger.warning(f"No plugin manager found for mode {active_mode} - skipping display and rotating to next mode") display_result = False - elif manager_to_display: + else: plugin_id = getattr(manager_to_display, 'plugin_id', active_mode) try: logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}") - can_display = False - if hasattr(manager_to_display, 'display'): - # Opt #1: look up (or compute once) whether display() accepts display_mode - _cache_key = plugin_id - if _cache_key not in self._plugin_accepts_display_mode: - import inspect as _inspect - self._plugin_accepts_display_mode[_cache_key] = ( - 'display_mode' in _inspect.signature(manager_to_display.display).parameters - ) - _accepts_display_mode = self._plugin_accepts_display_mode[_cache_key] + if plugin_id not in self._plugin_accepts_display_mode: + self._plugin_accepts_display_mode[plugin_id] = ( + 'display_mode' in inspect.signature(manager_to_display.display).parameters + ) + _accepts_display_mode = self._plugin_accepts_display_mode[plugin_id] - pm = self.plugin_manager - display_lock = None - can_display = True - if pm and hasattr(pm, 'get_plugin_lock'): - display_lock = pm.get_plugin_lock(plugin_id) - can_display = display_lock.acquire(blocking=False) + pm = self.plugin_manager + display_lock = pm.get_plugin_lock(plugin_id) if pm else None + can_display = display_lock is None or display_lock.acquire(blocking=False) - if not can_display: - # update() in flight on the worker — hold - # the last frame; not a plugin failure - result = True - elif pm and hasattr(pm, 'plugin_executor'): - # PluginExecutor's own thread.join(timeout) can - # return before the real display() call - # finishes (a lingering daemon thread keeps - # running it) -- so the lock is released from - # inside the wrapped call itself, whichever - # thread actually finishes it, rather than - # here when this dispatch merely returns. - release_guard = threading.Lock() - released = {'done': False} + if display_lock is None: + # Only when plugin loading failed part-way. + result = self._display_once( + manager_to_display, active_mode, _accepts_display_mode, + force_clear=self.force_change) + elif not can_display: + # update() in flight on the worker — hold + # the last frame; not a plugin failure + result = True + else: + # PluginExecutor's own thread.join(timeout) can + # return before the real display() call + # finishes (a lingering daemon thread keeps + # running it) -- so the lock is released from + # inside the wrapped call itself, whichever + # thread actually finishes it, rather than + # here when this dispatch merely returns. + release_guard = threading.Lock() + released = {'done': False} - def _release_display_lock(): - with release_guard: - if released['done']: - return - released['done'] = True - if display_lock is not None: - display_lock.release() + def _release_display_lock(): + with release_guard: + if released['done']: + return + released['done'] = True + display_lock.release() - if _accepts_display_mode: - def _display_target(display_mode=None, force_clear=False): - try: - return manager_to_display.display( - display_mode=display_mode, force_clear=force_clear) - finally: - _release_display_lock() - else: - def _display_target(force_clear=False): - try: - return manager_to_display.display(force_clear=force_clear) - finally: - _release_display_lock() - - try: - result = self.plugin_manager.plugin_executor.execute_display( - types.SimpleNamespace(display=_display_target), - plugin_id, - force_clear=self.force_change, - display_mode=active_mode if _accepts_display_mode else None, - # Already resolved and cached above. - # Without this the executor re-derives - # it with inspect.signature() against - # the SimpleNamespace built two lines - # up -- a fresh callable every call, so - # nothing there can ever cache. - accepts_display_mode=_accepts_display_mode - ) - except Exception: # pragma: no cover - defensive; - # execute_display catches everything - # internally, but guarantee the lock is - # never leaked if something unexpected - # slips through. - _release_display_lock() - raise + if _accepts_display_mode: + def _display_target(display_mode=None, force_clear=False): + try: + return manager_to_display.display( + display_mode=display_mode, force_clear=force_clear) + finally: + _release_display_lock() else: - # Fallback to direct call if executor not available - try: - if _accepts_display_mode: - result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change) - else: - result = manager_to_display.display(force_clear=self.force_change) - finally: - if display_lock is not None: - display_lock.release() - - logger.debug(f"display() returned: {result} (type: {type(result)})") - # Check if display() returned a boolean (new behavior) - if isinstance(result, bool): - display_result = result - if not display_result: - logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode) - + def _display_target(force_clear=False): + try: + return manager_to_display.display(force_clear=force_clear) + finally: + _release_display_lock() + + try: + result = pm.plugin_executor.execute_display( + types.SimpleNamespace(display=_display_target), + plugin_id, + force_clear=self.force_change, + display_mode=active_mode if _accepts_display_mode else None, + # Already resolved and cached above. + # Without this the executor re-derives + # it with inspect.signature() against + # the SimpleNamespace built above -- a + # fresh callable every call, so nothing + # there can ever cache. + accepts_display_mode=_accepts_display_mode + ) + except Exception: # pragma: no cover - defensive; + # execute_display catches everything + # internally, but guarantee the lock is + # never leaked if something unexpected + # slips through. + _release_display_lock() + raise + + logger.debug(f"display() returned: {result} (type: {type(result)})") + if isinstance(result, bool): + display_result = result + if not display_result: + logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode) + # Record success only when display() actually ran this # frame -- a skipped frame (lock busy) held the last # frame, not a real success, and must not clear # force_change or the pending mode-switch clear will # be lost when display() finally does run. if can_display: - if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - self.plugin_manager.health_tracker.record_success(plugin_id) + health_tracker = self._health_tracker() + if health_tracker is not None: + health_tracker.record_success(plugin_id) self.force_change = False except Exception as exc: # pylint: disable=broad-except logger.exception("Error displaying %s", self.current_display_mode) - # Record failure - if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: - self.plugin_manager.health_tracker.record_failure(plugin_id, exc) + health_tracker = self._health_tracker() + if health_tracker is not None: + health_tracker.record_failure(plugin_id, exc) self.force_change = True display_result = False - display_failed_due_to_exception = True # Mark that this was an exception, not just no content - + display_failed_due_to_exception = True + # If display() returned False, skip to next mode immediately if not display_result: if self.on_demand_active: - # Skip to next on-demand mode if no content logger.info("No content for on-demand mode %s, skipping to next mode", active_mode) - - # Guard against empty on_demand_modes to prevent ZeroDivisionError - if not self.on_demand_modes or len(self.on_demand_modes) == 0: + if not self.on_demand_modes: logger.warning("On-demand active but no modes configured, skipping rotation") - logger.debug("on_demand_modes is empty, cannot rotate to next mode") - # Skip rotation and continue to next iteration - continue - - # Move to next mode in rotation (only if on_demand_modes is non-empty) - self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) - next_mode = self.on_demand_modes[self.on_demand_mode_index] - - # Only log when next_mode is valid - if next_mode: - logger.info("Rotating to next on-demand mode: %s (index %d/%d)", - next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) - self.current_display_mode = next_mode - self.force_change = True - self._publish_on_demand_state() - continue - else: - logger.warning("Next on-demand mode is invalid, skipping rotation") continue + self._advance_on_demand() + continue else: logger.info("No content to display for %s, skipping to next mode", active_mode) # Don't clear display when immediately moving to next mode - this causes black flashes # The next mode will render immediately with force_clear=True, which is sufficient - + # Only skip all modes for this plugin if there was an exception (broken plugin) # If it's just "no content", we should still try other modes (recent, upcoming) if display_failed_due_to_exception: current_plugin_id = self.mode_to_plugin_id.get(active_mode) if current_plugin_id and current_plugin_id in self.plugin_display_modes: plugin_modes = self.plugin_display_modes[current_plugin_id] - logger.warning("Skipping all %d mode(s) for plugin %s due to exception: %s", + logger.warning("Skipping all %d mode(s) for plugin %s due to exception: %s", len(plugin_modes), current_plugin_id, plugin_modes) # Find the next mode that's not from this plugin next_index = self.current_mode_index @@ -2353,7 +2324,7 @@ class DisplayController: self.current_mode_index = next_index self.current_display_mode = next_mode self.force_change = True - logger.info("Switching to mode: %s (skipped plugin %s due to exception)", + logger.info("Switching to mode: %s (skipped plugin %s due to exception)", self.current_display_mode, current_plugin_id) found_next = True break @@ -2370,9 +2341,7 @@ class DisplayController: else: # Get base duration for current mode base_duration = self._get_display_duration(active_mode) - dynamic_enabled = ( - manager_to_display and self._plugin_supports_dynamic(manager_to_display) - ) + dynamic_enabled = self._plugin_supports_dynamic(manager_to_display) # Log dynamic duration status if dynamic_enabled: @@ -2473,296 +2442,252 @@ class DisplayController: self._check_on_demand_expiration() continue - # For plugins, call display multiple times to allow game rotation - if manager_to_display and hasattr(manager_to_display, 'display'): - # High-FPS decision, in precedence order: - # 1. A plugin that declares needs_high_fps knows best - # (e.g. static-image sets it False for still PNGs, - # True for animated GIFs). - # 2. Back-compat: older static-image versions without - # the attribute keep the historical forced high-FPS - # (GIF support). - # 3. Otherwise scrolling plugins get high FPS. - plugin_id = getattr(manager_to_display, 'plugin_id', None) - declared = getattr(manager_to_display, 'needs_high_fps', None) - if declared is not None: - needs_high_fps = bool(declared) - logger.debug( - "[DisplayController] FPS check for %s (plugin=%s) - " - "plugin declares needs_high_fps=%s", - active_mode, plugin_id, needs_high_fps) - elif plugin_id == 'static-image': - needs_high_fps = True - logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support") - else: - has_enable_scrolling = hasattr(manager_to_display, 'enable_scrolling') - enable_scrolling_value = getattr(manager_to_display, 'enable_scrolling', False) - needs_high_fps = has_enable_scrolling and enable_scrolling_value - logger.info( - "FPS check for %s - has_enable_scrolling: %s, enable_scrolling_value: %s, needs_high_fps: %s", - active_mode, - has_enable_scrolling, - enable_scrolling_value, - needs_high_fps, - ) - - target_duration = max_duration - start_time = time.time() - - def _should_exit_dynamic(elapsed_time: float) -> bool: - if not dynamic_enabled: - return False - # Add small grace period (0.5s) after min_duration to prevent - # premature exits due to timing issues - grace_period = 0.5 - if elapsed_time < min_duration + grace_period: - logger.debug( - "_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, returning False", - elapsed_time, - min_duration, - grace_period, - ) - return False - cycle_complete = self._plugin_cycle_complete(manager_to_display) - logger.debug( - "_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s", - elapsed_time, - min_duration + grace_period, - cycle_complete, - cycle_complete, - ) - if cycle_complete: - logger.debug( - "Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)", - active_mode, - elapsed_time, - min_duration, - grace_period, - ) - return cycle_complete - - loop_completed = False - - if needs_high_fps: - # Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS) - display_interval = 0.008 - logger.debug( - "Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)", - active_mode, - display_interval, - 1.0 / display_interval - ) - - # Deliberate: frames after the first call - # display() directly rather than through - # PluginExecutor. The executor spawns a thread per - # call, which at this loop's frame rate would cost - # more than the advisory timeout it buys -- and - # that timeout cannot cancel a hung plugin anyway - # (see execute_with_timeout). The first dispatch - # above still goes through it, so load-time - # failures are still caught and recorded. - while True: - _frame_start = time.perf_counter() - try: - with self._display_lock_or_skip(plugin_id) as can_display: - if can_display: - # Pass display_mode to maintain sticky manager state - if _accepts_display_mode: - result = manager_to_display.display(display_mode=active_mode, force_clear=False) - else: - result = manager_to_display.display(force_clear=False) - else: - # update() in flight — hold the last frame - result = True - if isinstance(result, bool) and not result: - logger.debug("Display returned False, breaking early") - break - except Exception: # pylint: disable=broad-except - logger.exception("Error during display update") - - # Multi-display sync: send follower frame after each render - self._send_follower_frame(manager_to_display) - - self._tick_plugin_updates() - # Throttled: one clock compare between passes. - self._service_pending_changes() - - # Pace to the frame deadline rather than sleeping a flat - # interval on top of the work. display() has already - # blocked on the panel's vsync by this point, so an - # unconditional sleep is added to a wait that already - # happened. Measured on a 2x128x64 chain at - # limit_refresh_rate_hz=100: ~4ms of render plus a flat - # 8ms put each iteration at ~12ms against a 10ms refresh - # grid, so every swap missed a refresh and the loop - # settled at 50fps where display_interval asks for 125 -- - # and with zero headroom, ~14% of frames slipped a - # further refresh, which is what reads as scroll stutter. - _remaining = display_interval - (time.perf_counter() - _frame_start) - # Yield even when the frame overran its budget, so plugin - # update threads and the web UI are not starved of the GIL. - time.sleep(_remaining if _remaining > 0 else 0.001) - - if (self.current_display_mode != active_mode - or not self.is_display_active): - logger.debug("Mode changed during high-FPS loop, breaking early") - break - - elapsed = time.time() - start_time - if elapsed >= target_duration: - logger.debug( - "Reached high-FPS target duration %.2fs for mode %s", - target_duration, - active_mode, - ) - loop_completed = True - break - if _should_exit_dynamic(elapsed): - logger.debug( - "Dynamic duration cycle complete for %s after %.2fs", - active_mode, - elapsed, - ) - loop_completed = True - break - else: - # Normal FPS for other plugins (1 second) - display_interval = 1.0 - logger.debug( - "Entering normal FPS loop for %s with display_interval=%.3fs", - active_mode, - display_interval - ) - - # Deliberate: frames after the first call - # display() directly rather than through - # PluginExecutor. The executor spawns a thread per - # call, which at this loop's frame rate would cost - # more than the advisory timeout it buys -- and - # that timeout cannot cancel a hung plugin anyway - # (see execute_with_timeout). The first dispatch - # above still goes through it, so load-time - # failures are still caught and recorded. - while True: - time.sleep(display_interval) - self._tick_plugin_updates() - - elapsed = time.time() - start_time - if elapsed >= target_duration: - logger.debug( - "Reached standard target duration %.2fs for mode %s", - target_duration, - active_mode, - ) - loop_completed = True - break - - try: - with self._display_lock_or_skip(plugin_id) as can_display: - if can_display: - # Pass display_mode to maintain sticky manager state - if _accepts_display_mode: - result = manager_to_display.display(display_mode=active_mode, force_clear=False) - else: - result = manager_to_display.display(force_clear=False) - else: - # update() in flight — hold the last frame - result = True - if isinstance(result, bool) and not result: - # For dynamic duration plugins, don't exit on False - keep looping - # until cycle is complete or max duration is reached - if not dynamic_enabled: - logger.info("Display returned False for %s (no dynamic duration), breaking early", active_mode) - break - else: - logger.debug("Display returned False for %s (dynamic duration enabled), continuing loop", active_mode) - except Exception: # pylint: disable=broad-except - logger.exception("Error during display update") - - # Multi-display sync: send follower frame after each render - self._send_follower_frame(manager_to_display) - - self._service_pending_changes() - if (self.current_display_mode != active_mode - or not self.is_display_active): - logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode) - break - - if _should_exit_dynamic(elapsed): - logger.info( - "Dynamic duration cycle complete for %s after %.2fs", - active_mode, - elapsed, - ) - loop_completed = True - break - - # LOAD-BEARING: if current_display_mode changed mid-loop (on-demand - # activation, live priority, etc.), restart the main loop now instead - # of falling into the "honour minimum duration" sleep below. That sleep - # can run for up to the *previous* mode's full display_duration (default - # 30s) and doesn't poll on-demand requests or re-check the mode, so a - # freshly-requested mode switch would sit invisible for up to 30s — or - # get clobbered by a queued stop request — before ever rendering. - # - # This guard was added in #298 (live priority interrupting long display - # durations) and was accidentally dropped in #330 as collateral damage of - # an unrelated time.monotonic() -> time.time() cleanup in the same hunk. - # Removing it again will silently reintroduce both issues. _activate_on_demand - # already sets force_change=True and clears the display, so the next loop - # iteration renders the new mode immediately. - # Likewise if the schedule turned the display off - # mid-screen: the next iteration blanks it. - if (self.current_display_mode != active_mode - or not self.is_display_active): - continue - - # Ensure we honour minimum duration when not dynamic and loop ended early - if ( - not dynamic_enabled - and not loop_completed - and not needs_high_fps - ): - elapsed = time.time() - start_time - remaining_sleep = max(0.0, max_duration - elapsed) - if remaining_sleep > 0: - self._sleep_with_plugin_updates(remaining_sleep) - - if dynamic_enabled: - elapsed_total = time.time() - start_time - cycle_done = self._plugin_cycle_complete(manager_to_display) - - # Log cycle completion status and metrics - if cycle_done: - logger.info( - "Dynamic duration cycle completed for %s after %.2fs (target: %.2fs, min: %.2fs, max: %.2fs)", - active_mode, - elapsed_total, - target_duration, - min_duration, - max_duration, - ) - elif elapsed_total >= max_duration: - logger.info( - "Dynamic duration cap reached before cycle completion for %s (%.2fs/%ds, min: %.2fs)", - active_mode, - elapsed_total, - int(max_duration), - min_duration, - ) - else: - logger.debug( - "Dynamic duration cycle in progress for %s: %.2fs elapsed (target: %.2fs, min: %.2fs, max: %.2fs)", - active_mode, - elapsed_total, - target_duration, - min_duration, - max_duration, - ) + # High-FPS decision, in precedence order: + # 1. A plugin that declares needs_high_fps knows best + # (e.g. static-image sets it False for still PNGs, + # True for animated GIFs). + # 2. Back-compat: older static-image versions without + # the attribute keep the historical forced high-FPS + # (GIF support). + # 3. Otherwise scrolling plugins get high FPS. + plugin_id = getattr(manager_to_display, 'plugin_id', None) + declared = getattr(manager_to_display, 'needs_high_fps', None) + if declared is not None: + needs_high_fps = bool(declared) + logger.debug( + "[DisplayController] FPS check for %s (plugin=%s) - " + "plugin declares needs_high_fps=%s", + active_mode, plugin_id, needs_high_fps) + elif plugin_id == 'static-image': + needs_high_fps = True + logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support") else: - # For non-plugin modes, use the original behavior - self._sleep_with_plugin_updates(max_duration) + has_enable_scrolling = hasattr(manager_to_display, 'enable_scrolling') + enable_scrolling_value = getattr(manager_to_display, 'enable_scrolling', False) + needs_high_fps = has_enable_scrolling and enable_scrolling_value + logger.info( + "FPS check for %s - has_enable_scrolling: %s, enable_scrolling_value: %s, needs_high_fps: %s", + active_mode, + has_enable_scrolling, + enable_scrolling_value, + needs_high_fps, + ) + + target_duration = max_duration + start_time = time.time() + + def _should_exit_dynamic(elapsed_time: float) -> bool: + if not dynamic_enabled: + return False + # Add small grace period (0.5s) after min_duration to prevent + # premature exits due to timing issues + grace_period = 0.5 + if elapsed_time < min_duration + grace_period: + logger.debug( + "_should_exit_dynamic: elapsed %.2fs < min_duration %.2fs + grace %.2fs, returning False", + elapsed_time, + min_duration, + grace_period, + ) + return False + cycle_complete = self._plugin_cycle_complete(manager_to_display) + logger.debug( + "_should_exit_dynamic: elapsed %.2fs >= min %.2fs, cycle_complete=%s, returning %s", + elapsed_time, + min_duration + grace_period, + cycle_complete, + cycle_complete, + ) + if cycle_complete: + logger.debug( + "Cycle complete detected for %s after %.2fs (min: %.2fs, grace: %.2fs)", + active_mode, + elapsed_time, + min_duration, + grace_period, + ) + return cycle_complete + + loop_completed = False + + if needs_high_fps: + # Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS) + display_interval = 0.008 + logger.debug( + "Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)", + active_mode, + display_interval, + 1.0 / display_interval + ) + + while True: + _frame_start = time.perf_counter() + try: + result = self._display_once( + manager_to_display, active_mode, _accepts_display_mode) + if isinstance(result, bool) and not result: + logger.debug("Display returned False, breaking early") + break + except Exception: # pylint: disable=broad-except + logger.exception("Error during display update") + + # Multi-display sync: send follower frame after each render + self._send_follower_frame(manager_to_display) + + self._tick_plugin_updates() + # Throttled: one clock compare between passes. + self._service_pending_changes() + + # Pace to the frame deadline rather than sleeping a flat + # interval on top of the work. display() has already + # blocked on the panel's vsync by this point, so an + # unconditional sleep is added to a wait that already + # happened. Measured on a 2x128x64 chain at + # limit_refresh_rate_hz=100: ~4ms of render plus a flat + # 8ms put each iteration at ~12ms against a 10ms refresh + # grid, so every swap missed a refresh and the loop + # settled at 50fps where display_interval asks for 125 -- + # and with zero headroom, ~14% of frames slipped a + # further refresh, which is what reads as scroll stutter. + _remaining = display_interval - (time.perf_counter() - _frame_start) + # Yield even when the frame overran its budget, so plugin + # update threads and the web UI are not starved of the GIL. + time.sleep(_remaining if _remaining > 0 else 0.001) + + if (self.current_display_mode != active_mode + or not self.is_display_active): + logger.debug("Mode changed during high-FPS loop, breaking early") + break + + elapsed = time.time() - start_time + if elapsed >= target_duration: + logger.debug( + "Reached high-FPS target duration %.2fs for mode %s", + target_duration, + active_mode, + ) + loop_completed = True + break + if _should_exit_dynamic(elapsed): + logger.debug( + "Dynamic duration cycle complete for %s after %.2fs", + active_mode, + elapsed, + ) + loop_completed = True + break + else: + # Normal FPS for other plugins (1 second) + display_interval = 1.0 + logger.debug( + "Entering normal FPS loop for %s with display_interval=%.3fs", + active_mode, + display_interval + ) + + while True: + time.sleep(display_interval) + self._tick_plugin_updates() + + elapsed = time.time() - start_time + if elapsed >= target_duration: + logger.debug( + "Reached standard target duration %.2fs for mode %s", + target_duration, + active_mode, + ) + loop_completed = True + break + + try: + result = self._display_once( + manager_to_display, active_mode, _accepts_display_mode) + if isinstance(result, bool) and not result: + # For dynamic duration plugins, don't exit on False - keep looping + # until cycle is complete or max duration is reached + if not dynamic_enabled: + logger.info("Display returned False for %s (no dynamic duration), breaking early", active_mode) + break + else: + logger.debug("Display returned False for %s (dynamic duration enabled), continuing loop", active_mode) + except Exception: # pylint: disable=broad-except + logger.exception("Error during display update") + + # Multi-display sync: send follower frame after each render + self._send_follower_frame(manager_to_display) + + self._service_pending_changes() + if (self.current_display_mode != active_mode + or not self.is_display_active): + logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode) + break + + if _should_exit_dynamic(elapsed): + logger.info( + "Dynamic duration cycle complete for %s after %.2fs", + active_mode, + elapsed, + ) + loop_completed = True + break + + # LOAD-BEARING: if current_display_mode changed mid-loop (on-demand + # activation, live priority, etc.), restart the main loop now instead + # of falling into the "honour minimum duration" sleep below. That sleep + # can run for up to the *previous* mode's full display_duration (default + # 30s) and doesn't poll on-demand requests or re-check the mode, so a + # freshly-requested mode switch would sit invisible for up to 30s — or + # get clobbered by a queued stop request — before ever rendering. + # _activate_on_demand already sets force_change=True and clears the + # display, so the next loop iteration renders the new mode immediately. + # Likewise if the schedule turned the display off + # mid-screen: the next iteration blanks it. + if (self.current_display_mode != active_mode + or not self.is_display_active): + continue + + # Ensure we honour minimum duration when not dynamic and loop ended early + if ( + not dynamic_enabled + and not loop_completed + and not needs_high_fps + ): + elapsed = time.time() - start_time + remaining_sleep = max(0.0, max_duration - elapsed) + if remaining_sleep > 0: + self._sleep_with_plugin_updates(remaining_sleep) + + if dynamic_enabled: + elapsed_total = time.time() - start_time + cycle_done = self._plugin_cycle_complete(manager_to_display) + + # Log cycle completion status and metrics + if cycle_done: + logger.info( + "Dynamic duration cycle completed for %s after %.2fs (target: %.2fs, min: %.2fs, max: %.2fs)", + active_mode, + elapsed_total, + target_duration, + min_duration, + max_duration, + ) + elif elapsed_total >= max_duration: + logger.info( + "Dynamic duration cap reached before cycle completion for %s (%.2fs/%ds, min: %.2fs)", + active_mode, + elapsed_total, + int(max_duration), + min_duration, + ) + else: + logger.debug( + "Dynamic duration cycle in progress for %s: %.2fs elapsed (target: %.2fs, min: %.2fs, max: %.2fs)", + active_mode, + elapsed_total, + target_duration, + min_duration, + max_duration, + ) # The dwell sleeps above return early when a pending change # (on-demand started or stopped, display scheduled off) has @@ -2780,14 +2705,7 @@ class DisplayController: self._clear_on_demand(reason='no-modes-available') # Fall through to normal rotation else: - # Rotate to next on-demand mode - self.on_demand_mode_index = (self.on_demand_mode_index + 1) % len(self.on_demand_modes) - next_mode = self.on_demand_modes[self.on_demand_mode_index] - logger.info("Rotating to next on-demand mode: %s (index %d/%d)", - next_mode, self.on_demand_mode_index, len(self.on_demand_modes)) - self.current_display_mode = next_mode - self.force_change = True - self._publish_on_demand_state() + self._advance_on_demand() continue # Check for live priority - don't rotate if current plugin has live content @@ -3324,22 +3242,33 @@ class DisplayController: @staticmethod def _vegas_scroll_speed(config: Dict[str, Any]) -> float: - """Vegas scroll speed in px/s. The default must match VegasModeConfig's - (50): a follower dead-reckons with this value between the leader's - position packets, so a different default made it run 50% fast.""" + """Vegas scroll speed in px/s, with VegasModeConfig's default. + + The default has to be the one Vegas itself uses: a follower + dead-reckons with this value between the leader's position packets, + so a different default makes it run at the wrong speed. + """ + from src.vegas_mode.config import VegasModeConfig vegas_cfg = (config.get('display', {}) or {}).get('vegas_scroll', {}) or {} - return float(vegas_cfg.get('scroll_speed', 50.0)) + return float(vegas_cfg.get('scroll_speed', VegasModeConfig.scroll_speed)) def cleanup(self): """Clean up resources.""" # Stop the async update worker first so no in-flight update() call # is still touching display/cache-backed resources while they're # torn down below. - if self.plugin_manager and hasattr(self.plugin_manager, 'stop_update_worker'): + if self.plugin_manager: try: self.plugin_manager.stop_update_worker() except Exception as e: logger.warning("Error stopping plugin update worker: %s", e) + # Vegas is torn down before the display manager: stopping it resets + # the display's scrolling state. + if self.vegas_coordinator is not None: + try: + self.vegas_coordinator.cleanup() + except Exception as e: + logger.warning("Error cleaning up Vegas mode: %s", e) # Shutdown config service if it exists if hasattr(self, 'config_service'): try: diff --git a/src/display_manager.py b/src/display_manager.py index 603ee042..0123e40e 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -38,8 +38,7 @@ from src.common.bdf_font import draw_bdf_text, load_bdf_face from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path from src.display_geometry import ( DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, - ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size, - resolve_double_sided, + compose_pixel_mapper_config, physical_size, resolve_double_sided, ) from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message from src.pi5_matrix_support import is_raspberry_pi_5 @@ -47,13 +46,13 @@ import threading import time from collections import OrderedDict from typing import Dict, Any, List, Optional, Tuple -import logging import math import zlib import freetype from src.common import snapshot_policy from src.deprecation import deprecated +from src.logging_config import get_logger from src.common.permission_utils import ( ensure_directory_permissions, ensure_file_permissions, @@ -61,9 +60,7 @@ from src.common.permission_utils import ( get_assets_file_mode, ) -# Get logger without configuring -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) # Set to INFO level +logger = get_logger(__name__) #: The strike 5x7.bdf is drawn at. FreeType renders a BDF at its own fixed #: size regardless, but a Face needs an active size before its metrics -- @@ -134,11 +131,6 @@ class _LogicalMatrix: setattr(object.__getattribute__(self, "_matrix"), name, value) -# Moved to src/display_geometry.py so the web preview, Starlark magnify and -# sync handshake compute the display size exactly as DisplayManager does -# without importing rgbmatrix. Aliased here for existing callers. -_resolve_double_sided = resolve_double_sided - class DisplayManager: """ @@ -160,7 +152,6 @@ class DisplayManager: """ _instance = None - _initialized = False def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -248,20 +239,16 @@ class DisplayManager: font_time = time.time() self._load_fonts() logger.info("Font loading completed in %.3f seconds", time.time() - font_time) - - # Initialize managers - # Calendar manager is now initialized by DisplayController - - # Orientation setting -> rpi-rgb-led-matrix "Rotate:" pixel-mapper suffix. - _ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES - def _build_pixel_mapper_config(self, hardware_config: dict) -> str: - """Compose pixel_mapper_config with the orientation setting. + def _new_canvas(self, width: int, height: int) -> None: + """Replace ``image``/``draw`` with a black canvas of the given size. - See :func:`src.display_geometry.compose_pixel_mapper_config`, which the - web preview shares so it sizes the canvas the same way. + Text is drawn 1-bit (``fontmode = "1"``): the panel has no partial + brightness, so anti-aliasing only smears glyphs. """ - return compose_pixel_mapper_config(hardware_config) + self.image = Image.new('RGB', (width, height)) + self.draw = ImageDraw.Draw(self.image) + self.draw.fontmode = "1" @staticmethod def _fallback_advice(cause: str, error: Exception) -> str: @@ -330,7 +317,7 @@ class DisplayManager: # logical (per-screen) size, and keep a full-chain buffer to tile # the rendered screen into once per frame. ds_config = self.config.get('display', {}).get('double_sided', {}) - ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config) + ds = resolve_double_sided(self.matrix.width, self.matrix.height, ds_config) self._double_sided = ds if ds is not None: self._physical_image = Image.new( @@ -339,9 +326,7 @@ class DisplayManager: self.matrix, ds['logical_width'], ds['logical_height']) # Create image with the (logical) display dimensions - self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + self._new_canvas(self.matrix.width, self.matrix.height) logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}") # Initialize font with Press Start 2P @@ -372,7 +357,7 @@ class DisplayManager: fallback_width, fallback_height = physical_size(self.config) # Mirror double-sided in fallback so the preview shows one screen. ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {} - ds = _resolve_double_sided(fallback_width, fallback_height, ds_config) + ds = resolve_double_sided(fallback_width, fallback_height, ds_config) self._double_sided = ds if ds is not None: fallback_width = ds['logical_width'] @@ -380,9 +365,7 @@ class DisplayManager: except Exception: fallback_width, fallback_height = 128, 32 - self.image = Image.new('RGB', (fallback_width, fallback_height)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + self._new_canvas(fallback_width, fallback_height) # Simple fallback visualization so web UI shows a realistic canvas try: self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0)) @@ -611,18 +594,14 @@ class DisplayManager: line, font=font, fill=(0, 0, 255)) def _draw_test_pattern(self): - """Draw a test pattern to verify the display is working.""" + """Draw a test pattern to verify the display is working. + + Only called from _setup_matrix once the matrix exists; fallback mode + draws its own "Simulation" canvas there. + """ try: self.clear() - - if self.matrix is None: - # Fallback mode - just draw on the image - self.draw.rectangle([0, 0, self.image.width-1, self.image.height-1], outline=(255, 0, 0)) - self.draw.line([0, 0, self.image.width-1, self.image.height-1], fill=(0, 255, 0)) - self.draw.text((10, 10), "Simulation", font=self.font, fill=(0, 0, 255)) - logger.info("Drew test pattern in fallback mode") - return - + # Draw a red rectangle border self.draw.rectangle([0, 0, self.matrix.width-1, self.matrix.height-1], outline=(255, 0, 0)) @@ -637,7 +616,7 @@ class DisplayManager: # Update the display once after everything is drawn self.update_display() - time.sleep(0.5) # Reduced from 1 second to 0.5 seconds for faster animation + time.sleep(0.5) except Exception as e: logger.error(f"Error drawing test pattern: {e}", exc_info=True) @@ -712,9 +691,7 @@ class DisplayManager: self.matrix = _LogicalMatrix(real_matrix, target_w, target_h) # With no hardware, the width/height properties fall through to # self.image, so swapping the buffer below is enough on its own. - self.image = Image.new('RGB', (target_w, target_h)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + self._new_canvas(target_w, target_h) yield finally: self.matrix = real_matrix @@ -834,28 +811,14 @@ class DisplayManager: try: if self.matrix is None: # Fallback mode - just clear the image - # Explicitly clear old image reference to help garbage collection old_image = getattr(self, 'image', None) width = old_image.width if old_image else 64 height = old_image.height if old_image else 64 - if old_image is not None: - del old_image - - self.image = Image.new('RGB', (width, height)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + self._new_canvas(width, height) logger.debug("Cleared display in fallback mode") return - - # Explicitly clear old image reference to help garbage collection - old_image = getattr(self, 'image', None) - if old_image is not None: - del old_image - - # Create a new black image - self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + + self._new_canvas(self.matrix.width, self.matrix.height) if not self._capture_mode_active: # Clear both canvases and the underlying matrix to ensure no artifacts. @@ -1315,14 +1278,11 @@ class DisplayManager: # Ensure image/draw are reset to a blank state if hasattr(self, 'image') and hasattr(self, 'draw'): try: - self.image = Image.new('RGB', (self.width, self.height)) - self.draw = ImageDraw.Draw(self.image) - self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs. + self._new_canvas(self.width, self.height) except (OSError, RuntimeError, ValueError, MemoryError): logger.debug("Canvas reset during cleanup failed", exc_info=True) # Reset the singleton state when cleaning up DisplayManager._instance = None - DisplayManager._initialized = False def format_date_with_ordinal(self, dt): """Formats a datetime object into 'Mon Aug 30th' style.""" @@ -1360,9 +1320,9 @@ class DisplayManager: options.pwm_bits = hardware_config.get('pwm_bits', 10) options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150) options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB') - # _build_pixel_mapper_config reads only class attributes, so the class - # stands in for an instance here. - options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config) + # Orientation becomes a "Rotate:" pixel mapper; the web preview + # composes it the same way so it sizes the canvas identically. + options.pixel_mapper_config = compose_pixel_mapper_config(hardware_config) options.row_address_type = hardware_config.get('row_address_type', 0) options.multiplexing = hardware_config.get('multiplexing', 0) options.panel_type = hardware_config.get('panel_type', '') @@ -1455,13 +1415,16 @@ class DisplayManager: that does not care gets a new frame every refresh. """ current_time = time.time() + # Scrolling callers set this every frame; log transitions only. + changed = self._scrolling_state['is_scrolling'] != is_scrolling self._scrolling_state['is_scrolling'] = is_scrolling if is_scrolling: self._scrolling_state['last_scroll_activity'] = current_time self.set_frame_hold(frame_hold) else: self._frame_hold = 1 - logger.debug(f"Scrolling state set to: {is_scrolling}") + if changed: + logger.debug("Scrolling state set to: %s", is_scrolling) def is_currently_scrolling(self) -> bool: """Check if the display is currently in a scrolling state.""" @@ -1530,9 +1493,6 @@ class DisplayManager: if not self._scrolling_state['deferred_updates']: return - if not self._scrolling_state['deferred_updates']: - return - # Process only a limited number of updates per call to avoid blocking max_updates_per_call = min(5, len(self._scrolling_state['deferred_updates'])) updates_to_process = self._scrolling_state['deferred_updates'][:max_updates_per_call] diff --git a/src/vegas_mode/__init__.py b/src/vegas_mode/__init__.py index 6cea87a5..eb1c1fa4 100644 --- a/src/vegas_mode/__init__.py +++ b/src/vegas_mode/__init__.py @@ -6,8 +6,8 @@ plugins' content is composed into a single horizontally scrolling display. Components: - VegasModeCoordinator: Main orchestrator for Vegas mode -- StreamManager: Manages plugin content streaming with 1-2 ahead buffering -- RenderPipeline: Handles 125 FPS rendering with double-buffering +- StreamManager: Plugin rotation, content fetching and pending-update tracking +- RenderPipeline: Strip composition and per-frame rendering - PluginAdapter: Converts plugin content to scrollable images - VegasModeConfig: Configuration management """ diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 6ba61526..8f33aab6 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -167,7 +167,7 @@ class VegasModeConfig: # Dynamic duration dynamic_duration_enabled: bool = True min_cycle_duration: int = 60 # Minimum seconds per full cycle - max_cycle_duration: int = 600 # Maximum seconds per full cycle + max_cycle_duration: int = 240 # Maximum seconds per full cycle @classmethod def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig': @@ -181,44 +181,50 @@ class VegasModeConfig: VegasModeConfig instance """ vegas_config = config.get('display', {}).get('vegas_scroll', {}) + # Missing keys fall back to the field defaults above, so each default + # is written once and the two cannot drift apart. + d = cls() + get = vegas_config.get return cls( - enabled=vegas_config.get('enabled', False), - scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), - separator_width=int(vegas_config.get('separator_width', 32)), - intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)), - render_width_pct=int(vegas_config.get('render_width_pct', 100)), + enabled=get('enabled', d.enabled), + scroll_speed=float(get('scroll_speed', d.scroll_speed)), + separator_width=int(get('separator_width', d.separator_width)), + intra_plugin_gap=int(get('intra_plugin_gap', d.intra_plugin_gap)), + render_width_pct=int(get('render_width_pct', d.render_width_pct)), min_content_separation=int( - vegas_config.get('min_content_separation', 24)), - min_cut_gap=int(vegas_config.get('min_cut_gap', 6)), - smooth_scroll=vegas_config.get('smooth_scroll', True), - continuous_scroll=vegas_config.get('continuous_scroll', True), + get('min_content_separation', d.min_content_separation)), + min_cut_gap=int(get('min_cut_gap', d.min_cut_gap)), + smooth_scroll=get('smooth_scroll', d.smooth_scroll), + continuous_scroll=get('continuous_scroll', d.continuous_scroll), extend_threshold_screens=float( - vegas_config.get('extend_threshold_screens', 2.0)), - auto_trim=vegas_config.get('auto_trim', True), - trim_threshold=int(vegas_config.get('trim_threshold', 10)), - content_padding=int(vegas_config.get('content_padding', 8)), - min_plugin_width=int(vegas_config.get('min_plugin_width', 8)), - lead_in_width=int(vegas_config.get('lead_in_width', 0)), - plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)), + get('extend_threshold_screens', d.extend_threshold_screens)), + auto_trim=get('auto_trim', d.auto_trim), + trim_threshold=int(get('trim_threshold', d.trim_threshold)), + content_padding=int(get('content_padding', d.content_padding)), + min_plugin_width=int(get('min_plugin_width', d.min_plugin_width)), + lead_in_width=int(get('lead_in_width', d.lead_in_width)), + plugins_per_cycle=int(get('plugins_per_cycle', d.plugins_per_cycle)), max_plugin_width_ratio=float( - vegas_config.get('max_plugin_width_ratio', 0.0)), - overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')), - plugin_order=list(vegas_config.get('plugin_order', [])), - excluded_plugins=set(vegas_config.get('excluded_plugins', [])), - live_in_ticker=bool(vegas_config.get('live_in_ticker', False)), + get('max_plugin_width_ratio', d.max_plugin_width_ratio)), + overflow_mode=str(get('overflow_mode', d.overflow_mode)), + plugin_order=list(get('plugin_order', d.plugin_order)), + excluded_plugins=set(get('excluded_plugins', d.excluded_plugins)), + live_in_ticker=bool(get('live_in_ticker', d.live_in_ticker)), # Clamped: a weight below 1 would drop the plugin from the rotation # entirely, and a very large one starves everything else. - live_weight=max(1, min(10, int(vegas_config.get('live_weight', 3)))), - favorite_live_weight=max( - 1, min(10, int(vegas_config.get('favorite_live_weight', 5)))), - target_fps=int(vegas_config.get('target_fps', 125)), - buffer_ahead=int(vegas_config.get('buffer_ahead', 2)), - frame_based_scrolling=vegas_config.get('frame_based_scrolling', True), - scroll_delay=float(vegas_config.get('scroll_delay', 0.02)), - dynamic_duration_enabled=vegas_config.get('dynamic_duration_enabled', True), - min_cycle_duration=int(vegas_config.get('min_cycle_duration', 60)), - max_cycle_duration=int(vegas_config.get('max_cycle_duration', 600)), + live_weight=max(1, min(10, int(get('live_weight', d.live_weight)))), + favorite_live_weight=max(1, min(10, int( + get('favorite_live_weight', d.favorite_live_weight)))), + target_fps=int(get('target_fps', d.target_fps)), + buffer_ahead=int(get('buffer_ahead', d.buffer_ahead)), + frame_based_scrolling=get( + 'frame_based_scrolling', d.frame_based_scrolling), + scroll_delay=float(get('scroll_delay', d.scroll_delay)), + dynamic_duration_enabled=get( + 'dynamic_duration_enabled', d.dynamic_duration_enabled), + min_cycle_duration=int(get('min_cycle_duration', d.min_cycle_duration)), + max_cycle_duration=int(get('max_cycle_duration', d.max_cycle_duration)), ) def to_dict(self) -> Dict[str, Any]: @@ -260,22 +266,6 @@ class VegasModeConfig: """Get the frame interval in seconds for target FPS.""" return 1.0 / max(1, self.target_fps) - def is_plugin_included(self, plugin_id: str) -> bool: - """ - Check if a plugin should be included in Vegas scroll. - - This is consistent with get_ordered_plugins - plugins not explicitly - in plugin_order are still included (appended at the end) unless excluded. - - Args: - plugin_id: Plugin identifier to check - - Returns: - True if plugin should be included - """ - # Plugins are included unless explicitly excluded - return plugin_id not in self.excluded_plugins - def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]: """ Get plugins in configured order, filtering excluded ones. diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index f755326c..c053c06b 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -145,13 +145,8 @@ class VegasModeCoordinator: # Static pause handling self._static_pause_active = False - self._static_pause_plugin: Optional['BasePlugin'] = None - self._static_pause_start: Optional[float] = None self._saved_scroll_position: Optional[int] = None - # Track which plugins should use STATIC mode (pause scroll) - self._static_mode_plugins: set = set() - # Statistics self.stats = { 'total_runtime_seconds': 0.0, @@ -235,7 +230,9 @@ class VegasModeCoordinator: returns immediately, collapsing the inter-iteration gap to <1 ms. Args: - callback: Callable with no arguments (typically _tick_plugin_updates) + callback: Callable with no arguments. The display controller + passes _tick_plugin_updates_for_vegas, which also reports the + plugins that got fresh data through mark_plugin_updated(). """ self._update_callback = callback @@ -417,9 +414,6 @@ class VegasModeCoordinator: if not self.start(): return False - # Update static mode plugin list on iteration start - self._update_static_mode_plugins() - frame_interval = self.vegas_config.get_frame_interval() if self.vegas_config.continuous_scroll: # The strip is continuously extended and trimmed, so its width says @@ -733,13 +727,6 @@ class VegasModeCoordinator: return status - def get_ordered_plugins(self) -> List[str]: - """Get the current ordered list of plugins in Vegas scroll.""" - if hasattr(self.plugin_manager, 'plugins'): - available = list(self.plugin_manager.plugins.keys()) - return self.vegas_config.get_ordered_plugins(available) - return [] - # ------------------------------------------------------------------------- # Static pause handling (for STATIC display mode) # ------------------------------------------------------------------------- @@ -795,8 +782,6 @@ class VegasModeCoordinator: # Save current scroll position for smooth resume self._saved_scroll_position = self.render_pipeline.get_scroll_position() self._static_pause_active = True - self._static_pause_plugin = plugin - self._static_pause_start = time.time() self.stats['static_pauses'] += 1 logger.info("Static pause started for plugin: %s", plugin_id) @@ -823,9 +808,9 @@ class VegasModeCoordinator: logger.info("Static pause interrupted by live priority") return False - # Yield immediately if multi-display follower mode becomes active + # On-demand, a WiFi message, the schedule, follower mode... if self._interrupt_check and self._interrupt_check(): - logger.info("Static pause interrupted by sync follower mode") + logger.info("Static pause interrupted by the display controller") return False # Sleep in small increments to remain responsive @@ -860,8 +845,6 @@ class VegasModeCoordinator: # Clear pause state self._static_pause_active = False - self._static_pause_plugin = None - self._static_pause_start = None # Restore scroll position if we're resuming if should_resume_scrolling and self._saved_scroll_position is not None: @@ -875,29 +858,6 @@ class VegasModeCoordinator: else: logger.debug("Static pause ended (interrupted, not resuming scroll)") - def _update_static_mode_plugins(self) -> None: - """Update the set of plugins using STATIC display mode.""" - self._static_mode_plugins.clear() - - for plugin_id in self.get_ordered_plugins(): - plugin = self.plugin_manager.get_plugin(plugin_id) - if plugin: - try: - mode = plugin.get_vegas_display_mode() - if mode == VegasDisplayMode.STATIC: - self._static_mode_plugins.add(plugin_id) - except Exception: - logger.exception( - "Error getting vegas display mode for plugin %s", - plugin_id - ) - - if self._static_mode_plugins: - logger.info( - "Static mode plugins: %s", - ', '.join(self._static_mode_plugins) - ) - def cleanup(self) -> None: """Clean up all resources.""" self.stop() diff --git a/src/vegas_mode/geometry.py b/src/vegas_mode/geometry.py index 50cff306..d0e396a0 100644 --- a/src/vegas_mode/geometry.py +++ b/src/vegas_mode/geometry.py @@ -230,54 +230,6 @@ def blank_runs( return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist())) -def find_blank_cut( - img: Image.Image, - target: int, - search_radius: int, - threshold: int = DEFAULT_INK_THRESHOLD, -) -> int: - """ - Find a column near ``target`` that carries no ink, so an image can be cut - there without slicing through a glyph or logo. - - Used when a single oversized segment has to be narrowed to fit a width - budget. Cutting at an arbitrary column would leave half a character - hanging at the panel edge; snapping to the nearest gap hides the cut. - - Args: - img: Image to cut - target: Preferred cut column - search_radius: How far either side of ``target`` to look - threshold: Ink threshold - - Returns: - A blank column within the search window, or ``target`` clamped to the - image bounds when the window contains no blank column at all. - """ - width = img.width - target = max(0, min(target, width)) - if search_radius <= 0 or width == 0: - return target - - ink = column_has_ink(img, threshold) - - # target may legitimately equal width (a cut after the last column), but - # there is no column to inspect there, so both bounds stop at width - 1. - lo = max(0, min(target - search_radius, width - 1)) - hi = max(0, min(target + search_radius, width - 1)) - - # Walk outwards from target so the nearest gap wins. - for offset in range(0, search_radius + 1): - right = target + offset - if lo <= right <= hi and not ink[right]: - return right - left = target - offset - if lo <= left <= hi and not ink[left]: - return left - - return target - - class DeadWindowStats(NamedTuple): """How much of a composed ticker reads as blank to a viewer.""" diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index 01ef8cca..e7a579b4 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -48,15 +48,8 @@ class PluginAdapter: from src.vegas_mode.config import VegasModeConfig config = VegasModeConfig() self.config = config - # Handle both property and method access patterns - self.display_width = ( - display_manager.width() if callable(display_manager.width) - else display_manager.width - ) - self.display_height = ( - display_manager.height() if callable(display_manager.height) - else display_manager.height - ) + self.display_width = display_manager.width + self.display_height = display_manager.height # Cache for recently fetched content (prevents redundant fetch) self._content_cache: dict = {} @@ -185,13 +178,14 @@ class PluginAdapter: Trim dead space off a segment, then cache it. Every content path funnels through here so trimming is applied - uniformly. Previously only the scroll_helper path had its margins - stripped, which left plugins that render onto a full-display canvas - contributing their entire blank canvas to the ticker. + uniformly; a plugin that renders onto a full-display canvas would + otherwise contribute its whole blank canvas to the ticker. - Each image is trimmed independently because compose_scroll_content() - treats every image as its own item and inserts separator_width between - them — so a per-image trim is what makes that separator the real gap. + Each image is trimmed independently. The render pipeline joins one + plugin's images with a gap measured from their ink + (RenderPipeline._join_plugin_rows) and puts separator_width only + between plugins, so the margins a row keeps are content_padding, not + whatever blank canvas the plugin happened to draw it on. Args: images: Raw content from one of the fetch paths @@ -602,8 +596,11 @@ class PluginAdapter: Narrow a single oversized image to the budget, advancing a window through it across cycles. - The cut is snapped to the nearest blank column so it does not slice - through a glyph or logo and leave half a character at the panel edge. + Cuts land only at item boundaries: the middle of a blank run at least + ``min_cut_gap`` columns wide. The window ends at the last boundary + inside the budget, or overruns to the next one when there is none, so + an item is never sliced. An image with no such runs (a map, a chart) + is continuous content and is cropped to the budget exactly. Rotation is tracked as an index into the strip's item boundaries rather than as a pixel column, because a ticker re-renders between fetches. A diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index d9b713f5..b9e5914b 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -1,8 +1,8 @@ """ Render Pipeline for Vegas Mode -Handles high-FPS (125 FPS) rendering with double-buffering for smooth scrolling. -Uses the existing ScrollHelper for numpy-optimized scroll operations. +Composes plugin content into one wide strip and renders the visible window of +it each frame, using ScrollHelper for the numpy-backed scroll. """ import logging @@ -10,7 +10,7 @@ import os import time import threading from collections import deque -from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING +from typing import Optional, List, Any, Dict, Deque from PIL import Image from src.common.scroll_helper import ScrollHelper @@ -18,11 +18,13 @@ from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.geometry import separation_gap from src.vegas_mode.stream_manager import StreamManager -if TYPE_CHECKING: - pass - logger = logging.getLogger(__name__) +#: Shortest gap between multi-display sync sends from the leader, for both the +#: Vegas scroll position and the controller's per-frame follower images. The +#: payloads are raw and cheap, and 90/s is above the follower's render rate. +SYNC_SEND_INTERVAL = 1.0 / 90 + class RenderPipeline: """ @@ -31,8 +33,9 @@ class RenderPipeline: Key responsibilities: - Compose content segments into scrollable image - Manage scroll position and velocity - - Handle 125 FPS rendering loop - - Double-buffer for hot-swap during updates + - Render one frame per call at the target FPS + - Extend the strip (continuous mode) or recompose and hot-swap it (swap + mode) as content changes - Track scroll cycle completion """ @@ -59,18 +62,10 @@ class RenderPipeline: self.stream_manager = stream_manager self.sync_manager = None # Optional DisplaySyncManager — set by coordinator self.sync_follower_left = True # True = follower is LEFT of leader (default) - self._sync_send_interval = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate self._last_sync_send = 0.0 - # Display dimensions (handle both property and method access patterns) - self.display_width = ( - display_manager.width() if callable(display_manager.width) - else display_manager.width - ) - self.display_height = ( - display_manager.height() if callable(display_manager.height) - else display_manager.height - ) + self.display_width = display_manager.width + self.display_height = display_manager.height # ScrollHelper for optimized scrolling self.scroll_helper = ScrollHelper( @@ -82,11 +77,6 @@ class RenderPipeline: # Configure scroll helper self._configure_scroll_helper() - # Double-buffer for composed images - self._active_scroll_image: Optional[Image.Image] = None - self._staging_scroll_image: Optional[Image.Image] = None - self._buffer_lock = threading.Lock() - # Group prepared off the render thread, waiting to be appended. self._prepared_group = None # Plugins that need the shared canvas, appended one at a time. @@ -96,13 +86,9 @@ class RenderPipeline: self._prefetch_lock = threading.Lock() # Render state - self._is_rendering = False self._cycle_complete = False self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll - # Timing - self._last_frame_time = 0.0 - self._frame_interval = config.get_frame_interval() self._cycle_start_time = 0.0 # Statistics @@ -192,10 +178,6 @@ class RenderPipeline: logger.error("ScrollHelper failed to create cached image") return False - # Store reference to composed image - with self._buffer_lock: - self._active_scroll_image = self.scroll_helper.cached_image - # Track which plugins are in this scroll (get safely via buffer status) self._segments_in_scroll = self.stream_manager.get_active_plugin_ids() @@ -335,8 +317,6 @@ class RenderPipeline: element_gap=0, ) if appended: - with self._buffer_lock: - self._active_scroll_image = self.scroll_helper.cached_image logger.info( "[%s] Appended deferred content: strip now %dpx, %dpx ahead", plugin_id, self.scroll_helper.total_scroll_width, @@ -420,9 +400,6 @@ class RenderPipeline: # Keep a screen's worth behind the viewport as a safety margin. self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width) - with self._buffer_lock: - self._active_scroll_image = self.scroll_helper.cached_image - self._segments_in_scroll = [pid for pid, _ in grouped] self.stats['composition_count'] += 1 self.stats['extensions'] = self.stats.get('extensions', 0) + 1 @@ -565,7 +542,7 @@ class RenderPipeline: # leader's via TCP image transfer at each new_cycle) at scroll_x ± display_width. if self.sync_manager: now = time.time() - if now - self._last_sync_send >= self._sync_send_interval: + if now - self._last_sync_send >= SYNC_SEND_INTERVAL: self._last_sync_send = now self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position) @@ -603,7 +580,6 @@ class RenderPipeline: Returns True when: - Cycle is complete and we should start fresh - - Staging buffer has new content - A plugin currently visible in the scroll has pending updated data (e.g. a live score changed) — standalone (non-sync) mode only """ @@ -613,16 +589,11 @@ class RenderPipeline: # When multi-display sync is active, defer mid-cycle hot swaps until the # cycle ends naturally. Hot swaps block the render loop for 15-30ms while # the image is rebuilt, causing a freeze+jump that the follower perceives - # as a speed-up. Deferring to cycle boundaries keeps transitions clean. - # Staging buffer content is still pre-loaded; it just applies at cycle end. + # as a speed-up. Deferring to cycle boundaries keeps transitions clean; + # the pending updates are still applied, by the recompose at cycle end. if self.sync_manager is not None: return False - # Check if we need more content in the buffer - buffer_status = self.stream_manager.get_buffer_status() - if buffer_status['staging_count'] > 0: - return True - # Trigger recompose when pending updates affect visible segments, so # live score/status changes reach the display within a few seconds # instead of waiting for the next full cycle. @@ -652,10 +623,12 @@ class RenderPipeline: def hot_swap_content(self) -> bool: """ - Hot-swap to new composed content. + Refetch the plugins with pending updates and recompose the strip. - Called when staging buffer has updated content. - Swaps atomically to prevent visual glitches. + Swap mode only (continuous mode uses :meth:`refresh_updated_plugins`). + Called when :meth:`should_recompose` finds a visible plugin with + pending updates. The scroll resumes at the same relative position in + the rebuilt strip. Returns: True if swap occurred @@ -668,9 +641,7 @@ class RenderPipeline: old_width = self.scroll_helper.total_scroll_width old_pos = self.scroll_helper.scroll_position - # Process any pending updates self.stream_manager.process_updates() - self.stream_manager.swap_buffers() # Recompose with updated content if self.compose_scroll_content(): @@ -729,21 +700,17 @@ class RenderPipeline: result = self.compose_scroll_content() if result and self.sync_manager: - # When sync is active, start the leader past the lead-in gap so it - # immediately shows content, leaving the follower on the blank gap - # for a clean transition rather than near-end content wrapping - # around. This tracks lead_in_width rather than assuming a full - # display width of gap, which is no longer the default. + # Start the leader past the lead-in gap so it immediately shows + # content, leaving the follower on the blank gap for a clean + # transition rather than near-end content wrapping around. self.scroll_helper.scroll_position = float(self.config.lead_in_width) - if result and self.sync_manager: # Signal follower that a new cycle started (triggers its own rebuild) self.sync_manager.send_new_cycle() # Push the actual scroll image over TCP so follower has identical pixels. # Done in a background thread to not block the render loop (~15ms transfer). if self.scroll_helper.cached_image is not None: - import threading as _t - _t.Thread( + threading.Thread( target=self.sync_manager.send_scroll_image, args=(self.scroll_helper.cached_image,), daemon=True, name="sync-image-push" @@ -792,7 +759,6 @@ class RenderPipeline: """ old_fps = self.config.target_fps self.config = new_config - self._frame_interval = new_config.get_frame_interval() # Reconfigure scroll helper self._configure_scroll_helper() @@ -805,10 +771,6 @@ class RenderPipeline: self.scroll_helper.reset_scroll() self.scroll_helper.clear_cache() - with self._buffer_lock: - self._active_scroll_image = None - self._staging_scroll_image = None - self._cycle_complete = False self._segments_in_scroll = [] self._frame_times = deque(maxlen=100) diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index cb7cc2bc..8faaaead 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -31,22 +31,14 @@ logger = logging.getLogger(__name__) @dataclass class ContentSegment: - """Represents a segment of scrollable content from a plugin.""" + """One plugin's content for a cycle. + + A STATIC segment carries no images: it marks where the coordinator pauses + the scroll to show the plugin full-screen. + """ plugin_id: str images: List[Image.Image] - total_width: int display_mode: VegasDisplayMode = field(default=VegasDisplayMode.FIXED_SEGMENT) - fetched_at: float = field(default_factory=time.time) - is_stale: bool = False - - @property - def image_count(self) -> int: - return len(self.images) - - @property - def is_static(self) -> bool: - """Check if this segment should trigger a static pause.""" - return self.display_mode == VegasDisplayMode.STATIC class StreamManager: @@ -54,10 +46,12 @@ class StreamManager: Manages streaming of plugin content for Vegas scroll mode. Key responsibilities: - - Maintain ordered list of plugins to stream - - Prefetch content 1-2 plugins ahead of current position - - Handle plugin data updates via double-buffer swap - - Manage content lifecycle and staleness + - Maintain the ordered (and priority-weighted) rotation of plugins + - Fill the active buffer with a cycle's worth of segments (swap mode), or + hand out the next group of plugins directly (continuous mode) + - Track plugins whose data changed in ``_pending_updates``, which + :meth:`process_updates` (swap mode) or + :meth:`invalidate_pending_updates` (continuous mode) consumes """ def __init__( @@ -78,14 +72,14 @@ class StreamManager: self.plugin_manager = plugin_manager self.plugin_adapter = plugin_adapter - # Content queue (double-buffered) + # Segments composed into the current cycle (swap mode only). self._active_buffer: Deque[ContentSegment] = deque() - self._staging_buffer: Deque[ContentSegment] = deque() - self._buffer_lock = threading.RLock() # RLock for reentrant access + # Reentrant: _prefetch_content releases and re-acquires it around the + # slow fetch while a caller may already hold it. + self._buffer_lock = threading.RLock() - # Plugin rotation state + # Plugin rotation, and the position of the next plugin to fetch in it. self._ordered_plugins: List[str] = [] - self._current_index: int = 0 self._prefetch_index: int = 0 # Update tracking @@ -97,7 +91,6 @@ class StreamManager: self.stats = { 'segments_fetched': 0, 'segments_served': 0, - 'buffer_swaps': 0, 'fetch_errors': 0, } @@ -167,9 +160,7 @@ class StreamManager: with self._buffer_lock: return { 'active_count': len(self._active_buffer), - 'staging_count': len(self._staging_buffer), 'total_plugins': len(self._ordered_plugins), - 'current_index': self._current_index, 'prefetch_index': self._prefetch_index, 'stats': self.stats.copy(), } @@ -190,8 +181,9 @@ class StreamManager: """ Mark a plugin as having updated data. - Called when a plugin's data changes. Triggers content refresh - for that plugin in the staging buffer. + Only records it in ``_pending_updates``. The refetch happens later, + in :meth:`process_updates` (swap mode) or by dropping the plugin's + caches in :meth:`invalidate_pending_updates` (continuous mode). Args: plugin_id: Plugin that was updated @@ -242,11 +234,6 @@ class StreamManager: ) return updated - def has_pending_updates(self) -> bool: - """Check if any plugins have pending updates awaiting processing.""" - with self._buffer_lock: - return len(self._pending_updates) > 0 - def has_pending_updates_for_visible_segments(self) -> bool: """Check if pending updates affect plugins currently in the active buffer.""" with self._buffer_lock: @@ -309,19 +296,6 @@ class StreamManager: logger.debug("Processed in-place updates for %d plugins", len(updated_plugins)) - def swap_buffers(self) -> None: - """ - Swap active and staging buffers. - - Called when staging buffer has updated content ready. - """ - with self._buffer_lock: - if self._staging_buffer: - # True swap: staging becomes active, old active is discarded - self._active_buffer, self._staging_buffer = self._staging_buffer, deque() - self.stats['buffer_swaps'] += 1 - logger.debug("Swapped buffers, active now has %d segments", len(self._active_buffer)) - def refresh(self) -> None: """ Refresh the plugin list and content. @@ -337,61 +311,49 @@ class StreamManager: self._refresh_plugin_list() if len(self._ordered_plugins) != old_count: - logger.info( + logger.debug( "Plugin list refreshed: %d -> %d plugins", old_count, len(self._ordered_plugins) ) def _refresh_plugin_list(self) -> None: - """Refresh the ordered list of plugins from plugin manager.""" - logger.info("=" * 60) - logger.info("REFRESHING PLUGIN LIST FOR VEGAS SCROLL") - logger.info("=" * 60) + """Refresh the ordered list of plugins from plugin manager. - # Get all enabled plugins + Runs at every cycle start and every ``_refresh_interval`` seconds, so + it logs one INFO summary; the per-plugin decisions are at DEBUG. + """ available_plugins = [] + loaded = 0 if hasattr(self.plugin_manager, 'plugins'): - logger.info( - "Checking %d loaded plugins for Vegas scroll", - len(self.plugin_manager.plugins) - ) + loaded = len(self.plugin_manager.plugins) for plugin_id, plugin in self.plugin_manager.plugins.items(): - has_enabled = hasattr(plugin, 'enabled') - is_enabled = getattr(plugin, 'enabled', False) - logger.info( - "[%s] class=%s, has_enabled=%s, enabled=%s", - plugin_id, plugin.__class__.__name__, has_enabled, is_enabled - ) - if has_enabled and is_enabled: - # Check vegas content type - skip 'none' unless in STATIC mode - content_type = self.plugin_adapter.get_content_type(plugin, plugin_id) + if not getattr(plugin, 'enabled', False): + logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id) + continue - # Also check display mode - STATIC plugins should be included - # even if their content_type is 'none' - display_mode = VegasDisplayMode.FIXED_SEGMENT - try: - display_mode = plugin.get_vegas_display_mode() - except Exception: - # Plugin error should not abort refresh; use default mode - logger.exception( - "[%s] (%s) get_vegas_display_mode() failed, using default", - plugin_id, plugin.__class__.__name__ - ) - - logger.info( - "[%s] content_type=%s, display_mode=%s", - plugin_id, content_type, display_mode.value + # Content type 'none' is left out, except for STATIC plugins, + # which pause the scroll rather than contributing to it. + content_type = self.plugin_adapter.get_content_type(plugin, plugin_id) + display_mode = VegasDisplayMode.FIXED_SEGMENT + try: + display_mode = plugin.get_vegas_display_mode() + except Exception: + # Plugin error should not abort refresh; use default mode + logger.exception( + "[%s] (%s) get_vegas_display_mode() failed, using default", + plugin_id, plugin.__class__.__name__ ) - if content_type != 'none' or display_mode == VegasDisplayMode.STATIC: - available_plugins.append(plugin_id) - logger.info("[%s] --> INCLUDED in Vegas scroll", plugin_id) - else: - logger.info("[%s] --> EXCLUDED from Vegas scroll", plugin_id) - else: - logger.info("[%s] --> SKIPPED (not enabled)", plugin_id) - + included = (content_type != 'none' + or display_mode == VegasDisplayMode.STATIC) + logger.debug( + "[%s] Vegas: %s (content_type=%s, display_mode=%s)", + plugin_id, "included" if included else "excluded", + content_type, display_mode.value + ) + if included: + available_plugins.append(plugin_id) else: logger.warning( "plugin_manager does not have plugins attribute: %s", @@ -400,24 +362,20 @@ class StreamManager: # Apply ordering from config (outside lock for potentially slow operation) ordered_plugins = self.config.get_ordered_plugins(available_plugins) - logger.info( - "Vegas scroll plugin list: %d available -> %d ordered", - len(available_plugins), len(ordered_plugins) - ) - logger.info("Ordered plugins: %s", ordered_plugins) - ordered_plugins = self._apply_priority_weights(ordered_plugins) # Atomically update shared state under lock to avoid races with prefetchers with self._buffer_lock: self._ordered_plugins = ordered_plugins - # Reset indices if needed - if self._current_index >= len(self._ordered_plugins): - self._current_index = 0 if self._prefetch_index >= len(self._ordered_plugins): self._prefetch_index = 0 - logger.info("=" * 60) + slots = (f", {len(ordered_plugins)} slots" + if len(ordered_plugins) != len(set(ordered_plugins)) else "") + logger.info( + "Vegas rotation: %d of %d loaded plugin(s)%s: %s", + len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins) + ) def _plugin_weight(self, plugin_id: str) -> int: """Slots per cycle for one plugin. @@ -490,7 +448,7 @@ class StreamManager: schedule = self._unclump_seam(schedule) boosted = {p: w for p, w in weights.items() if w > 1} - logger.info( + logger.debug( "Vegas rotation weighted: %d slots for %d plugins (boosted: %s)", len(schedule), len(ordered), boosted) return schedule @@ -607,11 +565,6 @@ class StreamManager: ContentSegment or None if fetch failed """ try: - logger.info("=" * 60) - logger.info("[%s] FETCHING CONTENT", plugin_id) - logger.info("=" * 60) - - # Get plugin instance if not hasattr(self.plugin_manager, 'plugins'): logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id) return None @@ -621,18 +574,11 @@ class StreamManager: logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id) return None - logger.info( - "[%s] Plugin found: class=%s, enabled=%s", - plugin_id, plugin.__class__.__name__, getattr(plugin, 'enabled', 'N/A') - ) - - # Get display mode from plugin display_mode = VegasDisplayMode.FIXED_SEGMENT try: display_mode = plugin.get_vegas_display_mode() - logger.info("[%s] Display mode: %s", plugin_id, display_mode.value) except (AttributeError, TypeError) as e: - logger.info( + logger.debug( "[%s] get_vegas_display_mode() not available: %s (using FIXED_SEGMENT)", plugin_id, e ) @@ -644,21 +590,21 @@ class StreamManager: segment = ContentSegment( plugin_id=plugin_id, images=[], # No images needed for static pause - total_width=0, display_mode=display_mode ) self.stats['segments_fetched'] += 1 - logger.info( + logger.debug( "[%s] Created STATIC placeholder (pause trigger)", plugin_id ) return segment # Get content via adapter for SCROLL/FIXED_SEGMENT modes - logger.info("[%s] Calling plugin_adapter.get_content()...", plugin_id) images = self.plugin_adapter.get_content(plugin, plugin_id) if not images: - logger.warning("[%s] NO CONTENT RETURNED from plugin_adapter", plugin_id) + # The adapter already warns when every content path failed; + # an empty result is otherwise routine (nothing scheduled). + logger.debug("[%s] No Vegas content this cycle", plugin_id) return None # Calculate total width @@ -667,17 +613,14 @@ class StreamManager: segment = ContentSegment( plugin_id=plugin_id, images=images, - total_width=total_width, display_mode=display_mode ) self.stats['segments_fetched'] += 1 - logger.info( - "[%s] SEGMENT CREATED: %d images, %dpx total, mode=%s", + logger.debug( + "[%s] Segment: %d image(s), %dpx, mode=%s", plugin_id, len(images), total_width, display_mode.value ) - logger.info("=" * 60) - return segment except Exception: @@ -696,24 +639,6 @@ class StreamManager: if len(self._active_buffer) < low_water: self._prefetch_content(count=low_water - len(self._active_buffer)) - def get_all_content_for_composition(self) -> List[Image.Image]: - """ - Get all buffered content as a flat list of images. - - Skips STATIC segments as they don't have images to compose. - - Prefer get_grouped_content_for_composition(): flattening loses the - plugin boundaries, which is what tells the compositor where a - separator belongs and where it does not. - - Returns: - List of all images in buffer order - """ - all_images = [] - for _plugin_id, images in self.get_grouped_content_for_composition(): - all_images.extend(images) - return all_images - def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]: """ Get buffered content grouped by the plugin that produced it. @@ -816,8 +741,6 @@ class StreamManager: """Reset the stream manager state.""" with self._buffer_lock: self._active_buffer.clear() - self._staging_buffer.clear() - self._current_index = 0 self._prefetch_index = 0 self._pending_updates.clear() diff --git a/test/conftest.py b/test/conftest.py index 6bfba2e2..c04502e4 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -105,7 +105,6 @@ def _reset_display_manager_globals(): # An instance left here is what the next module's DisplayManager() call # gets back -- potentially one built against a MagicMock matrix. dm_mod.DisplayManager._instance = None - dm_mod.DisplayManager._initialized = False # Put a binding back if a patch outlived the module that started it. # Restoring rather than failing: a leak reported against an innocent module diff --git a/test/test_display_controller_cleanup.py b/test/test_display_controller_cleanup.py new file mode 100644 index 00000000..5511e0d8 --- /dev/null +++ b/test/test_display_controller_cleanup.py @@ -0,0 +1,59 @@ +"""DisplayController.cleanup() must tear down Vegas mode. + +VegasModeCoordinator.cleanup() stops the scroll, resets the render pipeline and +stream manager and drops the adapter's content cache, but the controller never +called it, so none of that teardown ran at shutdown. +""" + +import os +from unittest.mock import MagicMock + +os.environ.setdefault("EMULATOR", "true") + +from src.display_controller import DisplayController + + +def _controller(vegas_coordinator): + dc = object.__new__(DisplayController) + dc.plugin_manager = MagicMock() + dc.config_service = MagicMock() + dc._font_usage_publisher = None + dc.display_manager = MagicMock() + dc.vegas_coordinator = vegas_coordinator + return dc + + +def test_cleanup_tears_down_vegas(): + vegas = MagicMock() + _controller(vegas).cleanup() + vegas.cleanup.assert_called_once_with() + + +def test_vegas_is_torn_down_before_the_display_manager(): + # Stopping Vegas resets the display's scrolling state, so the display + # manager has to still be there when it runs. + order = [] + vegas = MagicMock() + vegas.cleanup.side_effect = lambda: order.append('vegas') + dc = _controller(vegas) + dc.display_manager.cleanup.side_effect = lambda: order.append('display') + + dc.cleanup() + + assert order == ['vegas', 'display'] + + +def test_cleanup_without_vegas_does_not_raise(): + dc = _controller(None) + dc.cleanup() + dc.display_manager.cleanup.assert_called_once_with() + + +def test_a_failing_vegas_teardown_does_not_stop_the_rest(): + vegas = MagicMock() + vegas.cleanup.side_effect = RuntimeError('boom') + dc = _controller(vegas) + + dc.cleanup() + + dc.display_manager.cleanup.assert_called_once_with() diff --git a/test/test_display_controller_startup_validation.py b/test/test_display_controller_startup_validation.py new file mode 100644 index 00000000..d7941d7e --- /dev/null +++ b/test/test_display_controller_startup_validation.py @@ -0,0 +1,77 @@ +"""Startup validation runs each check once. + +DisplayController ran StartupValidator.validate_all() twice -- once before the +plugin manager existed and again after -- so every config, cache, display and +systemd-unit warning was logged twice at every boot. The second pass only +needs the plugin checks. +""" + +import logging +from unittest.mock import MagicMock, patch + + +class RecordingValidator: + """Stands in for StartupValidator and records which checks ran.""" + + calls = [] + + def __init__(self, config_manager, plugin_manager=None, cache_manager=None): + self.plugin_manager = plugin_manager + self.errors = [] + self.warnings = [] + + def validate_all(self): + RecordingValidator.calls.append(('validate_all', self.plugin_manager is not None)) + self.warnings = ['config warning'] + return True, [], list(self.warnings) + + def _validate_plugins(self): + RecordingValidator.calls.append(('plugins',)) + self.warnings = ['plugin warning'] + + +def _build_controller(mock_config_manager, mock_display_manager, + mock_cache_manager, config): + from src.display_controller import DisplayController + + mock_config_manager.get_config.return_value = config + mock_config_manager.load_config.return_value = config + mock_pm = MagicMock() + mock_pm.discover_plugins.return_value = [] + mock_pm.plugins = {} + mock_pm.plugin_manifests = {} + mock_pm.plugin_last_update = {} + mock_pm.health_tracker = None + + with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \ + patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \ + patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \ + patch('src.display_controller.FontManager'), \ + patch('src.plugin_system.PluginManager', return_value=mock_pm), \ + patch('src.startup_validator.StartupValidator', RecordingValidator): + controller = DisplayController() + return controller + + +def test_each_check_runs_once(mock_config_manager, mock_display_manager, + mock_cache_manager, test_config_with_plugins, + emulator_mode, caplog): + RecordingValidator.calls = [] + with caplog.at_level(logging.WARNING, logger='src.display_controller'): + controller = _build_controller(mock_config_manager, mock_display_manager, + mock_cache_manager, test_config_with_plugins) + try: + full_passes = [c for c in RecordingValidator.calls if c[0] == 'validate_all'] + assert len(full_passes) == 1, RecordingValidator.calls + + plugin_checks = ( + sum(1 for c in RecordingValidator.calls if c[0] == 'plugins') + + sum(1 for c in full_passes if c[1])) + assert plugin_checks == 1, RecordingValidator.calls + + config_warnings = [r for r in caplog.records + if 'config warning' in r.getMessage()] + assert len(config_warnings) == 1 + assert any('plugin warning' in r.getMessage() for r in caplog.records) + finally: + controller.cleanup() diff --git a/test/test_display_controller_vegas_tick.py b/test/test_display_controller_vegas_tick.py index 538e9272..42d7f3ca 100644 --- a/test/test_display_controller_vegas_tick.py +++ b/test/test_display_controller_vegas_tick.py @@ -83,15 +83,9 @@ class TestVegasCoordinatorCallbackWiring: fake_coordinator = MagicMock() - import src.display_controller as dc_module - original_imported = dc_module._vegas_mode_imported - original_class = dc_module.VegasModeCoordinator - try: - dc_module._vegas_mode_imported = True - dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator) + from unittest.mock import patch + with patch('src.vegas_mode.VegasModeCoordinator', + MagicMock(return_value=fake_coordinator)): dc._initialize_vegas_mode() - finally: - dc_module._vegas_mode_imported = original_imported - dc_module.VegasModeCoordinator = original_class fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas) diff --git a/test/test_display_dirty_tracking.py b/test/test_display_dirty_tracking.py index cd607c0b..01af2884 100644 --- a/test/test_display_dirty_tracking.py +++ b/test/test_display_dirty_tracking.py @@ -28,7 +28,6 @@ def dm(tmp_path_factory): """One real DisplayManager on the emulator (it's a process singleton).""" from src.display_manager import DisplayManager DisplayManager._instance = None - DisplayManager._initialized = False manager = DisplayManager({ "display": { "hardware": {"rows": 32, "cols": 64, "chain_length": 2, @@ -60,7 +59,6 @@ def dm(tmp_path_factory): "test module.") yield manager DisplayManager._instance = None - DisplayManager._initialized = False class _SwapSpy: @@ -230,7 +228,6 @@ class TestKillSwitch: def test_config_flag_wires_through(self): from src.display_manager import DisplayManager DisplayManager._instance = None - DisplayManager._initialized = False try: manager = DisplayManager({ "display": { @@ -243,7 +240,6 @@ class TestKillSwitch: assert manager._dirty_tracking_enabled is False finally: DisplayManager._instance = None - DisplayManager._initialized = False if __name__ == "__main__": diff --git a/test/test_display_manager_logging.py b/test/test_display_manager_logging.py new file mode 100644 index 00000000..f543e5fe --- /dev/null +++ b/test/test_display_manager_logging.py @@ -0,0 +1,49 @@ +"""display_manager's logger must follow the level run.py configures. + +The module pinned its own logger to INFO at import, which overrides the root +level, so ``run.py -d`` never showed a single DEBUG line from the display +manager -- the dirty-tracking, fallback-mode and scrolling-state traces were +unreachable without editing the source. +""" + +import logging +import os + +os.environ.setdefault("EMULATOR", "true") + +import src.display_manager as dm + + +def test_the_module_sets_no_level_of_its_own(): + assert dm.logger.level == logging.NOTSET + + +def test_scrolling_state_logs_only_when_it_changes(caplog): + # Vegas and every scrolling plugin set the state on each frame, so a line + # per call was ~120 DEBUG lines a second once debug output was visible. + dm_obj = object.__new__(dm.DisplayManager) + dm_obj._frame_hold = 1 + dm_obj._scrolling_state = {'is_scrolling': False, 'last_scroll_activity': 0} + + with caplog.at_level(logging.DEBUG, logger='src.display_manager'): + for _ in range(5): + dm_obj.set_scrolling_state(True, frame_hold=2) + dm_obj.set_scrolling_state(False) + dm_obj.set_scrolling_state(False) + + lines = [r.getMessage() for r in caplog.records + if r.getMessage().startswith('Scrolling state set to')] + assert lines == ['Scrolling state set to: True', 'Scrolling state set to: False'] + # The state itself still updates on every call. + assert dm_obj._scrolling_state['is_scrolling'] is False + assert dm_obj._frame_hold == 1 + + +def test_debug_output_appears_when_the_root_is_at_debug(): + root = logging.getLogger() + previous = root.level + root.setLevel(logging.DEBUG) + try: + assert dm.logger.isEnabledFor(logging.DEBUG) + finally: + root.setLevel(previous) diff --git a/test/test_display_pending_changes.py b/test/test_display_pending_changes.py index 8a4e4684..50aa4574 100644 --- a/test/test_display_pending_changes.py +++ b/test/test_display_pending_changes.py @@ -154,7 +154,6 @@ def vegas_coordinator(controller): coord._update_callback = None coord._update_tick_running = False coord.sync_manager = None - coord._update_static_mode_plugins = lambda: None coord._check_static_plugin_trigger = lambda: None coord.frames = 0 diff --git a/test/test_vegas_config.py b/test/test_vegas_config.py index a639896e..3b51903c 100644 --- a/test/test_vegas_config.py +++ b/test/test_vegas_config.py @@ -2,7 +2,7 @@ Tests for src/vegas_mode/config.py Covers VegasModeConfig: from_config, to_dict, get_frame_interval, -is_plugin_included, get_ordered_plugins, validate. +get_ordered_plugins, validate. """ import pytest @@ -38,6 +38,29 @@ class TestVegasModeConfigDefaults: cfg = VegasModeConfig() assert len(cfg.excluded_plugins) == 0 + def test_defaults_match_the_shipped_template(self): + # The template, the web UI help and CONFIG_REFERENCE all document + # these values. max_cycle_duration defaulted to 600 in code while all + # three said 240, so an install without the key ran iterations 2.5x + # longer than documented. + import json + from pathlib import Path + template = json.loads( + (Path(__file__).resolve().parent.parent / "config" + / "config.template.json").read_text(encoding="utf-8")) + shipped = template["display"]["vegas_scroll"] + defaults = VegasModeConfig().to_dict() + mismatched = {k: (v, defaults[k]) for k, v in shipped.items() + if k in defaults and defaults[k] != v} + assert not mismatched, f"template vs code default: {mismatched}" + + def test_missing_keys_read_the_field_defaults(self): + # from_config used to repeat every default; with no keys set it must + # produce exactly the dataclass defaults. + assert VegasModeConfig.from_config({}).to_dict() == VegasModeConfig().to_dict() + assert (VegasModeConfig.from_config({"display": {"vegas_scroll": {}}}).to_dict() + == VegasModeConfig().to_dict()) + # --------------------------------------------------------------------------- # from_config @@ -147,24 +170,6 @@ class TestGetFrameInterval: assert result == 1.0 -# --------------------------------------------------------------------------- -# is_plugin_included -# --------------------------------------------------------------------------- - -class TestIsPluginIncluded: - def test_not_excluded_is_included(self): - cfg = VegasModeConfig(excluded_plugins={"bad_plugin"}) - assert cfg.is_plugin_included("good_plugin") is True - - def test_excluded_plugin_not_included(self): - cfg = VegasModeConfig(excluded_plugins={"bad_plugin"}) - assert cfg.is_plugin_included("bad_plugin") is False - - def test_empty_exclusions_all_included(self): - cfg = VegasModeConfig() - assert cfg.is_plugin_included("anything") is True - - # --------------------------------------------------------------------------- # get_ordered_plugins # --------------------------------------------------------------------------- diff --git a/test/test_vegas_continuous_refresh.py b/test/test_vegas_continuous_refresh.py index 422a4921..3b885f1b 100644 --- a/test/test_vegas_continuous_refresh.py +++ b/test/test_vegas_continuous_refresh.py @@ -108,10 +108,10 @@ class TestInvalidatePendingUpdates: # Left unconsumed they accumulate forever and nothing ever refreshes. stream = self._manager({'baseball': SimpleNamespace()}) stream.mark_plugin_updated('baseball') - assert stream.has_pending_updates() is True + assert stream._pending_updates stream.invalidate_pending_updates() - assert stream.has_pending_updates() is False + assert not stream._pending_updates assert stream.invalidate_pending_updates() == [] def test_no_pending_updates_does_no_work(self): @@ -133,7 +133,6 @@ class TestInvalidatePendingUpdates: class TestContinuousModeReachesTheRefresh: def _pipeline(self): stream = MagicMock() - stream.get_buffer_status.return_value = {'staging_count': 0} return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream def test_refresh_delegates_to_the_stream_manager(self): @@ -161,7 +160,6 @@ class TestContinuousModeReachesTheRefresh: pipeline.refresh_updated_plugins() assert pipeline.scroll_helper.scroll_position == 1234 - stream.swap_buffers.assert_not_called() stream.process_updates.assert_not_called() diff --git a/test/test_vegas_coordinator_iteration.py b/test/test_vegas_coordinator_iteration.py new file mode 100644 index 00000000..329b36fa --- /dev/null +++ b/test/test_vegas_coordinator_iteration.py @@ -0,0 +1,54 @@ +"""run_iteration() does no per-iteration static-mode bookkeeping. + +Every iteration used to rebuild a set of STATIC-mode plugins -- asking each +plugin for its display mode and logging the result at INFO -- that nothing +ever read. The static pause is driven by _check_static_plugin_trigger(), which +looks at the next segment, not at that set. +""" + +import logging +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock + +from src.plugin_system.base_plugin import VegasDisplayMode +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.coordinator import VegasModeCoordinator + + +def _coordinator(plugins): + coord = VegasModeCoordinator.__new__(VegasModeCoordinator) + coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': { + 'enabled': True, 'max_cycle_duration': 0}}}) + coord.render_pipeline = MagicMock() + coord.stream_manager = MagicMock() + coord.display_manager = MagicMock() + coord.plugin_manager = SimpleNamespace(plugins=plugins, get_plugin=plugins.get) + coord.stats = {'cycles_completed': 0, 'interruptions': 0} + coord._state_lock = threading.Lock() + coord._is_active = True + coord._is_paused = False + coord._should_stop = False + coord._fps_last_health_log = 0.0 + coord._fps_was_degraded = False + coord._live_priority_check = None + coord._live_priority_active = False + coord._interrupt_check = None + coord._interrupt_check_interval = 10 + coord._update_callback = None + coord._update_tick_running = False + coord._check_static_plugin_trigger = lambda: None + coord.run_frame = lambda: True + return coord + + +def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog): + static = MagicMock() + static.get_vegas_display_mode.return_value = VegasDisplayMode.STATIC + coord = _coordinator({'static-plugin': static}) + + with caplog.at_level(logging.INFO, logger='src.vegas_mode.coordinator'): + assert coord.run_iteration() is True + + static.get_vegas_display_mode.assert_not_called() + assert not any('Static mode plugins' in r.getMessage() for r in caplog.records) diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index 4c410314..28e80a9c 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -416,7 +416,7 @@ class TestStreamGrouping: from src.plugin_system.base_plugin import VegasDisplayMode imgs = [Image.new('RGB', (10, 8)) for _ in range(count)] return ContentSegment( - plugin_id=plugin_id, images=imgs, total_width=10 * count, + plugin_id=plugin_id, images=imgs, display_mode=mode or VegasDisplayMode.SCROLL) def test_grouping_preserves_plugin_boundaries(self): @@ -437,10 +437,6 @@ class TestStreamGrouping: sm = self._stream([self._seg('a', 0), self._seg('b', 2)]) assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b'] - def test_flat_accessor_still_matches_grouped_total(self): - sm = self._stream([self._seg('a', 3), self._seg('b', 2)]) - assert len(sm.get_all_content_for_composition()) == 5 - class TestApiBoundsMatchValidate: """ @@ -980,8 +976,8 @@ class TestRotationAcrossMultipleCycles: """ The single-image crop advances a window across cycles. The second and later passes are where start + budget can land exactly on the image width, which - crashed find_blank_cut in the field and lost that plugin's content for the - cycle. First-pass-only tests never reach it. + once crashed the cut and lost that plugin's content for the cycle. + First-pass-only tests never reach it. """ def test_window_advances_over_many_cycles_without_error(self): diff --git a/test/test_vegas_geometry.py b/test/test_vegas_geometry.py index 5a336fc0..2573fc16 100644 --- a/test/test_vegas_geometry.py +++ b/test/test_vegas_geometry.py @@ -10,7 +10,6 @@ from src.vegas_mode.geometry import ( content_bounds, dead_window_stats, edge_blank, - find_blank_cut, separation_gap, trim_to_content, window_coverage_stats, @@ -320,51 +319,3 @@ class TestSeparationGap: # flat gap; measured separation lifts them to the 24px target. card = paint(make_img(150), 0, 150) assert separation_gap(card, card, target=24, minimum=8) == 24 - - -class TestFindBlankCut: - def test_snaps_to_the_nearest_gap(self): - img = paint(make_img(200), 0, 90) - paint(img, 110, 200) - # 100 is inside the 90..110 gap already. - assert find_blank_cut(img, 100, 20) == 100 - - def test_walks_outwards_to_find_a_gap(self): - img = paint(make_img(200), 0, 95) - paint(img, 105, 200) - cut = find_blank_cut(img, 90, 20) - assert 95 <= cut < 105 - - def test_solid_ink_returns_the_target(self): - assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100 - - def test_target_at_image_width_does_not_index_past_the_end(self): - # A cut after the last column is legal. Indexing ink[width] raised - # IndexError in the field, losing that plugin's content for the cycle. - # Reached once the rotation offset advances so start + budget lands - # exactly on the image width. - img = paint(make_img(1840), 0, 1840) - assert find_blank_cut(img, 1840, 32) == 1840 - - def test_target_past_image_width_is_clamped(self): - img = paint(make_img(100), 0, 100) - assert find_blank_cut(img, 500, 32) == 100 - - def test_target_at_width_with_a_trailing_gap_snaps_back(self): - # Content 0..179, blank 180..199. The nearest blank column to 200 is - # 199, not the start of the gap — nearest is what keeps the cut as - # close as possible to the requested budget. - img = paint(make_img(200), 0, 180) - assert find_blank_cut(img, 200, 32) == 199 - - def test_zero_radius_returns_the_target(self): - assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50 - - def test_negative_target_is_clamped_to_zero(self): - assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0 - - @pytest.mark.parametrize("target", [0, 1, 50, 99, 100]) - def test_never_raises_across_the_range(self, target): - img = paint(make_img(100), 0, 100) - cut = find_blank_cut(img, target, 16) - assert 0 <= cut <= 100 diff --git a/test/test_vegas_render_pipeline_recompose.py b/test/test_vegas_render_pipeline_recompose.py index 2e453337..092ea768 100644 --- a/test/test_vegas_render_pipeline_recompose.py +++ b/test/test_vegas_render_pipeline_recompose.py @@ -24,7 +24,6 @@ class FakeDisplayManager: def _make_pipeline(sync_manager=None): stream_manager = MagicMock() - stream_manager.get_buffer_status.return_value = {'staging_count': 0} pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager) pipeline.sync_manager = sync_manager return pipeline, stream_manager @@ -37,7 +36,7 @@ class TestShouldRecompose: stream_manager.has_pending_updates_for_visible_segments.return_value = False assert pipeline.should_recompose() is True - def test_no_pending_updates_no_staging_does_not_recompose(self): + def test_no_pending_updates_does_not_recompose(self): pipeline, stream_manager = _make_pipeline() stream_manager.has_pending_updates_for_visible_segments.return_value = False assert pipeline.should_recompose() is False @@ -49,12 +48,6 @@ class TestShouldRecompose: stream_manager.has_pending_updates_for_visible_segments.return_value = True assert pipeline.should_recompose() is True - def test_staging_buffer_content_triggers_recompose(self): - pipeline, stream_manager = _make_pipeline() - stream_manager.get_buffer_status.return_value = {'staging_count': 1} - stream_manager.has_pending_updates_for_visible_segments.return_value = False - assert pipeline.should_recompose() is True - def test_sync_active_defers_pending_updates_to_cycle_boundary(self): """Sync-mode deferral (PR #330's actual intent) must still hold: pending updates alone must NOT trigger a mid-cycle hot-swap when a diff --git a/test/test_vegas_stream_log_volume.py b/test/test_vegas_stream_log_volume.py new file mode 100644 index 00000000..2f2e17c6 --- /dev/null +++ b/test/test_vegas_stream_log_volume.py @@ -0,0 +1,79 @@ +"""StreamManager logs one INFO line per plugin-list refresh. + +The refresh runs at each cycle start and every 30 seconds. It used to write +decorative "=" * 60 banners plus a line per plugin ("INCLUDED", "SKIPPED"), +and each fetch added its own banners and "FETCHING CONTENT" / "SEGMENT +CREATED" lines, all at INFO: dozens of journal lines a minute on a Pi, burying +anything that mattered. Per-plugin detail belongs at DEBUG. +""" + +import logging +from types import SimpleNamespace +from unittest.mock import MagicMock + +from PIL import Image + +from src.plugin_system.base_plugin import VegasDisplayMode +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.stream_manager import StreamManager + +LOGGER = "src.vegas_mode.stream_manager" + + +class _Plugin: + def __init__(self, enabled=True, mode=VegasDisplayMode.SCROLL): + self.enabled = enabled + self._mode = mode + + def get_vegas_display_mode(self): + return self._mode + + +def _stream(plugins): + adapter = MagicMock() + adapter.get_content_type.return_value = 'multi' + adapter.get_content.return_value = [Image.new('RGB', (20, 8))] + return StreamManager(VegasModeConfig(), SimpleNamespace(plugins=plugins), adapter) + + +def _info(caplog): + return [r for r in caplog.records + if r.name == LOGGER and r.levelno == logging.INFO] + + +def test_a_refresh_logs_a_single_info_summary(caplog): + stream = _stream({ + 'clock': _Plugin(), + 'weather': _Plugin(), + 'off': _Plugin(enabled=False), + }) + with caplog.at_level(logging.DEBUG, logger=LOGGER): + stream._refresh_plugin_list() + + info = _info(caplog) + assert len(info) == 1, [r.getMessage() for r in info] + summary = info[0].getMessage() + assert 'clock' in summary and 'weather' in summary + assert 'off' not in summary.split(':', 1)[1] + # The per-plugin decisions are still there for anyone at DEBUG. + debug = [r.getMessage() for r in caplog.records if r.levelno == logging.DEBUG] + assert any('off' in m and 'not enabled' in m for m in debug) + + +def test_fetching_content_logs_nothing_at_info(caplog): + stream = _stream({'clock': _Plugin()}) + with caplog.at_level(logging.DEBUG, logger=LOGGER): + segment = stream._fetch_plugin_content('clock') + + assert segment is not None + assert _info(caplog) == [] + + +def test_no_decorative_banners(caplog): + stream = _stream({'clock': _Plugin(), 'static': _Plugin(mode=VegasDisplayMode.STATIC)}) + with caplog.at_level(logging.DEBUG, logger=LOGGER): + stream._refresh_plugin_list() + stream._fetch_plugin_content('clock') + stream._fetch_plugin_content('static') + + assert not any(set(r.getMessage()) == {'='} for r in caplog.records)