From 861be4e03cf8f17a7779b0f415ff47ad1dfb4157 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Fri, 10 Jul 2026 10:01:48 -0400 Subject: [PATCH] Add AFL scoreboard plugin Fork the sport-agnostic soccer-scoreboard into a single-league AFL (Australian Football League) scoreboard. - New afl_managers.py: ESPN australian-football/afl fetch + parsing, with AFL quarter (Q1-Q4) / HALF / Final / pre-game period_text mapping. - New single-league manager.py (AflScoreboardPlugin): afl_live / afl_recent / afl_upcoming modes, keeping switch/scroll display, dynamic duration, live priority + celebration, and Vegas scroll hooks. - Verbatim sport-generic modules (sports.py, game_renderer.py, data_sources.py, base_odds_manager.py, dynamic_team_resolver.py, scroll_display.py) with soccer wording/logo-dir constants updated for AFL. - Flat single-league config_schema.json (draft-07) with full customization parity lifted from soccer's per-league schema. - manifest.json, README.md, requirements.txt, plugins.json registry entry. - test/harness.json fixture (live/recent/upcoming) + test_afl_plugin.py smoke tests (mode routing, live content, AFL quarter parsing). Data source: https://site.api.espn.com/apis/site/v2/sports/australian-football/afl/scoreboard Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins.json | 22 + plugins/afl-scoreboard/.gitignore | 146 + plugins/afl-scoreboard/LICENSE | 17 + plugins/afl-scoreboard/README.md | 152 + plugins/afl-scoreboard/afl_managers.py | 302 ++ plugins/afl-scoreboard/base_odds_manager.py | 276 ++ plugins/afl-scoreboard/config_schema.json | 880 +++++ plugins/afl-scoreboard/data_sources.py | 324 ++ .../afl-scoreboard/dynamic_team_resolver.py | 63 + plugins/afl-scoreboard/game_renderer.py | 550 +++ plugins/afl-scoreboard/manager.py | 961 +++++ plugins/afl-scoreboard/manifest.json | 39 + plugins/afl-scoreboard/requirements.txt | 12 + plugins/afl-scoreboard/scroll_display.py | 717 ++++ plugins/afl-scoreboard/sports.py | 3157 +++++++++++++++++ plugins/afl-scoreboard/test/harness.json | 285 ++ plugins/afl-scoreboard/test_afl_plugin.py | 252 ++ 17 files changed, 8155 insertions(+) create mode 100644 plugins/afl-scoreboard/.gitignore create mode 100644 plugins/afl-scoreboard/LICENSE create mode 100644 plugins/afl-scoreboard/README.md create mode 100644 plugins/afl-scoreboard/afl_managers.py create mode 100644 plugins/afl-scoreboard/base_odds_manager.py create mode 100644 plugins/afl-scoreboard/config_schema.json create mode 100644 plugins/afl-scoreboard/data_sources.py create mode 100644 plugins/afl-scoreboard/dynamic_team_resolver.py create mode 100644 plugins/afl-scoreboard/game_renderer.py create mode 100644 plugins/afl-scoreboard/manager.py create mode 100644 plugins/afl-scoreboard/manifest.json create mode 100644 plugins/afl-scoreboard/requirements.txt create mode 100644 plugins/afl-scoreboard/scroll_display.py create mode 100644 plugins/afl-scoreboard/sports.py create mode 100644 plugins/afl-scoreboard/test/harness.json create mode 100644 plugins/afl-scoreboard/test_afl_plugin.py diff --git a/plugins.json b/plugins.json index c54ca1a4..85fc04a8 100644 --- a/plugins.json +++ b/plugins.json @@ -972,6 +972,28 @@ "verified": false, "screenshot": "", "latest_version": "1.0.0" + }, + { + "id": "afl-scoreboard", + "name": "AFL Scoreboard", + "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", + "author": "ChuckBuilds", + "category": "sports", + "tags": [ + "afl", + "australian-football", + "sports", + "scoreboard", + "live-scores" + ], + "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", + "branch": "main", + "plugin_path": "plugins/afl-scoreboard", + "stars": 0, + "downloads": 0, + "verified": true, + "screenshot": "", + "latest_version": "1.0.0" } ] } diff --git a/plugins/afl-scoreboard/.gitignore b/plugins/afl-scoreboard/.gitignore new file mode 100644 index 00000000..979acecd --- /dev/null +++ b/plugins/afl-scoreboard/.gitignore @@ -0,0 +1,146 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypcache__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# OS specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Project specific +*.log +config.json +*.pem diff --git a/plugins/afl-scoreboard/LICENSE b/plugins/afl-scoreboard/LICENSE new file mode 100644 index 00000000..e653a0c1 --- /dev/null +++ b/plugins/afl-scoreboard/LICENSE @@ -0,0 +1,17 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2025 LEDMatrix Team + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/plugins/afl-scoreboard/README.md b/plugins/afl-scoreboard/README.md new file mode 100644 index 00000000..305d6538 --- /dev/null +++ b/plugins/afl-scoreboard/README.md @@ -0,0 +1,152 @@ +----------------------------------------------------------------------------------- +### Connect with ChuckBuilds + +- Show support on Youtube: https://www.youtube.com/@ChuckBuilds +- Stay in touch on Instagram: https://www.instagram.com/ChuckBuilds/ +- Want to chat or need support? Reach out on the ChuckBuilds Discord: https://discord.com/invite/uW36dVAtcT +- Feeling Generous? Support the project: + - Github Sponsorship: https://github.com/sponsors/ChuckBuilds + - Buy Me a Coffee: https://buymeacoffee.com/chuckbuilds + - Ko-fi: https://ko-fi.com/chuckbuilds/ + +----------------------------------------------------------------------------------- + +# AFL Scoreboard Plugin + +A plugin for LEDMatrix that displays live, recent, and upcoming **AFL (Australian +Football League)** games with real-time scores and game status. + +## Features + +- **Live Game Tracking**: Real-time scores, quarter (Q1–Q4), and running clock +- **Recent Games**: Recently completed games with final scores +- **Upcoming Games**: Scheduled games with start times +- **Favorite Teams**: Prioritize (or exclusively show) games involving your favorite teams +- **Switch or Scroll**: Show one game at a time, or scroll all games horizontally +- **Dynamic Duration & Live Priority**: Spend more time on live games; let live games interrupt the rotation +- **Background Data Fetching**: Efficient API calls without blocking the display + +## Display Modes + +The plugin exposes three display modes: + +1. **afl_live** — currently active games (quarter + clock) +2. **afl_recent** — recently completed games with final scores +3. **afl_upcoming** — scheduled upcoming games with start times + +During a live game the status area shows: + +- **Q1 / Q2 / Q3 / Q4** — the current quarter, followed by the running clock (e.g. `Q3 12:45`) +- **HALF** — the main break after the second quarter +- **Final** — completed game +- the scheduled start time for upcoming games + +## Configuration + +AFL is a single league, so all settings live at the top level of the plugin +config (there is no per-league nesting). + +```json +{ + "enabled": true, + "favorite_teams": ["COLL", "GEEL", "RICH"], + "exclude_teams": [], + "show_favorite_teams_only": false, + "display_modes": { + "live": true, + "recent": true, + "upcoming": true, + "live_display_mode": "switch", + "recent_display_mode": "switch", + "upcoming_display_mode": "switch" + }, + "display_duration": 30, + "live_game_duration": 20, + "non_favorite_live_game_duration": 0, + "recent_game_duration": 15, + "upcoming_game_duration": 15, + "recent_games_to_show": 5, + "upcoming_games_to_show": 10, + "update_interval_seconds": 300, + "live_update_interval": 30, + "show_records": false, + "show_odds": false, + "live_priority": false, + "celebration_enabled": true, + "celebration_duration": 8 +} +``` + +### Key settings + +| Setting | Default | Effect | +|---|---|---| +| `favorite_teams` | `[]` | AFL team abbreviations to prioritize (e.g. `COLL`, `GEEL`, `RICH`). | +| `exclude_teams` | `[]` | Teams to always hide — from both the live rotation and recent/final scores (spoiler protection). | +| `show_favorite_teams_only` | `false` | Only show games involving `favorite_teams`. | +| `display_modes.*_display_mode` | `switch` | `switch` shows one game at a time; `scroll` scrolls all games horizontally. | +| `display_duration` | `30` | How long each display mode stays on screen before cycling. | +| `live_game_duration` | `20` | Seconds each live game stays on screen. | +| `non_favorite_live_game_duration` | `0` | Shorter dwell for live games with no favorite team (0 = off). | +| `live_priority` | `false` | Let live AFL games interrupt the recent/upcoming rotation. | +| `dynamic_duration` | off | Size a mode's total on-screen time to the number of games it has. | +| `mode_durations` | null | Fix the total duration of each mode (overrides dynamic calculation). | + +## Team Names & Abbreviations + +The `favorite_teams` / `exclude_teams` fields require the **ESPN API +abbreviation** for each team (e.g. `"COLL"` for Collingwood, `"GEEL"` for +Geelong). Full team names are not supported. + +> **Tip:** If you're unsure of an abbreviation, enable debug logging — the plugin +> logs `home_abbr` and `away_abbr` for every game it processes. + +## Team Logos + +Team logos are downloaded automatically from ESPN's CDN on first use and cached to +disk — no manual asset work is required. If a logo can't be fetched, a generated +text-abbreviation placeholder is drawn instead. + +## Data Source + +Game data is fetched from ESPN's public AFL scoreboard endpoint (no API key +required): + +``` +https://site.api.espn.com/apis/site/v2/sports/australian-football/afl/scoreboard +``` + +## Dependencies + +This plugin requires the main LEDMatrix installation and uses the plugin system +base classes. + +## Installation + +The easiest way is the Plugin Store in the LEDMatrix web UI: + +1. Open `http://your-pi-ip:5000` +2. Open the **Plugin Manager** tab +3. Find **AFL Scoreboard** in the **Plugin Store** section and click **Install** +4. Open the plugin's tab in the second nav row to configure favorite teams and + display modes + +Manual install: copy this directory into your LEDMatrix `plugins_directory` +(default `plugin-repos/`) and restart the display service. + +## Testing + +- `python test_afl_plugin.py` — standalone smoke tests (display modes, mode + routing, live content, AFL quarter parsing). No network or host required. +- `test/harness.json` — a deterministic fixture (one live, one recent, one + upcoming game) for the core plugin safety harness + (`LEDMatrix/scripts/check_plugin.py`). + +## Troubleshooting + +- **No games showing**: Confirm the ESPN endpoint is reachable and that at least + one display mode is enabled. +- **Missing team logos**: The plugin auto-downloads logos; check the display's + internet access and logo cache directory permissions. +- **Slow updates**: Adjust `update_interval_seconds` / `live_update_interval`. +- **API errors**: Check your internet connection and ESPN API availability. diff --git a/plugins/afl-scoreboard/afl_managers.py b/plugins/afl-scoreboard/afl_managers.py new file mode 100644 index 00000000..b8390d46 --- /dev/null +++ b/plugins/afl-scoreboard/afl_managers.py @@ -0,0 +1,302 @@ +""" +AFL (Australian Football League) Managers for LEDMatrix + +This module provides manager classes for the AFL. AFL is a single-league sport +(ESPN sport slug ``australian-football``, league ``afl``), so unlike the +multi-league soccer plugin this fork was based on, there is exactly one set of +Live/Recent/Upcoming managers. +""" + +import logging +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, Optional +import pytz + +from sports import SportsCore, SportsLive, SportsRecent, SportsUpcoming + +# ESPN API base URL for Australian Football (AFL) +ESPN_AFL_BASE_URL = "https://site.api.espn.com/apis/site/v2/sports/australian-football" + +# ESPN league identifier for the AFL scoreboard endpoint +AFL_LEAGUE_KEY = "afl" + +# League display names (single league) +LEAGUE_NAMES = { + "afl": "AFL", +} + + +class BaseAflManager(SportsCore): + """Base class for AFL managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _shared_data = None + _last_shared_update = 0 + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager, league_key: str = AFL_LEAGUE_KEY): + """ + Initialize base AFL manager. + + Args: + config: Configuration dictionary + display_manager: Display manager instance + cache_manager: Cache manager instance + league_key: League identifier (always 'afl') + """ + self.logger = logging.getLogger(f"AFL-{league_key}") + self.league_key = league_key + self.league_name = LEAGUE_NAMES.get(league_key, league_key) + + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="afl", # config key => "afl_scoreboard" + ) + + # Set sport and league for ESPN API (after parent init to avoid overwrite) + self.sport = "australian-football" + self.league = league_key + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("afl_recent", False) + self.upcoming_enabled = display_modes.get("afl_upcoming", False) + self.live_enabled = display_modes.get("afl_live", False) + + self.logger.info( + f"Initialized {self.league_name} manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + + def _fetch_afl_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches game data for the AFL using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ + now = datetime.now(pytz.utc) + + # Fetch a date range (past 2 weeks to future 2 weeks) + start_date = now - timedelta(days=14) + end_date = now + timedelta(days=14) + date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" + + cache_key = f"afl_schedule_{date_str}" + url = f"{ESPN_AFL_BASE_URL}/{self.league_key}/scoreboard" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {self.league_name}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {self.league_name} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {self.league_name}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.delete(cache_key) + + # Start background fetch if service is available + if self.background_service and self.background_enabled: + self.logger.info( + f"Starting background fetch for {self.league_name} schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {self.league_name}: {len(result.data.get('events', []))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {self.league_name}: {result.error}" + ) + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="australian-football", + year=now.year, + url=url, + cache_key=cache_key, + params={"dates": date_str, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback, + ) + + # Track the request + if not hasattr(self, 'background_fetch_requests'): + self.background_fetch_requests = {} + self.background_fetch_requests[date_str] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + else: + # Fallback to synchronous fetch if background service not available + self.logger.warning( + "Background service not available, using synchronous fetch" + ) + try: + response = self.session.get( + url, + params={"dates": date_str, "limit": 1000}, + headers=self.headers, + timeout=30, + ) + response.raise_for_status() + data = response.json() + + # Cache the data + self.cache_manager.set(cache_key, data) + self.logger.info(f"Synchronously fetched {self.league_name} schedule") + return data + + except Exception as e: + self.logger.error(f"Failed to fetch {self.league_name} schedule: {e}") + return None + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, AflLiveManager): + # Live games should fetch only current games, not entire schedule + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached schedule data + return self._fetch_afl_api_data(use_cache=True) + + def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: + """Extract relevant game details from the ESPN AFL API response. + + AFL is played in four quarters (Q1-Q4) with a running clock. ESPN exposes + the quarter via ``status.period`` (1-4) and the running clock via + ``status.displayClock``. There is a single running integer ``score`` per + team (no goals/behinds breakdown for v1). + """ + details, home_team, away_team, status, situation = self._extract_game_details_common(game_event) + if details is None or home_team is None or away_team is None or status is None: + return None + + try: + period = status.get("period", 0) + period_text = "" + status_state = status["type"]["state"] + status_name = status["type"]["name"] + + if status_state == "halftime" or status_name == "STATUS_HALFTIME": + # ESPN can set state="in" AND name="STATUS_HALFTIME" together, so + # this guard precedes the generic "in" branch. AFL halftime is the + # break after Q2. + period_text = "HALF" + elif status_state == "in": + if period == 0: + period_text = "Start" + elif 1 <= period <= 4: + period_text = f"Q{period}" + elif period >= 5: + # Overtime / extra periods (rare, e.g. finals extra time) + period_text = f"OT{period - 4}" + else: + period_text = f"Q{period}" + elif status_state == "post": + period_text = "Final" + elif status_state == "pre": + period_text = details.get("game_time", "") + + # Append the running clock for live games (e.g. "Q3 12:34") + clock = status.get("displayClock", "") + if clock and status_state == "in": + period_text = f"{period_text} {clock}" if period_text else clock + + details.update({ + "period": period, + "period_text": period_text, + "clock": clock, + "league": self.league_key, # Add league field for scroll display + }) + + # Basic validation + if not details['home_abbr'] or not details['away_abbr']: + self.logger.warning(f"Missing team abbreviation in event: {details['id']}") + return None + + self.logger.debug( + f"Extracted: {details['away_abbr']}@{details['home_abbr']}, " + f"Status: {status['type']['name']}, Live: {details['is_live']}, " + f"Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" + ) + + return details + except Exception as e: + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) + return None + + +class AflLiveManager(BaseAflManager, SportsLive): + """Manager for live AFL games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager, league_key: str = AFL_LEAGUE_KEY): + super().__init__(config, display_manager, cache_manager, league_key) + self.logger = logging.getLogger(f"AFLLive-{league_key}") + self.logger.info(f"Initialized {self.league_name} LiveManager in live mode") + + +class AflRecentManager(BaseAflManager, SportsRecent): + """Manager for recently completed AFL games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager, league_key: str = AFL_LEAGUE_KEY): + super().__init__(config, display_manager, cache_manager, league_key) + self.logger = logging.getLogger(f"AFLRecent-{league_key}") + self.logger.info( + f"Initialized {self.league_name} RecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class AflUpcomingManager(BaseAflManager, SportsUpcoming): + """Manager for upcoming AFL games.""" + + def __init__(self, config: Dict[str, Any], display_manager, cache_manager, league_key: str = AFL_LEAGUE_KEY): + super().__init__(config, display_manager, cache_manager, league_key) + self.logger = logging.getLogger(f"AFLUpcoming-{league_key}") + self.logger.info( + f"Initialized {self.league_name} UpcomingManager with {len(self.favorite_teams)} favorite teams" + ) + + +def create_afl_managers(config, display_manager, cache_manager): + """Create AFL Live, Recent, and Upcoming managers.""" + return ( + AflLiveManager(config, display_manager, cache_manager, AFL_LEAGUE_KEY), + AflRecentManager(config, display_manager, cache_manager, AFL_LEAGUE_KEY), + AflUpcomingManager(config, display_manager, cache_manager, AFL_LEAGUE_KEY), + ) diff --git a/plugins/afl-scoreboard/base_odds_manager.py b/plugins/afl-scoreboard/base_odds_manager.py new file mode 100644 index 00000000..3759d5ec --- /dev/null +++ b/plugins/afl-scoreboard/base_odds_manager.py @@ -0,0 +1,276 @@ +""" +BaseOddsManager - Base class for odds data fetching and management. + +This base class provides core odds fetching functionality that can be inherited +by plugins that need odds data. +""" + +import logging +import requests +import json +from typing import Dict, Any, Optional, List + +class BaseOddsManager: + """ + Base class for odds data fetching and management. + + Provides core functionality for: + - ESPN API odds fetching + - Caching and data processing + - Error handling and timeouts + - League mapping and data extraction + + Plugins can inherit from this class to get odds functionality. + """ + + def __init__(self, cache_manager, config_manager=None): + """ + Initialize the base odds manager. + + Args: + cache_manager: Cache manager instance for data persistence + config_manager: Configuration manager (optional) + """ + self.cache_manager = cache_manager + self.config_manager = config_manager + self.logger = logging.getLogger(__name__) + self.base_url = "https://sports.core.api.espn.com/v2/sports" + + # Configuration with defaults + self.update_interval = 3600 # 1 hour default + self.request_timeout = 30 # 30 seconds default + + # Load configuration if available + if config_manager: + self._load_configuration() + + def _load_configuration(self): + """Load configuration from config manager.""" + if not self.config_manager: + return + + try: + config = self.config_manager.get_config() + odds_config = config.get("base_odds_manager", {}) + + self.update_interval = odds_config.get( + "update_interval", self.update_interval + ) + self.request_timeout = odds_config.get("timeout", self.request_timeout) + + self.logger.debug( + f"BaseOddsManager configuration loaded: " + f"update_interval={self.update_interval}s, " + f"timeout={self.request_timeout}s" + ) + + except Exception as e: + self.logger.warning(f"Failed to load BaseOddsManager configuration: {e}") + + def get_odds( + self, + sport: str | None, + league: str | None, + event_id: str, + update_interval_seconds: Optional[int] = None, + ) -> Optional[Dict[str, Any]]: + """ + Fetch odds data for a specific game. + + Args: + sport: Sport name (e.g., 'afl') + league: League name (e.g., 'eng.1') + event_id: ESPN event ID + update_interval_seconds: Override default update interval + + Returns: + Dictionary containing odds data or None if unavailable + """ + if sport is None or league is None: + raise ValueError("Sport and League cannot be None") + + # Use provided interval or default + interval = update_interval_seconds or self.update_interval + cache_key = f"odds_espn_{sport}_{league}_{event_id}" + + # Check cache first + cached_data = self.cache_manager.get(cache_key) + + if cached_data: + # Filter out the "no_odds" marker - it should not be returned + # as valid odds data. Treat it as a cache miss so a fresh API + # call is made once the cache entry expires. + if isinstance(cached_data, dict) and cached_data.get("no_odds"): + self.logger.debug(f"Cached no-odds marker for {cache_key}, skipping") + else: + self.logger.info(f"Using cached odds from ESPN for {cache_key}") + return cached_data + + self.logger.info(f"Cache miss - fetching fresh odds from ESPN for {cache_key}") + + try: + # Map league names to ESPN API format + league_mapping = { + "eng.1": "eng.1", + "esp.1": "esp.1", + "ger.1": "ger.1", + "ita.1": "ita.1", + "fra.1": "fra.1", + "usa.1": "usa.1", + "uefa.champions": "uefa.champions", + "uefa.europa": "uefa.europa", + } + + espn_league = league_mapping.get(league, league) + url = f"{self.base_url}/{sport}/leagues/{espn_league}/events/{event_id}/competitions/{event_id}/odds" + self.logger.info(f"Requesting odds from URL: {url}") + + response = requests.get(url, timeout=self.request_timeout) + response.raise_for_status() + raw_data = response.json() + + self.logger.debug( + f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}" + ) + + odds_data = self._extract_espn_data(raw_data) + if odds_data: + self.logger.info(f"Successfully extracted odds data: {odds_data}") + else: + self.logger.debug("No odds data available for this game") + + if odds_data: + self.cache_manager.set(cache_key, odds_data, ttl=interval) + self.logger.info(f"Saved odds data to cache for {cache_key}") + else: + self.logger.debug(f"No odds data available for {cache_key}") + # Cache the fact that no odds are available to avoid repeated API calls + self.cache_manager.set(cache_key, {"no_odds": True}, ttl=interval) + + return odds_data + + except requests.exceptions.RequestException as e: + self.logger.exception(f"Error fetching odds from ESPN API for {cache_key}") + except json.JSONDecodeError: + self.logger.exception( + f"Error decoding JSON response from ESPN API for {cache_key}" + ) + + # Return cached odds on error, but filter out the no_odds sentinel + cached = self.cache_manager.get(cache_key) + if isinstance(cached, dict) and cached.get("no_odds"): + return None + return cached + + def _extract_espn_data(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Extract and format odds data from ESPN API response. + + Args: + data: Raw ESPN API response data + + Returns: + Formatted odds data dictionary or None + """ + self.logger.debug(f"Extracting ESPN odds data. Data keys: {list(data.keys())}") + + if "items" in data and data["items"]: + self.logger.debug(f"Found {len(data['items'])} items in odds data") + item = data["items"][0] + self.logger.debug(f"First item keys: {list(item.keys())}") + + # Extract the odds data directly from the item + extracted_data = { + "details": item.get("details"), + "over_under": item.get("overUnder"), + "spread": item.get("spread"), + "home_team_odds": { + "money_line": item.get("homeTeamOdds", {}).get("moneyLine"), + "spread_odds": item.get("homeTeamOdds", {}) + .get("current", {}) + .get("pointSpread", {}) + .get("value"), + }, + "away_team_odds": { + "money_line": item.get("awayTeamOdds", {}).get("moneyLine"), + "spread_odds": item.get("awayTeamOdds", {}) + .get("current", {}) + .get("pointSpread", {}) + .get("value"), + }, + } + self.logger.debug( + f"Returning extracted odds data: {json.dumps(extracted_data, indent=2)}" + ) + return extracted_data + + # Check if this is a valid empty response or an unexpected structure + if ( + "count" in data + and data["count"] == 0 + and "items" in data + and data["items"] == [] + ): + # This is a valid empty response - no odds available for this game + self.logger.debug("Valid empty response - no odds available for this game") + return None + + # Unexpected structure + self.logger.warning( + f"Unexpected odds data structure: {json.dumps(data, indent=2)}" + ) + return None + + def get_multiple_odds( + self, + sport: str, + league: str, + event_ids: List[str], + update_interval_seconds: Optional[int] = None, + ) -> Dict[str, Dict[str, Any]]: + """ + Fetch odds data for multiple games. + + Args: + sport: Sport name + league: League name + event_ids: List of ESPN event IDs + update_interval_seconds: Override default update interval + + Returns: + Dictionary mapping event_id to odds data + """ + results = {} + + for event_id in event_ids: + try: + odds_data = self.get_odds( + sport, league, event_id, update_interval_seconds + ) + if odds_data: + results[event_id] = odds_data + except Exception as e: + self.logger.error(f"Error fetching odds for event {event_id}: {e}") + continue + + return results + + def clear_cache(self, sport: Optional[str] = None, league: Optional[str] = None, event_id: Optional[str] = None): + """ + Clear odds cache for specific criteria. + + Args: + sport: Sport name (optional) + league: League name (optional) + event_id: Event ID (optional) + """ + if sport and league and event_id: + # Clear specific event + cache_key = f"odds_espn_{sport}_{league}_{event_id}" + self.cache_manager.clear_cache(cache_key) + self.logger.info(f"Cleared cache for {cache_key}") + else: + # Clear all odds cache + self.cache_manager.clear_cache() + self.logger.info("Cleared all cache") + diff --git a/plugins/afl-scoreboard/config_schema.json b/plugins/afl-scoreboard/config_schema.json new file mode 100644 index 00000000..04328f2b --- /dev/null +++ b/plugins/afl-scoreboard/config_schema.json @@ -0,0 +1,880 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AFL Scoreboard Plugin Configuration", + "description": "Configuration schema for the AFL (Australian Football League) Scoreboard plugin", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "title": "Enable Plugin", + "description": "Enable or disable the AFL scoreboard plugin" + }, + "favorite_teams": { + "type": "array", + "items": { + "type": "string", + "description": "AFL team name or abbreviation" + }, + "uniqueItems": true, + "maxItems": 20, + "description": "AFL team abbreviations to prioritize (e.g. 'COLL', 'GEEL', 'RICH'). Leave empty to show all teams.", + "default": [], + "title": "Favorite Teams" + }, + "exclude_teams": { + "type": "array", + "items": { + "type": "string", + "description": "ENG team name or abbreviation" + }, + "uniqueItems": true, + "maxItems": 20, + "default": [], + "description": "Team abbreviations to always hide from live rotation and recent/final scores for this league (spoiler protection). Takes precedence over Favorite Teams and Show All Live." + }, + "show_favorite_teams_only": { + "type": "boolean", + "default": true, + "description": "Only show games from favorite teams" + }, + "display_modes": { + "type": "object", + "title": "Display Modes", + "description": "Control which game types to show and how they are displayed", + "properties": { + "live": { + "type": "boolean", + "default": true, + "description": "Show live AFL games" + }, + "live_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for live games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "recent": { + "type": "boolean", + "default": true, + "description": "Show recently completed AFL games" + }, + "recent_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for recent games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + }, + "upcoming": { + "type": "boolean", + "default": true, + "description": "Show upcoming AFL games" + }, + "upcoming_display_mode": { + "type": "string", + "enum": [ + "switch", + "scroll" + ], + "default": "switch", + "description": "Display mode for upcoming games: 'switch' shows one game at a time, 'scroll' scrolls all games horizontally" + } + }, + "additionalProperties": false + }, + "scroll_settings": { + "type": "object", + "title": "Scroll Settings", + "description": "Settings for scroll display mode (when display mode is set to 'scroll')", + "properties": { + "scroll_speed": { + "type": "number", + "default": 1.0, + "minimum": 0.01, + "maximum": 200.0, + "description": "Scroll speed in pixels per second" + }, + "scroll_delay": { + "type": "number", + "default": 0.01, + "minimum": 0.001, + "maximum": 0.1, + "description": "Delay between scroll frames in seconds (lower = smoother)" + }, + "gap_between_games": { + "type": "integer", + "default": 48, + "minimum": 8, + "maximum": 128, + "description": "Gap in pixels between game cards when scrolling" + }, + "show_league_separators": { + "type": "boolean", + "default": true, + "description": "Show league icons between different leagues" + }, + "dynamic_duration": { + "type": "boolean", + "default": true, + "description": "Automatically calculate display duration based on content width" + }, + "game_card_width": { + "type": "integer", + "default": 128, + "minimum": 32, + "maximum": 512, + "description": "Width of each game card in scroll mode (pixels). Default 128. Set lower on multi-panel chains to show more games simultaneously." + } + } + }, + "live_priority": { + "type": "boolean", + "default": true, + "description": "Give live games priority over other modes. When enabled, live games will interrupt the normal mode rotation and be displayed immediately when available." + }, + "display_duration": { + "type": "number", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Duration in seconds to display each game" + }, + "game_display_duration": { + "type": "number", + "default": 15, + "minimum": 3, + "maximum": 60, + "description": "Duration in seconds to show each individual game before rotating to the next game within the same mode" + }, + "live_game_duration": { + "type": "integer", + "default": 20, + "minimum": 10, + "maximum": 120, + "description": "Duration in seconds to display each live game before rotating to the next. When a separate non-favorite duration is set, this applies to games with a favorite team; it applies to ALL live games when no favorite teams are configured." + }, + "non_favorite_live_game_duration": { + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 120, + "description": "Duration in seconds for live games that do NOT involve a favorite team. Only applies when favorite teams are set AND non-favorite live games are shown ('show_favorite_teams_only' off, or 'show_all_live' on). 0 (default) = use live_game_duration for every live game (no change)." + }, + "recent_game_duration": { + "type": "integer", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Duration in seconds to display each recent game before rotating to the next game" + }, + "upcoming_game_duration": { + "type": "integer", + "default": 15, + "minimum": 5, + "maximum": 60, + "description": "Duration in seconds to display each upcoming game before rotating to the next game" + }, + "update_interval_seconds": { + "type": "integer", + "default": 3600, + "minimum": 30, + "maximum": 86400, + "description": "How often to fetch new data (seconds)" + }, + "live_update_interval": { + "type": "integer", + "default": 30, + "minimum": 10, + "maximum": 300, + "description": "Update interval for live games (seconds)" + }, + "recent_update_interval": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for recent games (seconds)" + }, + "upcoming_update_interval": { + "type": "integer", + "default": 3600, + "minimum": 60, + "maximum": 86400, + "description": "Update interval for upcoming games (seconds)" + }, + "recent_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "show_records": { + "type": "boolean", + "default": false, + "description": "Show team records (wins-losses)" + }, + "show_ranking": { + "type": "boolean", + "default": false, + "description": "Show team rankings (when available)" + }, + "show_odds": { + "type": "boolean", + "default": true, + "description": "Show betting odds for games" + }, + "celebration_enabled": { + "type": "boolean", + "default": true, + "description": "Show a celebratory takeover screen when a favorite team scores or wins a live game" + }, + "celebration_duration": { + "type": "integer", + "default": 8, + "minimum": 3, + "maximum": 30, + "description": "How long the goal/win celebration stays on screen (seconds)" + }, + "celebrate_opponent_goals": { + "type": "boolean", + "default": false, + "description": "Also celebrate when the opponent scores, not just favorites (off by default)" + }, + "game_limits": { + "type": "object", + "title": "Game Limits", + "description": "Control how many games to show", + "properties": { + "recent_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + }, + "upcoming_games_to_show": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 20, + "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time." + } + } + }, + "display_options": { + "type": "object", + "title": "Display Options", + "description": "Additional information to show", + "properties": { + "show_records": { + "type": "boolean", + "default": false, + "description": "Show team records (wins-losses)" + }, + "show_ranking": { + "type": "boolean", + "default": false, + "description": "Show team rankings (when available)" + }, + "show_odds": { + "type": "boolean", + "default": true, + "description": "Show betting odds" + } + } + }, + "filtering": { + "type": "object", + "title": "Filtering Options", + "description": "Control which teams are shown", + "properties": { + "show_favorite_teams_only": { + "type": "boolean", + "default": true, + "description": "Only show games from favorite teams" + }, + "show_all_live": { + "type": "boolean", + "default": false, + "description": "Show all live games, not just favorites" + }, + "favorite_live_boost": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "default": 2, + "description": "How many turns your favorite team's live game gets in the rotation for every 1 turn other live games get. Your favorite's game is also always queued first whenever the live rotation refreshes. Set to 1 for even rotation." + } + } + }, + "dynamic_duration": { + "type": "object", + "title": "AFL Dynamic Duration Settings", + "description": "Configure dynamic duration settings for AFL games.", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for AFL games" + }, + "min_duration_seconds": { + "type": "number", + "minimum": 10, + "maximum": 300, + "default": 30, + "description": "Minimum total duration in seconds for this mode, even if few games are available" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for AFL games" + }, + "modes": { + "type": "object", + "title": "Per-Mode Settings for AFL", + "description": "Configure dynamic duration for specific AFL modes", + "properties": { + "live": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for AFL live games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for AFL live games" + } + } + }, + "recent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for AFL recent games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for AFL recent games" + } + } + }, + "upcoming": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Enable dynamic duration for AFL upcoming games" + }, + "max_duration_seconds": { + "type": "number", + "minimum": 60, + "maximum": 600, + "description": "Max duration for AFL upcoming games" + } + } + } + } + } + } + }, + "mode_durations": { + "type": "object", + "title": "Mode-Level Durations", + "description": "Control total duration for each mode type. If not set, uses dynamic calculation (total_games \u00d7 per_game_duration).", + "properties": { + "recent_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Recent mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "upcoming_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Upcoming mode before rotating to next mode. Default: null (uses dynamic calculation)." + }, + "live_mode_duration": { + "type": [ + "number", + "null" + ], + "default": null, + "minimum": 10, + "maximum": 600, + "description": "Total duration in seconds for Live mode before rotating to next mode. Default: null (uses dynamic calculation)." + } + } + }, + "background_service": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable background service for data fetching" + }, + "max_workers": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 10, + "description": "Maximum number of worker threads" + }, + "request_timeout": { + "type": "integer", + "default": 30, + "minimum": 5, + "maximum": 120, + "description": "Request timeout in seconds" + }, + "max_retries": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 10, + "description": "Maximum number of retries for failed requests" + }, + "priority": { + "type": "integer", + "default": 2, + "minimum": 1, + "maximum": 5, + "description": "Background service priority" + } + }, + "additionalProperties": false + } + }, + "customization": { + "type": "object", + "title": "Display Customization", + "description": "Customize fonts for different text elements on the scoreboard", + "properties": { + "score_text": { + "type": "object", + "title": "Game Score", + "description": "Font settings for the game score display", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use for scores", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "period_text": { + "type": "object", + "title": "Period/Clock", + "description": "Font settings for period, clock, and time remaining text", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "team_name": { + "type": "object", + "title": "Team Names", + "description": "Font settings for team name abbreviations", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 8 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status Messages", + "description": "Font settings for status text (e.g., 'Next Game', 'Final')", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "detail_text": { + "type": "object", + "title": "Details/Odds", + "description": "Font settings for odds and other detail information", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "4x6-font.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 6 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "rank_text": { + "type": "object", + "title": "Rankings", + "description": "Font settings for ranking displays", + "properties": { + "font": { + "type": "string", + "title": "Font Family", + "description": "Select the font to use", + "enum": [ + "PressStart2P-Regular.ttf", + "4x6-font.ttf", + "5by7.regular.ttf", + "5x7.bdf", + "4x6.bdf", + "cozette.bdf" + ], + "default": "PressStart2P-Regular.ttf" + }, + "font_size": { + "type": "integer", + "title": "Font Size", + "description": "Font size in pixels", + "minimum": 4, + "maximum": 16, + "default": 10 + } + }, + "x-propertyOrder": [ + "font", + "font_size" + ], + "additionalProperties": false + }, + "layout": { + "type": "object", + "title": "Layout Positioning", + "description": "Adjust X,Y coordinate offsets for elements. Values are relative to default positions. Use negative values to move left/up, positive to move right/down.", + "properties": { + "home_logo": { + "type": "object", + "title": "Home Team Logo", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "away_logo": { + "type": "object", + "title": "Away Team Logo", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from default position (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "score": { + "type": "object", + "title": "Game Score", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from center (default: 0)" + } + }, + "additionalProperties": false + }, + "status_text": { + "type": "object", + "title": "Status/Period Text", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from top (default: 0)" + } + }, + "additionalProperties": false + }, + "date": { + "type": "object", + "title": "Game Date", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from default position (default: 0)" + } + }, + "additionalProperties": false + }, + "time": { + "type": "object", + "title": "Game Time", + "properties": { + "x_offset": { + "type": "integer", + "default": 0, + "description": "Horizontal offset from center (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from date position (default: 0)" + } + }, + "additionalProperties": false + }, + "records": { + "type": "object", + "title": "Records/Rankings", + "properties": { + "away_x_offset": { + "type": "integer", + "default": 0, + "description": "Away team record horizontal offset from left (default: 0)" + }, + "home_x_offset": { + "type": "integer", + "default": 0, + "description": "Home team record horizontal offset from right (default: 0)" + }, + "y_offset": { + "type": "integer", + "default": 0, + "description": "Vertical offset from bottom (default: 0)" + } + }, + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "home_logo", + "away_logo", + "score", + "status_text", + "date", + "time", + "records" + ], + "additionalProperties": false + } + }, + "x-propertyOrder": [ + "score_text", + "period_text", + "team_name", + "status_text", + "detail_text", + "rank_text", + "layout" + ], + "additionalProperties": false + }, + "additionalProperties": false, + "required": [ + "enabled" + ], + "x-propertyOrder": [ + "enabled", + "favorite_teams", + "exclude_teams", + "show_favorite_teams_only", + "display_modes", + "scroll_settings", + "live_priority", + "display_duration", + "game_display_duration", + "live_game_duration", + "non_favorite_live_game_duration", + "recent_game_duration", + "upcoming_game_duration", + "update_interval_seconds", + "live_update_interval", + "recent_update_interval", + "upcoming_update_interval", + "recent_games_to_show", + "upcoming_games_to_show", + "show_records", + "show_ranking", + "show_odds", + "celebration_enabled", + "celebration_duration", + "celebrate_opponent_goals", + "game_limits", + "display_options", + "filtering", + "dynamic_duration", + "mode_durations", + "background_service" + ] +} \ No newline at end of file diff --git a/plugins/afl-scoreboard/data_sources.py b/plugins/afl-scoreboard/data_sources.py new file mode 100644 index 00000000..0dc2b4ac --- /dev/null +++ b/plugins/afl-scoreboard/data_sources.py @@ -0,0 +1,324 @@ +""" +Pluggable Data Source Architecture + +This module provides abstract data sources that can be plugged into the sports system +to support different APIs and data providers. +""" + +from abc import ABC, abstractmethod +from typing import Dict, List +import requests +import logging +from datetime import datetime + +class DataSource(ABC): + """Abstract base class for data sources.""" + + def __init__(self, logger: logging.Logger): + self.logger = logger + self.session = requests.Session() + + # Configure retry strategy + from requests.adapters import HTTPAdapter + from urllib3.util.retry import Retry + + retry_strategy = Retry( + total=5, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + + @abstractmethod + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games for a sport/league.""" + + @abstractmethod + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule for a sport/league within date range.""" + + @abstractmethod + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings for a sport/league.""" + + def get_headers(self) -> Dict[str, str]: + """Get headers for API requests.""" + return { + 'User-Agent': 'LEDMatrix/1.0', + 'Accept': 'application/json' + } + + +class ESPNDataSource(DataSource): + """ESPN API data source.""" + + def __init__(self, logger: logging.Logger): + super().__init__(logger) + self.base_url = "https://site.api.espn.com/apis/site/v2/sports" + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from ESPN API.""" + try: + now = datetime.now() + formatted_date = now.strftime("%Y%m%d") + url = f"{self.base_url}/{sport}/{league}/scoreboard" + response = self.session.get(url, params={"dates": formatted_date, "limit": 1000}, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + events = data.get('events', []) + + # Filter for live games + live_events = [event for event in events + if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in'] + + self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}") + return live_events + + except Exception as e: + self.logger.error(f"Error fetching live games from ESPN: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from ESPN API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/{sport}/{league}/scoreboard" + + params = { + 'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}", + "limit": 1000 + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + events = data.get('events', []) + + self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}") + return events + + except Exception as e: + self.logger.error(f"Error fetching schedule from ESPN: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from ESPN API.""" + # College sports use rankings endpoint, professional leagues use standings + college_leagues = [ + "mens-college-basketball", + "womens-college-basketball", + "college-football", + ] + + # For college sports, use rankings endpoint directly + if league in college_leagues: + try: + url = f"{self.base_url}/{sport}/{league}/rankings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched rankings for {sport}/{league}") + return data + except Exception as e: + self.logger.debug(f"Error fetching rankings from ESPN for {sport}/{league}: {e}") + return {} + + # For professional leagues, try standings endpoint first + try: + url = f"{self.base_url}/{sport}/{league}/standings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched standings for {sport}/{league}") + return data + except Exception as e: + # If standings doesn't exist, try rankings as fallback + if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 404: + try: + url = f"{self.base_url}/{sport}/{league}/rankings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug(f"Fetched rankings for {sport}/{league} (fallback)") + return data + except Exception: + # Both endpoints failed - standings/rankings may not be available for this sport/league + self.logger.debug(f"Standings/rankings not available for {sport}/{league} from ESPN API") + return {} + else: + # Non-404 error - log at debug level since standings are optional + self.logger.debug(f"Error fetching standings from ESPN for {sport}/{league}: {e}") + return {} + + +class MLBAPIDataSource(DataSource): + """MLB API data source.""" + + def __init__(self, logger: logging.Logger): + super().__init__(logger) + self.base_url = "https://statsapi.mlb.com/api/v1" + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from MLB API.""" + try: + url = f"{self.base_url}/schedule" + params = { + 'sportId': 1, # MLB + 'date': datetime.now().strftime('%Y-%m-%d'), + 'hydrate': 'game,team,venue,weather' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + games = data.get('dates', [{}])[0].get('games', []) + + # Filter for live games + live_games = [game for game in games + if game.get('status', {}).get('abstractGameState') == 'Live'] + + self.logger.debug(f"Fetched {len(live_games)} live games from MLB API") + return live_games + + except Exception as e: + self.logger.error(f"Error fetching live games from MLB API: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from MLB API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/schedule" + + params = { + 'sportId': 1, # MLB + 'startDate': start_date.strftime('%Y-%m-%d'), + 'endDate': end_date.strftime('%Y-%m-%d'), + 'hydrate': 'game,team,venue' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + all_games = [] + for date_data in data.get('dates', []): + all_games.extend(date_data.get('games', [])) + + self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API") + return all_games + + except Exception as e: + self.logger.error(f"Error fetching schedule from MLB API: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from MLB API.""" + try: + url = f"{self.base_url}/standings" + params = { + 'leagueId': 103, # American League + 'season': datetime.now().year, + 'standingsType': 'regularSeason' + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug("Fetched standings from MLB API") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from MLB API: {e}") + return {} + + +class AFLAPIDataSource(DataSource): + """AFL API data source (generic structure).""" + + def __init__(self, logger: logging.Logger, api_key: str = None): + super().__init__(logger) + self.api_key = api_key + self.base_url = "https://api.football-data.org/v4" # Example API + + def get_headers(self) -> Dict[str, str]: + """Get headers with API key for afl API.""" + headers = super().get_headers() + if self.api_key: + headers['X-Auth-Token'] = self.api_key + return headers + + def fetch_live_games(self, sport: str, league: str) -> List[Dict]: + """Fetch live games from afl API.""" + try: + # This would need to be adapted based on the specific afl API + url = f"{self.base_url}/matches" + params = { + 'status': 'LIVE', + 'competition': league + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + matches = data.get('matches', []) + + self.logger.debug(f"Fetched {len(matches)} live games from afl API") + return matches + + except Exception as e: + self.logger.error(f"Error fetching live games from afl API: {e}") + return [] + + def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]: + """Fetch schedule from afl API.""" + try: + start_date, end_date = date_range + url = f"{self.base_url}/matches" + + params = { + 'competition': league, + 'dateFrom': start_date.strftime('%Y-%m-%d'), + 'dateTo': end_date.strftime('%Y-%m-%d') + } + + response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15) + response.raise_for_status() + + data = response.json() + matches = data.get('matches', []) + + self.logger.debug(f"Fetched {len(matches)} scheduled games from afl API") + return matches + + except Exception as e: + self.logger.error(f"Error fetching schedule from afl API: {e}") + return [] + + def fetch_standings(self, sport: str, league: str) -> Dict: + """Fetch standings from afl API.""" + try: + url = f"{self.base_url}/competitions/{league}/standings" + response = self.session.get(url, headers=self.get_headers(), timeout=15) + response.raise_for_status() + + data = response.json() + self.logger.debug("Fetched standings from afl API") + return data + + except Exception as e: + self.logger.error(f"Error fetching standings from afl API: {e}") + return {} + + +# Factory function removed - sport classes now instantiate data sources directly diff --git a/plugins/afl-scoreboard/dynamic_team_resolver.py b/plugins/afl-scoreboard/dynamic_team_resolver.py new file mode 100644 index 00000000..e79cd646 --- /dev/null +++ b/plugins/afl-scoreboard/dynamic_team_resolver.py @@ -0,0 +1,63 @@ +""" +Simplified DynamicTeamResolver for afl plugin use +""" + +import logging +import time +from typing import Dict, List + +logger = logging.getLogger(__name__) + +class DynamicTeamResolver: + """ + Simplified resolver for dynamic team names to actual team abbreviations. + + This class handles special team names that represent dynamic groups + for afl leagues. + """ + + # Cache for rankings data + _rankings_cache: Dict[str, List[str]] = {} + _cache_timestamp: float = 0 + _cache_duration: int = 3600 # 1 hour cache + + def __init__(self, request_timeout: int = 30): + """Initialize the dynamic team resolver.""" + self.request_timeout = request_timeout + self.logger = logger + + def resolve_teams(self, team_list: List[str], sport: str = 'afl') -> List[str]: + """ + Resolve a list of team names, expanding dynamic team names. + + Args: + team_list: List of team names + sport: Sport type for context (default: 'afl') + + Returns: + List of resolved team abbreviations + """ + if not team_list: + return [] + + resolved_teams = [] + + for team in team_list: + # For afl, we just pass through team names as-is + # No dynamic patterns like AP_TOP_25 for afl + resolved_teams.append(team) + + # Remove duplicates while preserving order + seen = set() + unique_teams = [] + for team in resolved_teams: + if team not in seen: + seen.add(team) + unique_teams.append(team) + + return unique_teams + + def _is_cache_valid(self) -> bool: + """Check if the rankings cache is still valid.""" + return time.time() - self._cache_timestamp < self._cache_duration + diff --git a/plugins/afl-scoreboard/game_renderer.py b/plugins/afl-scoreboard/game_renderer.py new file mode 100644 index 00000000..cfc9edf4 --- /dev/null +++ b/plugins/afl-scoreboard/game_renderer.py @@ -0,0 +1,550 @@ +""" +Game Renderer for AFL Scoreboard Plugin + +Extracts game rendering logic into a reusable component that can be used by both +switch mode (one game at a time) and scroll mode (all games scrolling horizontally). + +This module provides: +- GameRenderer class for rendering individual game cards as PIL Images +- Pre-loading of team logos for performance +- Support for live, recent, and upcoming game layouts +- Consistent rendering across all display modes +""" + +import logging +import os +from pathlib import Path +from typing import Dict, Any, Optional, Tuple +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + + +class GameRenderer: + """ + Renders individual game cards as PIL Images for display. + + This class extracts the rendering logic from the sports manager classes + to provide a reusable component for both switch and scroll display modes. + """ + + def __init__( + self, + display_width: int, + display_height: int, + config: Dict[str, Any], + logo_cache: Optional[Dict[str, Image.Image]] = None, + custom_logger: Optional[logging.Logger] = None + ): + """ + Initialize the GameRenderer. + + Args: + display_width: Width of the display/game card + display_height: Height of the display/game card + config: Configuration dictionary + logo_cache: Optional shared logo cache dictionary + custom_logger: Optional custom logger instance + """ + self.display_width = display_width + self.display_height = display_height + self.config = config + self.logger = custom_logger or logger + + # Shared logo cache for performance + self._logo_cache = logo_cache if logo_cache is not None else {} + + # Load fonts + self.fonts = self._load_fonts() + + # Display options + self.show_odds = config.get("show_odds", False) + self.show_records = config.get("show_records", False) + self.show_ranking = config.get("show_ranking", False) + + # Rankings cache (populated externally) + self._team_rankings_cache: Dict[str, int] = {} + + def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: + """Load fonts used by the scoreboard from config or use defaults.""" + fonts = {} + + # Get customization config + customization = self.config.get('customization', {}) + + # Load fonts from config with defaults for backward compatibility + score_config = customization.get('score_text', {}) + period_config = customization.get('period_text', {}) + team_config = customization.get('team_name', {}) + status_config = customization.get('status_text', {}) + detail_config = customization.get('detail_text', {}) + rank_config = customization.get('rank_text', {}) + + try: + fonts["score"] = self._load_custom_font(score_config, default_size=10) + fonts["time"] = self._load_custom_font(period_config, default_size=8) + fonts["team"] = self._load_custom_font(team_config, default_size=8) + fonts["status"] = self._load_custom_font(status_config, default_size=6) + fonts["detail"] = self._load_custom_font(detail_config, default_size=6, default_font='4x6.ttf') + fonts["rank"] = self._load_custom_font(rank_config, default_size=10) + self.logger.debug("Successfully loaded fonts from config") + except Exception as e: + self.logger.error(f"Error loading fonts: {e}, using defaults") + # Fallback to hardcoded defaults + try: + fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + fonts["team"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + fonts["status"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["rank"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + except IOError: + self.logger.warning("Fonts not found, using default PIL font.") + default_font = ImageFont.load_default() + fonts = {k: default_font for k in ["score", "time", "team", "status", "detail", "rank"]} + + return fonts + + def _load_custom_font(self, element_config: Dict[str, Any], default_size: int = 8, default_font: str = 'PressStart2P-Regular.ttf') -> ImageFont.FreeTypeFont: + """Load a custom font from an element configuration dictionary.""" + font_name = element_config.get('font', default_font) + font_size = int(element_config.get('font_size', default_size)) + font_path = os.path.join('assets', 'fonts', font_name) + + try: + if os.path.exists(font_path): + if font_path.lower().endswith('.ttf'): + return ImageFont.truetype(font_path, font_size) + elif font_path.lower().endswith('.bdf'): + try: + return ImageFont.truetype(font_path, font_size) + except Exception: + self.logger.warning(f"Could not load BDF font {font_name}, using default") + except Exception as e: + self.logger.error(f"Error loading font {font_name}: {e}") + + # Fallback to default font + default_font_path = os.path.join('assets', 'fonts', default_font) + try: + if os.path.exists(default_font_path): + return ImageFont.truetype(default_font_path, font_size) + except Exception: + pass + + return ImageFont.load_default() + + def set_rankings_cache(self, rankings: Dict[str, int]) -> None: + """Set the team rankings cache for display.""" + self._team_rankings_cache = rankings + + def preload_logos(self, games: list, logo_dir: Path) -> None: + """ + Pre-load team logos for all games to improve scroll performance. + + Args: + games: List of game dictionaries + logo_dir: Path to logo directory + """ + for game in games: + for team_key in ['home_abbr', 'away_abbr']: + abbr = game.get(team_key, '') + if abbr and abbr not in self._logo_cache: + logo_path = game.get(f'{team_key.replace("abbr", "logo_path")}') + if logo_path: + logo = self._load_and_resize_logo( + game.get(team_key.replace('abbr', 'id'), ''), + abbr, + logo_path, + game.get(f'{team_key.replace("abbr", "logo_url")}') + ) + if logo: + self._logo_cache[abbr] = logo + + self.logger.debug(f"Preloaded {len(self._logo_cache)} team logos") + + def _load_and_resize_logo( + self, + team_id: str, + team_abbrev: str, + logo_path: Path, + logo_url: Optional[str] = None + ) -> Optional[Image.Image]: + """Load and resize a team logo with caching.""" + if team_abbrev in self._logo_cache: + return self._logo_cache[team_abbrev] + + try: + # Try to load from path + if os.path.exists(logo_path): + logo = Image.open(logo_path) + if logo.mode != "RGBA": + logo = logo.convert("RGBA") + + # Crop transparent padding then scale so ink fills display_height. + # thumbnail into a display_height square box preserves aspect ratio + # and prevents wide logos from exceeding their half-card slot. + bbox = logo.getbbox() + if bbox: + logo = logo.crop(bbox) + logo.thumbnail((self.display_height, self.display_height), Image.Resampling.LANCZOS) + + self._logo_cache[team_abbrev] = logo + return logo + else: + self.logger.debug(f"Logo not found at {logo_path}") + return None + + except Exception as e: + self.logger.error(f"Error loading logo for {team_abbrev}: {e}") + return None + + def _resize_logo_to_fit( + self, + logo: Image.Image, + max_width: int, + max_height: int + ) -> Image.Image: + """ + Resize a logo to fit within given dimensions while maintaining aspect ratio. + + Args: + logo: PIL Image of the logo + max_width: Maximum width in pixels + max_height: Maximum height in pixels + + Returns: + Resized logo image + """ + if logo.width <= max_width and logo.height <= max_height: + return logo + + # Create a copy to avoid modifying the cached version + resized_logo = logo.copy() + resized_logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + return resized_logo + + def _calculate_max_logo_dimensions( + self, + score_width: int, + side: str + ) -> Tuple[int, int]: + """ + Calculate maximum logo dimensions based on available space. + + Args: + score_width: Width of the score text in pixels + side: 'home' or 'away' to determine which side of the display + + Returns: + Tuple of (max_width, max_height) in pixels + """ + # Padding around score text and edges + score_padding = 8 # Space between logo and score text + edge_padding = 10 # Space from display edges + + # Calculate available width for each logo + center_x = self.display_width // 2 + score_left = center_x - (score_width // 2) + score_right = center_x + (score_width // 2) + + if side == 'away': + # Away logo on the left side + available_width = score_left - score_padding - edge_padding + else: # home + # Home logo on the right side + available_width = self.display_width - score_right - score_padding - edge_padding + + # Ensure minimum width (at least 20% of display width) + min_width = int(self.display_width * 0.2) + available_width = max(available_width, min_width) + + # Max height is slightly less than display height to leave room for status text + max_height = int(self.display_height * 0.85) + + return (available_width, max_height) + + def _draw_text_with_outline( + self, + draw: ImageDraw.Draw, + text: str, + position: Tuple[int, int], + font: ImageFont.FreeTypeFont, + fill: Tuple[int, int, int] = (255, 255, 255), + outline_color: Tuple[int, int, int] = (0, 0, 0) + ) -> None: + """Draw text with a black outline for better readability.""" + x, y = position + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + def render_game_card( + self, + game: Dict[str, Any], + game_type: str = "live" + ) -> Image.Image: + """ + Render a single game card as a PIL Image. + + Args: + game: Game dictionary with team info, scores, status, etc. + game_type: Type of game - 'live', 'recent', or 'upcoming' + + Returns: + PIL Image of the rendered game card + """ + # Create base image + main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) + overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) + draw_overlay = ImageDraw.Draw(overlay) + + # Calculate score text width first to determine available space for logos + home_score = str(game.get("home_score", "0")) + away_score = str(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + score_width = draw_overlay.textlength(score_text, font=self.fonts['score']) + + # Load logos + home_logo = self._load_and_resize_logo( + game.get("home_id", ""), + game.get("home_abbr", ""), + game.get("home_logo_path"), + game.get("home_logo_url") + ) + away_logo = self._load_and_resize_logo( + game.get("away_id", ""), + game.get("away_abbr", ""), + game.get("away_logo_path"), + game.get("away_logo_url") + ) + + if not home_logo or not away_logo: + # Draw placeholder text if logos fail + draw = ImageDraw.Draw(main_img) + self._draw_text_with_outline( + draw, + f"{game.get('away_abbr', '?')}@{game.get('home_abbr', '?')}", + (5, 5), + self.fonts['status'] + ) + return main_img.convert('RGB') + + center_y = self.display_height // 2 + + # Place logos — each centered within a slot on its side; cap at half the card + # width so home_slot_start stays non-negative on square/tall displays + logo_slot = min(self.display_height, self.display_width // 2) + away_x = (logo_slot - away_logo.width) // 2 + away_y = center_y - (away_logo.height // 2) + + home_slot_start = self.display_width - logo_slot + home_x = home_slot_start + (logo_slot - home_logo.width) // 2 + home_y = center_y - (home_logo.height // 2) + + # Draw logos + main_img.paste(home_logo, (home_x, home_y), home_logo) + main_img.paste(away_logo, (away_x, away_y), away_logo) + + # Draw scores (centered) + score_x = (self.display_width - score_width) // 2 + score_y = (self.display_height // 2) - 3 + self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score']) + + # Draw period/status based on game type + if game_type == "live": + self._draw_live_game_status(draw_overlay, game) + elif game_type == "recent": + self._draw_recent_game_status(draw_overlay, game) + elif game_type == "upcoming": + self._draw_upcoming_game_status(draw_overlay, game) + + # Draw odds if enabled + if self.show_odds and 'odds' in game and game['odds']: + self._draw_dynamic_odds(draw_overlay, game['odds']) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + self._draw_records_or_rankings(draw_overlay, game) + + # Composite the overlay onto main image + main_img = Image.alpha_composite(main_img, overlay) + return main_img.convert('RGB') + + def _draw_live_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw status elements for a live afl game.""" + # Period/Clock (Top center) - e.g., "1H 45'", "HALF", "2H 90+3'" + period_clock_text = game.get('period_text', '') + if not period_clock_text: + # Fallback to clock if period_text not available + clock = game.get('clock', '') + if clock: + period_clock_text = clock + else: + period_clock_text = "LIVE" + + # Handle halftime + if game.get("is_halftime"): + period_clock_text = "HALF" + elif game.get("is_period_break"): + period_clock_text = game.get("status_text", "BREAK") + + status_width = draw.textlength(period_clock_text, font=self.fonts['time']) + status_x = (self.display_width - status_width) // 2 + status_y = 1 + self._draw_text_with_outline(draw, period_clock_text, (status_x, status_y), self.fonts['time']) + + # No bottom date line for live games: the period/clock already conveys the + # game is in progress, and a third stacked line overflows the bottom on + # short panels (e.g. 128x32). Matches the baseball live scorebug. + + def _draw_recent_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw status elements for a recently completed afl game.""" + # Final status (Top center) - e.g., "Final", "Final/OT" + period_text = game.get("period_text", "Final") + if not period_text: + period_text = "Final" + status_width = draw.textlength(period_text, font=self.fonts['time']) + status_x = (self.display_width - status_width) // 2 + status_y = 1 + self._draw_text_with_outline(draw, period_text, (status_x, status_y), self.fonts['time']) + + # Game date (Bottom center) + game_date = game.get("game_date", "") + if game_date: + date_width = draw.textlength(game_date, font=self.fonts['detail']) + date_x = (self.display_width - date_width) // 2 + date_y = self.display_height - 7 + self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) + + def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw status elements for an upcoming afl game.""" + # Game time (Top center) + game_time = game.get("game_time", "") + if game_time: + time_width = draw.textlength(game_time, font=self.fonts['time']) + time_x = (self.display_width - time_width) // 2 + time_y = 1 + self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) + + # Game date (Bottom center) + game_date = game.get("game_date", "") + if game_date: + date_width = draw.textlength(game_date, font=self.fonts['detail']) + date_x = (self.display_width - date_width) // 2 + date_y = self.display_height - 7 + self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['detail']) + + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any]) -> None: + """Draw odds with dynamic positioning.""" + try: + if not odds: + return + + home_team_odds = odds.get("home_team_odds", {}) + away_team_odds = odds.get("away_team_odds", {}) + home_spread = home_team_odds.get("spread_odds") + away_spread = away_team_odds.get("spread_odds") + + # Get top-level spread as fallback + top_level_spread = odds.get("spread") + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine favored team + home_favored = home_spread is not None and isinstance(home_spread, (int, float)) and home_spread < 0 + away_favored = away_spread is not None and isinstance(away_spread, (int, float)) and away_spread < 0 + + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = "home" + elif away_favored: + favored_spread = away_spread + favored_side = "away" + + # Show the negative spread + if favored_spread is not None: + spread_text = str(favored_spread) + font = self.fonts["detail"] + + if favored_side == "home": + spread_width = draw.textlength(spread_text, font=font) + spread_x = self.display_width - spread_width + spread_y = 0 + else: + spread_x = 0 + spread_y = 0 + + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + + # Show over/under on opposite side + over_under = odds.get("over_under") + if over_under is not None and isinstance(over_under, (int, float)): + ou_text = f"O/U: {over_under}" + font = self.fonts["detail"] + ou_width = draw.textlength(ou_text, font=font) + + if favored_side == "home": + ou_x = 0 + elif favored_side == "away": + ou_x = self.display_width - ou_width + else: + ou_x = (self.display_width - ou_width) // 2 + ou_y = 0 + + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + + except Exception as e: + self.logger.error(f"Error drawing odds: {e}") + + def _draw_records_or_rankings(self, draw: ImageDraw.Draw, game: Dict) -> None: + """Draw team records or rankings.""" + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + except IOError: + record_font = ImageFont.load_default() + + away_abbr = game.get('away_abbr', '') + home_abbr = game.get('home_abbr', '') + + record_bbox = draw.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height - 4 + + # Away team info + if away_abbr: + away_text = self._get_team_display_text(away_abbr, game.get('away_record', '')) + if away_text: + away_record_x = 3 + self._draw_text_with_outline(draw, away_text, (away_record_x, record_y), record_font) + + # Home team info + if home_abbr: + home_text = self._get_team_display_text(home_abbr, game.get('home_record', '')) + if home_text: + home_record_bbox = draw.textbbox((0, 0), home_text, font=record_font) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = self.display_width - home_record_width - 3 + self._draw_text_with_outline(draw, home_text, (home_record_x, record_y), record_font) + + def _get_team_display_text(self, abbr: str, record: str) -> str: + """Get the display text for a team (ranking or record).""" + if self.show_ranking and self.show_records: + # Rankings replace records when both are enabled + rank = self._team_rankings_cache.get(abbr, 0) + if rank > 0: + return f"#{rank}" + return '' + elif self.show_ranking: + rank = self._team_rankings_cache.get(abbr, 0) + if rank > 0: + return f"#{rank}" + return '' + elif self.show_records: + return record + return '' diff --git a/plugins/afl-scoreboard/manager.py b/plugins/afl-scoreboard/manager.py new file mode 100644 index 00000000..00b2ce18 --- /dev/null +++ b/plugins/afl-scoreboard/manager.py @@ -0,0 +1,961 @@ +""" +AFL Scoreboard Plugin for LEDMatrix + +Displays live, recent, and upcoming AFL (Australian Football League) games with +real-time scores and game status. + +Display Modes: +- Switch Mode: Display one game at a time with timed transitions +- Scroll Mode: High-FPS horizontal scrolling of all games + +AFL is a single-league sport (ESPN sport ``australian-football`` / league +``afl``), so this plugin is a flattened, single-league fork of the multi-league +soccer scoreboard: the three display modes are ``afl_live``, ``afl_recent`` and +``afl_upcoming``. +""" + +import logging +import time +import threading +from typing import Dict, Any, Set, Optional, List + +try: + from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode + from src.background_data_service import get_background_service + from base_odds_manager import BaseOddsManager +except ImportError: + BasePlugin = None + VegasDisplayMode = None + get_background_service = None + BaseOddsManager = None + +# Import scroll display components +try: + from scroll_display import ScrollDisplayManager + SCROLL_AVAILABLE = True +except ImportError: + ScrollDisplayManager = None + SCROLL_AVAILABLE = False + +# Import the manager factory +from afl_managers import create_afl_managers + +logger = logging.getLogger(__name__) + +# The single AFL "league" key and its display name. +AFL_LEAGUE_KEY = "afl" +AFL_LEAGUE_NAME = "AFL" + +# The three display modes exposed by this plugin (also declared in manifest.json). +MODE_TYPES = ("live", "recent", "upcoming") +DISPLAY_MODES = tuple(f"afl_{m}" for m in MODE_TYPES) # afl_live, afl_recent, afl_upcoming + + +def _mode_type_of(display_mode: str) -> Optional[str]: + """Return 'live'/'recent'/'upcoming' for an afl_* display mode, else None.""" + if not display_mode: + return None + if display_mode.endswith("_live"): + return "live" + if display_mode.endswith("_recent"): + return "recent" + if display_mode.endswith("_upcoming"): + return "upcoming" + return None + + +class AflScoreboardPlugin(BasePlugin if BasePlugin else object): + """ + AFL scoreboard plugin using manager classes. + + Delegates ESPN fetch + parsing to the AFL Live/Recent/Upcoming managers and + handles mode cycling, dynamic duration, scroll vs switch display, live + priority/celebration, and Vegas scroll integration. + """ + + def __init__( + self, + plugin_id: str, + config: Dict[str, Any], + display_manager, + cache_manager, + plugin_manager, + ): + """Initialize the AFL scoreboard plugin.""" + if BasePlugin: + super().__init__( + plugin_id, config, display_manager, cache_manager, plugin_manager + ) + + self.plugin_id = plugin_id + self.config = config + self.display_manager = display_manager + self.cache_manager = cache_manager + self.plugin_manager = plugin_manager + + self.logger = logger + + # Basic configuration + self.is_enabled = config.get("enabled", True) + + # Get display dimensions from display_manager properties + 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) + + self.logger.debug(f"AFL plugin received config keys: {list(config.keys())}") + + # Global settings + self.display_duration = float(config.get("display_duration", 30)) + self.game_display_duration = float(config.get("game_display_duration", 15)) + self.live_priority = bool(config.get("live_priority", False)) + + # Initialize background service if available + self.background_service = None + if get_background_service: + try: + self.background_service = get_background_service( + self.cache_manager, max_workers=1 + ) + self.logger.info("Background service initialized") + except Exception as e: + self.logger.warning(f"Could not initialize background service: {e}") + + # Managers keyed by mode type: {'live': mgr, 'recent': mgr, 'upcoming': mgr} + self._managers: Dict[str, Any] = {} + self._initialize_managers() + + # Per-mode display method ('switch' or 'scroll') + self._display_mode_settings = self._parse_display_mode_settings() + + # Initialize scroll display manager if available + self._scroll_manager: Optional[ScrollDisplayManager] = None + if SCROLL_AVAILABLE and ScrollDisplayManager: + try: + self._scroll_manager = ScrollDisplayManager( + self.display_manager, + self.config, + self.logger + ) + self.logger.info("Scroll display manager initialized") + except Exception as e: + self.logger.warning(f"Could not initialize scroll display manager: {e}") + self._scroll_manager = None + else: + self.logger.debug("Scroll mode not available - ScrollDisplayManager not imported") + + # Track current scroll state + self._scroll_active: Dict[str, bool] = {} # {scroll_key: is_active} + self._scroll_prepared: Dict[str, bool] = {} # {scroll_key: is_prepared} + + # Track active update threads to prevent accumulation of stale threads + self._active_update_threads: Dict[str, threading.Thread] = {} + + # Lock to protect shared mutable state during config reload + self._config_lock = threading.Lock() + + # Enable high-FPS mode only when a mode actually uses scroll display. + self.enable_scrolling = self._has_any_scroll_mode() + if self.enable_scrolling: + self.logger.info("High-FPS scrolling enabled for AFL scoreboard") + + # Mode cycling + self.current_mode_index = 0 + self.last_mode_switch = 0 + self.modes = self._get_available_modes() + + self.logger.info( + f"AFL scoreboard plugin initialized - {self.display_width}x{self.display_height}, " + f"modes: {self.modes}" + ) + + # Dynamic duration tracking + self._dynamic_cycle_seen_modes: Set[str] = set() + self._dynamic_mode_to_manager_key: Dict[str, str] = {} + self._dynamic_manager_progress: Dict[str, Set[str]] = {} + self._dynamic_managers_completed: Set[str] = set() + self._dynamic_cycle_complete = False + + # Current display context for granular dynamic duration + self._current_display_league: Optional[str] = AFL_LEAGUE_KEY + self._current_display_mode_type: Optional[str] = None + + # ------------------------------------------------------------------ + # Manager initialization / config adaptation + # ------------------------------------------------------------------ + def _initialize_managers(self) -> None: + """Create the AFL Live/Recent/Upcoming managers.""" + try: + manager_config = self._adapt_config_for_manager() + live, recent, upcoming = create_afl_managers( + manager_config, self.display_manager, self.cache_manager + ) + self._managers = { + "live": live, + "recent": recent, + "upcoming": upcoming, + } + self.logger.info("AFL managers initialized") + except Exception as e: + self.logger.error(f"Error initializing managers: {e}", exc_info=True) + self._managers = {} + + def _adapt_config_for_manager(self) -> Dict[str, Any]: + """ + Adapt the flat plugin config to the manager-expected structure. + + The managers read their settings from ``config['afl_scoreboard']`` (see + ``sports.py`` / ``afl_managers.py``); this maps the flat top-level plugin + config into that nested shape. + """ + cfg = self.config + + display_modes_config = cfg.get("display_modes", {}) + manager_display_modes = { + "afl_live": display_modes_config.get("live", True), + "afl_recent": display_modes_config.get("recent", True), + "afl_upcoming": display_modes_config.get("upcoming", True), + } + + filtering = cfg.get("filtering", {}) + + manager_config = { + "afl_scoreboard": { + "enabled": cfg.get("enabled", True), + "favorite_teams": cfg.get("favorite_teams", []), + "exclude_teams": cfg.get("exclude_teams", []), + "display_modes": manager_display_modes, + "recent_games_to_show": cfg.get("recent_games_to_show", 5), + "upcoming_games_to_show": cfg.get("upcoming_games_to_show", 10), + "show_records": cfg.get("show_records", False), + "show_ranking": cfg.get("show_rankings", cfg.get("show_ranking", False)), + "show_odds": cfg.get("show_odds", False), + "update_interval_seconds": cfg.get("update_interval_seconds", 300), + "live_update_interval": cfg.get("live_update_interval", 30), + "live_game_duration": cfg.get("live_game_duration", 20), + "non_favorite_live_game_duration": cfg.get("non_favorite_live_game_duration", 0), + "recent_game_duration": cfg.get("recent_game_duration", 15), + "upcoming_game_duration": cfg.get("upcoming_game_duration", 15), + "live_priority": cfg.get("live_priority", False), + "show_favorite_teams_only": filtering.get( + "show_favorite_teams_only", + cfg.get("show_favorite_teams_only", False), + ), + "show_all_live": filtering.get( + "show_all_live", cfg.get("show_all_live", False) + ), + "favorite_live_boost": filtering.get( + "favorite_live_boost", cfg.get("favorite_live_boost", 2) + ), + "filtering": filtering if filtering else { + "show_favorite_teams_only": cfg.get("show_favorite_teams_only", False), + "show_all_live": cfg.get("show_all_live", False), + }, + "background_service": cfg.get("background_service", { + "request_timeout": 30, + "max_retries": 3, + "priority": 2, + }), + } + } + + # Global config - timezone + timezone_str = cfg.get("timezone") + if not timezone_str and hasattr(self.cache_manager, 'config_manager'): + timezone_str = self.cache_manager.config_manager.get_timezone() + if not timezone_str: + timezone_str = "UTC" + + display_config = cfg.get("display", {}) + if not display_config and hasattr(self.cache_manager, 'config_manager'): + display_config = self.cache_manager.config_manager.get_display_config() + + customization_config = cfg.get("customization", {}) + + manager_config.update({ + "timezone": timezone_str, + "display": display_config, + "customization": customization_config, + }) + + return manager_config + + def _parse_display_mode_settings(self) -> Dict[str, str]: + """Return {mode_type: 'switch'|'scroll'} from config.display_modes.""" + display_modes_config = self.config.get("display_modes", {}) + return { + "live": display_modes_config.get("live_display_mode", "switch"), + "recent": display_modes_config.get("recent_display_mode", "switch"), + "upcoming": display_modes_config.get("upcoming_display_mode", "switch"), + } + + # ------------------------------------------------------------------ + # Mode / manager helpers + # ------------------------------------------------------------------ + def _get_manager(self, mode_type: str): + """Get the manager for a mode type ('live'/'recent'/'upcoming').""" + return self._managers.get(mode_type) + + def _get_display_mode(self, mode_type: str) -> str: + """Return 'switch' or 'scroll' for the given mode type.""" + return self._display_mode_settings.get(mode_type, "switch") + + def _mode_enabled(self, mode_type: str) -> bool: + """Whether this mode type is enabled in config.display_modes.""" + return bool(self.config.get("display_modes", {}).get(mode_type, True)) + + def _has_any_scroll_mode(self) -> bool: + """Return True if any enabled mode uses scroll display.""" + if not self._scroll_manager: + return False + for mode_type in MODE_TYPES: + if self._get_display_mode(mode_type) == "scroll" and self._mode_enabled(mode_type): + return True + return False + + def _get_available_modes(self) -> list: + """Get the list of enabled display modes (afl_live/recent/upcoming).""" + modes = [f"afl_{m}" for m in MODE_TYPES if self._mode_enabled(m)] + if not modes: + modes = list(DISPLAY_MODES) + return modes + + def _get_current_manager(self): + """Get the manager for the current cycling mode.""" + with self._config_lock: + modes = self.modes + mode_index = self.current_mode_index + if not modes: + return None + current_mode = modes[mode_index % len(modes)] + mode_type = _mode_type_of(current_mode) + if not mode_type: + return None + return self._get_manager(mode_type) + + def _manager_has_displayable_games(self, manager, mode_type: str) -> bool: + """Return True if the manager currently has games for this mode. + + In switch mode an empty manager must be skipped: its ``display()`` clears + the shared canvas when it has no games, which would blank the panel. + """ + if manager is None: + return False + if mode_type == "live": + return bool(getattr(manager, "live_games", None)) + return bool(getattr(manager, "games_list", None)) + + def _get_games_from_manager(self, manager, mode_type: str) -> List[Dict]: + """Get the games list from a manager based on mode type.""" + if manager is None: + return [] + if mode_type == "live": + return list(getattr(manager, "live_games", []) or []) + games = getattr(manager, "games_list", None) + if games is None: + attr = "recent_games" if mode_type == "recent" else "upcoming_games" + games = getattr(manager, attr, []) + return list(games or []) + + def _get_rankings_cache(self) -> Dict[str, int]: + """Combined team rankings cache from all managers.""" + rankings: Dict[str, int] = {} + for manager in self._managers.values(): + if manager: + manager_rankings = getattr(manager, "_team_rankings_cache", {}) + if manager_rankings: + rankings.update(manager_rankings) + return rankings + + def _ensure_manager_updated(self, manager) -> None: + if manager: + try: + manager.update() + except Exception as e: + self.logger.warning(f"Error updating manager: {e}") + + # ------------------------------------------------------------------ + # Config change + # ------------------------------------------------------------------ + def on_config_change(self, new_config: Dict[str, Any]) -> None: + """Apply config changes at runtime without restart.""" + if BasePlugin: + super().on_config_change(new_config) + else: + self.config = new_config or {} + + self.is_enabled = self.config.get("enabled", True) + self.display_duration = float(self.config.get("display_duration", 30)) + self.game_display_duration = float(self.config.get("game_display_duration", 15)) + self.live_priority = bool(self.config.get("live_priority", False)) + + with self._config_lock: + # Drain in-flight update threads before replacing managers + for name, thread in list(self._active_update_threads.items()): + if thread.is_alive(): + thread.join(timeout=10.0) + self._active_update_threads.clear() + + self._scroll_prepared.clear() + self._scroll_active.clear() + + self._initialize_managers() + self._display_mode_settings = self._parse_display_mode_settings() + self.modes = self._get_available_modes() + self.current_mode_index = 0 + self.enable_scrolling = self._has_any_scroll_mode() + + self.logger.info("AFL config updated at runtime - reinitialized.") + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + def update(self) -> None: + """Update AFL game data using parallel manager updates.""" + if not self.is_enabled: + return + + with self._config_lock: + managers_snapshot = dict(self._managers) + + update_tasks = [] + for mode_type in MODE_TYPES: + manager = managers_snapshot.get(mode_type) + if manager: + update_tasks.append((f"AFL {mode_type.title()}", manager.update)) + + if not update_tasks: + return + + def run_update_with_error_handling(name: str, update_func): + try: + update_func() + except Exception as e: + self.logger.error(f"Error updating {name} manager: {e}", exc_info=True) + + started_threads = {} + with self._config_lock: + for name, update_func in update_tasks: + existing_thread = self._active_update_threads.get(name) + if existing_thread: + if existing_thread.is_alive(): + self.logger.debug(f"Skipping update for {name} - previous thread still running") + continue + else: + del self._active_update_threads[name] + + thread = threading.Thread( + target=run_update_with_error_handling, + args=(name, update_func), + daemon=True, + name=f"Update-{name}", + ) + thread.start() + self._active_update_threads[name] = thread + started_threads[name] = thread + + for name, thread in started_threads.items(): + thread.join(timeout=25.0) + if thread.is_alive(): + self.logger.warning(f"Manager update thread {thread.name} did not complete within timeout") + else: + with self._config_lock: + if name in self._active_update_threads: + del self._active_update_threads[name] + + # ------------------------------------------------------------------ + # Display + # ------------------------------------------------------------------ + def _display_scroll_mode(self, display_mode: str, mode_type: str, force_clear: bool) -> bool: + """Handle display for scroll mode.""" + if not self._scroll_manager: + self.logger.warning("Scroll mode requested but scroll manager not available") + return self._display_switch_mode(mode_type, force_clear) + + scroll_key = f"{display_mode}_{mode_type}" + + if not self._scroll_prepared.get(scroll_key, False): + manager = self._get_manager(mode_type) + self._ensure_manager_updated(manager) + + live_priority_active = ( + mode_type == "live" and self.live_priority and self.has_live_content() + ) + + games = self._get_games_from_manager(manager, mode_type) + for game in games: + game.setdefault("league", AFL_LEAGUE_KEY) + if not isinstance(game.get("status"), dict): + game["status"] = {} + if "state" not in game["status"]: + state_map = {"live": "in", "recent": "post", "upcoming": "pre"} + game["status"]["state"] = state_map.get(mode_type, "pre") + + if live_priority_active: + games = [g for g in games if g.get("is_live", False) and not g.get("is_final", False)] + + if not games: + self.logger.debug(f"No games to scroll for {display_mode}") + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + return False + + rankings = self._get_rankings_cache() + success = self._scroll_manager.prepare_and_display( + games, mode_type, [AFL_LEAGUE_KEY], rankings + ) + if success: + self._scroll_prepared[scroll_key] = True + self._scroll_active[scroll_key] = True + self.logger.info(f"[AFL Scroll] Started scrolling {len(games)} {mode_type} games") + else: + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + return False + + if self._scroll_active.get(scroll_key, False): + displayed = self._scroll_manager.display_frame(mode_type) + if displayed: + if self._scroll_manager.is_complete(mode_type): + self.logger.info(f"[AFL Scroll] Cycle complete for {display_mode}") + self._scroll_prepared[scroll_key] = False + self._scroll_active[scroll_key] = False + self._dynamic_cycle_complete = True + return True + self._scroll_active[scroll_key] = False + return False + + return False + + def _display_switch_mode(self, mode_type: str, force_clear: bool) -> bool: + """Handle display for switch mode.""" + manager = self._get_manager(mode_type) + if not manager or not self._manager_has_displayable_games(manager, mode_type): + return False + + self._current_display_league = AFL_LEAGUE_KEY + self._current_display_mode_type = mode_type + + result = manager.display(force_clear) + if result is not False: + try: + self._record_dynamic_progress(manager) + except Exception as progress_err: + self.logger.debug("Dynamic progress tracking failed: %s", progress_err) + self._evaluate_dynamic_cycle_completion() + return result if result is not None else True + + def display(self, display_mode: str = None, force_clear: bool = False) -> bool: + """Display AFL games with mode cycling.""" + if not self.is_enabled: + return False + + try: + # A goal/win celebration takes over the screen for live modes. + is_live_request = display_mode is None or display_mode.endswith("_live") + if is_live_request: + live_manager = self._get_manager("live") + if ( + live_manager + and hasattr(live_manager, "has_active_celebration") + and live_manager.has_active_celebration() + ): + self._current_display_league = AFL_LEAGUE_KEY + self._current_display_mode_type = "live" + if live_manager.display(force_clear): + return True + + # Explicit display mode requested by the host. + if display_mode: + mode_type = _mode_type_of(display_mode) + if not mode_type: + self.logger.warning(f"Unknown display_mode: {display_mode}") + return False + + if self._get_display_mode(mode_type) == "scroll": + return self._display_scroll_mode(display_mode, mode_type, force_clear) + return self._display_switch_mode(mode_type, force_clear) + + # Internal mode cycling (no explicit mode). + current_time = time.time() + with self._config_lock: + modes = self.modes + mode_index = self.current_mode_index + if not modes: + return False + mode_index = mode_index % len(modes) + + # Stay on / switch to live when there is live content. + should_stay_on_live = False + if self.has_live_content(): + current_mode = modes[mode_index] + if current_mode and current_mode.endswith("_live"): + should_stay_on_live = True + else: + for i, mode in enumerate(modes): + if mode.endswith("_live"): + mode_index = i + self.current_mode_index = i + force_clear = True + self.last_mode_switch = current_time + self.logger.info(f"Live content detected - switching to display mode: {mode}") + break + + if not should_stay_on_live and current_time - self.last_mode_switch >= self.display_duration: + mode_index = (mode_index + 1) % len(modes) + self.current_mode_index = mode_index + self.last_mode_switch = current_time + force_clear = True + self.logger.info(f"Switching to display mode: {modes[mode_index]}") + + current_mode = modes[mode_index] + mode_type = _mode_type_of(current_mode) + if not mode_type: + return False + + if self._get_display_mode(mode_type) == "scroll": + return self._display_scroll_mode(current_mode, mode_type, force_clear) + return self._display_switch_mode(mode_type, force_clear) + + except Exception as e: + self.logger.error(f"Error in display method: {e}", exc_info=True) + return False + + # ------------------------------------------------------------------ + # Live priority / content + # ------------------------------------------------------------------ + def has_live_priority(self) -> bool: + """Whether live priority is enabled.""" + return bool(self.is_enabled and self.live_priority) + + def _live_manager_has_favorite_live(self, live_manager) -> bool: + live_games = getattr(live_manager, "live_games", []) + if not live_games: + return False + show_all_live = getattr(live_manager, "show_all_live", False) + if show_all_live: + return True + favorite_teams = getattr(live_manager, "favorite_teams", []) + if favorite_teams: + return any( + game.get("home_abbr") in favorite_teams or game.get("away_abbr") in favorite_teams + for game in live_games + ) + return False + + def has_live_content(self) -> bool: + """Whether there is live content worth showing.""" + if not self.is_enabled: + return False + live_manager = self._get_manager("live") + if not live_manager: + return False + if ( + hasattr(live_manager, "has_active_celebration") + and live_manager.has_active_celebration() + ): + return True + return self._live_manager_has_favorite_live(live_manager) + + def get_live_modes(self) -> list: + """Return ['afl_live'] when there is live content, else [].""" + if not self.is_enabled: + return [] + live_manager = self._get_manager("live") + if not live_manager: + return [] + if ( + hasattr(live_manager, "has_active_celebration") + and live_manager.has_active_celebration() + ): + return ["afl_live"] + if self._live_manager_has_favorite_live(live_manager): + return ["afl_live"] + return [] + + def get_info(self) -> Dict[str, Any]: + """Get plugin information.""" + try: + current_manager = self._get_current_manager() + with self._config_lock: + modes = self.modes + mode_index = self.current_mode_index + current_mode = modes[mode_index % len(modes)] if modes else "none" + + info = { + "plugin_id": self.plugin_id, + "name": "AFL Scoreboard", + "version": "1.0.0", + "enabled": self.is_enabled, + "display_size": f"{self.display_width}x{self.display_height}", + "league": AFL_LEAGUE_NAME, + "current_mode": current_mode, + "available_modes": modes, + "display_duration": self.display_duration, + "live_priority": self.live_priority, + "show_records": self.config.get("show_records", False), + "show_odds": self.config.get("show_odds", False), + } + if current_manager and hasattr(current_manager, "get_info"): + try: + info["current_manager_info"] = current_manager.get_info() + except Exception as e: + info["current_manager_info"] = f"Error getting manager info: {e}" + return info + except Exception as e: + self.logger.error(f"Error getting plugin info: {e}") + return {"plugin_id": self.plugin_id, "name": "AFL Scoreboard", "error": str(e)} + + # ------------------------------------------------------------------ + # Dynamic duration hooks + # ------------------------------------------------------------------ + def reset_cycle_state(self) -> None: + if BasePlugin: + super().reset_cycle_state() + self._dynamic_cycle_seen_modes.clear() + self._dynamic_mode_to_manager_key.clear() + self._dynamic_manager_progress.clear() + self._dynamic_managers_completed.clear() + self._dynamic_cycle_complete = False + + def is_cycle_complete(self) -> bool: + if not self._dynamic_feature_enabled(): + return True + self._evaluate_dynamic_cycle_completion() + return self._dynamic_cycle_complete + + def _dynamic_feature_enabled(self) -> bool: + if not self.is_enabled: + return False + return self.supports_dynamic_duration() + + def supports_dynamic_duration(self) -> bool: + """Whether dynamic duration is enabled for the current display context.""" + if not self.is_enabled or not self._current_display_mode_type: + return False + mode_type = self._current_display_mode_type + dynamic = self.config.get("dynamic_duration", {}) + mode_config = dynamic.get("modes", {}).get(mode_type, {}) + if "enabled" in mode_config: + return bool(mode_config.get("enabled", False)) + if "enabled" in dynamic: + return bool(dynamic.get("enabled", False)) + return False + + def get_dynamic_duration_cap(self) -> Optional[float]: + """Dynamic duration cap for the current display context.""" + if not self.is_enabled: + return None + if not self._current_display_mode_type: + if BasePlugin: + return super().get_dynamic_duration_cap() + return None + mode_type = self._current_display_mode_type + dynamic = self.config.get("dynamic_duration", {}) + mode_config = dynamic.get("modes", {}).get(mode_type, {}) + for source in (mode_config, dynamic): + if "max_duration_seconds" in source: + try: + cap = float(source.get("max_duration_seconds")) + if cap > 0: + return cap + except (TypeError, ValueError): + pass + return None + + def _get_mode_duration(self, mode_type: str) -> Optional[float]: + """Fixed total duration for a mode from config.mode_durations, if set.""" + mode_durations = self.config.get("mode_durations", {}) + value = mode_durations.get(f"{mode_type}_mode_duration") + if value is not None: + try: + return float(value) + except (TypeError, ValueError): + pass + return None + + def _get_game_duration(self, mode_type: str, manager=None) -> float: + if manager is not None: + manager_duration = getattr(manager, "game_display_duration", None) + if manager_duration is not None: + return float(manager_duration) + return float(self.config.get(f"{mode_type}_game_duration", 15)) + + def get_cycle_duration(self, display_mode: str = None) -> Optional[float]: + """Expected cycle duration for a display mode.""" + if not self.is_enabled or not display_mode: + return None + mode_type = _mode_type_of(display_mode) + if not mode_type: + return None + + effective_duration = self._get_mode_duration(mode_type) + if effective_duration is not None: + if self._dynamic_feature_enabled(): + cap = self.get_dynamic_duration_cap() + if cap is not None: + effective_duration = min(effective_duration, cap) + return effective_duration + + manager = self._get_manager(mode_type) + games = self._get_games_from_manager(manager, mode_type) + if not games: + return None + total_duration = len(games) * self._get_game_duration(mode_type, manager) + + if self._dynamic_feature_enabled(): + cap = self.get_dynamic_duration_cap() + if cap is not None: + total_duration = min(total_duration, cap) + return total_duration + + def _record_dynamic_progress(self, current_manager) -> None: + """Track progress through games for dynamic duration.""" + with self._config_lock: + modes = self.modes + mode_index = self.current_mode_index + if not self._dynamic_feature_enabled() or not modes: + self._dynamic_cycle_complete = True + return + + current_mode = modes[mode_index % len(modes)] + self._dynamic_cycle_seen_modes.add(current_mode) + + manager_key = self._build_manager_key(current_mode, current_manager) + self._dynamic_mode_to_manager_key[current_mode] = manager_key + + total_games = self._get_total_games_for_manager(current_manager) + if total_games <= 1: + self._dynamic_managers_completed.add(manager_key) + return + + current_index = getattr(current_manager, "current_game_index", None) + if current_index is None: + current_index = 0 + progress_set = self._dynamic_manager_progress.setdefault(manager_key, set()) + progress_set.add(f"index-{current_index}") + valid_identifiers = {f"index-{idx}" for idx in range(total_games)} + progress_set.intersection_update(valid_identifiers) + if len(progress_set) >= total_games: + self._dynamic_managers_completed.add(manager_key) + + def _evaluate_dynamic_cycle_completion(self) -> None: + if not self._dynamic_feature_enabled(): + self._dynamic_cycle_complete = True + return + with self._config_lock: + modes = self.modes + if not modes: + self._dynamic_cycle_complete = True + return + + for mode_name in modes: + if mode_name not in self._dynamic_cycle_seen_modes: + self._dynamic_cycle_complete = False + return + manager_key = self._dynamic_mode_to_manager_key.get(mode_name) + if not manager_key: + self._dynamic_cycle_complete = False + return + if manager_key not in self._dynamic_managers_completed: + mode_type = _mode_type_of(mode_name) + manager = self._get_manager(mode_type) if mode_type else None + if self._get_total_games_for_manager(manager) <= 1: + self._dynamic_managers_completed.add(manager_key) + else: + self._dynamic_cycle_complete = False + return + self._dynamic_cycle_complete = True + + @staticmethod + def _build_manager_key(mode_name: str, manager) -> str: + manager_name = manager.__class__.__name__ if manager else "None" + return f"{mode_name}:{manager_name}" + + @staticmethod + def _get_total_games_for_manager(manager) -> int: + if manager is None: + return 0 + for attr in ("live_games", "games_list", "recent_games", "upcoming_games"): + value = getattr(manager, attr, None) + if isinstance(value, list): + return len(value) + return 0 + + # ------------------------------------------------------------------ + # Vegas scroll mode support + # ------------------------------------------------------------------ + def get_vegas_content(self) -> Optional[Any]: + """Return cached scroll image(s) for Vegas continuous scroll mode.""" + if not getattr(self, "_scroll_manager", None): + return None + + images = self._scroll_manager.get_all_vegas_content_items() + if not images: + self.logger.info("[AFL Vegas] Triggering scroll content generation") + self._ensure_scroll_content_for_vegas() + images = self._scroll_manager.get_all_vegas_content_items() + + if images: + total_width = sum(img.width for img in images) + self.logger.info("[AFL Vegas] Returning %d image(s), %dpx total", len(images), total_width) + return images + return None + + def get_vegas_content_type(self) -> str: + """Plugin provides multiple scrollable items (games).""" + return 'multi' + + def get_vegas_display_mode(self) -> 'VegasDisplayMode': + """Get the display mode for Vegas scroll integration.""" + if VegasDisplayMode: + config_mode = self.config.get("vegas_mode") + if config_mode: + try: + return VegasDisplayMode(config_mode) + except ValueError: + self.logger.warning(f"Invalid vegas_mode '{config_mode}' in config, using SCROLL") + return VegasDisplayMode.SCROLL + return "scroll" + + def _ensure_scroll_content_for_vegas(self) -> None: + """Generate scroll content across all mode types for Vegas mode.""" + if not getattr(self, "_scroll_manager", None): + self.logger.debug("[AFL Vegas] No scroll manager available") + return + + games: List[Dict] = [] + for mode_type in MODE_TYPES: + manager = self._get_manager(mode_type) + for game in self._get_games_from_manager(manager, mode_type): + game.setdefault("league", AFL_LEAGUE_KEY) + if not isinstance(game.get("status"), dict): + game["status"] = {} + if "state" not in game["status"]: + state_map = {"live": "in", "recent": "post", "upcoming": "pre"} + game["status"]["state"] = state_map.get(mode_type, "pre") + games.append(game) + + if not games: + self.logger.debug("[AFL Vegas] No games available") + return + + success = self._scroll_manager.prepare_and_display(games, 'mixed', [AFL_LEAGUE_KEY], None) + if success: + self.logger.info(f"[AFL Vegas] Generated scroll content: {len(games)} games") + else: + self.logger.warning("[AFL Vegas] Failed to generate scroll content") + + def cleanup(self) -> None: + """Clean up resources.""" + try: + if hasattr(self, "_scroll_manager") and self._scroll_manager: + if hasattr(self._scroll_manager, "cleanup"): + self._scroll_manager.cleanup() + self._scroll_manager = None + self.logger.info("AFL scoreboard plugin cleanup completed") + except Exception as e: + self.logger.error(f"Error during cleanup: {e}") diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json new file mode 100644 index 00000000..02fa952b --- /dev/null +++ b/plugins/afl-scoreboard/manifest.json @@ -0,0 +1,39 @@ +{ + "id": "afl-scoreboard", + "name": "AFL Scoreboard", + "version": "1.0.0", + "author": "ChuckBuilds", + "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", + "category": "sports", + "tags": [ + "afl", + "australian-football", + "sports", + "scoreboard", + "live-scores" + ], + "display_modes": [ + "afl_live", + "afl_recent", + "afl_upcoming" + ], + "versions": [ + { + "released": "2026-07-10", + "version": "1.0.0", + "notes": "Initial release: live, recent, and upcoming AFL games from ESPN with switch/scroll display modes, favorite-team filtering, dynamic duration, live priority, and Vegas scroll support.", + "ledmatrix_min": "2.0.0" + } + ], + "last_updated": "2026-07-10", + "stars": 0, + "downloads": 0, + "verified": true, + "screenshot": "", + "entry_point": "manager.py", + "class_name": "AflScoreboardPlugin", + "config_schema": "config_schema.json", + "compatible_versions": [ + ">=2.0.0" + ] +} diff --git a/plugins/afl-scoreboard/requirements.txt b/plugins/afl-scoreboard/requirements.txt new file mode 100644 index 00000000..7fc0a98e --- /dev/null +++ b/plugins/afl-scoreboard/requirements.txt @@ -0,0 +1,12 @@ +# AFL Scoreboard Plugin Requirements + +# Core dependencies (handled by main LEDMatrix) +# requests - for API calls +# pytz - for timezone handling +# Pillow - for image manipulation + +# These are already included in the main LEDMatrix requirements +# but listed here for reference: +# requests>=2.25.0 +# pytz>=2021.1 +# Pillow>=8.0.0 diff --git a/plugins/afl-scoreboard/scroll_display.py b/plugins/afl-scoreboard/scroll_display.py new file mode 100644 index 00000000..586cbcfc --- /dev/null +++ b/plugins/afl-scoreboard/scroll_display.py @@ -0,0 +1,717 @@ +""" +Scroll Display Handler for AFL 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 (Premier League, La Liga, etc.) 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 +from pathlib import Path +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__) + +# League names for display (matches manager.py LEAGUE_NAMES) +LEAGUE_NAMES = { + 'eng.1': 'Premier League', + 'esp.1': 'La Liga', + 'ger.1': 'Bundesliga', + 'ita.1': 'Serie A', + 'fra.1': 'Ligue 1', + 'usa.1': 'MLS', + 'mex.1': 'Liga MX', + 'ned.1': 'Eredivisie', + 'por.1': 'Primeira Liga', + 'sco.1': 'Scottish Premiership', + 'bel.1': 'Belgian Pro League', + 'tur.super_lig': 'Turkish Super Lig', + 'eng.2': 'Championship', + 'eng.league_cup': 'EFL Cup', + 'eng.fa': 'FA Cup', + 'uefa.champions': 'Champions League', + 'uefa.europa': 'Europa League', + 'uefa.europa.conf': 'Conference League', + 'fifa.friendly': 'International Friendly', + 'conmebol.libertadores': 'Copa Libertadores', + 'fifa.worldq.uefa': 'World Cup Qualifying (UEFA)', + 'uefa.nations': 'UEFA Nations League', + 'fifa.world': 'FIFA World Cup', + 'fifa.world.u20': 'FIFA U-20 World Cup', + 'concacaf.nations.league': 'CONCACAF Nations League', + 'concacaf.gold': 'CONCACAF Gold Cup', + 'concacaf.champions': 'CONCACAF Champions Cup', + 'conmebol.copa.america': 'Copa America', + 'uefa.euro': 'UEFA Euro', + 'club.friendly': 'Club Friendly', +} + + +class ScrollDisplay: + """ + Handles scroll mode display for the AFL Scoreboard plugin. + + Coordinates with ScrollHelper for high-FPS scrolling and manages + game card rendering and league separator icons. + """ + + def __init__( + self, + display_manager: Any, + display_width: int, + display_height: int, + config: Dict[str, Any], + plugin_dir: str + ): + """ + Initialize the ScrollDisplay handler. + + Args: + display_manager: Display manager instance for rendering + display_width: Width of the display in pixels + display_height: Height of the display in pixels + config: Plugin configuration dictionary + plugin_dir: Path to the plugin directory for assets + """ + self.display_manager = display_manager + self.display_width = display_width + self.display_height = display_height + self.config = config + self.plugin_dir = plugin_dir + self.logger = logging.getLogger(__name__) + + # Shared logo cache reused across renders so each team logo is loaded once + self._logo_cache: Dict[str, Image.Image] = {} + + # Initialize ScrollHelper if available + self.scroll_helper: Optional[Any] = None + if ScrollHelper: + self.scroll_helper = ScrollHelper( + display_width, + display_height, + self.logger + ) + self._configure_scroll_helper() + else: + self.logger.warning("ScrollHelper not available - scroll mode will be limited") + + # State tracking + 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: bool = False + self._scroll_start_time: float = 0 + self._frame_count: int = 0 + self._fps_sample_start: float = 0 + + # League separator icons cache + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + def _get_scroll_speed(self) -> float: + """Get scroll speed from config with fallback.""" + scroll_config = self.config.get('scroll_mode', {}) + return scroll_config.get('scroll_speed', 50.0) + + def _get_target_fps(self) -> int: + """Get target FPS from config with fallback.""" + scroll_config = self.config.get('scroll_mode', {}) + return scroll_config.get('target_fps', 30) + + def _get_scroll_settings(self) -> Dict[str, Any]: + """Get scroll-related settings from config.""" + scroll_config = self.config.get('scroll_mode', {}) + return { + 'scroll_speed': scroll_config.get('scroll_speed', 50.0), + 'scroll_delay': scroll_config.get('scroll_delay', 0.01), + 'target_fps': scroll_config.get('target_fps', 30), + 'gap_between_games': scroll_config.get('gap_between_games', 24), + 'show_league_separators': scroll_config.get('show_league_separators', True), + 'min_duration': scroll_config.get('min_duration', 30), + 'max_duration': scroll_config.get('max_duration', 300), + 'game_card_width': scroll_config.get('game_card_width', 128), + } + + def _configure_scroll_helper(self) -> None: + """Configure scroll helper with settings from config.""" + if not self.scroll_helper: + return + + 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 + self.scroll_helper.set_dynamic_duration_settings( + enabled=True, + min_duration=scroll_settings.get('min_duration', 30), + max_duration=scroll_settings.get('max_duration', 300), + buffer=0.2 + ) + + # Use frame-based scrolling for better FPS control + self.scroll_helper.set_frame_based_scrolling(True) + + # Convert scroll_speed from pixels/second to pixels/frame + if scroll_delay > 0: + pixels_per_frame = scroll_speed * scroll_delay + else: + pixels_per_frame = scroll_speed / 100.0 + + pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 + self.logger.info( + f"[AFL Scroll] ScrollHelper configured: {pixels_per_frame:.2f} px/frame, " + f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s)" + ) + + def _load_separator_icons(self) -> None: + """Load league separator icons from assets directory.""" + separator_dir = Path(self.plugin_dir) / "assets" / "separators" + + # Map league keys to separator icon filenames + separator_files = { + 'eng.1': 'premier_league.png', + 'esp.1': 'la_liga.png', + 'ger.1': 'bundesliga.png', + 'ita.1': 'serie_a.png', + 'fra.1': 'ligue_1.png', + 'usa.1': 'mls.png', + 'mex.1': 'liga_mx.png', + 'ned.1': 'eredivisie.png', + 'por.1': 'primeira_liga.png', + 'sco.1': 'scottish_premiership.png', + 'bel.1': 'belgian_pro_league.png', + 'tur.super_lig': 'turkish_super_lig.png', + 'eng.2': 'championship.png', + 'eng.league_cup': 'efl_cup.png', + 'eng.fa': 'fa_cup.png', + 'uefa.champions': 'champions_league.png', + 'uefa.europa': 'europa_league.png', + 'uefa.europa.conf': 'conference_league.png', + 'fifa.friendly': 'international_friendly.png', + 'conmebol.libertadores': 'copa_libertadores.png', + 'fifa.worldq.uefa': 'world_cup_qualifying.png', + 'uefa.nations': 'nations_league.png', + 'fifa.world': 'world_cup.png', + 'fifa.world.u20': 'world_cup_u20.png', + 'concacaf.nations.league': 'concacaf_nations.png', + 'concacaf.gold': 'gold_cup.png', + 'concacaf.champions': 'concacaf_champions.png', + 'conmebol.copa.america': 'copa_america.png', + 'uefa.euro': 'euro.png', + 'club.friendly': 'club_friendly.png', + } + + for league_key, filename in separator_files.items(): + icon_path = separator_dir / filename + if icon_path.exists(): + try: + icon = Image.open(icon_path).convert('RGBA') + # Scale to fit display height if needed + if icon.height > self.display_height - 4: + scale = (self.display_height - 4) / icon.height + new_width = int(icon.width * scale) + new_height = int(icon.height * scale) + icon = icon.resize((new_width, new_height), Image.LANCZOS) + self._separator_icons[league_key] = icon + self.logger.debug(f"Loaded {LEAGUE_NAMES[league_key]} separator icon: {icon.size}") + except Exception as e: + self.logger.error(f"Error loading {LEAGUE_NAMES[league_key]} separator icon: {e}") + else: + self.logger.debug(f"{LEAGUE_NAMES[league_key]} separator icon not found at {icon_path} (will skip separator)") + + def _determine_game_type(self, game: Dict, game_type: str = 'upcoming') -> str: + """ + Determine the game type from the game's status or flags. + + Checks in order: + 1. Boolean flags (is_live, is_final/is_recent, is_upcoming) + 2. Status state mapping (in/post/pre) + 3. Explicit game_type hint from game dict + 4. Provided game_type parameter as fallback + + Args: + game: Game dictionary + game_type: Fallback game type if status is missing or unknown + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + # First check boolean flags (pipeline game dicts) + if game.get('is_live'): + return 'live' + if game.get('is_final') or game.get('is_recent'): + return 'recent' + if game.get('is_upcoming'): + return 'upcoming' + + # Fall back to status.state mapping (with normalization) + status = game.get('status') + if isinstance(status, dict): + state = status.get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + + # Check for explicit game_type hint from game dict + game_type_hint = game.get('game_type') + if game_type_hint in ('live', 'recent', 'upcoming'): + return game_type_hint + + # Return provided fallback if type cannot be determined + return game_type + + 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., ['eng.1', 'esp.1']) + 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", "eng.1") # Default to Premier League 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 + separator = self._separator_icons.get(game_league) + if separator: + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + 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 {LEAGUE_NAMES.get(game_league, game_league)} separator icon (first league)") + 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 {LEAGUE_NAMES.get(game_league, game_league)} separator icon") + + current_league = game_league + + # Render game card - determine type from game state + # Use caller's game_type as fallback (if valid), otherwise 'upcoming' + try: + fallback_type = game_type if game_type in ('live', 'recent', 'upcoming') else 'upcoming' + individual_game_type = self._determine_game_type(game, fallback_type) + 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 + ) + + # Set cache_type marker for Vegas mode detection + # This allows manager to verify the cache is Vegas mixed content vs. single-type + self.scroll_helper.cache_type = game_type + + # Log what we loaded + league_summary = ", ".join([f"{LEAGUE_NAMES.get(league, league)}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[AFL Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[AFL 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 + if self._frame_count % 300 == 0: # Log every ~10 seconds at 30fps + elapsed = time.time() - self._scroll_start_time + avg_fps = self._frame_count / elapsed if elapsed > 0 else 0 + self.logger.debug( + f"[AFL Scroll] Frame {self._frame_count}, " + f"elapsed: {elapsed:.1f}s, avg FPS: {avg_fps:.1f}" + ) + + return True + except Exception as e: + self.logger.error(f"Error displaying scroll frame: {e}") + return False + + def is_scroll_complete(self) -> bool: + """ + Check if the scroll cycle is complete. + + Returns: + True if scroll has completed one full cycle + """ + if not self.scroll_helper: + return True + return self.scroll_helper.is_scroll_complete() + + def get_scroll_duration(self) -> float: + """ + Get the calculated scroll duration. + + Returns: + Duration in seconds, or 0 if not available + """ + if not self.scroll_helper: + return 0 + return self.scroll_helper.calculated_duration + + def reset_scroll(self) -> None: + """Reset scroll position to the beginning.""" + if self.scroll_helper: + self.scroll_helper.reset_scroll() + self._scroll_start_time = time.time() + self._frame_count = 0 + + def clear_cache(self) -> None: + """Clear the scroll cache.""" + 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 + + def has_content(self) -> bool: + """ + Check if scroll content is available. + + Returns: + True if content is ready for scrolling + """ + return bool(self.scroll_helper and self.scroll_helper.cached_image) + + def get_current_game_count(self) -> int: + """Get the number of games in the current scroll.""" + return len(self._current_games) + + def get_current_leagues(self) -> List[str]: + """Get the list of leagues in the current scroll.""" + return self._current_leagues.copy() + + 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.""" + self.clear_cache() + + +class ScrollDisplayManager: + """ + Manages scroll display instances for different game types. + + This class provides a higher-level interface for the afl 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 + ): + """ + Initialize the ScrollDisplayManager. + + Args: + display_manager: Display manager instance + config: Plugin configuration dictionary + custom_logger: Optional custom logger instance + """ + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + + # Determine plugin directory for asset loading + self._plugin_dir = str(Path(__file__).parent) + + # 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', 'mixed') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + self._scroll_displays[game_type] = ScrollDisplay( + self.display_manager, + display_width, + display_height, + self.config, + self._plugin_dir + ) + 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 any scroll display has a cached image ready for display + """ + 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/afl-scoreboard/sports.py b/plugins/afl-scoreboard/sports.py new file mode 100644 index 00000000..ad2dc794 --- /dev/null +++ b/plugins/afl-scoreboard/sports.py @@ -0,0 +1,3157 @@ +import logging +import os +import re +import secrets +import threading +import time +from abc import ABC, abstractmethod +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +import pytz +import requests +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +# Import simplified dependencies for plugin use +from dynamic_team_resolver import DynamicTeamResolver +from base_odds_manager import BaseOddsManager +from data_sources import ESPNDataSource + +# Import main logo downloader (same as football plugin) +import sys +from pathlib import Path +# Add parent directory to path to import from src +plugin_dir = Path(__file__).resolve().parent +project_root = plugin_dir.parent.parent +if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) +from src.logo_downloader import LogoDownloader, download_missing_logo + + +class SportsCore(ABC): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + self.logger = logger + self.config = config + self.cache_manager = cache_manager + self.config_manager = getattr(cache_manager, "config_manager", None) + # Initialize odds manager + self.odds_manager = BaseOddsManager(self.cache_manager, self.config_manager) + self.display_manager = display_manager + # Get display dimensions from matrix (same as base SportsCore class) + # This ensures proper scaling for different display sizes + 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: + # Fallback to width/height properties (which also check matrix) + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + self.sport_key = sport_key + self.sport = None + self.league = None + + # Initialize new architecture components (will be overridden by sport-specific classes) + self.sport_config = None + # Initialize data source + self.data_source = ESPNDataSource(logger) + self.mode_config = config.get( + f"{sport_key}_scoreboard", {} + ) # Changed config key + self.is_enabled: bool = self.mode_config.get("enabled", False) + self.show_odds: bool = self.mode_config.get("show_odds", False) + # Use LogoDownloader to get the correct default logo directory for AFL + default_logo_dir = Path(LogoDownloader().get_logo_directory("afl")) + self.logo_dir = self._initialize_logo_dir(default_logo_dir) + self.update_interval: int = self.mode_config.get("update_interval_seconds", 60) + self.show_records: bool = self.mode_config.get("show_records", False) + self.show_ranking: bool = self.mode_config.get("show_ranking", False) + # Number of games to show (instead of time-based windows) + self.recent_games_to_show: int = self.mode_config.get( + "recent_games_to_show", 5 + ) # Show last 5 games + self.upcoming_games_to_show: int = self.mode_config.get( + "upcoming_games_to_show", 10 + ) # Show next 10 games + filtering_config = self.mode_config.get("filtering", {}) + self.show_favorite_teams_only: bool = self.mode_config.get( + "show_favorite_teams_only", + filtering_config.get("show_favorite_teams_only", False), + ) + self.show_all_live: bool = self.mode_config.get( + "show_all_live", + filtering_config.get("show_all_live", False), + ) + try: + self.favorite_live_boost: int = max(1, min(5, int(self.mode_config.get( + "favorite_live_boost", + filtering_config.get("favorite_live_boost", 2), + )))) + except (TypeError, ValueError): + self.favorite_live_boost = 2 + + self.session = requests.Session() + retry_strategy = Retry( + total=5, # increased number of retries + backoff_factor=1, # increased backoff factor + # added 429 to retry list + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "HEAD", "OPTIONS"], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + self._logo_cache = {} + + # Set up headers + self.headers = { + "User-Agent": "LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + } + self.last_update = 0 + self.current_game = None + self._last_rendered_game_id: Optional[str] = None # Skip redundant redraws + # Thread safety lock for shared game state + self._games_lock = threading.RLock() + self.fonts = self._load_fonts() + + # Initialize dynamic team resolver and resolve favorite teams + self.dynamic_resolver = DynamicTeamResolver() + raw_favorite_teams = self.mode_config.get("favorite_teams", []) + self.favorite_teams = self.dynamic_resolver.resolve_teams( + raw_favorite_teams, sport_key + ) + raw_exclude_teams = self.mode_config.get("exclude_teams", []) + self.exclude_teams = self.dynamic_resolver.resolve_teams( + raw_exclude_teams, sport_key + ) + + # Log dynamic team resolution + if raw_favorite_teams != self.favorite_teams: + self.logger.info( + f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}" + ) + else: + self.logger.info(f"Favorite teams: {self.favorite_teams}") + if self.exclude_teams: + self.logger.info(f"Excluded teams: {self.exclude_teams}") + + self.logger.setLevel(logging.INFO) + + # Initialize team rankings cache + self._team_rankings_cache = {} + self._rankings_cache_timestamp = 0 + self._rankings_cache_duration = 3600 # Cache rankings for 1 hour + + # Initialize warning tracking + self._last_warning_time = 0 + + # Initialize background data service with optimized settings + # Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries + try: + from src.background_data_service import get_background_service + + self.background_service = get_background_service( + self.cache_manager, max_workers=1 + ) + self.background_fetch_requests = {} # Track background fetch requests + self.background_enabled = True + self.logger.info( + "Background service enabled with 1 worker (memory optimized)" + ) + except ImportError: + # Fallback if background service is not available + self.background_service = None + self.background_fetch_requests = {} + self.background_enabled = False + self.logger.warning( + "Background service not available - using synchronous fetching" + ) + + def _is_favorite_game(self, game: Dict) -> bool: + return bool(self.favorite_teams) and ( + game.get("home_abbr") in self.favorite_teams + or game.get("away_abbr") in self.favorite_teams + ) + + def _effective_live_duration(self, game): + """How long the given live game should stay on screen before rotating. + + Non-favorite live games use non_favorite_live_game_duration, but only + when it is set (> 0) AND favorite teams are configured. With no favorites + (or the knob at 0) every live game uses game_display_duration - identical + to the prior single-duration behavior. When show_favorite_teams_only is + on, non-favorite games are never shown, so this naturally never fires.""" + non_fav = getattr(self, "non_favorite_live_game_duration", 0) or 0 + if ( + non_fav > 0 + and self.favorite_teams + and game is not None + and not self._is_favorite_game(game) + ): + return non_fav + return self.game_display_duration + + def _classify_live_game(self, home_abbr, away_abbr) -> bool: + """Whether a live game should be included in the live rotation. + + Priority: excluded team (never shown, overrides everything) > + show_all_live > show_favorite_teams_only disabled > no favorites + configured (fallback: show all) > favorite-teams-only membership. + """ + if home_abbr in self.exclude_teams or away_abbr in self.exclude_teams: + return False + if self.show_all_live: + return True + if not self.show_favorite_teams_only: + return True + if not self.favorite_teams: + return True + return home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + + def _swrr_advance(self, games: List[Dict]) -> Optional[Dict]: + """Pick the next live game to display via Smooth Weighted Round-Robin. + + Favorite-team games get weight ``favorite_live_boost`` (shown that many + turns for every 1 turn other live games get); everything else gets + weight 1. Per-game weight state (``self._swrr_weights``) persists + across calls so spacing stays even indefinitely - no fixed-length + cycle, so no clustering seam at a cycle boundary. A brand-new game + (including a favorite's game that just went live) starts at weight 0 + and gets its full weight added on the very next call, so a live + favorite naturally wins the first pick after it appears ("queued + first" on refresh) without any special-cased branch. + + With ``favorite_live_boost == 1`` (or no favorite live), every game + has weight 1, so this always just returns games in their existing + order - identical behavior to the plain round-robin it replaces. + """ + if not games: + return None + weights = {} + for g in games: + gid = g.get("id") + if gid is None: + continue + weights[gid] = self.favorite_live_boost if self._is_favorite_game(g) else 1 + if not weights: + return None + + if not hasattr(self, "_swrr_weights"): + self._swrr_weights = {} + # Drop state for games no longer live; keep it for games still live. + self._swrr_weights = {gid: w for gid, w in self._swrr_weights.items() if gid in weights} + + for gid, w in weights.items(): + self._swrr_weights[gid] = self._swrr_weights.get(gid, 0) + w + + total_weight = sum(weights.values()) + ids_in_order = [g.get("id") for g in games if g.get("id") in weights] + best_gid = max(ids_in_order, key=lambda gid: self._swrr_weights[gid]) + self._swrr_weights[best_gid] -= total_weight + return next(g for g in games if g.get("id") == best_gid) + + def _initialize_logo_dir(self, configured_path: Path) -> Path: + """Resolve and ensure a writable logo directory, falling back when necessary.""" + downloader = LogoDownloader() + resolved_configured = self._resolve_project_path(configured_path) + candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured) + + for candidate in candidates: + candidate_path = self._resolve_project_path(candidate) + if downloader.ensure_logo_directory(str(candidate_path)): + if candidate_path != resolved_configured: + self.logger.warning( + "Configured logo directory '%s' is not writable; using fallback '%s'", + resolved_configured, + candidate_path, + ) + return candidate_path + + self.logger.error( + "Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)", + resolved_configured, + ) + return resolved_configured + + def _resolve_project_path(self, path: Path) -> Path: + """Convert relative paths to absolute ones rooted at the project directory.""" + if path.is_absolute(): + return path + # For plugin, go up 2 levels: plugin_dir -> plugins -> project_root + plugin_dir = Path(__file__).resolve().parent + project_root = plugin_dir.parent.parent + return (project_root / path).resolve() + + def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]: + """Return fallback directories to try when the configured directory is not writable.""" + import tempfile + fallbacks: List[Path] = [] + + env_override = os.environ.get("LEDMATRIX_LOGO_DIR") + if env_override: + env_path = Path(env_override) + if not env_path.is_absolute(): + env_path = self._resolve_project_path(env_path) + fallbacks.append(env_path / "afl_logos") + + cache_dir = getattr(self.cache_manager, "cache_dir", None) + if cache_dir: + fallbacks.append(Path(cache_dir) / "logos" / "afl") + + try: + fallbacks.append(Path.home() / ".ledmatrix" / "logos" / "afl") + except Exception: + pass + + fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / "afl") + + unique_fallbacks: List[Path] = [] + seen = set() + for candidate in fallbacks: + if candidate == configured_dir: + continue + if candidate not in seen: + unique_fallbacks.append(candidate) + seen.add(candidate) + + return unique_fallbacks + + def _get_season_schedule_dates(self) -> tuple[str, str]: + return "", "" + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Placeholder draw method - subclasses should override.""" + # This base method will be simple, subclasses provide specifics + try: + img = Image.new("RGB", (self.display_width, self.display_height), (0, 0, 0)) + draw = ImageDraw.Draw(img) + status = game.get("status_text", "N/A") + self._draw_text_with_outline(draw, status, (2, 2), self.fonts["status"]) + self.display_manager.image.paste(img, (0, 0)) + # Don't call update_display here, let subclasses handle it after drawing + except Exception as e: + self.logger.error( + f"Error in base _draw_scorebug_layout: {e}", exc_info=True + ) + + def display(self, force_clear: bool = False) -> bool: + """Render the current game. Returns False when nothing can be shown.""" + if not self.is_enabled: # Check if module is enabled + return False + + if not self.current_game: + # Don't clear the display when returning False - let the caller handle skipping + # Clearing here would show a blank screen before the next mode is displayed + current_time = time.time() + if not hasattr(self, "_last_warning_time"): + self._last_warning_time = 0 + if current_time - getattr(self, "_last_warning_time", 0) > 300: + self.logger.warning( + f"No game data available to display in {self.__class__.__name__}" + ) + setattr(self, "_last_warning_time", current_time) + return False + + try: + self._draw_scorebug_layout(self.current_game, force_clear) + # display_manager.update_display() should be called within subclass draw methods + # or after calling display() in the main loop. Let's keep it out of the base display. + return True + except Exception as e: + self.logger.error( + f"Error during display call in {self.__class__.__name__}: {e}", + exc_info=True, + ) + return False + + def _load_custom_font_from_element_config(self, element_config: Dict[str, Any], default_size: int = 8) -> ImageFont.FreeTypeFont: + """ + Load a custom font from an element configuration dictionary. + + Args: + element_config: Configuration dict for a single element containing 'font' and 'font_size' keys + default_size: Default font size if not specified in config + + Returns: + PIL ImageFont object + """ + # Get font name and size, with defaults + font_name = element_config.get('font', 'PressStart2P-Regular.ttf') + font_size = int(element_config.get('font_size', default_size)) # Ensure integer for PIL + + # Build font path + font_path = os.path.join('assets', 'fonts', font_name) + + # Try to load the font + try: + if os.path.exists(font_path): + # Try loading as TTF first (works for both TTF and some BDF files with PIL) + if font_path.lower().endswith('.ttf'): + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded font: {font_name} at size {font_size}") + return font + elif font_path.lower().endswith('.bdf'): + # PIL's ImageFont.truetype() can sometimes handle BDF files + # If it fails, we'll fall through to the default font + try: + font = ImageFont.truetype(font_path, font_size) + self.logger.debug(f"Loaded BDF font: {font_name} at size {font_size}") + return font + except Exception: + self.logger.warning(f"Could not load BDF font {font_name} with PIL, using default") + # Fall through to default + else: + self.logger.warning(f"Unknown font file type: {font_name}, using default") + else: + self.logger.warning(f"Font file not found: {font_path}, using default") + except Exception as e: + self.logger.error(f"Error loading font {font_name}: {e}, using default") + + # Fall back to default font + default_font_path = os.path.join('assets', 'fonts', 'PressStart2P-Regular.ttf') + try: + if os.path.exists(default_font_path): + return ImageFont.truetype(default_font_path, font_size) + else: + self.logger.warning("Default font not found, using PIL default") + return ImageFont.load_default() + except Exception as e: + self.logger.error(f"Error loading default font: {e}") + return ImageFont.load_default() + + def _get_layout_offset(self, element: str, axis: str, default: int = 0) -> int: + """ + Get layout offset for a specific element and axis. + + Args: + element: Element name (e.g., 'home_logo', 'score', 'status_text') + axis: 'x_offset' or 'y_offset' (or 'away_x_offset', 'home_x_offset' for records) + default: Default value if not configured (default: 0) + + Returns: + Offset value from config or default (always returns int) + """ + try: + layout_config = self.config.get('customization', {}).get('layout', {}) + element_config = layout_config.get(element, {}) + offset_value = element_config.get(axis, default) + + # Ensure we return an integer (handle float/string from config) + if isinstance(offset_value, (int, float)): + return int(offset_value) + elif isinstance(offset_value, str): + try: + return int(float(offset_value)) + except ValueError: + self.logger.warning(f"Invalid offset value '{offset_value}' for {element}.{axis}, using default {default}") + return default + return default + except Exception as e: + self.logger.debug(f"Error getting layout offset for {element}.{axis}: {e}, using default {default}") + return default + + def _load_fonts(self): + """Load fonts used by the scoreboard from config or use defaults.""" + fonts = {} + + # Get customization config, with backward compatibility + customization = self.config.get('customization', {}) + + # Load fonts from config with defaults for backward compatibility + score_config = customization.get('score_text', {}) + period_config = customization.get('period_text', {}) + team_config = customization.get('team_name', {}) + status_config = customization.get('status_text', {}) + detail_config = customization.get('detail_text', {}) + rank_config = customization.get('rank_text', {}) + + try: + fonts["score"] = self._load_custom_font_from_element_config(score_config, default_size=10) + fonts["time"] = self._load_custom_font_from_element_config(period_config, default_size=8) + fonts["team"] = self._load_custom_font_from_element_config(team_config, default_size=8) + fonts["status"] = self._load_custom_font_from_element_config(status_config, default_size=6) + fonts["detail"] = self._load_custom_font_from_element_config(detail_config, default_size=6) + fonts["rank"] = self._load_custom_font_from_element_config(rank_config, default_size=10) + self.logger.info("Successfully loaded fonts from config") + except Exception as e: + self.logger.error(f"Error loading fonts: {e}, using defaults") + # Fallback to hardcoded defaults + try: + fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + fonts["team"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) + fonts["status"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts["rank"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + except IOError: + self.logger.warning("Fonts not found, using default PIL font.") + fonts["score"] = ImageFont.load_default() + fonts["time"] = ImageFont.load_default() + fonts["team"] = ImageFont.load_default() + fonts["status"] = ImageFont.load_default() + fonts["detail"] = ImageFont.load_default() + fonts["rank"] = ImageFont.load_default() + return fonts + + def _draw_dynamic_odds( + self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int + ) -> None: + """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" + try: + # Skip odds rendering in test mode or if odds data is invalid + if ( + not odds + or isinstance(odds, dict) + and any( + isinstance(v, type) and hasattr(v, "__call__") + for v in odds.values() + ) + ): + self.logger.debug("Skipping odds rendering - test mode or invalid data") + return + + self.logger.debug(f"Drawing odds with data: {odds}") + + home_team_odds = odds.get("home_team_odds", {}) + away_team_odds = odds.get("away_team_odds", {}) + home_spread = home_team_odds.get("spread_odds") + away_spread = away_team_odds.get("spread_odds") + + # Get top-level spread as fallback + top_level_spread = odds.get("spread") + + # If we have a top-level spread and the individual spreads are None or 0, use the top-level + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine which team is favored (has negative spread) + # Add type checking to handle Mock objects in test environment + home_favored = False + away_favored = False + + if home_spread is not None and isinstance(home_spread, (int, float)): + home_favored = home_spread < 0 + if away_spread is not None and isinstance(away_spread, (int, float)): + away_favored = away_spread < 0 + + # Only show the negative spread (favored team) + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = "home" + self.logger.debug(f"Home team favored with spread: {favored_spread}") + elif away_favored: + favored_spread = away_spread + favored_side = "away" + self.logger.debug(f"Away team favored with spread: {favored_spread}") + else: + self.logger.debug( + "No clear favorite - spreads: home={home_spread}, away={away_spread}" + ) + + # Show the negative spread on the appropriate side + if favored_spread is not None: + spread_text = str(favored_spread) + font = self.fonts["detail"] # Use detail font for odds + + if favored_side == "home": + # Home team is favored, show spread on right side + spread_width = draw.textlength(spread_text, font=font) + spread_x = width - spread_width # Top right + spread_y = 0 + self._draw_text_with_outline( + draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + ) + self.logger.debug( + f"Showing home spread '{spread_text}' on right side" + ) + else: + # Away team is favored, show spread on left side + spread_x = 0 # Top left + spread_y = 0 + self._draw_text_with_outline( + draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0) + ) + self.logger.debug( + f"Showing away spread '{spread_text}' on left side" + ) + + # Show over/under on the opposite side of the favored team + over_under = odds.get("over_under") + if over_under is not None and isinstance(over_under, (int, float)): + ou_text = f"O/U: {over_under}" + font = self.fonts["detail"] # Use detail font for odds + ou_width = draw.textlength(ou_text, font=font) + + if favored_side == "home": + # Home favored, show O/U on left side (opposite of spread) + ou_x = 0 # Top left + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' on left side (home favored)" + ) + elif favored_side == "away": + # Away favored, show O/U on right side (opposite of spread) + ou_x = width - ou_width # Top right + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' on right side (away favored)" + ) + else: + # No clear favorite, show O/U in center + ou_x = (width - ou_width) // 2 + ou_y = 0 + self.logger.debug( + f"Showing O/U '{ou_text}' in center (no clear favorite)" + ) + + self._draw_text_with_outline( + draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0) + ) + + except Exception as e: + self.logger.error(f"Error drawing odds: {e}", exc_info=True) + + def _draw_text_with_outline( + self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0) + ): + """Draw text with a black outline for better readability.""" + x, y = position + for dx, dy in [ + (-1, -1), + (-1, 0), + (-1, 1), + (0, -1), + (0, 1), + (1, -1), + (1, 0), + (1, 1), + ]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + def _load_and_resize_logo( + self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None + ) -> Optional[Image.Image]: + """Load and resize a team logo, with caching and automatic download if missing.""" + self.logger.debug(f"Logo path: {logo_path}") + if team_abbrev in self._logo_cache: + self.logger.debug(f"Using cached logo for {team_abbrev}") + return self._logo_cache[team_abbrev] + + try: + # Try different filename variations first (for cases like TA&M vs TAANDM) + actual_logo_path = None + filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev) + + for filename in filename_variations: + test_path = logo_path.parent / filename + if test_path.exists(): + actual_logo_path = test_path + self.logger.debug(f"Found logo at alternative path: {actual_logo_path}") + break + + # If no variation found, try to download missing logo + if not actual_logo_path and not logo_path.exists(): + self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") + + # AFL is a single league, so logos live in one shared directory. + # LogoDownloader keys the cache directory by this identifier. + league = "afl" + + # Use main logo downloader (same as football plugin) - handles path resolution and permissions + download_missing_logo(league, team_id, team_abbrev, logo_path, logo_url) + actual_logo_path = logo_path + + # Use the original path if no alternative was found + if not actual_logo_path: + actual_logo_path = logo_path + + # Only try to open the logo if the file exists + if os.path.exists(actual_logo_path): + logo = Image.open(actual_logo_path) + else: + self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt") + return None + if logo.mode != 'RGBA': + logo = logo.convert('RGBA') + + max_width = int(self.display_width * 1.5) + max_height = int(self.display_height * 1.5) + logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + self._logo_cache[team_abbrev] = logo + return logo + + except Exception as e: + self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) + return None + + def _fetch_odds(self, game: Dict) -> None: + """Fetch odds for a specific game using async threading to prevent blocking.""" + try: + if not self.show_odds: + return + + # Determine update interval based on game state + is_live = game.get("is_live", False) + update_interval = ( + self.mode_config.get("live_odds_update_interval", 60) + if is_live + else self.mode_config.get("odds_update_interval", 3600) + ) + + # For upcoming games, use async fetch with short timeout to avoid blocking + # For live games, we want odds more urgently, but still use async to prevent blocking + import threading + import queue + + result_queue = queue.Queue() + + def fetch_odds(): + try: + odds_result = self.odds_manager.get_odds( + sport=self.sport, + league=self.league, + event_id=game["id"], + update_interval_seconds=update_interval, + ) + result_queue.put(('success', odds_result)) + except Exception as e: + result_queue.put(('error', e)) + + # Start odds fetch in a separate thread + odds_thread = threading.Thread(target=fetch_odds) + odds_thread.daemon = True + odds_thread.start() + + # Wait for result with timeout (shorter for upcoming games) + timeout = 2.0 if is_live else 1.5 # Live games get slightly longer timeout + try: + result_type, result_data = result_queue.get(timeout=timeout) + if result_type == 'success': + odds_data = result_data + if odds_data: + game["odds"] = odds_data + self.logger.debug( + f"Successfully fetched and attached odds for game {game['id']}" + ) + else: + self.logger.debug(f"No odds data returned for game {game['id']}") + else: + self.logger.debug(f"Odds fetch failed for game {game['id']}: {result_data}") + except queue.Empty: + # Timeout - odds will be fetched on next update if needed + # This prevents blocking the entire update() method + self.logger.debug(f"Odds fetch timed out for game {game['id']} (non-blocking)") + + except Exception as e: + self.logger.error( + f"Error fetching odds for game {game.get('id', 'N/A')}: {e}" + ) + + def _get_timezone(self): + """Get timezone from config, with fallback to cache_manager's config_manager.""" + try: + # First try plugin config + timezone_str = self.config.get("timezone") + # If not in plugin config, try to get from cache_manager's config_manager + if not timezone_str and hasattr(self, 'cache_manager') and hasattr(self.cache_manager, 'config_manager'): + timezone_str = self.cache_manager.config_manager.get_timezone() + # Final fallback to UTC + if not timezone_str: + timezone_str = "UTC" + + self.logger.debug(f"Using timezone: {timezone_str}") + return pytz.timezone(timezone_str) + except pytz.UnknownTimeZoneError: + self.logger.warning(f"Unknown timezone: {timezone_str}, falling back to UTC") + return pytz.utc + + def _should_log(self, warning_type: str, cooldown: int = 60) -> bool: + """Check if we should log a warning based on cooldown period.""" + current_time = time.time() + if current_time - self._last_warning_time > cooldown: + self._last_warning_time = current_time + return True + return False + + def _fetch_team_rankings(self) -> Dict[str, int]: + """Fetch team rankings/standings using the new architecture components.""" + current_time = time.time() + + # Check if we have cached rankings that are still valid + if ( + self._team_rankings_cache + and current_time - self._rankings_cache_timestamp + < self._rankings_cache_duration + ): + return self._team_rankings_cache + + try: + data = self.data_source.fetch_standings(self.sport, self.league) + + rankings = {} + + # Check if this is standings data (professional leagues like NBA, WNBA) + # Standings structure: data['children'] -> child['standings']['entries'] -> entry['team'] + if "children" in data: + # This is standings data (NBA, WNBA, etc.) + # Extract teams from all conferences/divisions + rank = 1 + for child in data.get("children", []): + standings = child.get("standings", {}) + entries = standings.get("entries", []) + + # Sort entries by win percentage or record (standings are already ordered) + for entry in entries: + team_info = entry.get("team", {}) + team_abbr = team_info.get("abbreviation", "") + + if team_abbr: + rankings[team_abbr] = rank + rank += 1 + + self.logger.debug(f"Fetched standings for {len(rankings)} teams") + + # Check if this is rankings data (college sports) + # Rankings structure: data['rankings'] -> ranking['ranks'] -> rank['team'] + elif "rankings" in data: + rankings_data = data.get("rankings", []) + + if rankings_data: + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + teams = first_ranking.get("ranks", []) + + for team_data in teams: + team_info = team_data.get("team", {}) + team_abbr = team_info.get("abbreviation", "") + current_rank = team_data.get("current", 0) + + if team_abbr and current_rank > 0: + rankings[team_abbr] = current_rank + + self.logger.debug(f"Fetched rankings for {len(rankings)} teams") + + # Cache the results + self._team_rankings_cache = rankings + self._rankings_cache_timestamp = current_time + + return rankings + + except Exception as e: + self.logger.error(f"Error fetching team rankings/standings: {e}") + return {} + + def _extract_game_details_common( + self, game_event: Dict + ) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]: + if not game_event: + return None, None, None, None, None + try: + # Safe access to competitions array + competitions = game_event.get("competitions", []) + if not competitions: + self.logger.warning(f"No competitions data for game {game_event.get('id', 'unknown')}") + return None, None, None, None, None + competition = competitions[0] + status = competition.get("status") + if not status: + self.logger.warning(f"No status data for game {game_event.get('id', 'unknown')}") + return None, None, None, None, None + competitors = competition.get("competitors", []) + game_date_str = game_event["date"] + situation = competition.get("situation") + start_time_utc = None + try: + # Parse the datetime string + if game_date_str.endswith('Z'): + game_date_str = game_date_str.replace('Z', '+00:00') + dt = datetime.fromisoformat(game_date_str) + # Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC) + if dt.tzinfo is None: + # If naive, assume it's UTC + start_time_utc = dt.replace(tzinfo=pytz.UTC) + else: + # Convert to pytz.UTC for consistency + start_time_utc = dt.astimezone(pytz.UTC) + except ValueError: + self.logger.warning(f"Could not parse game date: {game_date_str}") + + home_team = next( + (c for c in competitors if c.get("homeAway") == "home"), None + ) + away_team = next( + (c for c in competitors if c.get("homeAway") == "away"), None + ) + + if not home_team or not away_team: + self.logger.warning( + f"Could not find home or away team in event: {game_event.get('id')}" + ) + return None, None, None, None, None + + try: + home_abbr = home_team["team"]["abbreviation"] + except KeyError: + home_abbr = home_team["team"]["name"][:3] + try: + away_abbr = away_team["team"]["abbreviation"] + except KeyError: + away_abbr = away_team["team"]["name"][:3] + + # Check if this is a favorite team game BEFORE doing expensive logging + is_favorite_game = self.favorite_teams and ( + home_abbr in self.favorite_teams or away_abbr in self.favorite_teams + ) + + # Only log debug info for favorite team games + if is_favorite_game: + self.logger.debug( + f"Processing favorite team game: {game_event.get('id')}" + ) + self.logger.debug( + f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}" + ) + + game_time, game_date = "", "" + if start_time_utc: + local_time = start_time_utc.astimezone(self._get_timezone()) + game_time = local_time.strftime("%I:%M%p").lstrip("0") + + # Check date format from config + use_short_date_format = self.config.get("display", {}).get( + "use_short_date_format", False + ) + if use_short_date_format: + game_date = local_time.strftime("%-m/%-d") + else: + # Note: display_manager.format_date_with_ordinal will be handled by plugin wrapper + game_date = local_time.strftime("%m/%d") # Simplified for plugin + + home_record = ( + home_team.get("records", [{}])[0].get("summary", "") + if home_team.get("records") + else "" + ) + away_record = ( + away_team.get("records", [{}])[0].get("summary", "") + if away_team.get("records") + else "" + ) + + # Don't show "0-0" records - set to blank instead + if home_record in {"0-0", "0-0-0"}: + home_record = "" + if away_record in {"0-0", "0-0-0"}: + away_record = "" + + # Extract scores, handling both dict and direct value formats + def extract_score(team_data): + """Extract score from team data, handling dict or direct value.""" + score = team_data.get("score") + if score is None: + return "0" + + # If score is a dict (e.g., {"value": 75}), extract the value + if isinstance(score, dict): + score_value = score.get("value", 0) + # Also check for other possible keys + if score_value == 0: + score_value = score.get("displayValue", score.get("score", 0)) + else: + score_value = score + + # Convert to integer to remove decimal points, then to string + try: + # Handle string scores - check if it's a string representation of a dict first + if isinstance(score_value, str): + # Try to parse as float/int first + try: + score_value = float(score_value) + except ValueError: + # If it's not a number, try to extract number from string + numbers = re.findall(r'\d+', score_value) + if numbers: + score_value = float(numbers[0]) + else: + self.logger.warning(f"Could not extract score from string: {score_value}") + return "0" + # Convert to int to remove decimals, then to string + return str(int(float(score_value))) + except (ValueError, TypeError) as e: + self.logger.warning(f"Error extracting score: {e}, score type: {type(score)}, score value: {score}") + return "0" + + home_score = extract_score(home_team) + away_score = extract_score(away_team) + + # Extract logo URLs from ESPN API structure (logos is an array) + def extract_logo_url(team_data): + """Extract logo URL from team data.""" + team_info = team_data.get("team", {}) + logos = team_info.get("logos", []) + if logos and len(logos) > 0: + return logos[0].get("href") + # Fallback to direct logo field if logos array doesn't exist + return team_info.get("logo") + + home_logo_url = extract_logo_url(home_team) + away_logo_url = extract_logo_url(away_team) + + details = { + "id": game_event.get("id"), + "game_time": game_time, + "game_date": game_date, + "start_time_utc": start_time_utc, + "status_text": status["type"][ + "shortDetail" + ], # e.g., "Final", "7:30 PM", "Q1 12:34" + "is_live": status["type"]["state"] == "in", + "is_final": status["type"]["state"] == "post", + "is_upcoming": ( + status["type"]["state"] == "pre" + or status["type"]["name"].lower() + in ["scheduled", "pre-game", "status_scheduled"] + ), + "is_halftime": status["type"]["state"] == "halftime" + or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check + "is_period_break": status["type"]["name"] + == "STATUS_END_PERIOD", # Added Period Break check + "home_abbr": home_abbr, + "home_id": home_team["id"], + "home_score": home_score, + "home_logo_path": self.logo_dir + / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"), + "home_logo_url": home_logo_url, + "home_record": home_record, + "away_record": away_record, + "away_abbr": away_abbr, + "away_id": away_team["id"], + "away_score": away_score, + "away_logo_path": self.logo_dir + / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"), + "away_logo_url": away_logo_url, + "is_within_window": True, # Whether game is within display window + } + return details, home_team, away_team, status, situation + except Exception as e: + # Log the problematic event structure if possible + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) + return None, None, None, None, None + + @abstractmethod + def _extract_game_details(self, game_event: dict) -> dict | None: + details, _, _, _, _ = self._extract_game_details_common(game_event) + return details + + @abstractmethod + def _fetch_data(self) -> Optional[Dict]: + pass + + def _fetch_todays_games(self) -> Optional[Dict]: + """Fetch current/today's games for live updates (not entire season).""" + try: + # For NCAA Basketball, use no dates parameter to get current games + # This works around the date range limitation + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + + # Check cache first (short TTL for live data) + cache_key = f"{self.sport_key}_scoreboard_current" + cached_data = self.cache_manager.get(cache_key, max_age=30) # 30s cache for live data + if cached_data: + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.debug(f"Using cached current scoreboard for {self.sport}/{self.league}") + return cached_data + + # ESPN API anchors its schedule calendar to Eastern US time. + # Always query using the Eastern date + 1-day lookback to catch + # late-night games still in progress from the previous Eastern day. + tz = pytz.timezone("America/New_York") + now = datetime.now(tz) + yesterday = now - timedelta(days=1) + formatted_date = now.strftime("%Y%m%d") + formatted_date_yesterday = yesterday.strftime("%Y%m%d") + params = {"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000} + self.logger.debug(f"Fetching today's games for {self.sport}/{self.league} on date {formatted_date}") + + response = self.session.get( + url, + params=params, + headers=self.headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + events = data.get("events", []) + + self.logger.info( + f"Fetched {len(events)} current games for {self.sport} - {self.league}" + ) + + # Log status of each game for debugging + if events: + for event in events: + status = event.get("competitions", [{}])[0].get("status", {}) + status_type = status.get("type", {}) + state = status_type.get("state", "unknown") + name = status_type.get("name", "unknown") + self.logger.debug( + f"Event {event.get('id', 'unknown')}: state={state}, name={name}, " + f"shortDetail={status_type.get('shortDetail', 'N/A')}" + ) + + # Cache the result (short TTL for live data) + self.cache_manager.set(cache_key, data) + return {"events": events} + except requests.exceptions.RequestException as e: + self.logger.error( + f"API error fetching current games for {self.sport} - {self.league}: {e}" + ) + return None + + def _get_weeks_data(self) -> Optional[Dict]: + """ + Get partial data for immediate display while background fetch is in progress. + This fetches current/recent games only for quick response. + """ + try: + # Fetch current week and next few days for immediate display + now = datetime.now(pytz.utc) + immediate_events = [] + + start_date = now + timedelta(weeks=-2) + end_date = now + timedelta(weeks=1) + date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}" + url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard" + response = self.session.get( + url, + params={"dates": date_str, "limit": 1000}, + headers=self.headers, + timeout=10, + ) + response.raise_for_status() + data = response.json() + immediate_events = data.get("events", []) + + if immediate_events: + self.logger.info(f"Fetched {len(immediate_events)} events {date_str}") + return {"events": immediate_events} + + except requests.exceptions.RequestException as e: + self.logger.warning( + f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}" + ) + return None + + def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw): + pass + + def _get_team_record_text(self, abbr: str, record: str) -> str: + """Return the corner text for a team: ranking (if enabled/available) or record. + + When rankings are enabled they take precedence; unranked teams show + nothing rather than falling back to a record, matching the behavior of + the recent/upcoming layouts. + """ + if self.show_ranking: + rank = self._team_rankings_cache.get(abbr, 0) + return f"#{rank}" if rank > 0 else "" + if self.show_records: + return record + return "" + + def cleanup(self): + """Clean up resources when plugin is unloaded.""" + # Close HTTP session + if hasattr(self, 'session') and self.session: + try: + self.session.close() + except Exception as e: + self.logger.warning(f"Error closing session: {e}") + + # Clear caches + if hasattr(self, '_logo_cache'): + self._logo_cache.clear() + + self.logger.info(f"{self.__class__.__name__} cleanup completed") + + +class SportsUpcoming(SportsCore): + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.upcoming_games = [] # Store all fetched upcoming games initially + self.games_list = [] # Filtered list for display (favorite teams) + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mode_config.get( + "upcoming_update_interval", 3600 + ) # Check for recent games every hour + self.last_log_time = 0 + self.log_interval = 300 + self.last_warning_time = 0 + self.warning_cooldown = 300 + self.last_game_switch = 0 + self.game_display_duration = 15 # Display each upcoming game for 15 seconds + + def _select_games_for_display( + self, processed_games: List[Dict], favorite_teams: List[str] + ) -> List[Dict]: + """ + Single-pass game selection with proper deduplication and counting. + + When a game involves two favorite teams, it counts toward BOTH teams' limits. + This prevents unexpected game counts from the multi-pass algorithm. + """ + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc), + ) + + if not favorite_teams: + return sorted_games + + selected_games = [] + selected_ids = set() + team_counts = {team: 0 for team in favorite_teams} + + for game in sorted_games: + game_id = game.get("id") + if game_id in selected_ids: + continue + + home = game.get("home_abbr") + away = game.get("away_abbr") + + home_fav = home in favorite_teams + away_fav = away in favorite_teams + + if not home_fav and not away_fav: + continue + + home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show + away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show + + if home_needs or away_needs: + selected_games.append(game) + selected_ids.add(game_id) + if home_fav: + team_counts[home] += 1 + if away_fav: + team_counts[away] += 1 + + self.logger.debug( + f"Selected game {away}@{home}: team_counts={team_counts}" + ) + + if all(c >= self.upcoming_games_to_show for c in team_counts.values()): + self.logger.debug("All favorite teams satisfied, stopping selection") + break + + self.logger.info( + f"Selected {len(selected_games)} games for {len(favorite_teams)} " + f"favorite teams: {team_counts}" + ) + return selected_games + + def update(self): + """Update upcoming games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + try: + data = self._fetch_data() # Uses shared cache + if not data or "events" not in data: + self.logger.warning( + "No events found in shared data." + ) # Changed log prefix + if not self.games_list: + self.current_game = None + return + + events = data["events"] + # self.logger.info(f"Processing {len(events)} events from shared data.") # Changed log prefix + + processed_games = [] + favorite_games_found = 0 + all_upcoming_games = 0 # Count all upcoming games regardless of favorites + + for event in events: + game = self._extract_game_details(event) + # Count all upcoming games for debugging + if game and game["is_upcoming"]: + all_upcoming_games += 1 + + # Filter criteria: must be upcoming ('pre' state) + if game and game["is_upcoming"]: + # Only fetch odds for games that will be displayed + # If show_favorite_teams_only is True but no favorites configured, show all + if self.show_favorite_teams_only and self.favorite_teams: + if ( + game["home_abbr"] not in self.favorite_teams + and game["away_abbr"] not in self.favorite_teams + ): + continue + processed_games.append(game) + # Count favorite team games for logging + if self.favorite_teams and ( + game["home_abbr"] in self.favorite_teams + or game["away_abbr"] in self.favorite_teams + ): + favorite_games_found += 1 + if self.show_odds: + self._fetch_odds(game) + + # Enhanced logging for debugging + self.logger.info(f"Found {all_upcoming_games} total upcoming games in data") + self.logger.info( + f"Found {len(processed_games)} upcoming games after filtering" + ) + + if processed_games: + for game in processed_games[:3]: # Show first 3 + self.logger.info( + f" {game['away_abbr']}@{game['home_abbr']} - {game['start_time_utc']}" + ) + + if self.favorite_teams and all_upcoming_games > 0: + self.logger.info(f"Favorite teams: {self.favorite_teams}") + self.logger.info( + f"Found {favorite_games_found} favorite team upcoming games" + ) + + # Use single-pass algorithm for game selection + # This properly handles games between two favorite teams (counts for both) + if self.show_favorite_teams_only and self.favorite_teams: + team_games = self._select_games_for_display( + processed_games, self.favorite_teams + ) + else: + # No favorite teams: show N total games sorted by time (schedule view) + team_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.max.replace(tzinfo=timezone.utc), + )[:self.upcoming_games_to_show] + self.logger.info( + f"No favorites configured: showing {len(team_games)} total upcoming games" + ) + + # Log changes or periodically + should_log = ( + current_time - self.last_log_time >= self.log_interval + or len(team_games) != len(self.games_list) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.games_list, team_games) + ) + or (not self.games_list and team_games) + ) + + # Check if the list of games to display has changed (protected by lock for thread safety) + with self._games_lock: + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} upcoming games within window for display." + ) # Changed log prefix + self.games_list = team_games + self._last_rendered_game_id = None # Force redraw with new data + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time + else: + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[self.current_game_index] + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + # Same IDs but payload may have changed — update data and + # force a redraw so corrected scores/times appear. + self.current_game = self.games_list[ + self.current_game_index + ] + self._last_rendered_game_id = None + + if not self.games_list: + self.logger.info( + "No relevant upcoming games found to display." + ) + self.current_game = None + + if should_log and not self.games_list: + # Log favorite teams only if no games are found and logging is needed + self.logger.debug( + f"Favorite teams: {self.favorite_teams}" + ) # Changed log prefix + self.logger.debug( + f"Total upcoming games before filtering: {len(processed_games)}" + ) # Changed log prefix + self.last_log_time = current_time + elif should_log: + self.last_log_time = current_time + + except Exception as e: + self.logger.error( + f"Error updating upcoming games: {e}", exc_info=True + ) # Changed log prefix + # self.current_game = None # Decide if clear on error + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for an upcoming NCAA FB game.""" # Updated docstring + try: + # Clear the display first to ensure full coverage (like weather plugin does) + if force_clear: + self.display_manager.clear() + + # Use display_manager.matrix dimensions directly to ensure full display coverage + display_width = self.display_manager.matrix.width if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_width + display_height = self.display_manager.matrix.height if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_height + + main_img = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + missing_logos = [] + if not home_logo: + missing_logos.append(f"home ({game.get('home_abbr', 'N/A')})") + if not away_logo: + missing_logos.append(f"away ({game.get('away_abbr', 'N/A')})") + + self.logger.error( + f"Failed to load logos for game {game.get('id')}: {', '.join(missing_logos)}. " + f"Home logo path: {game.get('home_logo_path')}, " + f"Away logo path: {game.get('away_logo_path')}, " + f"Home logo URL: {game.get('home_logo_url')}, " + f"Away logo URL: {game.get('away_logo_url')}" + ) + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Logo Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image = main_img.convert("RGB") + self.display_manager.update_display() + return + + center_y = display_height // 2 + + # MLB-style logo positions with layout offsets + home_x = display_width - home_logo.width + 2 + self._get_layout_offset('home_logo', 'x_offset') + home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -2 + self._get_layout_offset('away_logo', 'x_offset') + away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') + main_img.paste(away_logo, (away_x, away_y), away_logo) + + # Draw Text Elements on Overlay + game_date = game.get("game_date", "") + game_time = game.get("game_time", "") + + # Note: Rankings are now handled in the records/rankings section below + + # League name at the top so the competition is identifiable (falls back + # to "Next Game" when the manager didn't set one). The small status font + # keeps long names like "Scottish Premiership" on a single line. + status_font = self.fonts["status"] + if display_width > 128: + status_font = self.fonts["time"] + status_text = getattr(self, "league_name", "") or "Next Game" + status_width = draw_overlay.textlength(status_text, font=status_font) + status_x = (display_width - status_width) // 2 + self._get_layout_offset('status_text', 'x_offset') + status_y = 1 + self._get_layout_offset('status_text', 'y_offset') + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), status_font + ) + + # Date text (centered, below "Next Game") with layout offsets + date_width = draw_overlay.textlength(game_date, font=self.fonts["time"]) + date_x = (display_width - date_width) // 2 + self._get_layout_offset('date', 'x_offset') + # Adjust Y position to stack date and time nicely + date_y = center_y - 7 + self._get_layout_offset('date', 'y_offset') + self._draw_text_with_outline( + draw_overlay, game_date, (date_x, date_y), self.fonts["time"] + ) + + # Time text (centered, below Date) with layout offsets + time_width = draw_overlay.textlength(game_time, font=self.fonts["time"]) + time_x = (display_width - time_width) // 2 + self._get_layout_offset('time', 'x_offset') + time_y = date_y + 9 + self._get_layout_offset('time', 'y_offset') + self._draw_text_with_outline( + draw_overlay, game_time, (time_x, time_y), self.fonts["time"] + ) + + # Draw odds if available + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], display_width, display_height + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug("Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + # Get team abbreviations + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self._get_layout_offset('records', 'y_offset') + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if away_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + away_text = "" + elif self.show_ranking: + # Show ranking only if available + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + away_text = "" + elif self.show_records: + # Show record only when rankings are disabled + away_text = game.get("away_record", "") + else: + away_text = "" + + if away_text: + away_record_x = 0 + self._get_layout_offset('records', 'away_x_offset') + self.logger.debug( + f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + away_text, + (away_record_x, record_y), + record_font, + ) + + # Display home team info + if home_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + home_text = "" + elif self.show_ranking: + # Show ranking only if available + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + home_text = "" + elif self.show_records: + # Show record only when rankings are disabled + home_text = game.get("home_record", "") + else: + home_text = "" + + if home_text: + home_record_bbox = draw_overlay.textbbox( + (0, 0), home_text, font=record_font + ) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = self.display_width - home_record_width + self._get_layout_offset('records', 'home_x_offset') + self.logger.debug( + f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + home_text, + (home_record_x, record_y), + record_font, + ) + + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() # Update display here + + except Exception as e: + self.logger.error( + f"Error displaying upcoming game: {e}", exc_info=True + ) # Changed log prefix + + def display(self, force_clear=False) -> bool: + """Display upcoming games, handling switching.""" + if not self.is_enabled: + return False + + if not self.games_list: + # Clear the display so old content doesn't persist + if force_clear: + self.display_manager.clear() + self.display_manager.update_display() + if self.current_game: + self.current_game = None # Clear state if list empty + current_time = time.time() + # Log warning periodically if no games found + if current_time - self.last_warning_time > self.warning_cooldown: + self.logger.info( + "No upcoming games found for favorite teams to display." + ) # Changed log prefix + self.last_warning_time = current_time + return False # Skip display update + + try: + current_time = time.time() + + # Check if it's time to switch games (protected by lock for thread safety) + with self._games_lock: + if ( + len(self.games_list) > 1 + and current_time - self.last_game_switch >= self.game_display_duration + ): + self.current_game_index = (self.current_game_index + 1) % len( + self.games_list + ) + self.current_game = self.games_list[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force redraw on switch + + # Log team switching with sport prefix + if self.current_game: + away_abbr = self.current_game.get("away_abbr", "UNK") + home_abbr = self.current_game.get("home_abbr", "UNK") + sport_prefix = ( + self.sport_key.upper() + if hasattr(self, "sport_key") + else "SPORT" + ) + self.logger.info( + f"[{sport_prefix} Upcoming] Showing {away_abbr} vs {home_abbr}" + ) + else: + self.logger.debug( + f"Switched to game index {self.current_game_index}" + ) + + if self.current_game: + # Skip redundant redraws when nothing changed + game_id = self.current_game.get("id") + if not force_clear and game_id == self._last_rendered_game_id: + return True # Already showing this game, no redraw needed + self._draw_scorebug_layout(self.current_game, force_clear) + self._last_rendered_game_id = game_id + + except Exception as e: + self.logger.error( + f"Error in display loop: {e}", exc_info=True + ) + return False + + return True + + +class SportsRecent(SportsCore): + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.recent_games = [] # Store all fetched recent games initially + self.games_list = [] # Filtered list for display (favorite teams) + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mode_config.get( + "recent_update_interval", 3600 + ) # Check for recent games every hour + self.last_game_switch = 0 + self.game_display_duration = self.mode_config.get("recent_game_duration", 15) + self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00 + + def _get_zero_clock_duration(self, game_id: str) -> float: + """Track how long a game has been at 0:00 clock.""" + current_time = time.time() + if game_id not in self._zero_clock_timestamps: + self._zero_clock_timestamps[game_id] = current_time + return 0.0 + return current_time - self._zero_clock_timestamps[game_id] + + def _clear_zero_clock_tracking(self, game_id: str) -> None: + """Clear tracking when game clock moves away from 0:00 or game ends.""" + if game_id in self._zero_clock_timestamps: + del self._zero_clock_timestamps[game_id] + + def _select_recent_games_for_display( + self, processed_games: List[Dict], favorite_teams: List[str] + ) -> List[Dict]: + """ + Single-pass game selection for recent games with proper deduplication. + + When a game involves two favorite teams, it counts toward BOTH teams' limits. + Games are sorted by most recent first. + """ + sorted_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + ) + + if not favorite_teams: + return sorted_games + + selected_games = [] + selected_ids = set() + team_counts = {team: 0 for team in favorite_teams} + + for game in sorted_games: + game_id = game.get("id") + if game_id in selected_ids: + continue + + home = game.get("home_abbr") + away = game.get("away_abbr") + + home_fav = home in favorite_teams + away_fav = away in favorite_teams + + if not home_fav and not away_fav: + continue + + home_needs = home_fav and team_counts[home] < self.recent_games_to_show + away_needs = away_fav and team_counts[away] < self.recent_games_to_show + + if home_needs or away_needs: + selected_games.append(game) + selected_ids.add(game_id) + if home_fav: + team_counts[home] += 1 + if away_fav: + team_counts[away] += 1 + + self.logger.debug( + f"Selected recent game {away}@{home}: team_counts={team_counts}" + ) + + if all(c >= self.recent_games_to_show for c in team_counts.values()): + self.logger.debug("All favorite teams satisfied, stopping selection") + break + + self.logger.info( + f"Selected {len(selected_games)} recent games for {len(favorite_teams)} " + f"favorite teams: {team_counts}" + ) + return selected_games + + def update(self): + """Update recent games data.""" + if not self.is_enabled: + return + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + self.last_update = current_time # Update time even if fetch fails + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + try: + data = self._fetch_data() # Uses shared cache + if not data or "events" not in data: + self.logger.warning( + "No events found in shared data." + ) # Changed log prefix + if not self.games_list: + self.current_game = None # Clear display if no games were showing + return + + events = data["events"] + self.logger.info( + f"Processing {len(events)} events from shared data." + ) # Changed log prefix + + # Define date range for "recent" games (last 21 days to capture games from 3 weeks ago) + now = datetime.now(timezone.utc) + recent_cutoff = now - timedelta(days=21) + self.logger.info( + f"Current time: {now}, Recent cutoff: {recent_cutoff} (21 days ago)" + ) + + # Process games and filter for final games, date range & favorite teams + processed_games = [] + for event in events: + game = self._extract_game_details(event) + if not game: + continue + + # Check if game appears finished even if not marked as "post" yet + game_id = game.get("id") + appears_finished = False + if not game.get("is_final", False): + period_text = game.get("period_text", "").lower() + if "final" in period_text: + appears_finished = True + self._clear_zero_clock_tracking(game_id) + else: + self._clear_zero_clock_tracking(game_id) + + # Filter criteria: must be final OR appear finished, AND within recent date range + is_eligible = game.get("is_final", False) or appears_finished + if is_eligible: + game_time = game.get("start_time_utc") + if game_time and game_time >= recent_cutoff: + # Excluded teams are hidden from final/recent scores too + # (spoiler protection), regardless of every other filter. + if ( + game.get("home_abbr") in self.exclude_teams + or game.get("away_abbr") in self.exclude_teams + ): + continue + processed_games.append(game) + # Use single-pass algorithm for game selection + # This properly handles games between two favorite teams (counts for both) + if self.show_favorite_teams_only and self.favorite_teams: + team_games = self._select_recent_games_for_display( + processed_games, self.favorite_teams + ) + self.logger.info( + f"Filtering to favorites: {len(team_games)} games for teams {self.favorite_teams}" + ) + # Debug: Show which games are selected for display + for i, game in enumerate(team_games): + self.logger.info( + f"Game {i+1} for display: {game['away_abbr']} @ {game['home_abbr']} - {game.get('start_time_utc')} - Score: {game['away_score']}-{game['home_score']}" + ) + else: + # show_favorite_teams_only disabled or no favorites: show N total games sorted by time + team_games = sorted( + processed_games, + key=lambda g: g.get("start_time_utc") + or datetime.min.replace(tzinfo=timezone.utc), + reverse=True, + )[:self.recent_games_to_show] + reason = "show_favorite_teams_only disabled" if not self.show_favorite_teams_only else "no favorites configured" + self.logger.info( + f"Showing all games ({reason}): {len(team_games)} total recent games" + ) + + # Check if the list of games to display has changed (protected by lock for thread safety) + with self._games_lock: + new_game_ids = {g["id"] for g in team_games} + current_game_ids = {g["id"] for g in self.games_list} + + if new_game_ids != current_game_ids: + self.logger.info( + f"Found {len(team_games)} final games within window for display." + ) # Changed log prefix + self.games_list = team_games + self._last_rendered_game_id = None # Force redraw with new data + # Reset index if list changed or current game removed + if ( + not self.current_game + or not self.games_list + or self.current_game["id"] not in new_game_ids + ): + self.current_game_index = 0 + self.current_game = self.games_list[0] if self.games_list else None + self.last_game_switch = current_time # Reset switch timer + else: + # Try to maintain position if possible + try: + self.current_game_index = next( + i + for i, g in enumerate(self.games_list) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.games_list[ + self.current_game_index + ] # Update data just in case + except StopIteration: + self.current_game_index = 0 + self.current_game = self.games_list[0] + self.last_game_switch = current_time + + elif self.games_list: + # Same IDs but payload may have changed — update data and + # force a redraw so corrected scores/times appear. + self.current_game = self.games_list[self.current_game_index] + self._last_rendered_game_id = None + + if not self.games_list: + self.logger.info( + "No relevant recent games found to display." + ) + self.current_game = None + + except Exception as e: + self.logger.error( + f"Error updating recent games: {e}", exc_info=True + ) # Changed log prefix + # Don't clear current game on error, keep showing last known state + # self.current_game = None # Decide if we want to clear display on error + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the layout for a recently completed NCAA FB game.""" # Updated docstring + try: + # Clear the display first to ensure full coverage (like weather plugin does) + if force_clear: + self.display_manager.clear() + + # Use display_manager.matrix dimensions directly to ensure full display coverage + display_width = self.display_manager.matrix.width if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_width + display_height = self.display_manager.matrix.height if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_height + + main_img = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (display_width, display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw(overlay) + + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + self.logger.error( + f"Failed to load logos for game: {game.get('id')}" + ) # Changed log prefix + # Draw placeholder text if logos fail (similar to live) + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Logo Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image = main_img.convert("RGB") + self.display_manager.update_display() + return + + center_y = display_height // 2 + + # MLB-style logo positioning (closer to edges) with layout offsets + home_x = display_width - home_logo.width + 2 + self._get_layout_offset('home_logo', 'x_offset') + home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -2 + self._get_layout_offset('away_logo', 'x_offset') + away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') + main_img.paste(away_logo, (away_x, away_y), away_logo) + + # Draw Text Elements on Overlay + # Note: Rankings are now handled in the records/rankings section below + + # Final Scores (Centered, same position as live) with layout offsets + # Convert scores to integers to remove decimal points + def format_score(score): + """Format score as integer string, removing decimals.""" + try: + # Handle None or empty values + if score is None: + return "0" + + # If it's already a string, try to parse it + if isinstance(score, str): + # Remove any whitespace + score = score.strip() + # If empty, return 0 + if not score: + return "0" + # Try to extract number from string (handles cases where score might be a string representation of something else) + try: + return str(int(float(score))) + except ValueError: + # Try to extract first number from string + import re + numbers = re.findall(r'\d+', score) + if numbers: + return str(int(numbers[0])) + self.logger.warning(f"Could not parse score string: {score}") + return "0" + + # Handle dict (shouldn't happen if extraction worked, but be safe) + if isinstance(score, dict): + score_value = score.get("value", score.get("displayValue", 0)) + return str(int(float(score_value))) + + # Handle numeric types + return str(int(float(score))) + except (ValueError, TypeError) as e: + self.logger.warning(f"Error formatting score: {e}, score type: {type(score)}, score value: {score}") + return "0" + + home_score = format_score(game.get("home_score", "0")) + away_score = format_score(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') + # Centered vertically (matches the baseball recent layout) so the game + # date fits on the bottom line without colliding with the score. + score_y = (display_height // 2) - 3 + self._get_layout_offset('score', 'y_offset') + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # "Final" text (Top center) with layout offsets + status_text = game.get( + "period_text", "Final" + ) # Use formatted period text (e.g., "Final/OT") or default "Final" + status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) + status_x = (display_width - status_width) // 2 + self._get_layout_offset('status_text', 'x_offset') + status_y = 1 + self._get_layout_offset('status_text', 'y_offset') + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), self.fonts["time"] + ) + + # Game date (bottom center, one line above the edge) — when the game was + # played. Matches the baseball recent layout. + game_date = game.get("game_date", "") + if game_date: + date_width = draw_overlay.textlength(game_date, font=self.fonts["time"]) + date_x = (display_width - date_width) // 2 + self._get_layout_offset('date', 'x_offset') + date_y = display_height - 7 + self._get_layout_offset('date', 'y_offset') + self._draw_text_with_outline( + draw_overlay, game_date, (date_x, date_y), self.fonts["time"] + ) + + # Draw odds if available + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], display_width, display_height + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug("Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + # Get team abbreviations + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self._get_layout_offset('records', 'y_offset') + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if away_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + away_text = "" + elif self.show_ranking: + # Show ranking only if available + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + away_text = "" + elif self.show_records: + # Show record only when rankings are disabled + away_text = game.get("away_record", "") + else: + away_text = "" + + if away_text: + away_record_x = 0 + self._get_layout_offset('records', 'away_x_offset') + self.logger.debug( + f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + away_text, + (away_record_x, record_y), + record_font, + ) + + # Display home team info + if home_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + home_text = "" + elif self.show_ranking: + # Show ranking only if available + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + home_text = "" + elif self.show_records: + # Show record only when rankings are disabled + home_text = game.get("home_record", "") + else: + home_text = "" + + if home_text: + home_record_bbox = draw_overlay.textbbox( + (0, 0), home_text, font=record_font + ) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = display_width - home_record_width + self._get_layout_offset('records', 'home_x_offset') + self.logger.debug( + f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + home_text, + (home_record_x, record_y), + record_font, + ) + + self._custom_scorebug_layout(game, draw_overlay) + # Composite and display + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + # Assign directly like weather plugin does for full display coverage + self.display_manager.image = main_img + self.display_manager.update_display() # Update display here + + except Exception as e: + self.logger.error( + f"Error displaying recent game: {e}", exc_info=True + ) # Changed log prefix + + def display(self, force_clear=False) -> bool: + """Display recent games, handling switching.""" + if not self.is_enabled or not self.games_list: + # If disabled or no games, clear the display so old content doesn't persist + if force_clear or not self.games_list: + self.display_manager.clear() + self.display_manager.update_display() + if not self.games_list and self.current_game: + self.current_game = None # Clear internal state if list becomes empty + return False + + try: + current_time = time.time() + + # Check if it's time to switch games (protected by lock for thread safety) + with self._games_lock: + if ( + len(self.games_list) > 1 + and current_time - self.last_game_switch >= self.game_display_duration + ): + self.current_game_index = (self.current_game_index + 1) % len( + self.games_list + ) + self.current_game = self.games_list[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force redraw on switch + + # Log team switching with sport prefix + if self.current_game: + away_abbr = self.current_game.get("away_abbr", "UNK") + home_abbr = self.current_game.get("home_abbr", "UNK") + sport_prefix = ( + self.sport_key.upper() + if hasattr(self, "sport_key") + else "SPORT" + ) + self.logger.info( + f"[{sport_prefix} Recent] Showing {away_abbr} vs {home_abbr}" + ) + else: + self.logger.debug( + f"Switched to game index {self.current_game_index}" + ) + + if self.current_game: + # Skip redundant redraws when nothing changed + game_id = self.current_game.get("id") + if not force_clear and game_id == self._last_rendered_game_id: + return True # Already showing this game, no redraw needed + self._draw_scorebug_layout(self.current_game, force_clear) + self._last_rendered_game_id = game_id + + except Exception as e: + self.logger.error( + f"Error in display loop: {e}", exc_info=True + ) + return False + + return True + + +class SportsLive(SportsCore): + + def __init__( + self, + config: Dict[str, Any], + display_manager, + cache_manager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.update_interval = self.mode_config.get("live_update_interval", 15) + self.no_data_interval = 300 + # Log the configured interval for debugging + self.logger.info( + f"SportsLive initialized: live_update_interval={self.update_interval}s, " + f"no_data_interval={self.no_data_interval}s, " + f"mode_config keys={list(self.mode_config.keys())}" + ) + self.last_update = 0 + self.live_games = [] + self.current_game_index = 0 + self.last_game_switch = 0 + self.game_display_duration = self.mode_config.get("live_game_duration", 20) + # Optional shorter dwell for live games that involve NO favorite team. + # 0 (default) means "use game_display_duration for every live game" - + # i.e. today's behavior. Only bites when favorites are configured and + # show_favorite_teams_only is off (so non-favorite games are on screen). + try: + self.non_favorite_live_game_duration = int( + self.mode_config.get("non_favorite_live_game_duration", 0) or 0 + ) + except (TypeError, ValueError): + self.non_favorite_live_game_duration = 0 + self.last_display_update = 0 + self.last_log_time = 0 + self.log_interval = 300 + self.last_count_log_time = 0 # Track when we last logged count data + self.count_log_interval = 5 # Only log count data every 5 seconds + # Initialize test_mode - defaults to False (live mode) + self.test_mode = self.mode_config.get("test_mode", False) + # Track game update timestamps for stale data detection + self.game_update_timestamps = {} + self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default + + # Goal/win celebration takeover + self.celebration_enabled = self.mode_config.get("celebration_enabled", True) + self.celebration_duration = self.mode_config.get("celebration_duration", 8) + self.celebrate_opponent_goals = self.mode_config.get( + "celebrate_opponent_goals", False + ) + # Per-game score baselines for goal detection: {game_id: {"away": int, "home": int}} + self._score_baselines: Dict[str, Dict[str, int]] = {} + # Active celebration dict (a snapshot, so a win survives the game leaving + # live_games) or None. See _start_celebration for the shape. + self.active_celebration: Optional[Dict[str, Any]] = None + + def _get_live_status_text(self, game: Dict) -> str: + """Build the top status string for a live game (period + clock).""" + if game.get("is_halftime"): + return "HALF" + if game.get("is_period_break"): + return game.get("status_text", "BREAK") + # period_text already combines period + clock (e.g. "2H 75'") in the + # afl managers; fall back to the raw clock, then a generic "LIVE". + period_text = game.get("period_text", "") + if period_text: + return period_text + clock = game.get("clock", "") + if clock: + return clock + return "LIVE" + + # ------------------------------------------------------------------ + # Goal / win celebration + # ------------------------------------------------------------------ + @staticmethod + def _score_to_int(score) -> Optional[int]: + """Coerce an ESPN score value (str / int / dict) to an int, or None.""" + try: + if score is None: + return None + if isinstance(score, str): + s = score.strip() + if not s: + return None + try: + return int(float(s)) + except ValueError: + import re + numbers = re.findall(r"\d+", s) + return int(numbers[0]) if numbers else None + if isinstance(score, dict): + return int(float(score.get("value", score.get("displayValue", 0)))) + return int(float(score)) + except (ValueError, TypeError): + return None + + def _is_favorite(self, abbr: Optional[str]) -> bool: + return bool(self.favorite_teams) and abbr in self.favorite_teams + + def _should_celebrate_goal_for(self, abbr: Optional[str]) -> bool: + """Whether a goal by ``abbr`` should trigger a celebration.""" + if self._is_favorite(abbr): + return True + if not self.favorite_teams: + # No favorites configured: the user opted to show this game, so + # celebrate any goal in it. + return True + # Favorites exist but this team isn't one -> it's the opponent. + return self.celebrate_opponent_goals + + def has_active_celebration(self) -> bool: + """True while a celebration is within its display window.""" + c = self.active_celebration + return bool(c) and (time.time() - c["started_at"] < self.celebration_duration) + + def _start_celebration( + self, + game: Dict, + kind: str, + scored_side: str, + team_abbr: str, + away_score: int, + home_score: int, + ) -> None: + """Arm a goal or win celebration. ``scored_side`` is the side whose + score digit gets highlighted ('away' or 'home').""" + if kind == "win": + phrase = f"{team_abbr} WINS!" + else: + phrase = secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!")) + + self.active_celebration = { + "kind": kind, + "game": dict(game), # snapshot: survives the game leaving live_games + "scored_side": scored_side, + "team_abbr": team_abbr, + "away_score": away_score, + "home_score": home_score, + "started_at": time.time(), + "phrase": phrase, + } + # Pin focus to the involved game so the post-celebration scorebug + # resumes on it. + self.current_game = dict(game) + self.logger.info( + f"Celebration ({kind}) armed: {phrase} " + f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]" + ) + + def _check_for_goal(self, game: Dict) -> None: + """Compare a live game's score against the stored baseline and arm a + goal celebration when a celebratable team's score increments.""" + if not self.celebration_enabled: + return + game_id = game.get("id") + if not game_id: + return + away = self._score_to_int(game.get("away_score")) + home = self._score_to_int(game.get("home_score")) + if away is None or home is None: + return + + baseline = self._score_baselines.get(game_id) + # Always refresh the baseline; a first sighting must never celebrate + # (a match already in progress at boot would false-fire otherwise), + # and a decrement (VAR / disallowed goal) just re-bases silently. + self._score_baselines[game_id] = {"away": away, "home": home} + if baseline is None: + return + + away_scored = away > baseline["away"] + home_scored = home > baseline["home"] + if not (away_scored or home_scored): + return + + scored_side = None + if away_scored and self._should_celebrate_goal_for(game.get("away_abbr")): + scored_side = "away" + if scored_side is None and home_scored and self._should_celebrate_goal_for( + game.get("home_abbr") + ): + scored_side = "home" + if scored_side is None: + return + + self._start_celebration( + game, + "goal", + scored_side=scored_side, + team_abbr=game.get(f"{scored_side}_abbr", ""), + away_score=away, + home_score=home, + ) + + def _check_for_win(self, game: Dict) -> None: + """When a game we were tracking live goes final, arm a win celebration + if a favorite team won. Only fires once per game.""" + if not self.celebration_enabled: + return + game_id = game.get("id") + if not game_id: + return + # Only celebrate wins for games we actually watched go live; a game seen + # for the first time already-final (Pi started after full time) has no + # baseline and must not fire. + if game_id not in self._score_baselines: + return + # Consume the baseline so this can only fire once. + self._score_baselines.pop(game_id, None) + + away = self._score_to_int(game.get("away_score")) + home = self._score_to_int(game.get("home_score")) + if away is None or home is None: + return + + if away > home: + winner_side, winner_abbr = "away", game.get("away_abbr") + elif home > away: + winner_side, winner_abbr = "home", game.get("home_abbr") + else: + return # draw -> no win celebration + + # Wins are gated strictly on favorites (every game ends, so the + # "no favorites -> celebrate all" goal fallback would be too noisy). + if not self._is_favorite(winner_abbr): + return + + self._start_celebration( + game, + "win", + scored_side=winner_side, + team_abbr=winner_abbr, + away_score=away, + home_score=home, + ) + + def _fit_font(self, draw, text: str, max_width: int, fonts: list): + """Return the first font whose rendered ``text`` fits ``max_width``, + falling back to the last (smallest) font.""" + for font in fonts: + if draw.textlength(text, font=font) <= max_width - 2: + return font + return fonts[-1] + + def _draw_celebration_layout(self, celebration: Dict, force_clear: bool = False) -> None: + """Render the full-screen goal/win takeover.""" + if force_clear: + self.display_manager.clear() + + display_width = ( + self.display_manager.matrix.width + if hasattr(self.display_manager, "matrix") and self.display_manager.matrix + else self.display_width + ) + display_height = ( + self.display_manager.matrix.height + if hasattr(self.display_manager, "matrix") and self.display_manager.matrix + else self.display_height + ) + + elapsed = time.time() - celebration["started_at"] + game = celebration["game"] + + # Background: a brief color flash for the first ~1.2s, then black. + bg = (0, 0, 0, 255) + if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0: + bg = (12, 12, 48, 255) + main_img = Image.new("RGBA", (display_width, display_height), bg) + overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + + # Logos at the edges (best-effort: a logo failure must not blank the + # celebration). + try: + center_y = display_height // 2 + home_logo = self._load_and_resize_logo( + game.get("home_id"), game.get("home_abbr"), + game.get("home_logo_path"), game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game.get("away_id"), game.get("away_abbr"), + game.get("away_logo_path"), game.get("away_logo_url"), + ) + if home_logo: + main_img.paste( + home_logo, + (display_width - home_logo.width + 2, center_y - home_logo.height // 2), + home_logo, + ) + if away_logo: + main_img.paste( + away_logo, (-2, center_y - away_logo.height // 2), away_logo + ) + except Exception as e: + self.logger.debug(f"Celebration logo load failed: {e}") + + # Phrase across the top, shrunk to fit the panel width. + phrase = celebration["phrase"] + phrase_font = self._fit_font( + draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]] + ) + phrase_width = draw.textlength(phrase, font=phrase_font) + self._draw_text_with_outline( + draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font + ) + + # Score centered low, with the scoring/winning side's digit pulsing in a + # highlight color so the change reads at a glance. + away_text = str(celebration["away_score"]) + home_text = str(celebration["home_score"]) + score_font = self.fonts["score"] + segments = [ + (away_text, celebration["scored_side"] == "away"), + ("-", False), + (home_text, celebration["scored_side"] == "home"), + ] + total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments) + highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0) + x = (display_width - total_width) // 2 + y = display_height - 14 + for seg, is_highlight in segments: + color = highlight if is_highlight else (255, 255, 255) + self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color) + x += draw.textlength(seg, font=score_font) + + main_img = Image.alpha_composite(main_img, overlay).convert("RGB") + self.display_manager.image = main_img + self.display_manager.update_display() + + def display(self, force_clear: bool = False) -> bool: + """Render an active celebration as a full-screen takeover; otherwise + defer to the normal live scorebug.""" + if not self.is_enabled: + return False + celebration = self.active_celebration + if celebration: + if time.time() - celebration["started_at"] < self.celebration_duration: + try: + self._draw_celebration_layout(celebration, force_clear) + return True + except Exception as e: + self.logger.error( + f"Error drawing celebration: {e}", exc_info=True + ) + else: + self.active_celebration = None + # Reset the dwell so the scorebug resumes on the scoring/winning + # game for a full duration before rotation can move on. + self.last_game_switch = time.time() + return super().display(force_clear) + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the switch-mode scorebug for a live afl game. + + Mirrors the recent-game layout (logos at the edges, score centered low) + but shows the live period/clock at the top instead of "Final". + """ + try: + if force_clear: + self.display_manager.clear() + + display_width = self.display_manager.matrix.width if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_width + display_height = self.display_manager.matrix.height if hasattr(self.display_manager, 'matrix') and self.display_manager.matrix else self.display_height + + main_img = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 255)) + overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0)) + draw_overlay = ImageDraw.Draw(overlay) + + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + self.logger.error(f"Failed to load logos for live game: {game.get('id')}") + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Logo Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image = main_img.convert("RGB") + self.display_manager.update_display() + return + + center_y = display_height // 2 + + # Logos at the edges (matches recent/upcoming layouts) + home_x = display_width - home_logo.width + 2 + self._get_layout_offset('home_logo', 'x_offset') + home_y = center_y - (home_logo.height // 2) + self._get_layout_offset('home_logo', 'y_offset') + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -2 + self._get_layout_offset('away_logo', 'x_offset') + away_y = center_y - (away_logo.height // 2) + self._get_layout_offset('away_logo', 'y_offset') + main_img.paste(away_logo, (away_x, away_y), away_logo) + + # Score (centered, low — same position as recent games) + def format_score(score): + try: + if score is None: + return "0" + if isinstance(score, str): + score = score.strip() + if not score: + return "0" + try: + return str(int(float(score))) + except ValueError: + import re + numbers = re.findall(r'\d+', score) + return str(int(numbers[0])) if numbers else "0" + if isinstance(score, dict): + score_value = score.get("value", score.get("displayValue", 0)) + return str(int(float(score_value))) + return str(int(float(score))) + except (ValueError, TypeError): + return "0" + + home_score = format_score(game.get("home_score", "0")) + away_score = format_score(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset') + score_y = display_height - 14 + self._get_layout_offset('score', 'y_offset') + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # Live status (period + clock) at the top center + status_text = self._get_live_status_text(game) + status_width = draw_overlay.textlength(status_text, font=self.fonts["time"]) + status_x = (display_width - status_width) // 2 + self._get_layout_offset('status_text', 'x_offset') + status_y = 1 + self._get_layout_offset('status_text', 'y_offset') + self._draw_text_with_outline( + draw_overlay, status_text, (status_x, status_y), self.fonts["time"] + ) + + # Draw odds if available + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], display_width, display_height + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + except IOError: + record_font = ImageFont.load_default() + + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height + self._get_layout_offset('records', 'y_offset') + + if away_abbr: + away_text = self._get_team_record_text(away_abbr, game.get("away_record", "")) + if away_text: + away_record_x = 0 + self._get_layout_offset('records', 'away_x_offset') + self._draw_text_with_outline( + draw_overlay, away_text, (away_record_x, record_y), record_font + ) + + if home_abbr: + home_text = self._get_team_record_text(home_abbr, game.get("home_record", "")) + if home_text: + home_record_bbox = draw_overlay.textbbox((0, 0), home_text, font=record_font) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = display_width - home_record_width + self._get_layout_offset('records', 'home_x_offset') + self._draw_text_with_outline( + draw_overlay, home_text, (home_record_x, record_y), record_font + ) + + self._custom_scorebug_layout(game, draw_overlay) + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") + self.display_manager.image = main_img + self.display_manager.update_display() + + except Exception as e: + self.logger.error(f"Error displaying live game: {e}", exc_info=True) + + def _is_game_really_over(self, game: Dict) -> bool: + """Check if a game appears to be over even if API says it's live. + + AFL-specific: clock counts UP (e.g., 75', 90+3'), so we check for + 'final' in period_text. The 0:00 clock check used in countdown-clock + sports doesn't apply here. + """ + game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}" + + # Check if period_text indicates final + period_text = game.get("period_text", "").lower() + if "final" in period_text: + self.logger.debug( + f"_is_game_really_over({game_str}): " + f"returning True - 'final' in period_text='{period_text}'" + ) + return True + + self.logger.debug( + f"_is_game_really_over({game_str}): returning False " + f"(period_text='{period_text}', period={game.get('period', 0)})" + ) + return False + + def _detect_stale_games(self, games: List[Dict]) -> None: + """Remove games that appear stale or haven't updated.""" + current_time = time.time() + + for game in games[:]: # Copy list to iterate safely + game_id = game.get("id") + if not game_id: + continue + + # Check if game data is stale + timestamps = self.game_update_timestamps.get(game_id, {}) + last_seen = timestamps.get("last_seen", 0) + + if last_seen > 0 and current_time - last_seen > self.stale_game_timeout: + self.logger.warning( + f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(last seen {int(current_time - last_seen)}s ago)" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + continue + + # Also check if game appears to be over + if self._is_game_really_over(game): + self.logger.debug( + f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} " + f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})" + ) + games.remove(game) + if game_id in self.game_update_timestamps: + del self.game_update_timestamps[game_id] + + def update(self): + """Update live game data and handle game switching.""" + if not self.is_enabled: + return + + # Define current_time and interval before the problematic line (originally line 455) + # Ensure 'import time' is present at the top of the file. + current_time = time.time() + + # Define interval using a pattern similar to NFLLiveManager's update method. + # Uses getattr for robustness, assuming attributes for live_games, + # no_data_interval, and update_interval are available on self. + _live_games_attr = self.live_games + _no_data_interval_attr = ( + self.no_data_interval + ) # Default similar to NFLLiveManager + _update_interval_attr = ( + self.update_interval + ) # Default similar to NFLLiveManager + + # For live managers, always use the configured live_update_interval when checking for updates. + # Only use no_data_interval if we've recently checked and confirmed there are no live games. + # This ensures we check for live games frequently even if the list is temporarily empty. + # Only use no_data_interval if we have no live games AND we've checked recently (within last 5 minutes) + time_since_last_update = current_time - self.last_update + has_recently_checked = self.last_update > 0 and time_since_last_update < 300 + + if _live_games_attr: + # We have live games, use the configured update interval + interval = _update_interval_attr + elif has_recently_checked: + # We've checked recently and found no live games, use longer interval + interval = _no_data_interval_attr + else: + # First check or haven't checked in a while, use update interval to check for live games + interval = _update_interval_attr + + # Original line from traceback (line 455), now with variables defined: + if current_time - self.last_update >= interval: + self.last_update = current_time + + # Fetch rankings if enabled + if self.show_ranking: + self._fetch_team_rankings() + + # Fetch live game data + data = self._fetch_data() + new_live_games = [] + if not data: + self.logger.debug(f"No data returned from _fetch_data() for {self.sport_key}") + elif "events" not in data: + self.logger.debug(f"Data returned but no 'events' key for {self.sport_key}: {list(data.keys()) if isinstance(data, dict) else type(data)}") + elif data and "events" in data: + total_events = len(data["events"]) + self.logger.debug(f"Fetched {total_events} total events from API for {self.sport_key}") + + live_or_halftime_count = 0 + filtered_out_count = 0 + + for game in data["events"]: + details = self._extract_game_details(game) + if details: + # Log game status for debugging + status_state = game.get("competitions", [{}])[0].get("status", {}).get("type", {}).get("state", "unknown") + self.logger.debug( + f"Game {details.get('away_abbr', '?')}@{details.get('home_abbr', '?')}: " + f"state={status_state}, is_live={details.get('is_live')}, " + f"is_halftime={details.get('is_halftime')}, is_final={details.get('is_final')}" + ) + + # Filter out final games and games that appear to be over. + # A game we were tracking live going final may earn a win + # celebration before it drops out of the live list. + if details.get("is_final", False): + self._check_for_win(details) + continue + + if self._is_game_really_over(details): + self._check_for_win(details) + self.logger.info( + f"Skipping game that appears final: {details.get('away_abbr')}@{details.get('home_abbr')} " + f"(clock={details.get('clock')}, period={details.get('period')}, period_text={details.get('period_text')})" + ) + continue + + if details["is_live"] or details["is_halftime"]: + live_or_halftime_count += 1 + + # Filtering logic (see _classify_live_game for the + # full precedence order: exclude > show_all_live > + # favorites-only membership; matches SportsUpcoming). + should_include = self._classify_live_game( + details["home_abbr"], details["away_abbr"] + ) + + if not should_include: + filtered_out_count += 1 + self.logger.debug( + f"Filtered out live game {details.get('away_abbr')}@{details.get('home_abbr')}: " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams}" + ) + + if should_include: + # Track game timestamps for stale detection + game_id = details.get("id") + if game_id: + current_clock = details.get("clock", "") + current_score = f"{details.get('away_score', '0')}-{details.get('home_score', '0')}" + + if game_id not in self.game_update_timestamps: + self.game_update_timestamps[game_id] = {} + + timestamps = self.game_update_timestamps[game_id] + timestamps["last_seen"] = time.time() + + if timestamps.get("last_clock") != current_clock: + timestamps["last_clock"] = current_clock + timestamps["clock_changed_at"] = time.time() + if timestamps.get("last_score") != current_score: + timestamps["last_score"] = current_score + timestamps["score_changed_at"] = time.time() + + # Detect goals (per-side score increments) and arm + # a celebration when a celebratable team scores. + self._check_for_goal(details) + + if self.show_odds: + self._fetch_odds(details) + new_live_games.append(details) + + # Detect and remove stale games from persisted list + # (new_live_games has fresh last_seen, so stale check must + # run against the previous self.live_games) + with self._games_lock: + self._detect_stale_games(self.live_games) + + self.logger.info( + f"Live game filtering: {total_events} total events, " + f"{live_or_halftime_count} live/halftime, " + f"{filtered_out_count} filtered out, " + f"{len(new_live_games)} included | " + f"show_all_live={self.show_all_live}, " + f"show_favorite_teams_only={self.show_favorite_teams_only}, " + f"favorite_teams={self.favorite_teams if self.favorite_teams else '[] (showing all)'}" + ) + # Log changes or periodically + current_time_for_log = ( + time.time() + ) # Use a consistent time for logging comparison + should_log = ( + current_time_for_log - self.last_log_time >= self.log_interval + or len(new_live_games) != len(self.live_games) + or any( + g1["id"] != g2.get("id") + for g1, g2 in zip(self.live_games, new_live_games) + ) # Check if game IDs changed + or ( + not self.live_games and new_live_games + ) # Log if games appeared + ) + + if should_log: + if new_live_games: + filter_text = ( + "favorite teams" + if self.show_favorite_teams_only or self.show_all_live + else "all teams" + ) + self.logger.info( + f"Found {len(new_live_games)} live/halftime games for {filter_text}." + ) + for ( + game_info + ) in new_live_games: # Renamed game to game_info + self.logger.info( + f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})" + ) + else: + filter_text = ( + "favorite teams" + if self.show_favorite_teams_only or self.show_all_live + else "criteria" + ) + self.logger.info( + f"No live/halftime games found for {filter_text}." + ) + self.last_log_time = current_time_for_log + + # Update game list and current game (protected by lock for thread safety) + with self._games_lock: + if new_live_games: + # Check if the games themselves changed, not just scores/time + new_game_ids = {g["id"] for g in new_live_games} + current_game_ids = {g["id"] for g in self.live_games} + + if new_game_ids != current_game_ids: + self.live_games = sorted( + new_live_games, + key=lambda g: g.get("start_time_utc") + or datetime.now(timezone.utc), + ) # Sort by start time + # Reset index if current game is gone or list is new. + # Pick via weighted round-robin so a live favorite is + # queued first (see _swrr_advance). + if ( + not self.current_game + or self.current_game["id"] not in new_game_ids + ): + self.current_game = ( + self._swrr_advance(self.live_games) + if self.live_games else None + ) + self.current_game_index = next( + (i for i, g in enumerate(self.live_games) + if self.current_game and g["id"] == self.current_game["id"]), + 0, + ) + self.last_game_switch = current_time + else: + # Find current game's new index if it still exists + try: + self.current_game_index = next( + i + for i, g in enumerate(self.live_games) + if g["id"] == self.current_game["id"] + ) + self.current_game = self.live_games[ + self.current_game_index + ] # Update current_game with fresh data + except ( + StopIteration + ): # Should not happen if check above passed, but safety first + self.current_game_index = 0 + self.current_game = self.live_games[0] + self.last_game_switch = current_time + + else: + # Just update the data for the existing games + temp_game_dict = {g["id"]: g for g in new_live_games} + self.live_games = [ + temp_game_dict.get(g["id"], g) for g in self.live_games + ] # Update in place + if self.current_game: + self.current_game = temp_game_dict.get( + self.current_game["id"], self.current_game + ) + + # Display update handled by main loop based on interval + + else: + # No live games found + if self.live_games: # Were there games before? + self.logger.info( + "Live games previously showing have ended or are no longer live." + ) # Changed log prefix + self.live_games = [] + self.current_game = None + self.current_game_index = 0 + + # Prune game_update_timestamps for games no longer tracked + active_ids = {g["id"] for g in self.live_games} + self.game_update_timestamps = { + gid: ts for gid, ts in self.game_update_timestamps.items() + if gid in active_ids + } + # Prune goal baselines the same way. A game that vanishes + # without going final is re-based on reappearance (treated as + # a first sighting), which avoids a false goal. + self._score_baselines = { + gid: v for gid, v in self._score_baselines.items() + if gid in active_ids + } + + else: + # Error fetching data or no events + if self.live_games: # Were there games before? + self.logger.warning( + "Could not fetch update; keeping existing live game data for now." + ) # Changed log prefix + else: + self.logger.warning( + "Could not fetch data and no existing live games." + ) # Changed log prefix + self.current_game = None # Clear current game if fetch fails and no games were active + + # Handle game switching (protected by lock for thread safety). + # Hold the current game while a celebration is pending so the view + # doesn't rotate away mid-celebration — or in the window between the + # duration expiring and display() clearing it (display() resets the + # dwell timer when it clears, so the scoring game resumes first). + with self._games_lock: + if ( + len(self.live_games) > 1 + and self.active_celebration is None + and (current_time - self.last_game_switch) + >= self._effective_live_duration(self.current_game) + ): + # Weighted pick (see _swrr_advance) instead of plain +1 index - + # gives a live favorite's game extra turns per + # favorite_live_boost while everything else still rotates + # fairly (boost==1 reproduces the old flat round robin). + self.current_game = self._swrr_advance(self.live_games) + self.current_game_index = next( + (i for i, g in enumerate(self.live_games) + if self.current_game and g["id"] == self.current_game["id"]), + 0, + ) + self.last_game_switch = current_time + self.logger.info( + f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}" + ) # Changed log prefix + # Force display update via flag or direct call if needed, but usually let main loop handle diff --git a/plugins/afl-scoreboard/test/harness.json b/plugins/afl-scoreboard/test/harness.json new file mode 100644 index 00000000..b1117321 --- /dev/null +++ b/plugins/afl-scoreboard/test/harness.json @@ -0,0 +1,285 @@ +{ + "_comment": "Deterministic fixture for the plugin safety harness. Mocks an ESPN australian-football/afl scoreboard response with one live, one recent, and one upcoming game so live/recent/upcoming modes each render without a network call.", + "config": { + "enabled": true, + "timezone": "UTC", + "favorite_teams": [], + "display_duration": 15, + "display_modes": { + "live": true, + "recent": true, + "upcoming": true, + "live_display_mode": "switch", + "recent_display_mode": "switch", + "upcoming_display_mode": "switch" + }, + "recent_games_to_show": 5, + "upcoming_games_to_show": 10 + }, + "mock_data": { + "afl_scoreboard_current": { + "events": [ + { + "id": "live-demo-001", + "date": "2026-07-09T10:10Z", + "name": "Sydney Swans at Fremantle", + "shortName": "SYD @ FRE", + "competitions": [ + { + "id": "1133635", + "date": "2026-07-09T10:10Z", + "status": { + "clock": 765.0, + "displayClock": "12:45", + "period": 3, + "type": { + "id": "2", + "name": "STATUS_IN_PROGRESS", + "state": "in", + "completed": false, + "description": "In Progress", + "detail": "Q3 12:45", + "shortDetail": "Q3 12:45" + } + }, + "competitors": [ + { + "homeAway": "home", + "score": "54", + "team": { + "id": "1", + "abbreviation": "FRE", + "displayName": "Fremantle", + "shortDisplayName": "Fremantle", + "logos": [] + } + }, + { + "homeAway": "away", + "score": "48", + "team": { + "id": "4", + "abbreviation": "SYD", + "displayName": "Sydney Swans", + "shortDisplayName": "Sydney Swans", + "logos": [] + } + } + ] + } + ], + "status": { + "clock": 765.0, + "displayClock": "12:45", + "period": 3, + "type": { + "id": "2", + "name": "STATUS_IN_PROGRESS", + "state": "in", + "completed": false, + "description": "In Progress", + "detail": "Q3 12:45", + "shortDetail": "Q3 12:45" + } + } + } + ] + }, + "afl_schedule": { + "events": [ + { + "id": "1133629", + "date": "2026-07-10T09:40Z", + "name": "North Melbourne at Collingwood", + "shortName": "NMFC @ COLL", + "competitions": [ + { + "id": "1133629", + "date": "2026-07-10T09:40Z", + "status": { + "clock": 1939.0, + "displayClock": "32:19", + "period": 4, + "type": { + "id": "3", + "name": "STATUS_FINAL", + "state": "post", + "completed": true, + "description": "Final", + "detail": "Final", + "shortDetail": "Final" + } + }, + "competitors": [ + { + "homeAway": "home", + "score": "89", + "team": { + "id": "17", + "abbreviation": "COLL", + "displayName": "Collingwood", + "shortDisplayName": "Collingwood", + "logos": [] + } + }, + { + "homeAway": "away", + "score": "85", + "team": { + "id": "5", + "abbreviation": "NMFC", + "displayName": "North Melbourne", + "shortDisplayName": "North Melbourne", + "logos": [] + } + } + ] + } + ], + "status": { + "clock": 1939.0, + "displayClock": "32:19", + "period": 4, + "type": { + "id": "3", + "name": "STATUS_FINAL", + "state": "post", + "completed": true, + "description": "Final", + "detail": "Final", + "shortDetail": "Final" + } + } + }, + { + "id": "1133632", + "date": "2026-07-11T03:15Z", + "name": "Port Adelaide at St Kilda", + "shortName": "PORT @ STK", + "competitions": [ + { + "id": "1133632", + "date": "2026-07-11T03:15Z", + "status": { + "clock": 0.0, + "displayClock": "0:00", + "period": 0, + "type": { + "id": "1", + "name": "STATUS_SCHEDULED", + "state": "pre", + "completed": false, + "description": "Scheduled", + "detail": "Fri, July 10th at 8:15 PM EDT", + "shortDetail": "7/10 - 8:15 PM EDT" + } + }, + "competitors": [ + { + "homeAway": "home", + "score": "0", + "team": { + "id": "18", + "abbreviation": "STK", + "displayName": "St Kilda", + "shortDisplayName": "St Kilda", + "logos": [] + } + }, + { + "homeAway": "away", + "score": "0", + "team": { + "id": "7", + "abbreviation": "PORT", + "displayName": "Port Adelaide", + "shortDisplayName": "Port Adelaide", + "logos": [] + } + } + ] + } + ], + "status": { + "clock": 0.0, + "displayClock": "0:00", + "period": 0, + "type": { + "id": "1", + "name": "STATUS_SCHEDULED", + "state": "pre", + "completed": false, + "description": "Scheduled", + "detail": "Fri, July 10th at 8:15 PM EDT", + "shortDetail": "7/10 - 8:15 PM EDT" + } + } + }, + { + "id": "live-demo-001", + "date": "2026-07-09T10:10Z", + "name": "Sydney Swans at Fremantle", + "shortName": "SYD @ FRE", + "competitions": [ + { + "id": "1133635", + "date": "2026-07-09T10:10Z", + "status": { + "clock": 765.0, + "displayClock": "12:45", + "period": 3, + "type": { + "id": "2", + "name": "STATUS_IN_PROGRESS", + "state": "in", + "completed": false, + "description": "In Progress", + "detail": "Q3 12:45", + "shortDetail": "Q3 12:45" + } + }, + "competitors": [ + { + "homeAway": "home", + "score": "54", + "team": { + "id": "1", + "abbreviation": "FRE", + "displayName": "Fremantle", + "shortDisplayName": "Fremantle", + "logos": [] + } + }, + { + "homeAway": "away", + "score": "48", + "team": { + "id": "4", + "abbreviation": "SYD", + "displayName": "Sydney Swans", + "shortDisplayName": "Sydney Swans", + "logos": [] + } + } + ] + } + ], + "status": { + "clock": 765.0, + "displayClock": "12:45", + "period": 3, + "type": { + "id": "2", + "name": "STATUS_IN_PROGRESS", + "state": "in", + "completed": false, + "description": "In Progress", + "detail": "Q3 12:45", + "shortDetail": "Q3 12:45" + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/plugins/afl-scoreboard/test_afl_plugin.py b/plugins/afl-scoreboard/test_afl_plugin.py new file mode 100644 index 00000000..5c2551e7 --- /dev/null +++ b/plugins/afl-scoreboard/test_afl_plugin.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Smoke tests for the AFL scoreboard plugin. + +Runs standalone from the plugin directory (no LEDMatrix host required): + + cd plugins/afl-scoreboard + python test_afl_plugin.py + +Covers: +- The plugin class and its three display modes are exposed as expected. +- The plugin instantiates with stubbed managers and routes ``display()`` to the + right manager per mode (empty managers are skipped so the panel never blanks). +- ``get_live_modes`` / ``has_live_content`` reflect live favorite-team games. +- The AFL quarter ``period_text`` mapping (the sport-specific parsing logic). +""" + +from __future__ import annotations + +import logging +import sys +import types +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +if str(PLUGIN_DIR) not in sys.path: + sys.path.insert(0, str(PLUGIN_DIR)) + + +def _install_thirdparty_stubs() -> None: + """Stub heavy third-party deps so the plugin imports without them installed. + + ``setdefault`` means real packages (e.g. in CI) are used when present; the + stubs only kick in on a bare interpreter. The tests use ``__new__`` to skip + ``__init__``, so none of these are actually exercised at runtime. + """ + def _mod(name, **attrs): + m = sys.modules.get(name) + if m is None: + m = types.ModuleType(name) + sys.modules[name] = m + for k, v in attrs.items(): + if not hasattr(m, k): + setattr(m, k, v) + return m + + class _Any: + def __init__(self, *a, **k): + pass + + def __getattr__(self, _): + return _Any() + + def __call__(self, *a, **k): + return _Any() + + _mod("pytz", utc=_Any(), timezone=lambda *a, **k: _Any()) + _mod("requests", Session=_Any, get=lambda *a, **k: _Any(), exceptions=_Any()) + _mod("requests.adapters", HTTPAdapter=_Any) + _mod("urllib3") + _mod("urllib3.util") + _mod("urllib3.util.retry", Retry=_Any) + _mod("PIL", Image=_Any(), ImageDraw=_Any(), ImageFont=_Any(), ImageOps=_Any()) + _mod("PIL.Image", Resampling=_Any(), new=lambda *a, **k: _Any(), open=lambda *a, **k: _Any()) + _mod("PIL.ImageDraw", Draw=lambda *a, **k: _Any()) + _mod("PIL.ImageFont") + + +def _install_host_stubs() -> None: + for name in ( + "src", + "src.plugin_system", + "src.plugin_system.base_plugin", + "src.background_data_service", + "src.common", + "src.common.scroll_helper", + "src.logo_downloader", + ): + sys.modules.setdefault(name, types.ModuleType(name)) + sys.modules["src.plugin_system.base_plugin"].BasePlugin = object + sys.modules["src.plugin_system.base_plugin"].VegasDisplayMode = None + sys.modules["src.background_data_service"].get_background_service = lambda *a, **k: None + sys.modules["src.common.scroll_helper"].ScrollHelper = None + sys.modules["src.logo_downloader"].LogoDownloader = object + sys.modules["src.logo_downloader"].download_missing_logo = lambda *a, **k: None + + +_install_thirdparty_stubs() +_install_host_stubs() +logging.basicConfig(level=logging.CRITICAL) + +import manager # noqa: E402 + + +class FakeManager: + """Stand-in for a Live/Recent/Upcoming manager.""" + + def __init__(self, games=None, live_games=None, favorite_teams=None): + self.games_list = list(games or []) + self.live_games = list(live_games or []) + self.favorite_teams = list(favorite_teams or []) + self.show_all_live = False + self.display_calls = 0 + + def display(self, force_clear=False): + self.display_calls += 1 + if self.live_games: + return True + return bool(self.games_list) + + def has_active_celebration(self): + return False + + +def _make_plugin(live=None, recent=None, upcoming=None): + plugin = manager.AflScoreboardPlugin.__new__(manager.AflScoreboardPlugin) + plugin.is_enabled = True + plugin.logger = logging.getLogger("test") + plugin.live_priority = False + plugin.config = {} + plugin.display_duration = 15.0 + plugin.last_mode_switch = 0 + plugin.current_mode_index = 0 + plugin.modes = list(manager.DISPLAY_MODES) + plugin._scroll_manager = None + plugin._display_mode_settings = {"live": "switch", "recent": "switch", "upcoming": "switch"} + plugin._dynamic_cycle_seen_modes = set() + plugin._dynamic_mode_to_manager_key = {} + plugin._dynamic_manager_progress = {} + plugin._dynamic_managers_completed = set() + plugin._dynamic_cycle_complete = False + plugin._current_display_league = "afl" + plugin._current_display_mode_type = None + import threading + plugin._config_lock = threading.Lock() + plugin._managers = { + "live": live or FakeManager(), + "recent": recent or FakeManager(), + "upcoming": upcoming or FakeManager(), + } + return plugin + + +def test_display_modes_exposed() -> None: + assert manager.DISPLAY_MODES == ("afl_live", "afl_recent", "afl_upcoming"), manager.DISPLAY_MODES + assert manager._mode_type_of("afl_recent") == "recent" + assert manager._mode_type_of("afl_live") == "live" + assert manager._mode_type_of("bogus") is None + print(" [ok] display modes exposed: afl_live/afl_recent/afl_upcoming") + + +def test_populated_recent_displays() -> None: + recent = FakeManager(games=[{"id": "g1"}]) + plugin = _make_plugin(recent=recent) + result = plugin.display("afl_recent") + assert result is True, f"expected content, got {result!r}" + assert recent.display_calls == 1 + print(" [ok] afl_recent with a game displays") + + +def test_empty_recent_skipped() -> None: + recent = FakeManager(games=[]) + plugin = _make_plugin(recent=recent) + result = plugin.display("afl_recent") + assert result is False, f"expected False so controller skips, got {result!r}" + assert recent.display_calls == 0, "empty manager must not be asked to display (it blanks)" + print(" [ok] empty afl_recent returns False, manager not drawn") + + +def test_live_modes_and_content() -> None: + live = FakeManager( + live_games=[{"home_abbr": "COLL", "away_abbr": "GEEL"}], + favorite_teams=["COLL"], + ) + plugin = _make_plugin(live=live) + assert plugin.has_live_content() is True + assert plugin.get_live_modes() == ["afl_live"], plugin.get_live_modes() + + # No favorite in the live game -> no live content + live2 = FakeManager( + live_games=[{"home_abbr": "SYD", "away_abbr": "HAW"}], + favorite_teams=["COLL"], + ) + plugin2 = _make_plugin(live=live2) + assert plugin2.has_live_content() is False + assert plugin2.get_live_modes() == [] + print(" [ok] live content / get_live_modes track favorite live games") + + +def test_period_text_mapping() -> None: + """AFL quarters map to Q1..Q4 + HALF/Final/pre-game time.""" + import afl_managers + + mgr = afl_managers.BaseAflManager.__new__(afl_managers.BaseAflManager) + mgr.logger = logging.getLogger("test") + mgr.league_key = "afl" + + def common_stub(event): + details = { + "id": event["id"], + "home_abbr": "COLL", + "away_abbr": "GEEL", + "game_time": event.get("_game_time", ""), + "is_live": False, + "is_final": False, + "is_upcoming": False, + } + status = event["status"] + return details, {"a": 1}, {"a": 1}, status, None + + mgr._extract_game_details_common = common_stub + + def pt(state, name, period, clock="", game_time=""): + ev = { + "id": "x", + "_game_time": game_time, + "status": {"period": period, "displayClock": clock, + "type": {"state": state, "name": name}}, + } + return mgr._extract_game_details(ev)["period_text"] + + assert pt("in", "STATUS_IN_PROGRESS", 1, "18:20") == "Q1 18:20" + assert pt("in", "STATUS_IN_PROGRESS", 3, "05:00") == "Q3 05:00" + assert pt("in", "STATUS_IN_PROGRESS", 4, "00:30") == "Q4 00:30" + assert pt("halftime", "STATUS_HALFTIME", 2) == "HALF" + assert pt("post", "STATUS_FINAL", 4) == "Final" + assert pt("pre", "STATUS_SCHEDULED", 0, game_time="Sat 7:40 PM") == "Sat 7:40 PM" + print(" [ok] AFL quarter period_text mapping (Q1-Q4 / HALF / Final / pre-game)") + + +def main() -> int: + tests = [ + test_display_modes_exposed, + test_populated_recent_displays, + test_empty_recent_skipped, + test_live_modes_and_content, + test_period_text_mapping, + ] + for t in tests: + try: + t() + except AssertionError as e: + print(f" [FAIL] {t.__name__}: {e}") + return 1 + except Exception as e: # noqa: BLE001 + print(f" [ERROR] {t.__name__}: {e}") + return 1 + print("All AFL plugin tests passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())