From d076e74ce8c86bbc935e677cb074c39b48250f2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 08:09:31 -0400 Subject: [PATCH 1/2] feat(football-scoreboard): adopt the core scroll orchestration (2.11.0) Second B5 adoption, and the second of the three lineages (baseball is the hockey/lacrosse/baseball/basketball/ufc family; football is its own). Orchestration -- scroll-helper configuration, frame pumping, completion, settings resolution, native global_config['target_fps'] -- now comes from the core's src.common.sports_scroll. Only the football-specific content half stays here: game cards and league separator icons. scroll_display.py goes 679 -> 310 lines. Guarded, exactly as baseball: scroll_display.py selects between the core-backed subclass and scroll_display_legacy (the previous implementation, frozen) at import. The except clause matches only the core module's own absence, so a failure raised inside a present-but-broken core surfaces rather than silently downgrading. The floor stays at 2.0.0. With a working fallback the plugin does not require 3.2.0, it prefers it, so nobody is cut off from updates; the floor rises at B6 when the fallback is deleted. versions[0] uses ledmatrix_min_version. Football needed less than baseball did: its __init__ sets only attributes the core base already provides, so no custom __init__. It does pin game_card_width at 128 where core sizes cards to the panel, so scroll_settings_defaults() overrides that -- the byte-identical gate depends on it. Content methods were lifted verbatim from the legacy source rather than retyped. Verified: - all 16 harness renders (8 sizes x 2 screens) byte-for-byte identical to 2.10.1: `diff -r` of the before/after output directories is empty - test_core_fallback.py added, asserting all three configurations this plugin can meet, and checked that it bites: sabotaging the guard to match the unguarded recipe makes it exit 1, the healthy tree exits 0 - 10 of 11 suites pass; the one failure is test_football_plugin.py, an interactive script whose skip guard is in the not-yet-merged #246 - module-collision check clean across 42 plugins Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 4 +- plugins/football-scoreboard/CHANGELOG.md | 7 + plugins/football-scoreboard/manifest.json | 8 +- plugins/football-scoreboard/scroll_display.py | 943 ++++++------------ .../scroll_display_legacy.py | 685 +++++++++++++ .../football-scoreboard/test_core_fallback.py | 166 +++ 6 files changed, 1154 insertions(+), 659 deletions(-) create mode 100644 plugins/football-scoreboard/scroll_display_legacy.py create mode 100644 plugins/football-scoreboard/test_core_fallback.py diff --git a/plugins.json b/plugins.json index ea541df9..66045a69 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-03", + "last_updated": "2026-08-04", "plugins": [ { "id": "cricket-scoreboard", @@ -240,7 +240,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "2.10.1" + "latest_version": "2.11.0" }, { "id": "geochron", diff --git a/plugins/football-scoreboard/CHANGELOG.md b/plugins/football-scoreboard/CHANGELOG.md index 58783afc..47ce6526 100644 --- a/plugins/football-scoreboard/CHANGELOG.md +++ b/plugins/football-scoreboard/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.11.0] - 2026-08-04 + +### Changed +- **Scroll display now runs on the core's shared implementation.** The orchestration half of `scroll_display.py` — scroll-helper configuration, frame pumping, completion, settings resolution, and native `global_config['target_fps']` support — moves to the core's `src.common.sports_scroll` (LEDMatrix 3.2.0). Only the football-specific content half stays here: game cards and league separator icons. A fix to the shared behaviour now lands once in the core instead of being replicated across nine scoreboards. +- **Nothing changes on an older core.** The import is guarded: a core without `src.common.sports_scroll` falls back to `scroll_display_legacy.py`, the previous self-contained implementation, and the plugin behaves exactly as it did. This is why the minimum core version is unchanged at 2.0.0 — the plugin does not *require* 3.2.0, it merely prefers it. The fallback goes away in a later release, and the floor rises then. +- Verified byte-for-byte: all 16 safety-harness renders (8 panel sizes × 2 screens) are identical to 2.10.1, before and after. + ## [2.10.1] - 2026-08-03 ### Fixed diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 0c1ed8ca..9bf87222 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.10.1", + "version": "2.11.0", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,12 @@ "ncaa_fb_live" ], "versions": [ + { + "released": "2026-08-04", + "version": "2.11.0", + "notes": "Scroll display now uses the core's shared sports-scroll orchestration (LEDMatrix 3.2.0) when available, falling back to the bundled implementation on older cores. No behaviour change: all 16 harness renders are byte-for-byte identical.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-03", "version": "2.10.1", diff --git a/plugins/football-scoreboard/scroll_display.py b/plugins/football-scoreboard/scroll_display.py index 18fbcff5..7de0ef56 100644 --- a/plugins/football-scoreboard/scroll_display.py +++ b/plugins/football-scoreboard/scroll_display.py @@ -1,679 +1,310 @@ """ -Scroll Display Handler for Football Scoreboard Plugin - -Implements high-FPS horizontal scrolling of all matching games with league separator icons. -Uses ScrollHelper for efficient numpy-based scrolling and dynamic duration calculation. - -Features: -- Pre-rendered game cards for smooth scrolling -- League separator icons (NFL shield, NCAA FB logo) between different leagues -- Dynamic duration based on total content width -- FPS logging and performance monitoring -- Live priority support for scroll mode +Scroll Display Handler for Football Scoreboard Plugin. + +Orchestration (scroll-helper configuration, frame pumping, completion, +settings resolution, `global_config['target_fps']`) comes from the core's +`src.common.sports_scroll`, shipped in LEDMatrix 3.2.0. Only the *content* +half lives here: building this sport's game cards and separator icons. + +On a core that predates that module we fall back to `scroll_display_legacy`, +the previous self-contained implementation, so the plugin keeps working +unchanged. That fallback is why this plugin is safe to adopt core code ahead +of the B6 sunset -- the version floor alone does not protect users whose core +misreports its version (the v3.1.0 release reports "1.0.0"), and they would +otherwise get a plugin that fails to load. + +The content methods below are duplicated in the legacy module by design: it is +frozen, and deleting it at B6 leaves this file as the only copy. Fix bugs +here, not there. """ import logging -import time import os +import time from typing import Dict, Any, List, Optional -from PIL import Image -try: - from src.common.scroll_helper import ScrollHelper -except ImportError: - ScrollHelper = None +from PIL import Image from game_renderer import GameRenderer logger = logging.getLogger(__name__) - -class ScrollDisplay: - """ - Handles scroll display mode for the football scoreboard plugin. - - This class: - - Collects all games matching criteria (respecting live priority) - - Pre-renders each game using GameRenderer - - Adds league separator icons between different leagues - - Composes a single wide image using ScrollHelper - - Implements dynamic duration based on total content width - - Logs FPS and game count during scrolling - """ - - # Paths to league separator icons - NFL_SEPARATOR_ICON = "assets/sports/nfl_logos/NFL.png" - NCAA_FB_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_fb.png" - - def __init__( - self, - display_manager, - config: Dict[str, Any], - custom_logger: Optional[logging.Logger] = None, - global_config: Optional[Dict[str, Any]] = None - ): - """ - Initialize the ScrollDisplay handler. - - Args: - display_manager: Display manager instance - config: Plugin configuration dictionary - custom_logger: Optional custom logger instance - global_config: Optional global LEDMatrix configuration dictionary - """ - self.display_manager = display_manager - self.config = config - self.logger = custom_logger or logger - self.global_config = global_config or {} - - # Get display dimensions - if hasattr(display_manager, 'matrix') and display_manager.matrix is not None: - self.display_width = display_manager.matrix.width - self.display_height = display_manager.matrix.height - else: - self.display_width = getattr(display_manager, "width", 128) - self.display_height = getattr(display_manager, "height", 32) - - # Initialize ScrollHelper - if ScrollHelper: - self.scroll_helper = ScrollHelper( - self.display_width, +_USING_CORE_SCROLL = False +try: + from src.common.sports_scroll import ( + SportsScrollDisplay as _ScrollDisplayBase, + SportsScrollDisplayManager as _ScrollDisplayManagerBase, + ) + _USING_CORE_SCROLL = True +except ModuleNotFoundError as exc: + # Fall back only when the CORE module is absent. A bare `except + # ImportError` would also swallow a failure raised *inside* a core module + # that is present, silently loading the legacy copy and hiding a broken + # core install. + if exc.name not in {"src", "src.common", "src.common.sports_scroll"}: + raise + _ScrollDisplayBase = None + _ScrollDisplayManagerBase = None + + +if not _USING_CORE_SCROLL: + # Pre-3.2.0 core: use the previous implementation wholesale. + from scroll_display_legacy import ( # noqa: F401 + LegacyScrollDisplay as ScrollDisplay, + LegacyScrollDisplayManager as ScrollDisplayManager, + ) + logger.info( + "football-scoreboard: core src.common.sports_scroll not available; " + "using the bundled legacy scroll display" + ) +else: + + class ScrollDisplay(_ScrollDisplayBase): + """Football game cards and separator icons on the core scroll engine.""" + + # The ladder the legacy _get_scroll_settings walked, same order. + SCROLL_LEAGUE_KEYS = ("nfl", "ncaa_fb") + + NFL_SEPARATOR_ICON = "assets/sports/nfl_logos/NFL.png" + NCAA_FB_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_fb.png" + + def scroll_settings_defaults(self): + # Core sizes game cards to the panel; this plugin has always + # pinned them at 128px, and the byte-identical harness gate + # depends on keeping that. + return {**super().scroll_settings_defaults(), "game_card_width": 128} + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load NFL icon + if os.path.exists(self.NFL_SEPARATOR_ICON): + try: + nfl_icon = Image.open(self.NFL_SEPARATOR_ICON) + if nfl_icon.mode != "RGBA": + nfl_icon = nfl_icon.convert("RGBA") + # Resize to fit height while maintaining aspect ratio + aspect = nfl_icon.width / nfl_icon.height + new_width = int(separator_height * aspect) + nfl_icon = nfl_icon.resize((new_width, separator_height), Image.Resampling.LANCZOS) + self._separator_icons["nfl"] = nfl_icon + self.logger.debug(f"Loaded NFL separator icon: {new_width}x{separator_height}") + except Exception as e: + self.logger.error(f"Error loading NFL separator icon: {e}") + else: + self.logger.warning(f"NFL separator icon not found at {self.NFL_SEPARATOR_ICON}") + + # Load NCAA FB icon + if os.path.exists(self.NCAA_FB_SEPARATOR_ICON): + try: + ncaa_icon = Image.open(self.NCAA_FB_SEPARATOR_ICON) + if ncaa_icon.mode != "RGBA": + ncaa_icon = ncaa_icon.convert("RGBA") + # Resize to fit height while maintaining aspect ratio + aspect = ncaa_icon.width / ncaa_icon.height + new_width = int(separator_height * aspect) + ncaa_icon = ncaa_icon.resize((new_width, separator_height), Image.Resampling.LANCZOS) + self._separator_icons["ncaa_fb"] = ncaa_icon + self.logger.debug(f"Loaded NCAA FB separator icon: {new_width}x{separator_height}") + except Exception as e: + self.logger.error(f"Error loading NCAA FB separator icon: {e}") + else: + self.logger.warning(f"NCAA FB separator icon not found at {self.NCAA_FB_SEPARATOR_ICON}") + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + # Guard against status being None or non-dict + status = game.get('status') + if not isinstance(status, dict): + status = {} + state = status.get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare scrolling content from a list of games. + + Args: + games: List of game dictionaries with league info + game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) + leagues: List of leagues in order (e.g., ['nfl', 'ncaa_fb']) + rankings_cache: Optional team rankings cache + + Returns: + True if content was prepared successfully, False otherwise + """ + if not self.scroll_helper: + self.logger.error("ScrollHelper not available") + return False + + if not games: + self.logger.debug("No games to prepare for scrolling") + self.scroll_helper.clear_cache() + self._vegas_content_items = [] + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings + scroll_settings = self._get_scroll_settings() + gap_between_games = scroll_settings.get("gap_between_games", 24) + show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) + + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) + renderer = GameRenderer( + game_card_width, self.display_height, - self.logger + self.config, + logo_cache=self._logo_cache, + custom_logger=self.logger ) - # Configure scroll settings - self._configure_scroll_helper() - else: - self.scroll_helper = None - self.logger.error("ScrollHelper not available - scroll mode will not work") - - # Shared logo cache for game renderer - self._logo_cache: Dict[str, Image.Image] = {} - - # League separator icons cache - self._separator_icons: Dict[str, Image.Image] = {} - self._load_separator_icons() - - # Tracking state - self._current_games: List[Dict] = [] - self._current_game_type: str = "" - self._current_leagues: List[str] = [] - self._vegas_content_items: List[Image.Image] = [] - self._is_scrolling = False - self._scroll_start_time: Optional[float] = None - self._last_log_time: float = 0 - self._log_interval: float = 5.0 # Log every 5 seconds - - # Performance tracking - self._frame_count: int = 0 - self._fps_sample_start: float = time.time() - - def _configure_scroll_helper(self) -> None: - """Configure scroll helper with settings from config.""" - if not self.scroll_helper: - return - - # Get global scroll settings, then per-league overrides - # For now, use global settings - scroll_settings = self._get_scroll_settings() - - # Set scroll speed (pixels per second in time-based mode) - scroll_speed = scroll_settings.get("scroll_speed", 50.0) - self.scroll_helper.set_scroll_speed(scroll_speed) - - # Set scroll delay - scroll_delay = scroll_settings.get("scroll_delay", 0.01) - self.scroll_helper.set_scroll_delay(scroll_delay) - - # Enable dynamic duration - dynamic_duration = scroll_settings.get("dynamic_duration", True) - self.scroll_helper.set_dynamic_duration_settings( - enabled=dynamic_duration, - min_duration=30, - max_duration=600, # 10 minutes max - buffer=0.2 # 20% buffer to ensure scroll completes fully off screen - ) - - # Use frame-based scrolling for better FPS control - # In frame-based mode: scroll_speed is pixels per frame, scroll_delay controls frame rate - # This allows precise control: 1 px/frame at 0.01s delay = 100 FPS - self.scroll_helper.set_frame_based_scrolling(True) - - # Convert scroll_speed from pixels/second to pixels/frame for frame-based mode - # If scroll_speed is very low (like 1.0 px/s), treat it as pixels per frame directly - # Otherwise, calculate pixels per frame based on scroll_delay - if scroll_speed < 10.0: - # Low values are likely intended as pixels per frame - pixels_per_frame = scroll_speed - else: - # Higher values are pixels/second, convert to pixels/frame - pixels_per_frame = scroll_speed * scroll_delay - - # Clamp to reasonable range (0.1 to 5 pixels per frame for smooth scrolling) - pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) - self.scroll_helper.set_scroll_speed(pixels_per_frame) - - # Calculate effective pixels per second for logging - effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 - - self.logger.info( - f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, delay={scroll_delay}s " - f"(effective {effective_pps:.1f} px/s), dynamic_duration={dynamic_duration}" - ) - - # Honor the global smooth-scrolling FPS target (older cores lack the setter) - target_fps = self.global_config.get('target_fps') or self.global_config.get('scroll_target_fps') - try: - # Coerce before comparing: a malformed global config value - # must degrade to today's scroll_delay pacing, not raise. - target_fps = float(target_fps) if target_fps is not None else None - except (TypeError, ValueError): - target_fps = None - if target_fps: - if hasattr(self.scroll_helper, 'set_target_fps'): - self.scroll_helper.set_target_fps(target_fps) - else: - self.scroll_helper.target_fps = max(30.0, min(200.0, target_fps)) - self.scroll_helper.frame_time_target = 1.0 / self.scroll_helper.target_fps - - def _get_scroll_settings(self, league: str = None) -> Dict[str, Any]: - """Get scroll settings, optionally for a specific league.""" - # Default scroll settings - defaults = { - "scroll_speed": 50.0, - "scroll_delay": 0.01, - "gap_between_games": 48, - "show_league_separators": True, - "dynamic_duration": True, - "game_card_width": 128, - } - - # Try to get league-specific settings first - if league: - league_config = self.config.get(league, {}) - league_scroll = league_config.get("scroll_settings", {}) - if league_scroll: - return {**defaults, **league_scroll} - - # Fall back to NFL settings (usually first enabled) - nfl_config = self.config.get("nfl", {}) - nfl_scroll = nfl_config.get("scroll_settings", {}) - if nfl_scroll: - return {**defaults, **nfl_scroll} - - # Fall back to NCAA FB settings - ncaa_config = self.config.get("ncaa_fb", {}) - ncaa_scroll = ncaa_config.get("scroll_settings", {}) - if ncaa_scroll: - return {**defaults, **ncaa_scroll} - - return defaults - - def _load_separator_icons(self) -> None: - """Load and resize league separator icons.""" - separator_height = self.display_height - 4 # Leave some padding - - # Load NFL icon - if os.path.exists(self.NFL_SEPARATOR_ICON): - try: - nfl_icon = Image.open(self.NFL_SEPARATOR_ICON) - if nfl_icon.mode != "RGBA": - nfl_icon = nfl_icon.convert("RGBA") - # Resize to fit height while maintaining aspect ratio - aspect = nfl_icon.width / nfl_icon.height - new_width = int(separator_height * aspect) - nfl_icon = nfl_icon.resize((new_width, separator_height), Image.Resampling.LANCZOS) - self._separator_icons["nfl"] = nfl_icon - self.logger.debug(f"Loaded NFL separator icon: {new_width}x{separator_height}") - except Exception as e: - self.logger.error(f"Error loading NFL separator icon: {e}") - else: - self.logger.warning(f"NFL separator icon not found at {self.NFL_SEPARATOR_ICON}") - - # Load NCAA FB icon - if os.path.exists(self.NCAA_FB_SEPARATOR_ICON): - try: - ncaa_icon = Image.open(self.NCAA_FB_SEPARATOR_ICON) - if ncaa_icon.mode != "RGBA": - ncaa_icon = ncaa_icon.convert("RGBA") - # Resize to fit height while maintaining aspect ratio - aspect = ncaa_icon.width / ncaa_icon.height - new_width = int(separator_height * aspect) - ncaa_icon = ncaa_icon.resize((new_width, separator_height), Image.Resampling.LANCZOS) - self._separator_icons["ncaa_fb"] = ncaa_icon - self.logger.debug(f"Loaded NCAA FB separator icon: {new_width}x{separator_height}") - except Exception as e: - self.logger.error(f"Error loading NCAA FB separator icon: {e}") - else: - self.logger.warning(f"NCAA FB separator icon not found at {self.NCAA_FB_SEPARATOR_ICON}") - - def _determine_game_type(self, game: Dict) -> str: - """ - Determine the game type from the game's status. - - Args: - game: Game dictionary - - Returns: - Game type: 'live', 'recent', or 'upcoming' - """ - # Guard against status being None or non-dict - status = game.get('status') - if not isinstance(status, dict): - status = {} - state = status.get('state', '') - if state == 'in': - return 'live' - elif state == 'post': - return 'recent' - elif state == 'pre': - return 'upcoming' - else: - # Default to upcoming if state is unknown - return 'upcoming' - - def prepare_scroll_content( - self, - games: List[Dict], - game_type: str, - leagues: List[str], - rankings_cache: Dict[str, int] = None - ) -> bool: - """ - Prepare scrolling content from a list of games. - - Args: - games: List of game dictionaries with league info - game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) - leagues: List of leagues in order (e.g., ['nfl', 'ncaa_fb']) - rankings_cache: Optional team rankings cache - - Returns: - True if content was prepared successfully, False otherwise - """ - if not self.scroll_helper: - self.logger.error("ScrollHelper not available") - return False - - if not games: - self.logger.debug("No games to prepare for scrolling") - self.scroll_helper.clear_cache() - self._vegas_content_items = [] - return False - - self._current_games = games - self._current_game_type = game_type - self._current_leagues = leagues - - # Get scroll settings - scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) - show_separators = scroll_settings.get("show_league_separators", True) - game_card_width = scroll_settings.get("game_card_width", 128) - - # Create game renderer using game_card_width so cards are a fixed size - # regardless of the full chain width (display_width may span multiple panels) - renderer = GameRenderer( - game_card_width, - self.display_height, - self.config, - logo_cache=self._logo_cache, - custom_logger=self.logger - ) - if rankings_cache: - renderer.set_rankings_cache(rankings_cache) - - # Pre-render all game cards - content_items: List[Image.Image] = [] - current_league = None - game_count = 0 - league_counts: Dict[str, int] = {} - - for game in games: - game_league = game.get("league", "nfl") # Default to NFL if not specified - - # Add league separator if switching leagues OR if this is the first league - if show_separators: - if current_league is None: - # First league - add separator at the start - separator = self._separator_icons.get(game_league) - if separator: - # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) - # Center the separator vertically - y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) - content_items.append(sep_img) - self.logger.debug(f"Added {game_league} separator icon at start") - elif game_league != current_league: - # Switching leagues - add separator - separator = self._separator_icons.get(game_league) - if separator: - # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) - # Center the separator vertically - y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) - content_items.append(sep_img) - self.logger.debug(f"Added {game_league} separator icon") - - current_league = game_league - - # Render game card - determine type from game state - try: - individual_game_type = self._determine_game_type(game) - game_img = renderer.render_game_card(game, individual_game_type) + if rankings_cache: + renderer.set_rankings_cache(rankings_cache) + + # Pre-render all game cards + content_items: List[Image.Image] = [] + current_league = None + game_count = 0 + league_counts: Dict[str, int] = {} + + for game in games: + game_league = game.get("league", "nfl") # Default to NFL if not specified + + # Add league separator if switching leagues OR if this is the first league + if show_separators: + if current_league is None: + # First league - add separator at the start + separator = self._separator_icons.get(game_league) + if separator: + # Create a separator image with proper background + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + # Center the separator vertically + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {game_league} separator icon at start") + elif game_league != current_league: + # Switching leagues - add separator + separator = self._separator_icons.get(game_league) + if separator: + # Create a separator image with proper background + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + # Center the separator vertically + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {game_league} separator icon") + + current_league = game_league + + # Render game card - determine type from game state + try: + individual_game_type = self._determine_game_type(game) + game_img = renderer.render_game_card(game, individual_game_type) - # Add horizontal padding to prevent logos from being cut off at edges - # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off - padded_width = game_img.width + (padding * 2) - padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) - padded_img.paste(game_img, (padding, 0)) + # Add horizontal padding to prevent logos from being cut off at edges + # Logos are positioned at -10 and display_width+10, so we need padding + padding = 12 # Padding on each side to ensure logos aren't cut off + padded_width = game_img.width + (padding * 2) + padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) + padded_img.paste(game_img, (padding, 0)) - content_items.append(padded_img) - game_count += 1 - league_counts[game_league] = league_counts.get(game_league, 0) + 1 - except Exception as e: - self.logger.error(f"Error rendering game card: {e}") - continue - - if not content_items: - self.logger.warning("No game cards rendered") - return False - - # Store individual items for Vegas mode (avoids scroll_helper padding) - self._vegas_content_items = list(content_items) - - # Create scrolling image using ScrollHelper - self.scroll_helper.create_scrolling_image( - content_items, - item_gap=gap_between_games, - element_gap=0 # No element gap - each item is a complete game card - ) - - # Log what we loaded - league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) - self.logger.info( - f"[Football Scroll] Prepared {game_count} games for scrolling: {league_summary}" - ) - self.logger.info( - f"[Football Scroll] Total scroll width: {self.scroll_helper.total_scroll_width}px, " - f"Dynamic duration: {self.scroll_helper.calculated_duration}s" - ) - - # Reset tracking state - self._is_scrolling = True - self._scroll_start_time = time.time() - self._frame_count = 0 - self._fps_sample_start = time.time() - - return True - - def display_scroll_frame(self) -> bool: - """ - Display the next frame of the scrolling content. - - Returns: - True if a frame was displayed, False if scroll is complete or no content - """ - if not self.scroll_helper or not self.scroll_helper.cached_image: - return False - - # Update scroll position - self.scroll_helper.update_scroll_position() - - # Get visible portion - visible = self.scroll_helper.get_visible_portion() - if not visible: - return False - - # Display the visible portion - try: - self.display_manager.image = visible - self.display_manager.update_display() - - # Track frame rate - self._frame_count += 1 - self.scroll_helper.log_frame_rate() - - # Periodic logging - self._log_scroll_progress() - - return True - except Exception as e: - self.logger.error(f"Error displaying scroll frame: {e}") - return False - - def _log_scroll_progress(self) -> None: - """Log scroll progress and FPS periodically.""" - current_time = time.time() + content_items.append(padded_img) + game_count += 1 + league_counts[game_league] = league_counts.get(game_league, 0) + 1 + except Exception as e: + self.logger.error(f"Error rendering game card: {e}") + continue + + if not content_items: + self.logger.warning("No game cards rendered") + return False + + # Store individual items for Vegas mode (avoids scroll_helper padding) + self._vegas_content_items = list(content_items) + + # Create scrolling image using ScrollHelper + self.scroll_helper.create_scrolling_image( + content_items, + item_gap=gap_between_games, + element_gap=0 # No element gap - each item is a complete game card + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Football Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Football Scroll] Total scroll width: {self.scroll_helper.total_scroll_width}px, " + f"Dynamic duration: {self.scroll_helper.calculated_duration}s" + ) - if current_time - self._last_log_time >= self._log_interval: - # Calculate FPS - elapsed = current_time - self._fps_sample_start - if elapsed > 0: - fps = self._frame_count / elapsed - - # Get scroll info - scroll_info = self.scroll_helper.get_scroll_info() - - self.logger.info( - f"[Football Scroll] FPS: {fps:.1f}, " - f"Position: {scroll_info['scroll_position']:.0f}/{scroll_info['total_width']}px, " - f"Elapsed: {scroll_info.get('elapsed_time', 0):.1f}s/{scroll_info['dynamic_duration']}s" - ) - - # Reset FPS tracking - self._frame_count = 0 - self._fps_sample_start = current_time - self._last_log_time = current_time - - def is_scroll_complete(self) -> bool: - """Check if the scroll cycle is complete.""" - if not self.scroll_helper: - return True - return self.scroll_helper.is_scroll_complete() - - def reset_scroll(self) -> None: - """Reset the scroll position to the beginning.""" - if self.scroll_helper: - self.scroll_helper.reset_scroll() + # Reset tracking state + self._is_scrolling = True + self._scroll_start_time = time.time() self._frame_count = 0 self._fps_sample_start = time.time() - self.logger.debug("Scroll position reset") - - def get_scroll_info(self) -> Dict[str, Any]: - """Get current scroll state information.""" - if not self.scroll_helper: - return {"error": "ScrollHelper not available"} - - info = self.scroll_helper.get_scroll_info() - info.update({ - "game_count": len(self._current_games), - "game_type": self._current_game_type, - "leagues": self._current_leagues, - "is_scrolling": self._is_scrolling - }) - return info - - def get_dynamic_duration(self) -> int: - """Get the calculated dynamic duration for this scroll content.""" - if self.scroll_helper: - return self.scroll_helper.get_dynamic_duration() - return 60 # Default fallback - - def clear(self) -> None: - """Clear scroll content and reset state.""" - if self.scroll_helper: - self.scroll_helper.clear_cache() - self._current_games = [] - self._current_game_type = "" - self._current_leagues = [] - self._vegas_content_items = [] - self._is_scrolling = False - self._scroll_start_time = None - self.logger.debug("Scroll display cleared") - - -class ScrollDisplayManager: - """ - Manages scroll display instances for different game types. - - This class provides a higher-level interface for the football plugin - to manage scroll displays for live, recent, and upcoming games. - """ - - def __init__( - self, - display_manager, - config: Dict[str, Any], - custom_logger: Optional[logging.Logger] = None, - global_config: Optional[Dict[str, Any]] = None - ): - """ - Initialize the ScrollDisplayManager. - - Args: - display_manager: Display manager instance - config: Plugin configuration dictionary - custom_logger: Optional custom logger instance - global_config: Optional global LEDMatrix configuration dictionary - """ - self.display_manager = display_manager - self.config = config - self.logger = custom_logger or logger - self.global_config = global_config or {} - - # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} - self._current_game_type: Optional[str] = None - - def get_scroll_display(self, game_type: str) -> ScrollDisplay: - """ - Get or create a scroll display for a game type. - - Args: - game_type: Type of games ('live', 'recent', 'upcoming') - - Returns: - ScrollDisplay instance for the game type - """ - if game_type not in self._scroll_displays: - self._scroll_displays[game_type] = ScrollDisplay( - self.display_manager, - self.config, - self.logger, - global_config=self.global_config - ) - return self._scroll_displays[game_type] - - def prepare_and_display( - self, - games: List[Dict], - game_type: str, - leagues: List[str], - rankings_cache: Dict[str, int] = None - ) -> bool: - """ - Prepare content and start displaying scroll. - - Args: - games: List of game dictionaries - game_type: Type of games - leagues: List of leagues - rankings_cache: Optional team rankings cache - - Returns: - True if scroll was started successfully - """ - scroll_display = self.get_scroll_display(game_type) - - success = scroll_display.prepare_scroll_content( - games, game_type, leagues, rankings_cache - ) - if success: - self._current_game_type = game_type - - return success - - def display_frame(self, game_type: str = None) -> bool: - """ - Display the next frame of the current scroll. - - Args: - game_type: Optional game type (uses current if not specified) - - Returns: - True if a frame was displayed - """ - if game_type is None: - game_type = self._current_game_type - - if game_type is None: - return False - - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: - return False - - return scroll_display.display_scroll_frame() - - def is_complete(self, game_type: str = None) -> bool: - """Check if the current scroll is complete.""" - if game_type is None: - game_type = self._current_game_type - - if game_type is None: - return True - - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: return True - - return scroll_display.is_scroll_complete() - - def get_dynamic_duration(self, game_type: str = None) -> int: - """Get the dynamic duration for the current scroll.""" - if game_type is None: - game_type = self._current_game_type - - if game_type is None: - return 60 - - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: - return 60 - - return scroll_display.get_dynamic_duration() - - def has_cached_content(self) -> bool: - """ - Check if any scroll display has cached content. - - Returns: - True if at least one scroll display has a cached image - """ - for scroll_display in self._scroll_displays.values(): - if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: - if scroll_display.scroll_helper.cached_image is not None: - return True - return False - - def get_all_vegas_content_items(self) -> list: - """Collect _vegas_content_items from all scroll displays.""" - items = [] - for sd in self._scroll_displays.values(): - vegas_items = getattr(sd, '_vegas_content_items', None) - if vegas_items: - items.extend(vegas_items) - return items - - def clear_all(self) -> None: - """Clear all scroll displays.""" - for scroll_display in self._scroll_displays.values(): - scroll_display.clear() - self._current_game_type = None + class ScrollDisplayManager(_ScrollDisplayManagerBase): + """Football scroll manager -- everything but the extras below is core.""" + + display_class = ScrollDisplay + + def get_dynamic_duration(self, game_type: str = None) -> int: + """Get the dynamic duration for the current scroll.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return 60 + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 + + return scroll_display.get_dynamic_duration() + + def has_cached_content(self) -> bool: + """ + Check if any scroll display has cached content. + + Returns: + True if at least one scroll display has a cached image + """ + for scroll_display in self._scroll_displays.values(): + if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: + if scroll_display.scroll_helper.cached_image is not None: + return True + return False + diff --git a/plugins/football-scoreboard/scroll_display_legacy.py b/plugins/football-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..0007bd46 --- /dev/null +++ b/plugins/football-scoreboard/scroll_display_legacy.py @@ -0,0 +1,685 @@ +""" +Scroll Display Handler -- LEGACY FALLBACK. + +Used only on a core that predates `src.common.sports_scroll` (LEDMatrix +3.2.0). `scroll_display.py` prefers the core implementation and falls back +here, so this file is frozen: fix bugs in the core module, not here. It goes +away at the B6 sunset, once cores without that module are gone. +Scroll Display Handler for Football Scoreboard Plugin + +Implements high-FPS horizontal scrolling of all matching games with league separator icons. +Uses ScrollHelper for efficient numpy-based scrolling and dynamic duration calculation. + +Features: +- Pre-rendered game cards for smooth scrolling +- League separator icons (NFL shield, NCAA FB logo) between different leagues +- Dynamic duration based on total content width +- FPS logging and performance monitoring +- Live priority support for scroll mode +""" + +import logging +import time +import os +from typing import Dict, Any, List, Optional +from PIL import Image + +try: + from src.common.scroll_helper import ScrollHelper +except ImportError: + ScrollHelper = None + +from game_renderer import GameRenderer + +logger = logging.getLogger(__name__) + + +class LegacyScrollDisplay: + """ + Handles scroll display mode for the football scoreboard plugin. + + This class: + - Collects all games matching criteria (respecting live priority) + - Pre-renders each game using GameRenderer + - Adds league separator icons between different leagues + - Composes a single wide image using ScrollHelper + - Implements dynamic duration based on total content width + - Logs FPS and game count during scrolling + """ + + # Paths to league separator icons + NFL_SEPARATOR_ICON = "assets/sports/nfl_logos/NFL.png" + NCAA_FB_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_fb.png" + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + global_config: Optional[Dict[str, Any]] = None + ): + """ + Initialize the ScrollDisplay handler. + + Args: + display_manager: Display manager instance + config: Plugin configuration dictionary + custom_logger: Optional custom logger instance + global_config: Optional global LEDMatrix configuration dictionary + """ + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + self.global_config = global_config or {} + + # Get display dimensions + if hasattr(display_manager, 'matrix') and display_manager.matrix is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # Initialize ScrollHelper + if ScrollHelper: + self.scroll_helper = ScrollHelper( + self.display_width, + self.display_height, + self.logger + ) + # Configure scroll settings + self._configure_scroll_helper() + else: + self.scroll_helper = None + self.logger.error("ScrollHelper not available - scroll mode will not work") + + # Shared logo cache for game renderer + self._logo_cache: Dict[str, Image.Image] = {} + + # League separator icons cache + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + # Tracking state + self._current_games: List[Dict] = [] + self._current_game_type: str = "" + self._current_leagues: List[str] = [] + self._vegas_content_items: List[Image.Image] = [] + self._is_scrolling = False + self._scroll_start_time: Optional[float] = None + self._last_log_time: float = 0 + self._log_interval: float = 5.0 # Log every 5 seconds + + # Performance tracking + self._frame_count: int = 0 + self._fps_sample_start: float = time.time() + + def _configure_scroll_helper(self) -> None: + """Configure scroll helper with settings from config.""" + if not self.scroll_helper: + return + + # Get global scroll settings, then per-league overrides + # For now, use global settings + scroll_settings = self._get_scroll_settings() + + # Set scroll speed (pixels per second in time-based mode) + scroll_speed = scroll_settings.get("scroll_speed", 50.0) + self.scroll_helper.set_scroll_speed(scroll_speed) + + # Set scroll delay + scroll_delay = scroll_settings.get("scroll_delay", 0.01) + self.scroll_helper.set_scroll_delay(scroll_delay) + + # Enable dynamic duration + dynamic_duration = scroll_settings.get("dynamic_duration", True) + self.scroll_helper.set_dynamic_duration_settings( + enabled=dynamic_duration, + min_duration=30, + max_duration=600, # 10 minutes max + buffer=0.2 # 20% buffer to ensure scroll completes fully off screen + ) + + # Use frame-based scrolling for better FPS control + # In frame-based mode: scroll_speed is pixels per frame, scroll_delay controls frame rate + # This allows precise control: 1 px/frame at 0.01s delay = 100 FPS + self.scroll_helper.set_frame_based_scrolling(True) + + # Convert scroll_speed from pixels/second to pixels/frame for frame-based mode + # If scroll_speed is very low (like 1.0 px/s), treat it as pixels per frame directly + # Otherwise, calculate pixels per frame based on scroll_delay + if scroll_speed < 10.0: + # Low values are likely intended as pixels per frame + pixels_per_frame = scroll_speed + else: + # Higher values are pixels/second, convert to pixels/frame + pixels_per_frame = scroll_speed * scroll_delay + + # Clamp to reasonable range (0.1 to 5 pixels per frame for smooth scrolling) + pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + # Calculate effective pixels per second for logging + effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 + + self.logger.info( + f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, delay={scroll_delay}s " + f"(effective {effective_pps:.1f} px/s), dynamic_duration={dynamic_duration}" + ) + + # Honor the global smooth-scrolling FPS target (older cores lack the setter) + target_fps = self.global_config.get('target_fps') or self.global_config.get('scroll_target_fps') + try: + # Coerce before comparing: a malformed global config value + # must degrade to today's scroll_delay pacing, not raise. + target_fps = float(target_fps) if target_fps is not None else None + except (TypeError, ValueError): + target_fps = None + if target_fps: + if hasattr(self.scroll_helper, 'set_target_fps'): + self.scroll_helper.set_target_fps(target_fps) + else: + self.scroll_helper.target_fps = max(30.0, min(200.0, target_fps)) + self.scroll_helper.frame_time_target = 1.0 / self.scroll_helper.target_fps + + def _get_scroll_settings(self, league: str = None) -> Dict[str, Any]: + """Get scroll settings, optionally for a specific league.""" + # Default scroll settings + defaults = { + "scroll_speed": 50.0, + "scroll_delay": 0.01, + "gap_between_games": 48, + "show_league_separators": True, + "dynamic_duration": True, + "game_card_width": 128, + } + + # Try to get league-specific settings first + if league: + league_config = self.config.get(league, {}) + league_scroll = league_config.get("scroll_settings", {}) + if league_scroll: + return {**defaults, **league_scroll} + + # Fall back to NFL settings (usually first enabled) + nfl_config = self.config.get("nfl", {}) + nfl_scroll = nfl_config.get("scroll_settings", {}) + if nfl_scroll: + return {**defaults, **nfl_scroll} + + # Fall back to NCAA FB settings + ncaa_config = self.config.get("ncaa_fb", {}) + ncaa_scroll = ncaa_config.get("scroll_settings", {}) + if ncaa_scroll: + return {**defaults, **ncaa_scroll} + + return defaults + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load NFL icon + if os.path.exists(self.NFL_SEPARATOR_ICON): + try: + nfl_icon = Image.open(self.NFL_SEPARATOR_ICON) + if nfl_icon.mode != "RGBA": + nfl_icon = nfl_icon.convert("RGBA") + # Resize to fit height while maintaining aspect ratio + aspect = nfl_icon.width / nfl_icon.height + new_width = int(separator_height * aspect) + nfl_icon = nfl_icon.resize((new_width, separator_height), Image.Resampling.LANCZOS) + self._separator_icons["nfl"] = nfl_icon + self.logger.debug(f"Loaded NFL separator icon: {new_width}x{separator_height}") + except Exception as e: + self.logger.error(f"Error loading NFL separator icon: {e}") + else: + self.logger.warning(f"NFL separator icon not found at {self.NFL_SEPARATOR_ICON}") + + # Load NCAA FB icon + if os.path.exists(self.NCAA_FB_SEPARATOR_ICON): + try: + ncaa_icon = Image.open(self.NCAA_FB_SEPARATOR_ICON) + if ncaa_icon.mode != "RGBA": + ncaa_icon = ncaa_icon.convert("RGBA") + # Resize to fit height while maintaining aspect ratio + aspect = ncaa_icon.width / ncaa_icon.height + new_width = int(separator_height * aspect) + ncaa_icon = ncaa_icon.resize((new_width, separator_height), Image.Resampling.LANCZOS) + self._separator_icons["ncaa_fb"] = ncaa_icon + self.logger.debug(f"Loaded NCAA FB separator icon: {new_width}x{separator_height}") + except Exception as e: + self.logger.error(f"Error loading NCAA FB separator icon: {e}") + else: + self.logger.warning(f"NCAA FB separator icon not found at {self.NCAA_FB_SEPARATOR_ICON}") + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + # Guard against status being None or non-dict + status = game.get('status') + if not isinstance(status, dict): + status = {} + state = status.get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare scrolling content from a list of games. + + Args: + games: List of game dictionaries with league info + game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) + leagues: List of leagues in order (e.g., ['nfl', 'ncaa_fb']) + rankings_cache: Optional team rankings cache + + Returns: + True if content was prepared successfully, False otherwise + """ + if not self.scroll_helper: + self.logger.error("ScrollHelper not available") + return False + + if not games: + self.logger.debug("No games to prepare for scrolling") + self.scroll_helper.clear_cache() + self._vegas_content_items = [] + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings + scroll_settings = self._get_scroll_settings() + gap_between_games = scroll_settings.get("gap_between_games", 24) + show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) + + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) + renderer = GameRenderer( + game_card_width, + self.display_height, + self.config, + logo_cache=self._logo_cache, + custom_logger=self.logger + ) + if rankings_cache: + renderer.set_rankings_cache(rankings_cache) + + # Pre-render all game cards + content_items: List[Image.Image] = [] + current_league = None + game_count = 0 + league_counts: Dict[str, int] = {} + + for game in games: + game_league = game.get("league", "nfl") # Default to NFL if not specified + + # Add league separator if switching leagues OR if this is the first league + if show_separators: + if current_league is None: + # First league - add separator at the start + separator = self._separator_icons.get(game_league) + if separator: + # Create a separator image with proper background + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + # Center the separator vertically + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {game_league} separator icon at start") + elif game_league != current_league: + # Switching leagues - add separator + separator = self._separator_icons.get(game_league) + if separator: + # Create a separator image with proper background + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + # Center the separator vertically + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {game_league} separator icon") + + current_league = game_league + + # Render game card - determine type from game state + try: + individual_game_type = self._determine_game_type(game) + game_img = renderer.render_game_card(game, individual_game_type) + + # Add horizontal padding to prevent logos from being cut off at edges + # Logos are positioned at -10 and display_width+10, so we need padding + padding = 12 # Padding on each side to ensure logos aren't cut off + padded_width = game_img.width + (padding * 2) + padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) + padded_img.paste(game_img, (padding, 0)) + + content_items.append(padded_img) + game_count += 1 + league_counts[game_league] = league_counts.get(game_league, 0) + 1 + except Exception as e: + self.logger.error(f"Error rendering game card: {e}") + continue + + if not content_items: + self.logger.warning("No game cards rendered") + return False + + # Store individual items for Vegas mode (avoids scroll_helper padding) + self._vegas_content_items = list(content_items) + + # Create scrolling image using ScrollHelper + self.scroll_helper.create_scrolling_image( + content_items, + item_gap=gap_between_games, + element_gap=0 # No element gap - each item is a complete game card + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Football Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Football Scroll] Total scroll width: {self.scroll_helper.total_scroll_width}px, " + f"Dynamic duration: {self.scroll_helper.calculated_duration}s" + ) + + # Reset tracking state + self._is_scrolling = True + self._scroll_start_time = time.time() + self._frame_count = 0 + self._fps_sample_start = time.time() + + return True + + def display_scroll_frame(self) -> bool: + """ + Display the next frame of the scrolling content. + + Returns: + True if a frame was displayed, False if scroll is complete or no content + """ + if not self.scroll_helper or not self.scroll_helper.cached_image: + return False + + # Update scroll position + self.scroll_helper.update_scroll_position() + + # Get visible portion + visible = self.scroll_helper.get_visible_portion() + if not visible: + return False + + # Display the visible portion + try: + self.display_manager.image = visible + self.display_manager.update_display() + + # Track frame rate + self._frame_count += 1 + self.scroll_helper.log_frame_rate() + + # Periodic logging + self._log_scroll_progress() + + return True + except Exception as e: + self.logger.error(f"Error displaying scroll frame: {e}") + return False + + def _log_scroll_progress(self) -> None: + """Log scroll progress and FPS periodically.""" + current_time = time.time() + + if current_time - self._last_log_time >= self._log_interval: + # Calculate FPS + elapsed = current_time - self._fps_sample_start + if elapsed > 0: + fps = self._frame_count / elapsed + + # Get scroll info + scroll_info = self.scroll_helper.get_scroll_info() + + self.logger.info( + f"[Football Scroll] FPS: {fps:.1f}, " + f"Position: {scroll_info['scroll_position']:.0f}/{scroll_info['total_width']}px, " + f"Elapsed: {scroll_info.get('elapsed_time', 0):.1f}s/{scroll_info['dynamic_duration']}s" + ) + + # Reset FPS tracking + self._frame_count = 0 + self._fps_sample_start = current_time + self._last_log_time = current_time + + def is_scroll_complete(self) -> bool: + """Check if the scroll cycle is complete.""" + if not self.scroll_helper: + return True + return self.scroll_helper.is_scroll_complete() + + def reset_scroll(self) -> None: + """Reset the scroll position to the beginning.""" + if self.scroll_helper: + self.scroll_helper.reset_scroll() + self._frame_count = 0 + self._fps_sample_start = time.time() + self.logger.debug("Scroll position reset") + + def get_scroll_info(self) -> Dict[str, Any]: + """Get current scroll state information.""" + if not self.scroll_helper: + return {"error": "ScrollHelper not available"} + + info = self.scroll_helper.get_scroll_info() + info.update({ + "game_count": len(self._current_games), + "game_type": self._current_game_type, + "leagues": self._current_leagues, + "is_scrolling": self._is_scrolling + }) + return info + + def get_dynamic_duration(self) -> int: + """Get the calculated dynamic duration for this scroll content.""" + if self.scroll_helper: + return self.scroll_helper.get_dynamic_duration() + return 60 # Default fallback + + def clear(self) -> None: + """Clear scroll content and reset state.""" + if self.scroll_helper: + self.scroll_helper.clear_cache() + self._current_games = [] + self._current_game_type = "" + self._current_leagues = [] + self._vegas_content_items = [] + self._is_scrolling = False + self._scroll_start_time = None + self.logger.debug("Scroll display cleared") + + +class LegacyScrollDisplayManager: + """ + Manages scroll display instances for different game types. + + This class provides a higher-level interface for the football plugin + to manage scroll displays for live, recent, and upcoming games. + """ + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + global_config: Optional[Dict[str, Any]] = None + ): + """ + Initialize the ScrollDisplayManager. + + Args: + display_manager: Display manager instance + config: Plugin configuration dictionary + custom_logger: Optional custom logger instance + global_config: Optional global LEDMatrix configuration dictionary + """ + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + self.global_config = global_config or {} + + # Create scroll displays for each game type + self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + self._scroll_displays[game_type] = LegacyScrollDisplay( + self.display_manager, + self.config, + self.logger, + global_config=self.global_config + ) + return self._scroll_displays[game_type] + + def prepare_and_display( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare content and start displaying scroll. + + Args: + games: List of game dictionaries + game_type: Type of games + leagues: List of leagues + rankings_cache: Optional team rankings cache + + Returns: + True if scroll was started successfully + """ + scroll_display = self.get_scroll_display(game_type) + + success = scroll_display.prepare_scroll_content( + games, game_type, leagues, rankings_cache + ) + + if success: + self._current_game_type = game_type + + return success + + def display_frame(self, game_type: str = None) -> bool: + """ + Display the next frame of the current scroll. + + Args: + game_type: Optional game type (uses current if not specified) + + Returns: + True if a frame was displayed + """ + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return False + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return False + + return scroll_display.display_scroll_frame() + + def is_complete(self, game_type: str = None) -> bool: + """Check if the current scroll is complete.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return True + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return True + + return scroll_display.is_scroll_complete() + + def get_dynamic_duration(self, game_type: str = None) -> int: + """Get the dynamic duration for the current scroll.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return 60 + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 + + return scroll_display.get_dynamic_duration() + + def has_cached_content(self) -> bool: + """ + Check if any scroll display has cached content. + + Returns: + True if at least one scroll display has a cached image + """ + for scroll_display in self._scroll_displays.values(): + if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: + if scroll_display.scroll_helper.cached_image is not None: + return True + return False + + def get_all_vegas_content_items(self) -> list: + """Collect _vegas_content_items from all scroll displays.""" + items = [] + for sd in self._scroll_displays.values(): + vegas_items = getattr(sd, '_vegas_content_items', None) + if vegas_items: + items.extend(vegas_items) + return items + + def clear_all(self) -> None: + """Clear all scroll displays.""" + for scroll_display in self._scroll_displays.values(): + scroll_display.clear() + self._current_game_type = None + + diff --git a/plugins/football-scoreboard/test_core_fallback.py b/plugins/football-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..453840cf --- /dev/null +++ b/plugins/football-scoreboard/test_core_fallback.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""The guarded core import must behave on every core this plugin can meet. + +Adopting core code (B5) is only safe because `scroll_display.py` falls back to +`scroll_display_legacy.py` when the core lacks `src.common.sports_scroll`. +Nothing verified that claim — the safety harness renders against the *current* +core, so it exercises exactly one of the cases below. + +That gap matters because the version floor does not close it. A user who +installed from the v3.1.0 release reports `__version__ = "1.0.0"`, which the +install gate treats as untrustworthy and lets through, and that core has no +`src.common.sports_scroll`. The fallback is the only thing standing between +them and a scoreboard that fails to load. + + core module bundled copy expected + ----------------- ---------------- ------------------------------------ + present present core implementation (today) + absent present legacy implementation, fully working + absent absent the B6 state: fails, and names the + exact missing module + +The third row is not a supported configuration — it is what the sunset looks +like on a core that is too old. It is asserted so that when the fallback is +finally deleted, the failure is specific and attributable rather than a vague +crash, and so nobody deletes the fallback without meeting it. + +Run: /bin/python plugins/football-scoreboard/test_core_fallback.py +""" + +import importlib +import os +import sys + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +CORE_MODULE = "src.common.sports_scroll" + + +class _BlockModules: + """Make named modules un-importable, simulating an older core. + + A meta-path finder is used rather than deleting files: it is reversible, + leaves the checkout untouched, and reproduces exactly what Python does when + the module genuinely is not there — `ModuleNotFoundError` with `.name` set. + """ + + def __init__(self, *names): + self.names = set(names) + self._saved = {} + + def find_module(self, fullname, path=None): # pragma: no cover - legacy API + return self if fullname in self.names else None + + def find_spec(self, fullname, path=None, target=None): + if fullname in self.names: + raise ModuleNotFoundError(f"No module named {fullname!r}", name=fullname) + return None + + def __enter__(self): + for name in list(sys.modules): + if name in self.names or name.startswith("scroll_display"): + self._saved[name] = sys.modules.pop(name) + sys.meta_path.insert(0, self) + return self + + def __exit__(self, *exc): + sys.meta_path.remove(self) + for name in list(sys.modules): + if name.startswith("scroll_display"): + del sys.modules[name] + sys.modules.update(self._saved) + return False + + +def _fresh_scroll_display(): + for name in list(sys.modules): + if name.startswith("scroll_display"): + del sys.modules[name] + return importlib.import_module("scroll_display") + + +def test_core_present_uses_core(): + mod = _fresh_scroll_display() + assert mod._USING_CORE_SCROLL is True, ( + "core ships src.common.sports_scroll, so the plugin should be using it" + ) + bases = [c.__name__ for c in mod.ScrollDisplay.__mro__] + assert "SportsScrollDisplay" in bases, bases + assert mod.ScrollDisplayManager.display_class is mod.ScrollDisplay + + +def test_core_absent_falls_back_and_still_works(): + """The B5 guarantee. This is the case the harness cannot reach.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + + assert mod._USING_CORE_SCROLL is False, ( + "with the core module gone the plugin must fall back, not import it" + ) + assert mod.ScrollDisplay.__name__ == "LegacyScrollDisplay", ( + f"expected the bundled implementation, got {mod.ScrollDisplay.__name__}" + ) + # A fallback that loads but cannot draw is no fallback at all. + for method in ("prepare_scroll_content", "display_scroll_frame", + "is_scroll_complete", "get_dynamic_duration", + "_load_separator_icons", "_determine_game_type"): + assert hasattr(mod.ScrollDisplay, method), f"fallback lost {method}()" + assert hasattr(mod.ScrollDisplayManager, "prepare_and_display") + + +def test_sunset_state_fails_specifically(): + """The B6 state: no core module and no bundled copy. + + Not supported — asserted so the failure names the missing module rather + than surfacing as something unattributable. + """ + with _BlockModules(CORE_MODULE, "scroll_display_legacy"): + for name in list(sys.modules): + if name.startswith("scroll_display"): + del sys.modules[name] + try: + importlib.import_module("scroll_display") + except ModuleNotFoundError as exc: + assert exc.name in {CORE_MODULE, "scroll_display_legacy"}, ( + f"failure should name the missing module, got {exc.name!r}" + ) + else: + raise AssertionError( + "expected ModuleNotFoundError with neither implementation present" + ) + + +if __name__ == "__main__": + # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an + # exception raised *during* a test is what this suite is guarding against: + # when the fallback was removed, the escaping ModuleNotFoundError named the + # core module and an in-test skip handler swallowed it as "no core on + # PYTHONPATH" -- hiding the exact regression this file exists to catch. + # Once we know the core is importable, any ModuleNotFoundError from here on + # is a real failure. + try: + importlib.import_module(CORE_MODULE) + except ModuleNotFoundError as exc: + print(f"SKIP: needs a LEDMatrix core with {CORE_MODULE} on PYTHONPATH " + f"({exc})") + sys.exit(2) + + print("guarded core-import fallback tests") + print("=" * 55) + failures = [] + for t in (test_core_present_uses_core, + test_core_absent_falls_back_and_still_works, + test_sunset_state_fails_specifically): + try: + t() + print(f"PASS {t.__name__}") + except (AssertionError, ModuleNotFoundError) as e: + failures.append(t.__name__) + print(f"FAIL {t.__name__}: {e}") + print("=" * 55) + if failures: + print(f"{len(failures)} test(s) failed: {failures}") + sys.exit(1) + print("All tests passed.") From e1d52e6daf0e25b6cf9b6124b52d32baf5df2c82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:34:23 -0400 Subject: [PATCH 2/2] test(football-scoreboard): resolvability check, and fix the stale annotation Brings this plugin's fallback test up to the version that found the missing imports in the other six -- it resolves the globals each method reads, across both the display and manager classes and on both import paths, rather than checking that method names exist. This plugin's header was hand-written and is already clean; the point is that the old check could not have told us that. Also corrects the legacy manager's `Dict[str, ScrollDisplay]` annotation to the post-rename name. On Python 3.13 that annotation is not evaluated, so the reported NameError does not reproduce there, but it is wrong regardless and this repo supports 3.10 through 3.13. Not changed: get_all_vegas_content_items. It is not lost -- the core-backed manager inherits it from SportsScrollDisplayManager, and diffing the two shows the only differences are the type annotation, docstring, loop variable name and quote style. The logic is identical. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- .../scroll_display_legacy.py | 2 +- .../football-scoreboard/test_core_fallback.py | 91 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/plugins/football-scoreboard/scroll_display_legacy.py b/plugins/football-scoreboard/scroll_display_legacy.py index 0007bd46..afcf6cc8 100644 --- a/plugins/football-scoreboard/scroll_display_legacy.py +++ b/plugins/football-scoreboard/scroll_display_legacy.py @@ -552,7 +552,7 @@ def __init__( self.global_config = global_config or {} # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._scroll_displays: Dict[str, 'LegacyScrollDisplay'] = {} self._current_game_type: Optional[str] = None def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': diff --git a/plugins/football-scoreboard/test_core_fallback.py b/plugins/football-scoreboard/test_core_fallback.py index 453840cf..c668d8c2 100644 --- a/plugins/football-scoreboard/test_core_fallback.py +++ b/plugins/football-scoreboard/test_core_fallback.py @@ -105,7 +105,7 @@ def test_core_absent_falls_back_and_still_works(): # A fallback that loads but cannot draw is no fallback at all. for method in ("prepare_scroll_content", "display_scroll_frame", "is_scroll_complete", "get_dynamic_duration", - "_load_separator_icons", "_determine_game_type"): + "_load_separator_icons"): assert hasattr(mod.ScrollDisplay, method), f"fallback lost {method}()" assert hasattr(mod.ScrollDisplayManager, "prepare_and_display") @@ -132,6 +132,93 @@ def test_sunset_state_fails_specifically(): ) + +def _unresolvable_globals(cls, module): + """Globals a class's own methods read that nothing can resolve. + + Walks each method's AST for `Name` loads rather than its bytecode: the + bytecode's co_names mixes in attribute names, so `Image.Resampling.LANCZOS` + looked like a missing global. Locals, arguments and comprehension targets + are excluded, leaving only names Python would resolve globally. + + Reading the source rather than calling the method is deliberate -- building + a real display needs a display manager, fonts and assets, but an + unresolvable global is a load-time fact and needs none of that. `hasattr` + could not see this at all: the method exists; what it reaches for does not. + """ + import ast + import builtins + import inspect + import textwrap + import types + + # Resolve against the module the CLASS lives in, not the one we imported. + # On the fallback path ScrollDisplay is LegacyScrollDisplay, whose globals + # are scroll_display_legacy's -- checking scroll_display's namespace made + # every fallback look broken. + import sys as _sys + module = _sys.modules.get(cls.__module__, module) + + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(cls))) + except (OSError, TypeError): # pragma: no cover - source always available here + return [] + + missing = set() + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + bound = {a.arg for a in node.args.args + node.args.kwonlyargs} + if node.args.vararg: + bound.add(node.args.vararg.arg) + if node.args.kwarg: + bound.add(node.args.kwarg.arg) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store,)): + bound.add(sub.id) + elif isinstance(sub, (ast.Import, ast.ImportFrom)): + for alias in sub.names: + bound.add((alias.asname or alias.name).split(".")[0]) + elif isinstance(sub, ast.ExceptHandler) and sub.name: + bound.add(sub.name) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load): + name = sub.id + if (name in bound or hasattr(module, name) or hasattr(cls, name) + or hasattr(builtins, name)): + continue + missing.add(name) + return sorted(missing) + + +def test_content_methods_can_resolve_what_they_use(): + """The core path must be able to draw, not merely import. + + `_load_separator_icons` and `prepare_scroll_content` were lifted out of the + legacy module; their dependencies were not. Nothing caught it: the safety + harness renders the scoreboard screens rather than scroll mode, and the + earlier version of this file only checked that method names existed. + """ + mod = _fresh_scroll_display() + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"{cls.__name__} methods reference {missing}, which their module " + f"cannot resolve — they raise NameError on the core path" + ) + + +def test_fallback_content_methods_can_resolve_what_they_use(): + """Same check on the bundled implementation.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"the fallback's {cls.__name__} references {missing}, which " + f"its module cannot resolve" + ) + if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an # exception raised *during* a test is what this suite is guarding against: @@ -152,6 +239,8 @@ def test_sunset_state_fails_specifically(): failures = [] for t in (test_core_present_uses_core, test_core_absent_falls_back_and_still_works, + test_content_methods_can_resolve_what_they_use, + test_fallback_content_methods_can_resolve_what_they_use, test_sunset_state_fails_specifically): try: t()