diff --git a/plugins.json b/plugins.json
index fe57d4ea..3f8d4765 100644
--- a/plugins.json
+++ b/plugins.json
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
- "last_updated": "2026-04-03",
+ "last_updated": "2026-04-06",
"plugins": [
{
"id": "hello-world",
@@ -717,6 +717,29 @@
"verified": true,
"screenshot": "",
"latest_version": "1.0.0"
+ },
+ {
+ "id": "lacrosse-scoreboard",
+ "name": "Lacrosse Scoreboard",
+ "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules",
+ "author": "ChuckBuilds",
+ "category": "sports",
+ "tags": [
+ "lacrosse",
+ "ncaa",
+ "sports",
+ "scoreboard",
+ "live-scores"
+ ],
+ "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
+ "branch": "main",
+ "plugin_path": "plugins/lacrosse-scoreboard",
+ "stars": 0,
+ "downloads": 0,
+ "last_updated": "2026-04-06",
+ "verified": true,
+ "screenshot": "",
+ "latest_version": "1.0.3"
}
]
}
diff --git a/plugins/lacrosse-scoreboard/LICENSE b/plugins/lacrosse-scoreboard/LICENSE
new file mode 100644
index 00000000..e653a0c1
--- /dev/null
+++ b/plugins/lacrosse-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/lacrosse-scoreboard/README.md b/plugins/lacrosse-scoreboard/README.md
new file mode 100644
index 00000000..335e3b47
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/README.md
@@ -0,0 +1,210 @@
+# Lacrosse Scoreboard Plugin
+
+Live, recent, and upcoming NCAA Men's and Women's Lacrosse games on your LEDMatrix display. Real-time scores, schedules, favorite-team filtering, live-game priority, poll-rank badges, and both switch and scroll display modes — modeled on the existing hockey scoreboard plugin.
+
+## Features
+
+- **NCAA Men's Lacrosse** (Inside Lacrosse D1 Poll — top 20)
+- **NCAA Women's Lacrosse** (Inside Lacrosse / IWLCA Coaches Top 25 Poll)
+- **Live games** with quarter, clock, score, and optional shot totals
+- **Recent (completed) games** with final score and OT indicator
+- **Upcoming games** with start time, matchup, records, and rankings
+- **Favorite team filtering** — pin specific teams, or use the dynamic shortcuts `NCAA_MENS_TOP_20`, `NCAA_MENS_TOP_10`, `NCAA_MENS_TOP_5`, `NCAA_WOMENS_TOP_25`, `NCAA_WOMENS_TOP_10`, `NCAA_WOMENS_TOP_5` to auto-track whichever teams are currently in the poll
+- **Live priority** — force live favorite-team games to preempt the rotation
+- **Per-mode display style** — `switch` (one game card rotating) or `scroll` (horizontal ticker), independently configurable for live, recent, and upcoming
+- **Poll rank badges** — `#1`, `#2` overlays on team names, updated hourly from ESPN's public rankings feed
+- **Element customization** — toggle records, rankings, odds, shot totals; override layout offsets for logos, score, and status text
+- **Configurable durations, update intervals, and game counts** per league
+
+## Requirements
+
+- Python 3.9+
+- LEDMatrix core 2.0.0 or newer
+- A minimum display of 64×32 (128×32 recommended for full scroll and scoreboard layouts)
+- Internet access to reach the public ESPN API
+
+No API key is required.
+
+## Installation
+
+The plugin is installable from the LEDMatrix plugin store — search for **Lacrosse Scoreboard** and enable it. On first launch, team logos for any teams appearing in the current scoreboard window will be downloaded to `assets/sports/ncaa_logos/` automatically.
+
+To install manually from source:
+
+```bash
+cd /path/to/LEDMatrix
+python -m pip install --user pillow requests pytz # see requirements.txt
+cp -r /path/to/ledmatrix-plugins/plugins/lacrosse-scoreboard plugins/
+```
+
+Then add a `lacrosse-scoreboard` entry to your LEDMatrix `config.json` (see **Configuration** below) and restart the LEDMatrix service.
+
+## Dependencies
+
+From `requirements.txt`:
+
+- `Pillow>=9.0.0` — image compositing and logo rendering
+- `requests>=2.28.0` — ESPN API calls
+- `pytz>=2022.1` — timezone conversion for game start times
+- `urllib3>=1.26.0` — HTTP retry logic
+
+All dependencies are standard and already present in a typical LEDMatrix install.
+
+## Configuration
+
+The plugin config is split into per-league blocks. See `config_schema.json` for the authoritative list of fields and their defaults. Minimal working example:
+
+```json
+{
+ "enabled": true,
+ "defaults": {
+ "display_duration": 15,
+ "show_records": true,
+ "show_ranking": true,
+ "show_odds": false
+ },
+ "ncaa_mens": {
+ "enabled": true,
+ "display_modes": {
+ "live": true,
+ "live_display_mode": "switch",
+ "recent": true,
+ "recent_display_mode": "scroll",
+ "upcoming": true,
+ "upcoming_display_mode": "scroll"
+ },
+ "teams": {
+ "favorite_teams": ["NCAA_MENS_TOP_10", "JOHNS HOPKINS"],
+ "favorite_teams_only": false,
+ "show_all_live": true
+ },
+ "filtering": {
+ "recent_games_to_show": 5,
+ "upcoming_games_to_show": 10
+ },
+ "live_priority": true
+ },
+ "ncaa_womens": {
+ "enabled": true,
+ "display_modes": {
+ "live": true,
+ "live_display_mode": "switch",
+ "recent": true,
+ "recent_display_mode": "scroll",
+ "upcoming": true,
+ "upcoming_display_mode": "scroll"
+ },
+ "teams": {
+ "favorite_teams": ["MARYLAND", "NORTH CAROLINA", "SYRACUSE"],
+ "favorite_teams_only": false,
+ "show_all_live": true
+ }
+ }
+}
+```
+
+### Display modes per league
+
+Each of live / recent / upcoming can be independently enabled and given its own display style:
+
+- `switch` — one game card at a time, rotating on a timer
+- `scroll` — all matching games composited into a horizontal ticker that scrolls across the display
+
+### Live priority
+
+When `live_priority: true`, live games for configured favorite teams will interrupt the normal rotation whenever they are in progress.
+
+## Team Abbreviations
+
+**Important — NCAA lacrosse uses full-name abbreviations, not the short codes you may be used to from the football, basketball, or hockey plugins.** ESPN's lacrosse feed returns team abbreviations like `NORTH CAROLINA`, `JOHNS HOPKINS`, `SAINT JOSEPH'S`, not `UNC` / `JHU` / `SJU`. Use the full-name form in `favorite_teams` or the matching will fail silently.
+
+A few recurring examples (use exactly as shown, uppercase, with spaces, apostrophes, and periods as they appear):
+
+| Team | Abbreviation |
+|---|---|
+| Maryland | `MARYLAND` |
+| North Carolina | `NORTH CAROLINA` |
+| Syracuse | `SYRACUSE` |
+| Johns Hopkins | `JOHNS HOPKINS` |
+| Duke | `DUKE` |
+| Notre Dame | `NOTRE DAME` |
+| Princeton | `PRINCETON` |
+| Virginia | `VIRGINIA` |
+| Yale | `YALE` |
+| Harvard | `HARVARD` |
+| Cornell | `CORNELL` |
+| Penn State | `PENN STATE` |
+| Richmond | `RICHMOND` |
+| Saint Joseph's | `SAINT JOSEPH'S` |
+| Mount St. Mary's | `MOUNT ST. MARY'S` |
+| William & Mary | `WILLIAM & MARY` |
+| Long Island University | `LONG ISLAND UNIVERSI` *(ESPN truncates to 20 chars)* |
+
+If you're unsure of a team's exact abbreviation, hit the ESPN scoreboard endpoint directly and look at `events[].competitions[].competitors[].team.abbreviation`:
+
+```bash
+curl -s 'https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/scoreboard' \
+ | python -m json.tool | grep -A1 abbreviation
+```
+
+### Dynamic team shortcuts
+
+Instead of listing abbreviations manually, use one of these tokens in `favorite_teams` to auto-expand to the current poll:
+
+| Token | League | Expands to |
+|---|---|---|
+| `NCAA_MENS_TOP_5` | Men's | Top 5 of Inside Lacrosse D1 Men's Poll |
+| `NCAA_MENS_TOP_10` | Men's | Top 10 of Inside Lacrosse D1 Men's Poll |
+| `NCAA_MENS_TOP_20` | Men's | Full top 20 (the entire men's poll) |
+| `NCAA_WOMENS_TOP_5` | Women's | Top 5 of IWLCA Coaches Poll |
+| `NCAA_WOMENS_TOP_10` | Women's | Top 10 of IWLCA Coaches Poll |
+| `NCAA_WOMENS_TOP_25` | Women's | Full top 25 |
+
+Tokens can be mixed with literal abbreviations: `["NCAA_MENS_TOP_10", "JOHNS HOPKINS", "PRINCETON"]` tracks the current top 10 *plus* any of those two teams that aren't already in it.
+
+## Display Modes (plugin-level)
+
+The plugin exposes six granular display modes the LEDMatrix host rotation can cycle through:
+
+- `ncaa_mens_live`, `ncaa_mens_recent`, `ncaa_mens_upcoming`
+- `ncaa_womens_live`, `ncaa_womens_recent`, `ncaa_womens_upcoming`
+
+## Data Source
+
+Scores and schedules come from ESPN's public site API:
+
+- Men's scoreboard: `https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/scoreboard`
+- Men's rankings: `https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/rankings`
+- Women's scoreboard: `https://site.api.espn.com/apis/site/v2/sports/lacrosse/womens-college-lacrosse/scoreboard`
+- Women's rankings: `https://site.api.espn.com/apis/site/v2/sports/lacrosse/womens-college-lacrosse/rankings`
+
+Team logos are fetched from `https://a.espncdn.com/i/teamlogos/ncaa/500/{team_id}.png` and cached locally under `assets/sports/ncaa_logos/`.
+
+## Troubleshooting
+
+**My favorite team doesn't show up.** You're almost certainly using a short abbreviation like `UNC` or `JHU`. Lacrosse abbreviations are the full school name in uppercase — see **Team Abbreviations** above.
+
+**No games appear at all.** NCAA lacrosse is a spring sport. Men's runs roughly January through late May; women's runs February through late May. Outside that window, the ESPN scoreboard endpoint returns an empty `events[]` array and the plugin has nothing to display.
+
+**Rank badges (`#1`, `#2`) aren't appearing.** Ensure `display_options.show_ranking: true` (the default). Rankings are cached for 1 hour and are only populated for teams that appear in the current poll. Unranked teams show no badge, which is intentional.
+
+**Shot totals are always 0.** ESPN's lacrosse feed does not currently expose per-team shot counts in the `competitors[].statistics` array the way hockey does for saves. The `show_shots` toggle is wired but will remain empty until ESPN publishes the stat. Leave it off for now.
+
+**Tournament games show `TBD` placeholders.** ESPN uses team IDs `-1` and `-2` for bracket slots where the opponent hasn't been determined yet. The plugin renders these as text placeholders — they'll resolve to real logos once the bracket is set.
+
+**A team's logo is missing or looks wrong.** Delete the cached logo at `assets/sports/ncaa_logos/{ABBR}.png` (use the exact file name, spaces and all) and the plugin will re-download it from ESPN on the next update.
+
+## Testing
+
+A standalone smoke test is included at `test_lacrosse_plugin.py`:
+
+```bash
+cd plugins/lacrosse-scoreboard
+python test_lacrosse_plugin.py
+```
+
+It stubs the LEDMatrix host modules, imports every plugin module, exercises the dynamic team resolver against live ESPN rankings, and runs a 50-event window of both men's and women's scoreboard data through `Lacrosse._extract_game_details`, asserting that required fields are populated. No external test framework is required.
+
+## License
+
+See `LICENSE` in this directory.
diff --git a/plugins/lacrosse-scoreboard/base_odds_manager.py b/plugins/lacrosse-scoreboard/base_odds_manager.py
new file mode 100644
index 00000000..35b18270
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/base_odds_manager.py
@@ -0,0 +1,293 @@
+"""
+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 (odds ticker, scoreboards, etc.).
+
+Follows LEDMatrix configuration management patterns:
+- Single responsibility: Data fetching only
+- Reusable: Other plugins can inherit from it
+- Clean configuration: Separate config sections
+- Maintainable: Changes to odds logic affect all plugins
+"""
+
+import time
+import logging
+import requests
+import json
+from datetime import datetime, timedelta, timezone
+from typing import Dict, Any, Optional, List
+import pytz
+
+# Import the API counter function from web interface
+try:
+ from web_interface_v2 import increment_api_counter
+except ImportError:
+ # Fallback if web interface is not available
+ def increment_api_counter(kind: str, count: int = 1):
+ pass
+
+
+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., 'football', 'basketball')
+ league: League name (e.g., 'nfl', 'nba')
+ 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 = {
+ "ncaa_fb": "college-football",
+ "nfl": "nfl",
+ "nba": "nba",
+ "mlb": "mlb",
+ "nhl": "nhl",
+ }
+
+ 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()
+
+ # Increment API counter for odds data
+ increment_api_counter("odds", 1)
+ 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())}")
+
+ # The ESPN API returns odds data directly in the item, not in a providers array
+ # 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/lacrosse-scoreboard/config_schema.json b/plugins/lacrosse-scoreboard/config_schema.json
new file mode 100644
index 00000000..3457179f
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/config_schema.json
@@ -0,0 +1,1136 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Lacrosse Scoreboard Configuration",
+ "description": "Configuration schema for the Lacrosse Scoreboard plugin (NCAA Men's and Women's Lacrosse)",
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable or disable the lacrosse scoreboard plugin"
+ },
+ "defaults": {
+ "type": "object",
+ "description": "Default settings that can be inherited by leagues (can be overridden per league)",
+ "properties": {
+ "display_duration": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 60,
+ "description": "Default duration in seconds to display each game"
+ },
+ "show_records": {
+ "type": "boolean",
+ "default": false,
+ "description": "Default setting to show team records (wins-losses)"
+ },
+ "show_ranking": {
+ "type": "boolean",
+ "default": false,
+ "description": "Default setting to show team rankings (when available)"
+ },
+ "show_odds": {
+ "type": "boolean",
+ "default": false,
+ "description": "Default setting to show betting odds for games"
+ },
+ "update_interval_seconds": {
+ "type": "integer",
+ "default": 3600,
+ "minimum": 30,
+ "maximum": 86400,
+ "description": "Default base update interval in seconds for fetching game data"
+ },
+ "season_cache_duration_seconds": {
+ "type": "integer",
+ "default": 86400,
+ "minimum": 3600,
+ "maximum": 604800,
+ "description": "How long to cache season data (seconds)"
+ },
+ "show_shots": {
+ "type": "boolean",
+ "title": "Show shot totals",
+ "description": "Display shot totals when available from ESPN",
+ "default": false
+ }
+ }
+ },
+ "ncaa_mens": {
+ "type": "object",
+ "description": "NCAA Men's Lacrosse configuration",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable NCAA Men's Lacrosse games"
+ },
+ "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 NCAA Men's Lacrosse live 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 NCAA Men's Lacrosse recent 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 NCAA Men's Lacrosse upcoming 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"
+ }
+ }
+ },
+ "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": 50.0,
+ "minimum": 1.0,
+ "maximum": 200.0,
+ "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster."
+ },
+ "scroll_delay": {
+ "type": "number",
+ "default": 0.01,
+ "minimum": 0.001,
+ "maximum": 0.1,
+ "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling."
+ },
+ "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 ( shield, NCAA logos) 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."
+ }
+ }
+ },
+ "teams": {
+ "type": "object",
+ "description": "Team filtering and favorites configuration",
+ "properties": {
+ "favorite_teams": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "NCAA Men's Lacrosse favorite team abbreviations (e.g., ['BU', 'BC', 'MICH'])"
+ },
+ "favorite_teams_only": {
+ "type": "boolean",
+ "default": false,
+ "description": "Only show NCAA Men's Lacrosse games with favorite teams"
+ },
+ "show_all_live": {
+ "type": "boolean",
+ "default": false,
+ "description": "Show all live NCAA Men's Lacrosse games, not just favorites"
+ }
+ }
+ },
+ "filtering": {
+ "type": "object",
+ "description": "Game filtering and quantity settings",
+ "properties": {
+ "recent_games_to_show": {
+ "type": "integer",
+ "default": 5,
+ "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": 10,
+ "minimum": 1,
+ "maximum": 50,
+ "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time."
+ }
+ }
+ },
+ "update_intervals": {
+ "type": "object",
+ "description": "Data update frequency settings (in seconds)",
+ "properties": {
+ "base": {
+ "type": "integer",
+ "default": 300,
+ "minimum": 60,
+ "maximum": 900,
+ "description": "Base update interval for NCAA Men's Lacrosse data"
+ },
+ "live": {
+ "type": "integer",
+ "default": 60,
+ "minimum": 10,
+ "maximum": 300,
+ "description": "Update interval for live NCAA Men's Lacrosse games"
+ },
+ "recent": {
+ "type": "integer",
+ "default": 3600,
+ "minimum": 60,
+ "maximum": 86400,
+ "description": "Update interval for recent NCAA Men's Lacrosse games"
+ },
+ "upcoming": {
+ "type": "integer",
+ "default": 3600,
+ "minimum": 60,
+ "maximum": 86400,
+ "description": "Update interval for upcoming NCAA Men's Lacrosse games"
+ },
+ "odds": {
+ "type": "integer",
+ "default": 3600,
+ "minimum": 60,
+ "maximum": 86400,
+ "description": "Update interval for NCAA Men's Lacrosse betting odds data"
+ }
+ }
+ },
+ "display_durations": {
+ "type": "object",
+ "description": "How long to display games on screen (in seconds)",
+ "properties": {
+ "base": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 60,
+ "description": "Base display duration for NCAA Men's Lacrosse games"
+ },
+ "live": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 120,
+ "description": "Display duration for live NCAA Men's Lacrosse games"
+ },
+ "recent": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 60,
+ "description": "Display duration for recent NCAA Men's Lacrosse games"
+ },
+ "upcoming": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 60,
+ "description": "Display duration for upcoming NCAA Men's Lacrosse games"
+ }
+ }
+ },
+ "display_options": {
+ "type": "object",
+ "description": "What information to display on the scoreboard",
+ "properties": {
+ "show_records": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show NCAA Men's Lacrosse team records (wins-losses)"
+ },
+ "show_ranking": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show NCAA Men's Lacrosse team rankings/standings"
+ },
+ "show_odds": {
+ "type": "boolean",
+ "default": false,
+ "description": "Show betting odds for NCAA Men's Lacrosse games"
+ },
+ "show_shots": {
+ "type": "boolean",
+ "title": "Show shot totals",
+ "description": "Display shot totals when available from ESPN",
+ "default": false
+ }
+ }
+ },
+ "mode_durations": {
+ "type": "object",
+ "title": "Mode-Level Durations",
+ "description": "Control total duration for each mode type for NCAA Men's Lacrosse. 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). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)."
+ },
+ "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). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)."
+ },
+ "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). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)."
+ }
+ }
+ },
+ "live_priority": {
+ "type": "boolean",
+ "default": false,
+ "description": "Prioritize live NCAA Men's Lacrosse games over scheduled games"
+ },
+ "dynamic_duration": {
+ "type": "object",
+ "description": "Dynamic duration settings - automatically adjust display time based on content",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable dynamic duration for NCAA Men's Lacrosse games"
+ },
+ "max_duration_seconds": {
+ "type": "number",
+ "minimum": 60,
+ "maximum": 600,
+ "description": "Maximum duration in seconds when dynamic duration is enabled"
+ },
+ "modes": {
+ "type": "object",
+ "description": "Per-mode dynamic duration settings",
+ "properties": {
+ "live": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable dynamic duration for NCAA Men's Lacrosse live games"
+ },
+ "max_duration_seconds": {
+ "type": "number",
+ "minimum": 60,
+ "maximum": 600,
+ "description": "Maximum duration for NCAA Men's Lacrosse live games"
+ }
+ }
+ },
+ "recent": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable dynamic duration for NCAA Men's Lacrosse recent games"
+ },
+ "max_duration_seconds": {
+ "type": "number",
+ "minimum": 60,
+ "maximum": 600,
+ "description": "Maximum duration for NCAA Men's Lacrosse recent games"
+ }
+ }
+ },
+ "upcoming": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable dynamic duration for NCAA Men's Lacrosse upcoming games"
+ },
+ "max_duration_seconds": {
+ "type": "number",
+ "minimum": 60,
+ "maximum": 600,
+ "description": "Maximum duration for NCAA Men's Lacrosse upcoming games"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "ncaa_womens": {
+ "type": "object",
+ "description": "NCAA Women's Lacrosse configuration",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable NCAA Women's Lacrosse games"
+ },
+ "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 NCAA Women's Lacrosse live 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 NCAA Women's Lacrosse recent 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 NCAA Women's Lacrosse upcoming 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"
+ }
+ }
+ },
+ "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": 50.0,
+ "minimum": 1.0,
+ "maximum": 200.0,
+ "description": "Scroll speed in pixels per second (default: 50). Higher values scroll faster."
+ },
+ "scroll_delay": {
+ "type": "number",
+ "default": 0.01,
+ "minimum": 0.001,
+ "maximum": 0.1,
+ "description": "Delay between scroll frames in seconds (default: 0.01 = 100 FPS). Lower values = smoother scrolling."
+ },
+ "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 ( shield, NCAA logos) 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."
+ }
+ }
+ },
+ "teams": {
+ "type": "object",
+ "description": "Team filtering and favorites configuration",
+ "properties": {
+ "favorite_teams": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "default": [],
+ "description": "NCAA Women's Lacrosse favorite team abbreviations (e.g., ['WISC', 'MINN', 'OSU'])"
+ },
+ "favorite_teams_only": {
+ "type": "boolean",
+ "default": false,
+ "description": "Only show NCAA Women's Lacrosse games with favorite teams"
+ },
+ "show_all_live": {
+ "type": "boolean",
+ "default": false,
+ "description": "Show all live NCAA Women's Lacrosse games, not just favorites"
+ }
+ }
+ },
+ "filtering": {
+ "type": "object",
+ "description": "Game filtering and quantity settings",
+ "properties": {
+ "recent_games_to_show": {
+ "type": "integer",
+ "default": 5,
+ "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": 10,
+ "minimum": 1,
+ "maximum": 50,
+ "description": "With favorites: N games per favorite team. Without favorites: N total games sorted by time."
+ }
+ }
+ },
+ "update_intervals": {
+ "type": "object",
+ "description": "Data update frequency settings (in seconds)",
+ "properties": {
+ "base": {
+ "type": "integer",
+ "default": 300,
+ "minimum": 60,
+ "maximum": 900,
+ "description": "Base update interval for NCAA Women's Lacrosse data"
+ },
+ "live": {
+ "type": "integer",
+ "default": 60,
+ "minimum": 10,
+ "maximum": 300,
+ "description": "Update interval for live NCAA Women's Lacrosse games"
+ },
+ "recent": {
+ "type": "integer",
+ "default": 3600,
+ "minimum": 60,
+ "maximum": 86400,
+ "description": "Update interval for recent NCAA Women's Lacrosse games"
+ },
+ "upcoming": {
+ "type": "integer",
+ "default": 3600,
+ "minimum": 60,
+ "maximum": 86400,
+ "description": "Update interval for upcoming NCAA Women's Lacrosse games"
+ },
+ "odds": {
+ "type": "integer",
+ "default": 3600,
+ "minimum": 60,
+ "maximum": 86400,
+ "description": "Update interval for NCAA Women's Lacrosse betting odds data"
+ }
+ }
+ },
+ "display_durations": {
+ "type": "object",
+ "description": "How long to display games on screen (in seconds)",
+ "properties": {
+ "base": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 60,
+ "description": "Base display duration for NCAA Women's Lacrosse games"
+ },
+ "live": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 120,
+ "description": "Display duration for live NCAA Women's Lacrosse games"
+ },
+ "recent": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 60,
+ "description": "Display duration for recent NCAA Women's Lacrosse games"
+ },
+ "upcoming": {
+ "type": "number",
+ "default": 15,
+ "minimum": 5,
+ "maximum": 60,
+ "description": "Display duration for upcoming NCAA Women's Lacrosse games"
+ }
+ }
+ },
+ "display_options": {
+ "type": "object",
+ "description": "What information to display on the scoreboard",
+ "properties": {
+ "show_records": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show NCAA Women's Lacrosse team records (wins-losses)"
+ },
+ "show_ranking": {
+ "type": "boolean",
+ "default": true,
+ "description": "Show NCAA Women's Lacrosse team rankings/standings"
+ },
+ "show_odds": {
+ "type": "boolean",
+ "default": false,
+ "description": "Show betting odds for NCAA Women's Lacrosse games"
+ },
+ "show_shots": {
+ "type": "boolean",
+ "title": "Show shot totals",
+ "description": "Display shot totals when available from ESPN",
+ "default": false
+ }
+ }
+ },
+ "mode_durations": {
+ "type": "object",
+ "title": "Mode-Level Durations",
+ "description": "Control total duration for each mode type for NCAA Women's Lacrosse. 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). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)."
+ },
+ "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). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)."
+ },
+ "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). Set to null to use dynamic calculation. When mode cycles back, continues from last game shown (no repetition)."
+ }
+ }
+ },
+ "live_priority": {
+ "type": "boolean",
+ "default": false,
+ "description": "Prioritize live NCAA Women's Lacrosse games over scheduled games"
+ },
+ "dynamic_duration": {
+ "type": "object",
+ "description": "Dynamic duration settings - automatically adjust display time based on content",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable dynamic duration for NCAA Women's Lacrosse games"
+ },
+ "max_duration_seconds": {
+ "type": "number",
+ "minimum": 60,
+ "maximum": 600,
+ "description": "Maximum duration in seconds when dynamic duration is enabled"
+ },
+ "modes": {
+ "type": "object",
+ "description": "Per-mode dynamic duration settings",
+ "properties": {
+ "live": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable dynamic duration for NCAA Women's Lacrosse live games"
+ },
+ "max_duration_seconds": {
+ "type": "number",
+ "minimum": 60,
+ "maximum": 600,
+ "description": "Maximum duration for NCAA Women's Lacrosse live games"
+ }
+ }
+ },
+ "recent": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable dynamic duration for NCAA Women's Lacrosse recent games"
+ },
+ "max_duration_seconds": {
+ "type": "number",
+ "minimum": 60,
+ "maximum": 600,
+ "description": "Maximum duration for NCAA Women's Lacrosse recent games"
+ }
+ }
+ },
+ "upcoming": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable dynamic duration for NCAA Women's Lacrosse upcoming games"
+ },
+ "max_duration_seconds": {
+ "type": "number",
+ "minimum": 60,
+ "maximum": 600,
+ "description": "Maximum duration for NCAA Women's Lacrosse upcoming games"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "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 (only TTF fonts supported)",
+ "enum": [
+ "PressStart2P-Regular.ttf",
+ "4x6-font.ttf",
+ "5by7.regular.ttf"
+ ],
+ "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 (only TTF fonts supported)",
+ "enum": [
+ "PressStart2P-Regular.ttf",
+ "4x6-font.ttf",
+ "5by7.regular.ttf"
+ ],
+ "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 (only TTF fonts supported)",
+ "enum": [
+ "PressStart2P-Regular.ttf",
+ "4x6-font.ttf",
+ "5by7.regular.ttf"
+ ],
+ "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 (only TTF fonts supported)",
+ "enum": [
+ "PressStart2P-Regular.ttf",
+ "4x6-font.ttf",
+ "5by7.regular.ttf"
+ ],
+ "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 (only TTF fonts supported)",
+ "enum": [
+ "PressStart2P-Regular.ttf",
+ "4x6-font.ttf",
+ "5by7.regular.ttf"
+ ],
+ "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 (only TTF fonts supported)",
+ "enum": [
+ "PressStart2P-Regular.ttf",
+ "4x6-font.ttf",
+ "5by7.regular.ttf"
+ ],
+ "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
+ }
+ },
+ "required": [
+ "enabled"
+ ]
+}
diff --git a/plugins/lacrosse-scoreboard/data_sources.py b/plugins/lacrosse-scoreboard/data_sources.py
new file mode 100644
index 00000000..1f3dd813
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/data_sources.py
@@ -0,0 +1,291 @@
+"""
+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, Any, Optional, List
+import requests
+import logging
+from datetime import datetime, timedelta
+import time
+
+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."""
+ pass
+
+ @abstractmethod
+ def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
+ """Fetch schedule for a sport/league within date range."""
+ pass
+
+ @abstractmethod
+ def fetch_standings(self, sport: str, league: str) -> Dict:
+ """Fetch standings for a sport/league."""
+ pass
+
+ 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."""
+ 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 standings for {sport}/{league}")
+ return data
+
+ except Exception as e:
+ self.logger.error(f"Error fetching standings from ESPN: {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(f"Fetched standings from MLB API")
+ return data
+
+ except Exception as e:
+ self.logger.error(f"Error fetching standings from MLB API: {e}")
+ return {}
+
+
+class SoccerAPIDataSource(DataSource):
+ """Soccer 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 soccer 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 soccer API."""
+ try:
+ # This would need to be adapted based on the specific soccer 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 soccer API")
+ return matches
+
+ except Exception as e:
+ self.logger.error(f"Error fetching live games from soccer API: {e}")
+ return []
+
+ def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
+ """Fetch schedule from soccer 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 soccer API")
+ return matches
+
+ except Exception as e:
+ self.logger.error(f"Error fetching schedule from soccer API: {e}")
+ return []
+
+ def fetch_standings(self, sport: str, league: str) -> Dict:
+ """Fetch standings from soccer 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(f"Fetched standings from soccer API")
+ return data
+
+ except Exception as e:
+ self.logger.error(f"Error fetching standings from soccer API: {e}")
+ return {}
+
+
+# Factory function removed - sport classes now instantiate data sources directly
diff --git a/plugins/lacrosse-scoreboard/dynamic_team_resolver.py b/plugins/lacrosse-scoreboard/dynamic_team_resolver.py
new file mode 100644
index 00000000..37d54f4a
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/dynamic_team_resolver.py
@@ -0,0 +1,204 @@
+"""
+Simplified DynamicTeamResolver for plugin use
+"""
+
+import logging
+import time
+import requests
+from typing import Dict, List, Optional, Tuple
+
+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
+ like AP Top 25 rankings, which update automatically.
+ """
+
+ # Cache for rankings data. Each entry is keyed by (sport, token) and
+ # carries its own fetched-at timestamp so different tokens age
+ # independently. Historically this was a single shared timestamp, which
+ # meant fetching one token could extend the apparent freshness of
+ # unrelated ones.
+ _rankings_cache: Dict[str, Tuple[List[str], float]] = {}
+ _cache_duration: int = 3600 # 1 hour cache
+
+ # Supported dynamic team patterns.
+ #
+ # NCAA men's lacrosse is ranked via the Inside Lacrosse Division I Men's
+ # Lacrosse Poll (20 teams). NCAA women's lacrosse is ranked via the
+ # Inside Lacrosse/IWLCA Coaches Top 25 Poll. Both are exposed by ESPN at
+ # /sports/lacrosse/{mens,womens}-college-lacrosse/rankings
+ DYNAMIC_PATTERNS = {
+ # NCAA Men's Lacrosse (Inside Lacrosse Poll — top 20)
+ 'NCAA_MENS_TOP_20': {'sport': 'ncaa_mens_lacrosse', 'limit': 20},
+ 'NCAA_MENS_TOP_10': {'sport': 'ncaa_mens_lacrosse', 'limit': 10},
+ 'NCAA_MENS_TOP_5': {'sport': 'ncaa_mens_lacrosse', 'limit': 5},
+ # NCAA Women's Lacrosse (Inside Lacrosse/IWLCA Top 25 Poll)
+ 'NCAA_WOMENS_TOP_25': {'sport': 'ncaa_womens_lacrosse', 'limit': 25},
+ 'NCAA_WOMENS_TOP_10': {'sport': 'ncaa_womens_lacrosse', 'limit': 10},
+ 'NCAA_WOMENS_TOP_5': {'sport': 'ncaa_womens_lacrosse', 'limit': 5},
+ }
+
+ 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 = 'ncaam_lacrosse') -> List[str]:
+ """
+ Resolve a list of team names, expanding dynamic team names.
+
+ Args:
+ team_list: List of team names (can include dynamic names like
+ "NCAA_MENS_TOP_20" or "NCAA_WOMENS_TOP_25")
+ sport: Sport type for context (default: 'ncaam_lacrosse'). The pattern
+ itself carries the endpoint key, so this argument is informational.
+
+ Returns:
+ List of resolved team abbreviations
+ """
+ if not team_list:
+ return []
+
+ resolved_teams = []
+
+ for team in team_list:
+ if team in self.DYNAMIC_PATTERNS:
+ # Resolve dynamic team
+ dynamic_teams = self._resolve_dynamic_team(team, sport)
+ resolved_teams.extend(dynamic_teams)
+ self.logger.info(f"Resolved {team} to {len(dynamic_teams)} teams: {dynamic_teams[:5]}{'...' if len(dynamic_teams) > 5 else ''}")
+ elif self._is_potential_dynamic_team(team):
+ # Unknown dynamic team, skip it
+ self.logger.warning(f"Unknown dynamic team '{team}' - skipping")
+ else:
+ # Regular team name, add as-is
+ 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 _resolve_dynamic_team(self, dynamic_team: str, sport: str) -> List[str]:
+ """
+ Resolve a dynamic team name to actual team abbreviations.
+
+ Args:
+ dynamic_team: Dynamic team name (e.g., "AP_TOP_25")
+ sport: Sport type for context
+
+ Returns:
+ List of team abbreviations
+ """
+ try:
+ pattern_config = self.DYNAMIC_PATTERNS[dynamic_team]
+ pattern_sport = pattern_config['sport']
+ limit = pattern_config['limit']
+
+ # Check cache first (per-token TTL)
+ cache_key = f"{pattern_sport}_{dynamic_team}"
+ entry = self._rankings_cache.get(cache_key)
+ if entry is not None:
+ cached_teams, cached_at = entry
+ if cached_teams and (time.time() - cached_at) < self._cache_duration:
+ self.logger.debug(f"Using cached {dynamic_team} teams")
+ return cached_teams[:limit]
+
+ # Fetch fresh rankings
+ rankings = self._fetch_rankings(pattern_sport)
+ if rankings:
+ # Cache the results with this token's own timestamp
+ self._rankings_cache[cache_key] = (rankings, time.time())
+
+ self.logger.info(f"Fetched {len(rankings)} teams for {dynamic_team}")
+ return rankings[:limit]
+ else:
+ self.logger.warning(f"Failed to fetch rankings for {dynamic_team}")
+ return []
+
+ except Exception as e:
+ self.logger.error(f"Error resolving dynamic team {dynamic_team}: {e}")
+ return []
+
+ def _fetch_rankings(self, sport: str) -> List[str]:
+ """
+ Fetch current rankings from ESPN API.
+
+ Args:
+ sport: Sport type (e.g., 'ncaa_fb', 'ncaa_mens_lacrosse', 'ncaa_womens_lacrosse')
+
+ Returns:
+ List of team abbreviations in ranking order
+ """
+ try:
+ # Map sport to ESPN API endpoint
+ sport_mapping = {
+ 'ncaa_mens_lacrosse': 'lacrosse/mens-college-lacrosse/rankings',
+ 'ncaa_womens_lacrosse': 'lacrosse/womens-college-lacrosse/rankings',
+ }
+
+ endpoint = sport_mapping.get(sport)
+ if not endpoint:
+ self.logger.warning(f"Unsupported sport for rankings: {sport} - rankings may not be available")
+ return []
+
+ url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}"
+
+ headers = {
+ 'User-Agent': 'LEDMatrix/1.0',
+ 'Accept': 'application/json'
+ }
+
+ response = requests.get(url, headers=headers, timeout=self.request_timeout)
+ response.raise_for_status()
+
+ data = response.json()
+
+ # Extract team abbreviations from rankings
+ teams = []
+ if 'rankings' in data and data['rankings']:
+ ranking = data['rankings'][0] # Use first ranking (usually AP)
+ if 'ranks' in ranking:
+ for rank_item in ranking['ranks']:
+ team_info = rank_item.get('team', {})
+ abbr = team_info.get('abbreviation', '')
+ if abbr:
+ teams.append(abbr)
+ elif 'teams' in data:
+ # Alternative format - try to extract from teams array if rankings structure differs
+ for team_item in data.get('teams', []):
+ abbr = team_item.get('abbreviation', '')
+ if abbr:
+ teams.append(abbr)
+
+ if teams:
+ self.logger.debug(f"Fetched {len(teams)} ranked teams for {sport}")
+ else:
+ self.logger.debug(f"No rankings found for {sport} (API may not support lacrosse rankings yet)")
+ return teams
+
+ except requests.exceptions.RequestException as e:
+ # API may not support lacrosse rankings yet - this is expected
+ self.logger.debug(f"API request failed for {sport} rankings (may not be available): {e}")
+ return []
+ except Exception as e:
+ self.logger.debug(f"Error fetching rankings for {sport}: {e}")
+ return []
+
+ def _is_potential_dynamic_team(self, team: str) -> bool:
+ """Check if a team name looks like a dynamic team pattern."""
+ return (
+ team.startswith('AP_')
+ or team.startswith('TOP_')
+ or team.startswith('NCAA_MENS_')
+ or team.startswith('NCAA_WOMENS_')
+ )
diff --git a/plugins/lacrosse-scoreboard/game_renderer.py b/plugins/lacrosse-scoreboard/game_renderer.py
new file mode 100644
index 00000000..34c12fcb
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/game_renderer.py
@@ -0,0 +1,559 @@
+"""
+Game Renderer for Lacrosse Scoreboard Plugin
+
+Extracts game rendering logic into a reusable component for scroll display mode.
+Returns PIL Images instead of updating display directly.
+"""
+
+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__)
+
+# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1
+# Fall back to Image.LANCZOS for older versions
+try:
+ RESAMPLE_FILTER = Image.Resampling.LANCZOS
+except AttributeError:
+ RESAMPLE_FILTER = Image.LANCZOS
+
+
+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()
+
+ # Get logo directories from config
+ self.logo_dirs = {
+ 'ncaa_mens': config.get('ncaa_mens', {}).get('logo_dir', 'assets/sports/ncaa_logos'),
+ 'ncaa_womens': config.get('ncaa_womens', {}).get('logo_dir', 'assets/sports/ncaa_logos'),
+ 'ncaam_lacrosse': config.get('ncaa_mens', {}).get('logo_dir', 'assets/sports/ncaa_logos'),
+ 'ncaaw_lacrosse': config.get('ncaa_womens', {}).get('logo_dir', 'assets/sports/ncaa_logos'),
+ }
+
+ # Display options
+ defaults = config.get('defaults', {})
+ self.show_records = defaults.get('show_records', config.get('show_records', False))
+ self.show_ranking = defaults.get('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)
+ fonts["rank"] = self._load_custom_font(rank_config, default_size=10)
+ self.logger.debug("Successfully loaded fonts from config")
+ except Exception:
+ self.logger.exception("Error loading fonts, 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) -> ImageFont.FreeTypeFont:
+ """Load a custom font from an element configuration dictionary."""
+ font_name = element_config.get('font', 'PressStart2P-Regular.ttf')
+ 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', '.otf')):
+ return ImageFont.truetype(font_path, font_size)
+ elif font_path.lower().endswith('.bdf'):
+ # ImageFont.truetype does not support bitmap (BDF) fonts.
+ # Use ImageFont.load for BDFs; note that BDFs are bitmap
+ # fonts and ignore font_size — the glyph size is baked in.
+ try:
+ return ImageFont.load(font_path)
+ except Exception as e:
+ self.logger.warning(
+ f"Could not load BDF font {font_name}: {e}; 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', 'PressStart2P-Regular.ttf')
+ try:
+ if os.path.exists(default_font_path):
+ return ImageFont.truetype(default_font_path, font_size)
+ except Exception:
+ self.logger.debug(f"Could not load fallback font from {default_font_path}")
+
+ 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:
+ league = game.get('league', 'ncaa_mens')
+ for team_key in ['home_abbr', 'away_abbr']:
+ abbr = game.get(team_key, '')
+ # Use league-aware cache key to avoid collisions across leagues
+ cache_key = f"{league}_{abbr}"
+ if abbr and cache_key not in self._logo_cache:
+ # Get logo path from game or resolve from logo_dir
+ logo_path_str = game.get(f'{team_key.replace("abbr", "logo_path")}')
+ if logo_path_str:
+ # Resolve relative paths using logo_dir
+ logo_path = Path(logo_path_str) if os.path.isabs(logo_path_str) else logo_dir / logo_path_str
+ else:
+ logo_path = logo_dir / f"{abbr}.png"
+
+ # _load_and_resize_logo handles caching with league-aware key internally
+ self._load_and_resize_logo(abbr, logo_path, league)
+
+ self.logger.debug(f"Preloaded {len(self._logo_cache)} team logos")
+
+ def _get_logo_path(self, league: str, team_abbrev: str) -> Path:
+ """Get the logo path for a team based on league."""
+ logo_dir = self.logo_dirs.get(league, 'assets/sports/ncaa_logos')
+ return Path(logo_dir) / f"{team_abbrev}.png"
+
+ def _load_and_resize_logo(
+ self,
+ team_abbrev: str,
+ logo_path: Optional[Path] = None,
+ league: str = 'ncaa_mens'
+ ) -> Optional[Image.Image]:
+ """Load and resize a team logo with caching."""
+ cache_key = f"{league}_{team_abbrev}"
+ if cache_key in self._logo_cache:
+ return self._logo_cache[cache_key]
+
+ # Also check without league prefix for backward compatibility
+ if team_abbrev in self._logo_cache:
+ return self._logo_cache[team_abbrev]
+
+ try:
+ # Use provided path or get from league config
+ if logo_path is None or not os.path.exists(logo_path):
+ logo_path = self._get_logo_path(league, team_abbrev)
+
+ if logo_path and os.path.exists(logo_path):
+ # Use context manager to ensure file handle is closed
+ with Image.open(logo_path) as logo_file:
+ # Convert creates a copy; if already RGBA, use copy() to detach from file
+ if logo_file.mode != "RGBA":
+ logo = logo_file.convert("RGBA")
+ else:
+ logo = logo_file.copy()
+
+ # 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), RESAMPLE_FILTER)
+
+ self._logo_cache[cache_key] = 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 _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 _normalize_game_payload(self, game: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Normalize flat game payload fields into nested structure.
+
+ This allows render_game_card to work with both flat payloads
+ (home_abbr, home_score, away_abbr, away_score at top level) and
+ nested payloads (home_team/away_team/status dicts).
+
+ Args:
+ game: Game dictionary (flat or nested format)
+
+ Returns:
+ Game dictionary with normalized nested structure
+ """
+ # Create a copy to avoid mutating the original
+ normalized = dict(game)
+
+ # Check if we have flat fields that need normalization
+ has_flat_fields = any(
+ key in normalized for key in [
+ 'home_abbr', 'home_score', 'away_abbr', 'away_score',
+ 'home_name', 'away_name', 'home_record', 'away_record'
+ ]
+ )
+
+ if not has_flat_fields:
+ # Already in nested format or empty, return as-is
+ return normalized
+
+ # Normalize home_team
+ home_team = normalized.get('home_team', {})
+ if not isinstance(home_team, dict):
+ home_team = {}
+ # Only set values if they exist at top level and not already in nested dict
+ if 'home_abbr' in normalized and not home_team.get('abbrev'):
+ home_team['abbrev'] = normalized.get('home_abbr', '')
+ if 'home_score' in normalized and 'score' not in home_team:
+ home_team['score'] = normalized.get('home_score', '0')
+ if 'home_name' in normalized and not home_team.get('name'):
+ home_team['name'] = normalized.get('home_name', '')
+ if 'home_record' in normalized and not home_team.get('record'):
+ home_team['record'] = normalized.get('home_record', '')
+ normalized['home_team'] = home_team
+
+ # Normalize away_team
+ away_team = normalized.get('away_team', {})
+ if not isinstance(away_team, dict):
+ away_team = {}
+ if 'away_abbr' in normalized and not away_team.get('abbrev'):
+ away_team['abbrev'] = normalized.get('away_abbr', '')
+ if 'away_score' in normalized and 'score' not in away_team:
+ away_team['score'] = normalized.get('away_score', '0')
+ if 'away_name' in normalized and not away_team.get('name'):
+ away_team['name'] = normalized.get('away_name', '')
+ if 'away_record' in normalized and not away_team.get('record'):
+ away_team['record'] = normalized.get('away_record', '')
+ normalized['away_team'] = away_team
+
+ # Normalize status
+ status = normalized.get('status', {})
+ if not isinstance(status, dict):
+ status = {}
+ if 'status_text' in normalized and not status.get('detail'):
+ status['detail'] = normalized.get('status_text', '')
+ if 'period' in normalized and not status.get('period'):
+ status['period'] = normalized.get('period', '')
+ if 'clock' in normalized and not status.get('clock'):
+ status['clock'] = normalized.get('clock', '')
+ # Mirror into display_clock so the live renderer (which reads
+ # status['display_clock']) sees flat-payload clocks correctly.
+ if status.get('clock') and not status.get('display_clock'):
+ status['display_clock'] = status['clock']
+ if 'state' in normalized and not status.get('state'):
+ status['state'] = normalized.get('state', '')
+ normalized['status'] = status
+
+ return normalized
+
+ 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
+ """
+ # Normalize flat payload fields into nested structure if needed
+ # This allows render_game_card to work with both flat and nested game dicts
+ game = self._normalize_game_payload(game)
+
+ # 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)
+
+ # Get league for logo directory
+ league = game.get('league', 'ncaa_mens')
+ logo_dir = Path(self.logo_dirs.get(league, 'assets/sports/ncaa_logos'))
+
+ # Get team info (home_team/away_team dicts)
+ home_team = game.get('home_team', {})
+ away_team = game.get('away_team', {})
+ home_abbr = home_team.get('abbrev', '')
+ away_abbr = away_team.get('abbrev', '')
+
+ # Load logos
+ home_logo = self._load_and_resize_logo(
+ home_abbr,
+ logo_dir / f"{home_abbr}.png",
+ league
+ )
+ away_logo = self._load_and_resize_logo(
+ away_abbr,
+ logo_dir / f"{away_abbr}.png",
+ league
+ )
+
+ if not home_logo or not away_logo:
+ return self._render_error_card(f"{away_abbr or '?'}@{home_abbr or '?'}")
+
+ center_y = self.display_height // 2
+
+ # Draw 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)
+ main_img.paste(away_logo, (away_x, away_y), away_logo)
+
+ 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)
+ main_img.paste(home_logo, (home_x, home_y), home_logo)
+
+ # Draw scores (centered) - only for live and recent games
+ if game_type in ("live", "recent"):
+ home_score = str(home_team.get("score", "0"))
+ away_score = str(away_team.get("score", "0"))
+ score_text = f"{away_score}-{home_score}"
+ score_width = draw_overlay.textlength(score_text, font=self.fonts['score'])
+ 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'])
+ elif game_type == "upcoming":
+ # Draw "VS" for upcoming games
+ vs_text = "VS"
+ vs_width = draw_overlay.textlength(vs_text, font=self.fonts['score'])
+ vs_x = (self.display_width - vs_width) // 2
+ vs_y = (self.display_height // 2) - 3
+ self._draw_text_with_outline(draw_overlay, vs_text, (vs_x, vs_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 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 _render_error_card(self, message: str) -> Image.Image:
+ """Render an error message card."""
+ img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
+ draw = ImageDraw.Draw(img)
+ self._draw_text_with_outline(draw, message, (5, 5), self.fonts['status'])
+ return img
+
+ def _draw_live_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None:
+ """Draw status elements for a live game."""
+ # Period and Clock (Top center)
+ status = game.get('status', {})
+ period = status.get('period', 0)
+ # Prefer display_clock (nested ESPN payload), fall back to clock
+ # (flat payload normalized in _normalize_game_payload).
+ clock = status.get('display_clock') or status.get('clock', '')
+ state = status.get('state', '')
+
+ if state == 'in':
+ period_clock_text = f"P{period} {clock}".strip()
+ elif state == 'post':
+ period_clock_text = "Final"
+ else:
+ period_clock_text = status.get('short_detail', '')
+
+ 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'])
+
+ # Draw shots on goal (optional)
+ league = game.get('league', 'ncaa_mens')
+ show_shots = self.config.get(league, {}).get('show_shots', False)
+ if show_shots:
+ shots_font = self.fonts['detail']
+ home_shots = str(game.get("home_shots", "0"))
+ away_shots = str(game.get("away_shots", "0"))
+ shots_text = f"{away_shots} SHOTS {home_shots}"
+ shots_bbox = draw.textbbox((0, 0), shots_text, font=shots_font)
+ shots_height = shots_bbox[3] - shots_bbox[1]
+ shots_y = self.display_height - shots_height - 1
+ shots_width = draw.textlength(shots_text, font=shots_font)
+ shots_x = (self.display_width - shots_width) // 2
+ self._draw_text_with_outline(draw, shots_text, (shots_x, shots_y), shots_font)
+
+ def _draw_recent_game_status(self, draw: ImageDraw.Draw, _game: Dict) -> None:
+ """Draw status elements for a recently completed game.
+
+ Note: _game parameter reserved for future enhancements (e.g., OT indicator).
+ """
+ # Final status (Top center)
+ status_text = "Final"
+ status_width = draw.textlength(status_text, font=self.fonts['time'])
+ status_x = (self.display_width - status_width) // 2
+ status_y = 1
+ self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time'])
+
+ def _draw_upcoming_game_status(self, draw: ImageDraw.Draw, game: Dict) -> None:
+ """Draw status elements for an upcoming game."""
+ # Get game time from status
+ status = game.get('status', {})
+ game_time = status.get('short_detail', '')
+
+ 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'])
+ else:
+ # Fallback: try to parse start_time
+ start_time = game.get("start_time", "")
+ if start_time:
+ try:
+ from datetime import datetime, timezone
+
+ dt = datetime.fromisoformat(start_time.replace('Z', '+00:00'))
+ local_dt = dt.astimezone(timezone.utc) # Use UTC for now
+
+ game_date = local_dt.strftime("%b %d")
+
+ date_width = draw.textlength(game_date, font=self.fonts['time'])
+ date_x = (self.display_width - date_width) // 2
+ date_y = 1
+ self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time'])
+ except (ValueError, TypeError) as e:
+ self.logger.debug(f"Failed to parse start_time '{start_time}': {e}")
+
+ def _draw_records_or_rankings(self, draw: ImageDraw.Draw, game: Dict) -> None:
+ """Draw team records or rankings."""
+ # Use configurable detail font, with fallback to hardcoded default
+ record_font = self.fonts.get('detail')
+ if record_font is None:
+ try:
+ record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
+ except IOError:
+ record_font = ImageFont.load_default()
+
+ # Get team info (home_team/away_team dicts)
+ home_team = game.get('home_team', {})
+ away_team = game.get('away_team', {})
+ away_abbr = away_team.get('abbrev', '')
+ home_abbr = home_team.get('abbrev', '')
+ away_record = away_team.get('record', '')
+ home_record = home_team.get('record', '')
+
+ 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, 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, 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).
+
+ Rankings take precedence over records when both are enabled.
+ """
+ if self.show_ranking:
+ rank = self._team_rankings_cache.get(abbr, 0)
+ if rank > 0:
+ return f"#{rank}"
+ return ''
+ if self.show_records:
+ return record
+ return ''
diff --git a/plugins/lacrosse-scoreboard/lacrosse.py b/plugins/lacrosse-scoreboard/lacrosse.py
new file mode 100644
index 00000000..c2c65390
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/lacrosse.py
@@ -0,0 +1,421 @@
+import logging
+from datetime import datetime
+from typing import Any, Dict, Optional
+
+import pytz
+from PIL import Image, ImageDraw, ImageFont
+
+from data_sources import ESPNDataSource
+from sports import SportsCore, SportsLive
+
+
+class Lacrosse(SportsCore):
+ """Base class for lacrosse sports with common functionality."""
+
+ 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.data_source = ESPNDataSource(logger)
+ self.sport = "lacrosse"
+ self.show_shots = self.mode_config.get("show_shots", False)
+
+ def _fetch_season_schedule(
+ self,
+ *,
+ sport: str,
+ cache_key_prefix: str,
+ scoreboard_url: str,
+ season_start_mmdd: str,
+ season_end_mmdd: str = "0601",
+ use_cache: bool = True,
+ ) -> Optional[Dict]:
+ """Fetch the full ESPN lacrosse season schedule with caching.
+
+ Shared helper used by both NCAA men's and women's managers. Handles
+ season-year rollover (anything from July onward targets the next
+ calendar year's season), cache hits, background fetches, and
+ immediate partial-data returns.
+
+ Args:
+ sport: Sport identifier passed to the background fetch service
+ (e.g. ``"ncaa_mens_lacrosse"`` or ``"ncaa_womens_lacrosse"``).
+ cache_key_prefix: Cache key prefix; the season year is appended.
+ scoreboard_url: Full ESPN scoreboard URL for this league.
+ season_start_mmdd: First calendar date of the season window as
+ ``"MMDD"`` (e.g. ``"0101"`` for men's, ``"0201"`` for women's).
+ season_end_mmdd: Last calendar date of the season window; defaults
+ to June 1 which covers NCAA championships for both leagues.
+ use_cache: When True, consult the cache manager first and only
+ kick off a background fetch on a miss.
+ """
+ now = datetime.now(pytz.utc)
+ season_year = now.year
+ # After the NCAA championship (late May / June), roll to the next
+ # season year for caching purposes.
+ if now.month >= 7:
+ season_year = now.year + 1
+ datestring = f"{season_year}{season_start_mmdd}-{season_year}{season_end_mmdd}"
+ cache_key = f"{cache_key_prefix}_{season_year}"
+
+ if use_cache:
+ cached_data = self.cache_manager.get(cache_key)
+ if cached_data:
+ if isinstance(cached_data, dict) and "events" in cached_data:
+ self.logger.info(f"Using cached schedule for {season_year}")
+ return cached_data
+ elif isinstance(cached_data, list):
+ self.logger.info(
+ f"Using cached schedule for {season_year} (legacy format)"
+ )
+ return {"events": cached_data}
+ else:
+ self.logger.warning(
+ f"Invalid cached data format for {season_year}: {type(cached_data)}"
+ )
+ self.cache_manager.clear_cache(cache_key)
+
+ self.logger.info(
+ f"Fetching full {season_year} season schedule from ESPN API..."
+ )
+ self.logger.info(
+ f"Starting background fetch for {season_year} season schedule..."
+ )
+
+ def fetch_callback(result):
+ """Callback when background fetch completes."""
+ if result.success:
+ events = (getattr(result, "data", None) or {}).get("events") or []
+ self.logger.info(
+ f"Background fetch completed for {season_year}: {len(events)} events"
+ )
+ else:
+ self.logger.error(
+ f"Background fetch failed for {season_year}: {result.error}"
+ )
+ if season_year in self.background_fetch_requests:
+ del self.background_fetch_requests[season_year]
+
+ 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)
+
+ request_id = self.background_service.submit_fetch_request(
+ sport=sport,
+ year=season_year,
+ url=scoreboard_url,
+ cache_key=cache_key,
+ params={"dates": datestring, "limit": 1000},
+ headers=self.headers,
+ timeout=timeout,
+ max_retries=max_retries,
+ priority=priority,
+ callback=fetch_callback,
+ )
+ self.background_fetch_requests[season_year] = request_id
+
+ # For immediate response, return whatever partial data is available.
+ partial_data = self._get_weeks_data()
+ if partial_data:
+ return partial_data
+ return None
+
+ def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
+ """Extract relevant game details from ESPN Lacrosse API response."""
+ 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:
+ competition = game_event["competitions"][0]
+ status = competition["status"]
+
+ # Lacrosse shot totals (if exposed in statistics)
+ home_stats = home_team.get("statistics", [])
+ away_stats = away_team.get("statistics", [])
+ home_shots = next(
+ (
+ int(c["displayValue"])
+ for c in home_stats
+ if c.get("name") in ("shots", "totalShots")
+ ),
+ 0,
+ )
+ away_shots = next(
+ (
+ int(c["displayValue"])
+ for c in away_stats
+ if c.get("name") in ("shots", "totalShots")
+ ),
+ 0,
+ )
+
+ # Format period/quarter. NCAA men's & women's lacrosse use 4 quarters.
+ period = status.get("period", 0)
+ period_text = ""
+ if status["type"]["state"] == "in":
+ if period == 0:
+ period_text = "Start"
+ elif 1 <= period <= 4:
+ period_text = f"Q{period}"
+ elif period > 4:
+ period_text = f"OT{period - 4}"
+ elif status["type"]["state"] == "post":
+ if period > 4:
+ period_text = "Final/OT"
+ else:
+ period_text = "Final"
+ elif status["type"]["state"] == "pre":
+ period_text = details.get("game_time", "")
+
+ details.update(
+ {
+ "period": period,
+ "period_text": period_text,
+ "clock": status.get("displayClock", "0:00"),
+ "home_shots": home_shots,
+ "away_shots": away_shots,
+ }
+ )
+
+ 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
+
+ def _get_team_display_text(self, abbr: str, record: str) -> str:
+ """Pick the short text shown under a team logo.
+
+ When `show_ranking` is enabled, the poll rank (if any) wins and the
+ record is hidden. When only `show_records` is enabled, the W-L record
+ is shown. Unranked teams get an empty string under ranking-only mode.
+ """
+ if not abbr:
+ return ""
+ if self.show_ranking:
+ rank = self._team_rankings_cache.get(abbr, 0)
+ if rank > 0:
+ return f"#{rank}"
+ return ""
+ if self.show_records:
+ return record or ""
+ return ""
+
+
+class LacrosseLive(Lacrosse, SportsLive):
+ 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)
+
+ def _test_mode_update(self):
+ if not (self.current_game and self.current_game.get("is_live")):
+ return
+ # For testing, tick the clock down to show updates working.
+ clock_str = self.current_game.get("clock") or ""
+ try:
+ minutes_str, seconds_str = clock_str.split(":", 1)
+ minutes = int(minutes_str)
+ seconds = int(seconds_str)
+ except (ValueError, AttributeError):
+ # Malformed clock — reset to the start of a 15-minute quarter.
+ self.logger.debug(
+ f"Test clock reset: unparseable value {clock_str!r}"
+ )
+ self.current_game["clock"] = "15:00"
+ return
+ seconds -= 1
+ if seconds < 0:
+ seconds = 59
+ minutes -= 1
+ if minutes < 0:
+ minutes = 14 # 15-minute quarters (NCAA men's)
+ if self.current_game.get("period", 1) < 4:
+ self.current_game["period"] = self.current_game.get("period", 1) + 1
+ else:
+ self.current_game["period"] = 1
+ self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
+
+ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
+ """Draw the detailed scorebug layout for a live Lacrosse game."""
+ try:
+ 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)
+ 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.paste(main_img.convert("RGB"), (0, 0))
+ self.display_manager.update_display()
+ return
+
+ center_y = self.display_height // 2
+
+ home_x = (
+ self.display_width - home_logo.width + 10
+ + 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 = -10 + 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)
+
+ # Quarter and Clock (Top center)
+ period_clock_text = (
+ f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
+ )
+ if game.get("is_period_break"):
+ period_clock_text = game.get("status_text", "Quarter Break")
+
+ status_width = draw_overlay.textlength(
+ period_clock_text, font=self.fonts["time"]
+ )
+ status_x = (self.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,
+ period_clock_text,
+ (status_x, status_y),
+ self.fonts["time"],
+ )
+
+ # Scores (centered, slightly above bottom)
+ 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"])
+ score_x = (self.display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset')
+ score_y = (
+ self.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"]
+ )
+
+ # Shot totals
+ if self.show_shots:
+ try:
+ shots_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
+ except (OSError, IOError):
+ shots_font = ImageFont.load_default()
+ home_shots = str(game.get("home_shots", "0"))
+ away_shots = str(game.get("away_shots", "0"))
+ shots_text = f"{away_shots} SHOTS {home_shots}"
+ shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font)
+ shots_height = shots_bbox[3] - shots_bbox[1]
+ shots_y = self.display_height - shots_height - 1
+ shots_width = draw_overlay.textlength(shots_text, font=shots_font)
+ shots_x = (self.display_width - shots_width) // 2
+ self._draw_text_with_outline(
+ draw_overlay, shots_text, (shots_x, shots_y), shots_font
+ )
+
+ # Draw odds if available
+ if game.get("odds"):
+ self._draw_dynamic_odds(
+ draw_overlay, game["odds"], self.display_width, self.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 - 1
+
+ away_text = self._get_team_display_text(
+ away_abbr, game.get("away_record", "")
+ )
+ if away_text:
+ self._draw_text_with_outline(
+ draw_overlay,
+ away_text,
+ (3, record_y),
+ record_font,
+ )
+
+ home_text = self._get_team_display_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 = self.display_width - home_record_width - 3
+ self._draw_text_with_outline(
+ draw_overlay,
+ home_text,
+ (home_record_x, record_y),
+ record_font,
+ )
+
+ # Composite text overlay onto main image
+ 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()
+
+ except Exception as e:
+ self.logger.error(
+ f"Error displaying live Lacrosse game: {e}", exc_info=True
+ )
diff --git a/plugins/lacrosse-scoreboard/logo_downloader.py b/plugins/lacrosse-scoreboard/logo_downloader.py
new file mode 100644
index 00000000..8efe0ddb
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/logo_downloader.py
@@ -0,0 +1,142 @@
+"""
+Simplified LogoDownloader for plugin use
+"""
+
+import os
+import logging
+import requests
+from typing import Dict, Any, List, Optional, Tuple
+from pathlib import Path
+from PIL import Image, ImageDraw, ImageFont
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+logger = logging.getLogger(__name__)
+
+class LogoDownloader:
+ """Simplified logo downloader for team logos from ESPN API."""
+
+ def __init__(self, request_timeout: int = 30, retry_attempts: int = 3):
+ """Initialize the logo downloader with HTTP session and retry logic."""
+ self.request_timeout = request_timeout
+ self.retry_attempts = retry_attempts
+
+ # Set up session with retry logic
+ self.session = requests.Session()
+ retry_strategy = Retry(
+ total=retry_attempts,
+ backoff_factor=1,
+ 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)
+
+ # 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'
+ }
+
+ @staticmethod
+ def normalize_abbreviation(abbr: str) -> str:
+ """Normalize team abbreviation for filename."""
+ return abbr.upper()
+
+ @staticmethod
+ def get_logo_filename_variations(abbr: str) -> List[str]:
+ """Get possible filename variations for a team abbreviation."""
+ normalized = LogoDownloader.normalize_abbreviation(abbr)
+ variations = [f"{normalized}.png"]
+
+ # Add common variations
+ if normalized == "TA&M":
+ variations.append("TAANDM.png")
+ elif normalized == "TAMU":
+ variations.append("TA&M.png")
+
+ return variations
+
+def download_missing_logo(sport_key: str, team_id: str, team_abbr: str, logo_path: Path, logo_url: Optional[str] = None) -> bool:
+ """
+ Download missing logo for a team.
+
+ A placeholder logo is always written to disk as a fallback so the caller
+ can safely open the file afterwards. The return value reflects whether
+ the *real* logo was downloaded from the remote URL — callers that care
+ about distinguishing a real logo from a placeholder should inspect the
+ return value.
+
+ Args:
+ sport_key: Sport key (e.g., 'nfl', 'ncaa_fb')
+ team_id: Team ID
+ team_abbr: Team abbreviation
+ logo_path: Path where logo should be saved
+ logo_url: Optional logo URL
+
+ Returns:
+ True if the real logo was downloaded successfully from logo_url,
+ False if a placeholder was created as a fallback or the download failed.
+ """
+ try:
+ # Create directory if it doesn't exist
+ logo_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # If we have a logo URL, try to download it
+ if logo_url:
+ response = requests.get(logo_url, timeout=30)
+ if response.status_code == 200:
+ with open(logo_path, 'wb') as f:
+ f.write(response.content)
+ logger.info(f"Downloaded logo for {team_abbr} from {logo_url}")
+ return True
+
+ # If no URL or download failed, create a placeholder
+ create_placeholder_logo(team_abbr, logo_path)
+ return False
+
+ except Exception as e:
+ logger.error(f"Failed to download logo for {team_abbr}: {e}")
+ # Create placeholder as fallback
+ create_placeholder_logo(team_abbr, logo_path)
+ return False
+
+def create_placeholder_logo(team_abbr: str, logo_path: Path) -> None:
+ """Create a simple placeholder logo."""
+ try:
+ # Create a simple text-based logo
+ img = Image.new('RGBA', (64, 64), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(img)
+
+ # Try to load a font, falling back to PIL's built-in default if the
+ # bundled font is missing or cannot be read.
+ try:
+ font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
+ except (OSError, IOError) as e:
+ logger.debug(f"Placeholder font unavailable ({e}); using PIL default")
+ font = ImageFont.load_default()
+
+ # Draw team abbreviation
+ text = team_abbr[:3] # Limit to 3 characters
+ bbox = draw.textbbox((0, 0), text, font=font)
+ text_width = bbox[2] - bbox[0]
+ text_height = bbox[3] - bbox[1]
+
+ x = (64 - text_width) // 2
+ y = (64 - text_height) // 2
+
+ # Draw white text with black outline
+ 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=(0, 0, 0))
+ draw.text((x, y), text, font=font, fill=(255, 255, 255))
+
+ # Save the placeholder
+ img.save(logo_path)
+ logger.info(f"Created placeholder logo for {team_abbr}")
+
+ except Exception as e:
+ logger.error(f"Failed to create placeholder logo for {team_abbr}: {e}")
diff --git a/plugins/lacrosse-scoreboard/manager.py b/plugins/lacrosse-scoreboard/manager.py
new file mode 100644
index 00000000..bc646edc
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/manager.py
@@ -0,0 +1,2982 @@
+"""
+Lacrosse Scoreboard Plugin for LEDMatrix - Using Existing Managers
+
+This plugin provides NCAA Men's and NCAA Women's lacrosse scoreboard functionality by reusing
+the proven, working manager classes adapted from the lacrosse scoreboard plugin.
+"""
+
+import logging
+import time
+from typing import Dict, Any, Optional, Set, List, Tuple
+
+try:
+ from src.plugin_system.base_plugin import BasePlugin, VegasDisplayMode
+except ImportError:
+ BasePlugin = None
+ VegasDisplayMode = None
+
+# Import shared background service from LEDMatrix core
+try:
+ from src.background_data_service import get_background_service
+except ImportError:
+ get_background_service = None
+
+# Import scroll display components
+try:
+ from scroll_display import ScrollDisplayManager
+ SCROLL_AVAILABLE = True
+except ImportError:
+ ScrollDisplayManager = None
+ SCROLL_AVAILABLE = False
+
+# Import the copied manager classes.
+# This plugin ships NCAA men's and women's lacrosse only — there is no
+# pro lacrosse league in the scope of this plugin.
+from ncaam_lacrosse_managers import (
+ NCAAMLacrosseLiveManager,
+ NCAAMLacrosseRecentManager,
+ NCAAMLacrosseUpcomingManager,
+)
+from ncaaw_lacrosse_managers import (
+ NCAAWLacrosseLiveManager,
+ NCAAWLacrosseRecentManager,
+ NCAAWLacrosseUpcomingManager,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class LacrosseScoreboardPlugin(BasePlugin if BasePlugin else object):
+ """
+ Lacrosse scoreboard plugin.
+
+ Provides NCAA Men's and NCAA Women's lacrosse scoreboard functionality by
+ delegating to per-league manager classes, with support for live, recent,
+ and upcoming game modes, favorite-team filtering, poll-rank badges, and
+ per-mode switch/scroll display styles.
+ """
+
+ def __init__(
+ self,
+ plugin_id: str,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ plugin_manager,
+ ):
+ """Initialize the lacrosse 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)
+
+ # League configurations (defaults come from schema via plugin_manager merge)
+ self.logger.debug(f"Lacrosse plugin received config keys: {list(config.keys())}")
+
+ self.ncaa_mens_enabled = config.get("ncaa_mens", {}).get("enabled", False)
+ self.ncaa_womens_enabled = config.get("ncaa_womens", {}).get("enabled", False)
+
+ self.logger.info(
+ f"League enabled states - NCAA Men's: {self.ncaa_mens_enabled}, "
+ f"NCAA Women's: {self.ncaa_womens_enabled}"
+ )
+
+ # Live priority settings
+ self.ncaa_mens_live_priority = self.config.get("ncaa_mens", {}).get(
+ "live_priority", False
+ )
+ self.ncaa_womens_live_priority = self.config.get("ncaa_womens", {}).get(
+ "live_priority", False
+ )
+
+ # Global settings - read from defaults section with fallback
+ defaults = config.get("defaults", {})
+ self.display_duration = float(defaults.get("display_duration", config.get("display_duration", 30)))
+ self.game_display_duration = float(defaults.get("display_duration", config.get("game_display_duration", 15)))
+
+ # Additional settings - read from defaults section with fallback
+ self.show_records = defaults.get("show_records", config.get("show_records", False))
+ self.show_ranking = defaults.get("show_ranking", config.get("show_ranking", False))
+ self.show_odds = defaults.get("show_odds", config.get("show_odds", 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
+ )
+ except Exception as e:
+ self.logger.warning(f"Could not initialize background service: {e}")
+
+ # 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] = {} # {game_type: is_active}
+ self._scroll_prepared: Dict[str, bool] = {} # {game_type: is_prepared}
+
+ # Enable high-FPS mode for scroll display (allows 100+ FPS scrolling)
+ # This signals to the display controller to use high-FPS loop (8ms = 125 FPS)
+ self.enable_scrolling = self._scroll_manager is not None
+ if self.enable_scrolling:
+ self.logger.info("High-FPS scrolling enabled for lacrosse scoreboard")
+
+ # League registry: maps league IDs to their configuration and managers
+ # This structure makes it easy to add more leagues in the future
+ # Format: {league_id: {'enabled': bool, 'priority': int, 'live_priority': bool, 'managers': {...}}}
+ # The registry will be populated after managers are initialized
+ self._league_registry: Dict[str, Dict[str, Any]] = {}
+
+ # Track current display context for granular dynamic duration
+ self._current_display_league: Optional[str] = None # 'ncaa_mens' or 'ncaa_womens'
+ self._current_display_mode_type: Optional[str] = None # 'live', 'recent', 'upcoming'
+
+ # Initialize managers
+ self._initialize_managers()
+
+ # Initialize league registry after managers are created
+ # This centralizes league management and makes it easy to add more leagues
+ self._initialize_league_registry()
+
+ # Mode cycling (like football plugin)
+ self.current_mode_index = 0
+ self.last_mode_switch = time.time()
+ self.modes = self._get_available_modes()
+
+ # Dynamic duration tracking state
+ 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
+ # Track when single-game managers were first seen to ensure full duration
+ self._single_game_manager_start_times: Dict[str, float] = {}
+ # Track when each game ID was first seen to ensure full per-game duration
+ # Using game IDs instead of indices prevents start time resets when game order changes
+ self._game_id_start_times: Dict[str, Dict[str, float]] = {} # {manager_key: {game_id: start_time}}
+ # Track which managers were actually used for each display mode
+ self._display_mode_to_managers: Dict[str, Set[str]] = {} # {display_mode: {manager_key, ...}}
+
+ # Track last display mode to detect when we return after being away
+ self._last_display_mode: Optional[str] = None # Track previous display mode
+ self._last_display_mode_time: float = 0.0 # When we last saw this mode
+ self._current_active_display_mode: Optional[str] = None # Currently active external display mode
+
+ # Throttle logging for has_live_content() when returning False
+ self._last_live_content_false_log: float = 0.0 # Timestamp of last False log
+ self._live_content_log_interval: float = 60.0 # Log False results every 60 seconds
+
+ # Track current game for transition detection
+ # Format: {display_mode: {'game_id': str, 'league': str, 'last_log_time': float}}
+ self._current_game_tracking: Dict[str, Dict[str, Any]] = {}
+ self._game_transition_log_interval: float = 1.0 # Minimum seconds between game transition logs
+
+ # Track mode start times for per-mode duration enforcement
+ # Format: {display_mode: start_time} (e.g., {'ncaa_mens_recent': 1234567890.0})
+ # Reset when mode changes or full cycle completes
+ self._mode_start_time: Dict[str, float] = {}
+
+ # Sticky manager tracking - ensures we complete all games from one league before switching
+ self._sticky_manager_per_mode: Dict[str, Any] = {} # {display_mode: manager_instance}
+ self._sticky_manager_start_time: Dict[str, float] = {} # {display_mode: timestamp}
+
+ # Display mode settings parsing (for future scroll mode support in config schema)
+ self._display_mode_settings = self._parse_display_mode_settings()
+
+ # Initialize scroll display manager if available
+ self._scroll_manager = None
+ if SCROLL_AVAILABLE and ScrollDisplayManager:
+ try:
+ self._scroll_manager = ScrollDisplayManager(
+ self.display_manager,
+ self.config,
+ self.logger
+ )
+ self.logger.info("Lacrosse scroll display manager initialized")
+ except Exception as e:
+ self.logger.warning(f"Could not initialize scroll display manager: {e}")
+ else:
+ self.logger.info("Scroll display not available - scroll mode disabled")
+
+ # Scroll state tracking
+ self._scroll_prepared = {} # Tracks which scroll modes are prepared
+ self._scroll_active = {} # Tracks which scroll modes are active
+
+ self.logger.info(
+ f"Lacrosse scoreboard plugin initialized - {self.display_width}x{self.display_height}"
+ )
+ self.logger.info(
+ f"NCAA Men's enabled: {self.ncaa_mens_enabled}, NCAA Women's enabled: {self.ncaa_womens_enabled}"
+ )
+
+ def _initialize_managers(self):
+ """Initialize all manager instances."""
+ try:
+ # Create adapted configs for managers
+ ncaa_mens_config = self._adapt_config_for_manager("ncaa_mens")
+ ncaa_womens_config = self._adapt_config_for_manager("ncaa_womens")
+
+ # Initialize NCAA Men's managers if enabled
+ if self.ncaa_mens_enabled:
+ try:
+ self.ncaa_mens_live = NCAAMLacrosseLiveManager(
+ ncaa_mens_config, self.display_manager, self.cache_manager
+ )
+ self.ncaa_mens_recent = NCAAMLacrosseRecentManager(
+ ncaa_mens_config, self.display_manager, self.cache_manager
+ )
+ self.ncaa_mens_upcoming = NCAAMLacrosseUpcomingManager(
+ ncaa_mens_config, self.display_manager, self.cache_manager
+ )
+ self.logger.info("NCAA Men's Lacrosse managers initialized")
+ except Exception as e:
+ self.logger.error(f"Failed to initialize NCAA Men's Lacrosse managers: {e}", exc_info=True)
+ # Set to None so hasattr checks work correctly
+ if not hasattr(self, "ncaa_mens_live"):
+ self.ncaa_mens_live = None
+ if not hasattr(self, "ncaa_mens_recent"):
+ self.ncaa_mens_recent = None
+ if not hasattr(self, "ncaa_mens_upcoming"):
+ self.ncaa_mens_upcoming = None
+
+ # Initialize NCAA Women's managers if enabled
+ if self.ncaa_womens_enabled:
+ try:
+ self.ncaa_womens_live = NCAAWLacrosseLiveManager(
+ ncaa_womens_config, self.display_manager, self.cache_manager
+ )
+ self.ncaa_womens_recent = NCAAWLacrosseRecentManager(
+ ncaa_womens_config, self.display_manager, self.cache_manager
+ )
+ self.ncaa_womens_upcoming = NCAAWLacrosseUpcomingManager(
+ ncaa_womens_config, self.display_manager, self.cache_manager
+ )
+ self.logger.info("NCAA Women's Lacrosse managers initialized")
+ except Exception as e:
+ self.logger.error(f"Failed to initialize NCAA Women's Lacrosse managers: {e}", exc_info=True)
+ # Set to None so hasattr checks work correctly
+ if not hasattr(self, "ncaa_womens_live"):
+ self.ncaa_womens_live = None
+ if not hasattr(self, "ncaa_womens_recent"):
+ self.ncaa_womens_recent = None
+ if not hasattr(self, "ncaa_womens_upcoming"):
+ self.ncaa_womens_upcoming = None
+
+ except Exception as e:
+ self.logger.error(f"Error initializing managers: {e}", exc_info=True)
+
+ def _initialize_league_registry(self) -> None:
+ """
+ Initialize the league registry with all available leagues.
+
+ The league registry centralizes league management and makes it easy to:
+ - Add new leagues in the future (just add an entry here)
+ - Query enabled leagues for a mode type
+ - Get managers in priority order
+ - Check league completion status
+
+ Registry format:
+ {
+ 'league_id': {
+ 'enabled': bool, # Whether the league is enabled
+ 'priority': int, # Display priority (lower = higher priority)
+ 'live_priority': bool, # Whether live priority is enabled for this league
+ 'managers': {
+ 'live': Manager or None,
+ 'recent': Manager or None,
+ 'upcoming': Manager or None
+ }
+ }
+ }
+
+ This design allows the display logic to iterate through leagues in priority
+ order without hardcoding league names throughout the codebase.
+ """
+ # NCAA Men's Lacrosse league entry - highest priority (1)
+ self._league_registry['ncaa_mens'] = {
+ 'enabled': self.ncaa_mens_enabled,
+ 'priority': 1, # Highest priority - shows first
+ 'live_priority': self.ncaa_mens_live_priority,
+ 'managers': {
+ 'live': getattr(self, 'ncaa_mens_live', None),
+ 'recent': getattr(self, 'ncaa_mens_recent', None),
+ 'upcoming': getattr(self, 'ncaa_mens_upcoming', None),
+ }
+ }
+
+ # NCAA Women's Lacrosse league entry - second priority (2)
+ self._league_registry['ncaa_womens'] = {
+ 'enabled': self.ncaa_womens_enabled,
+ 'priority': 2, # Second priority - shows after NCAA Men's
+ 'live_priority': self.ncaa_womens_live_priority,
+ 'managers': {
+ 'live': getattr(self, 'ncaa_womens_live', None),
+ 'recent': getattr(self, 'ncaa_womens_recent', None),
+ 'upcoming': getattr(self, 'ncaa_womens_upcoming', None),
+ }
+ }
+
+ # Log registry state for debugging
+ enabled_leagues = [lid for lid, data in self._league_registry.items() if data['enabled']]
+ self.logger.info(
+ f"League registry initialized: {len(self._league_registry)} league(s) registered, "
+ f"{len(enabled_leagues)} enabled: {enabled_leagues}"
+ )
+
+ def _get_enabled_leagues_for_mode(self, mode_type: str) -> List[str]:
+ """
+ Get list of enabled leagues for a specific mode type in priority order.
+
+ This method respects both league-level and mode-level disabling:
+ - League must be enabled (league.enabled = True)
+ - Mode must be enabled for that league (league.display_modes.show_ = True)
+
+ Args:
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+
+ Returns:
+ List of league IDs in priority order (lower priority number = higher priority)
+ Example: ['ncaa_mens', 'ncaa_womens'] means NCAA Men's shows first, then NCAA Women's
+
+ This is the core method for sequential block display - it determines
+ which leagues should be shown and in what order.
+ """
+ enabled_leagues = []
+
+ # Iterate through all registered leagues
+ for league_id, league_data in self._league_registry.items():
+ # Check if league is enabled
+ if not league_data.get('enabled', False):
+ continue
+
+ # Check if this mode type is enabled for this league
+ # Get the league config to check display_modes settings
+ league_config = self.config.get(league_id, {})
+ display_modes_config = league_config.get("display_modes", {})
+
+ # Check the appropriate flag based on mode type
+ mode_enabled = True # Default to enabled if not specified
+ if mode_type == 'live':
+ mode_enabled = display_modes_config.get("live", display_modes_config.get("show_live", True))
+ elif mode_type == 'recent':
+ mode_enabled = display_modes_config.get("recent", display_modes_config.get("show_recent", True))
+ elif mode_type == 'upcoming':
+ mode_enabled = display_modes_config.get("upcoming", display_modes_config.get("show_upcoming", True))
+
+ # Only include if mode is enabled for this league
+ if mode_enabled:
+ enabled_leagues.append(league_id)
+
+ # Sort by priority (lower number = higher priority)
+ enabled_leagues.sort(key=lambda lid: self._league_registry[lid].get('priority', 999))
+
+ self.logger.debug(
+ f"Enabled leagues for {mode_type} mode: {enabled_leagues} "
+ f"(priorities: {[self._league_registry[lid].get('priority') for lid in enabled_leagues]})"
+ )
+
+ return enabled_leagues
+
+ def _get_managers_for_mode_type(self, mode_type: str) -> List:
+ """
+ Get managers in priority order for a specific mode type.
+
+ This method returns manager instances for all enabled leagues that have
+ the specified mode type enabled, sorted by league priority.
+
+ Args:
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+
+ Returns:
+ List of manager instances in priority order (highest priority first)
+ Managers are filtered to only include enabled leagues with the mode enabled
+
+ This is used by the sequential block display logic to determine which
+ leagues should be shown and in what order.
+ """
+ managers = []
+
+ # Get enabled leagues for this mode type in priority order
+ enabled_leagues = self._get_enabled_leagues_for_mode(mode_type)
+
+ # Get managers for each enabled league in priority order
+ for league_id in enabled_leagues:
+ manager = self._get_league_manager_for_mode(league_id, mode_type)
+ if manager:
+ managers.append(manager)
+ self.logger.debug(
+ f"Added {league_id} {mode_type} manager to priority list "
+ f"(priority: {self._league_registry[league_id].get('priority', 999)})"
+ )
+
+ self.logger.debug(
+ f"Managers in priority order for {mode_type}: "
+ f"{[m.__class__.__name__ for m in managers]}"
+ )
+
+ return managers
+
+ def _get_league_manager_for_mode(self, league_id: str, mode_type: str):
+ """
+ Get the manager instance for a specific league and mode type.
+
+ This is a convenience method that looks up managers from the league registry.
+ It provides a single point of access for getting managers, making the code
+ more maintainable and easier to extend.
+
+ Args:
+ league_id: League identifier ('ncaa_mens' or 'ncaa_womens')
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+
+ Returns:
+ Manager instance if found, None otherwise
+
+ The manager is retrieved from the league registry, which is populated
+ during initialization. If the league or mode doesn't exist, returns None.
+ """
+ # Check if league exists in registry
+ if league_id not in self._league_registry:
+ self.logger.warning(f"League {league_id} not found in registry")
+ return None
+
+ # Get managers dict for this league
+ managers = self._league_registry[league_id].get('managers', {})
+
+ # Get the manager for this mode type
+ manager = managers.get(mode_type)
+
+ if manager is None:
+ self.logger.debug(f"No manager found for {league_id} {mode_type}")
+
+ return manager
+
+ def _is_league_complete_for_mode(self, league_id: str, mode_type: str) -> bool:
+ """
+ Check if a league has completed showing all games for a specific mode type.
+
+ This is used in sequential block display to determine when to move from
+ one league to the next. A league is considered complete when all its games
+ have been shown for their full duration (tracked via dynamic duration system).
+
+ Args:
+ league_id: League identifier ('ncaa_mens' or 'ncaa_womens')
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+
+ Returns:
+ True if the league's manager for this mode is marked as complete,
+ False otherwise
+
+ The completion status is tracked in _dynamic_managers_completed set,
+ using manager keys in the format: "{league_id}_{mode_type}:ManagerClass"
+ """
+ # Get the manager for this league and mode
+ manager = self._get_league_manager_for_mode(league_id, mode_type)
+ if not manager:
+ # No manager means league can't be displayed, so consider it "complete"
+ # (nothing to show, so we can move on)
+ return True
+
+ # Build the manager key that matches what's used in progress tracking
+ # Format: "{league_id}_{mode_type}:ManagerClass"
+ manager_key = self._build_manager_key(f"{league_id}_{mode_type}", manager)
+
+ # Check if this manager is in the completed set
+ is_complete = manager_key in self._dynamic_managers_completed
+
+ if is_complete:
+ self.logger.debug(f"League {league_id} {mode_type} is complete (manager_key: {manager_key})")
+ else:
+ self.logger.debug(f"League {league_id} {mode_type} is not complete (manager_key: {manager_key})")
+
+ return is_complete
+
+ def _get_default_logo_dir(self, league: str) -> str:
+ """
+ Get the default logo directory for a league.
+ Matches the directories used in src/logo_downloader.py.
+ """
+ # Map leagues to their logo directories (matching logo_downloader.py)
+ logo_dir_map = {
+ 'ncaa_mens': 'assets/sports/ncaa_logos', # NCAA Men's Lacrosse uses ncaa_logos
+ 'ncaa_womens': 'assets/sports/ncaa_logos', # NCAA Women's Lacrosse uses ncaa_logos
+ }
+ # Default to league-specific directory if not in map
+ return logo_dir_map.get(league, f"assets/sports/{league}_logos")
+
+ def _parse_display_mode_settings(self) -> Dict[str, Dict[str, str]]:
+ """
+ Parse display mode settings from config.
+
+ Returns:
+ Dict mapping league -> game_type -> display_mode ('switch' or 'scroll')
+ e.g., {'ncaa_mens': {'live': 'switch', 'recent': 'switch', 'upcoming': 'switch'}}
+ """
+ settings = {}
+
+ for league in ['ncaa_mens', 'ncaa_womens']:
+ league_config = self.config.get(league, {})
+ display_modes_config = league_config.get("display_modes", {})
+
+ settings[league] = {
+ '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'),
+ }
+
+ self.logger.debug(f"Display mode settings for {league}: {settings[league]}")
+
+ return settings
+
+ def _get_display_mode(self, league: str, game_type: str) -> str:
+ """
+ Get the display mode for a specific league and game type.
+
+ Args:
+ league: 'ncaa_mens' or 'ncaa_womens'
+ game_type: 'live', 'recent', or 'upcoming'
+
+ Returns:
+ 'switch' or 'scroll'
+ """
+ if league not in self._display_mode_settings:
+ return 'switch'
+
+ return self._display_mode_settings[league].get(game_type, 'switch')
+
+ def _extract_mode_type(self, display_mode: str) -> Optional[str]:
+ """Extract mode type (live, recent, upcoming) from display mode string.
+
+ Args:
+ display_mode: Display mode string (e.g., 'ncaa_mens_live', 'ncaa_womens_recent')
+
+ Returns:
+ Mode type string ('live', 'recent', 'upcoming') or None
+ """
+ if display_mode.endswith('_live'):
+ return 'live'
+ elif display_mode.endswith('_recent'):
+ return 'recent'
+ elif display_mode.endswith('_upcoming'):
+ return 'upcoming'
+ return None
+
+ def _get_game_duration(self, league: str, mode_type: str, manager=None) -> float:
+ """Get game duration for a league and mode type combination.
+
+ Resolves duration using the following hierarchy:
+ 1. Manager's game_display_duration attribute (if manager provided)
+ 2. League-specific mode duration (e.g., ncaa_mens.live_game_duration from display_durations.live)
+ 3. League-specific default (15 seconds)
+
+ Args:
+ league: League name ('ncaa_mens' or 'ncaa_womens')
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+ manager: Optional manager instance (if provided, checks manager's game_display_duration)
+
+ Returns:
+ Game duration in seconds (float)
+ """
+ # First, try manager's game_display_duration if available
+ if manager:
+ manager_duration = getattr(manager, 'game_display_duration', None)
+ if manager_duration is not None:
+ return float(manager_duration)
+
+ # Next, try league-specific mode duration from display_durations
+ league_config = self.config.get(league, {})
+ display_durations = league_config.get("display_durations", {})
+ mode_duration_key = mode_type # e.g., 'live' maps to display_durations.live
+ mode_duration = display_durations.get(mode_duration_key)
+ if mode_duration is not None:
+ return float(mode_duration)
+
+ # Fallback to league-specific default (15 seconds)
+ return 15.0
+
+ def _get_mode_duration(self, league: str, mode_type: str) -> Optional[float]:
+ """
+ Get mode duration from config for a league/mode combination.
+
+ Checks per-league/per-mode settings first, then falls back to per-league settings.
+ Returns None if not configured (uses dynamic calculation).
+
+ Args:
+ league: League name ('ncaa_mens' or 'ncaa_womens')
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+
+ Returns:
+ Mode duration in seconds (float) or None if not configured
+ """
+ league_config = self.config.get(league, {})
+ mode_durations = league_config.get("mode_durations", {})
+
+ # Check per-mode setting (e.g., live_mode_duration, recent_mode_duration)
+ mode_duration_key = f"{mode_type}_mode_duration"
+ if mode_duration_key in mode_durations:
+ value = mode_durations[mode_duration_key]
+ if value is not None:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ pass
+
+ # No per-mode setting configured - return None to use dynamic calculation
+ return None
+
+ def _dynamic_feature_enabled(self) -> bool:
+ """Return True when dynamic duration should be active."""
+ if not self.is_enabled:
+ return False
+ return self.supports_dynamic_duration()
+
+ def _adapt_config_for_manager(self, league: str) -> Dict[str, Any]:
+ """
+ Adapt plugin config format to manager expected format.
+
+ Plugin uses: ncaa_mens: {...}, ncaa_womens: {...}
+ Managers expect: ncaam_lacrosse_scoreboard: {...}, ncaaw_lacrosse_scoreboard: {...}, etc.
+
+ Supports both new nested structure and old flat structure for backward compatibility.
+ """
+ league_config = self.config.get(league, {})
+ defaults = self.config.get("defaults", {})
+
+ # Map league names to sport_key format expected by managers
+ sport_key_map = {
+ "ncaa_mens": "ncaam_lacrosse",
+ "ncaa_womens": "ncaaw_lacrosse",
+ }
+ sport_key = sport_key_map.get(league, league)
+
+ # Extract nested configurations (new structure) with fallback to flat structure (old)
+ display_modes = league_config.get("display_modes", {})
+ teams_config = league_config.get("teams", {})
+ filtering_config = league_config.get("filtering", {})
+ update_intervals = league_config.get("update_intervals", {})
+ display_durations = league_config.get("display_durations", {})
+ display_options = league_config.get("display_options", {})
+
+ def resolve_mode_flag(*keys: str, default: bool = True) -> bool:
+ for key in keys:
+ if key in display_modes:
+ return bool(display_modes[key])
+ return default
+
+ live_flag = resolve_mode_flag("live", "show_live")
+ recent_flag = resolve_mode_flag("recent", "show_recent")
+ upcoming_flag = resolve_mode_flag("upcoming", "show_upcoming")
+
+ def resolve_value(nested_path: list, flat_keys: list, default):
+ """Resolve value from nested structure or fallback to flat structure."""
+ # Try nested structure first
+ current = league_config
+ for key in nested_path:
+ if isinstance(current, dict) and key in current:
+ current = current[key]
+ else:
+ current = None
+ break
+ if current is not None:
+ return current
+
+ # Try flat structure (backward compatibility)
+ for key in flat_keys:
+ if key in league_config:
+ return league_config[key]
+
+ # Try defaults
+ if nested_path:
+ current = defaults
+ for key in nested_path:
+ if isinstance(current, dict) and key in current:
+ current = current[key]
+ else:
+ return default
+ return current
+
+ return default
+
+ # Resolve team settings
+ favorite_teams = resolve_value(["teams", "favorite_teams"], ["favorite_teams"], [])
+ favorite_only = resolve_value(["teams", "favorite_teams_only"], ["favorite_teams_only"], False)
+ show_all_live = resolve_value(["teams", "show_all_live"], ["show_all_live"], False)
+
+ # Resolve filtering settings
+ recent_games_to_show = resolve_value(["filtering", "recent_games_to_show"], ["recent_games_to_show"], 5)
+ upcoming_games_to_show = resolve_value(["filtering", "upcoming_games_to_show"], ["upcoming_games_to_show"], 10)
+
+ # Resolve update intervals
+ update_interval_seconds = resolve_value(["update_intervals", "base"], ["update_interval_seconds"], 60)
+ live_update_interval = resolve_value(["update_intervals", "live"], ["live_update_interval"], 15)
+ recent_update_interval = resolve_value(["update_intervals", "recent"], ["recent_update_interval"], 3600)
+ upcoming_update_interval = resolve_value(["update_intervals", "upcoming"], ["upcoming_update_interval"], 3600)
+
+ # Resolve display durations
+ def resolve_live_duration() -> int:
+ # Try new nested structure
+ if "display_durations" in league_config and "live" in league_config["display_durations"]:
+ return int(league_config["display_durations"]["live"])
+ # Try old flat structure
+ if "live_game_duration" in league_config:
+ return int(league_config["live_game_duration"])
+ if "game_rotation_interval_seconds" in league_config:
+ return int(league_config["game_rotation_interval_seconds"])
+ if "live_display_duration" in league_config:
+ return int(league_config["live_display_duration"])
+ return 20
+
+ # Resolve display options with defaults fallback
+ show_records = resolve_value(["display_options", "show_records"], ["show_records"], self.show_records)
+ show_ranking = resolve_value(["display_options", "show_ranking"], ["show_ranking"], self.show_ranking)
+ show_odds = resolve_value(["display_options", "show_odds"], ["show_odds"], self.show_odds)
+ show_shots = resolve_value(["display_options", "show_shots"], ["show_shots"], False)
+
+ # Create manager config with expected structure
+ manager_config = {
+ f"{sport_key}_scoreboard": {
+ "enabled": league_config.get("enabled", False),
+ "favorite_teams": favorite_teams,
+ "display_modes": {
+ "live": live_flag,
+ "recent": recent_flag,
+ "upcoming": upcoming_flag,
+ },
+ "recent_games_to_show": recent_games_to_show,
+ "upcoming_games_to_show": upcoming_games_to_show,
+ "show_records": show_records,
+ "show_ranking": show_ranking,
+ "show_odds": show_odds,
+ "show_shots": show_shots,
+ "show_favorite_teams_only": favorite_only,
+ "show_all_live": show_all_live,
+ "live_priority": league_config.get("live_priority", False),
+ "update_interval_seconds": update_interval_seconds,
+ "live_update_interval": live_update_interval,
+ "recent_update_interval": recent_update_interval,
+ "upcoming_update_interval": upcoming_update_interval,
+ "live_game_duration": resolve_live_duration(),
+ "background_service": {
+ "request_timeout": 30,
+ "max_retries": 3,
+ "priority": 2,
+ },
+ }
+ }
+
+ # Add global config - get timezone from cache_manager's config_manager if available
+ timezone_str = self.config.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"
+
+ # Get display config from main config if available
+ display_config = self.config.get("display", {})
+ if not display_config and hasattr(self.cache_manager, 'config_manager'):
+ display_config = self.cache_manager.config_manager.get_display_config()
+
+ # Get customization config from main config (shared across all leagues)
+ customization_config = self.config.get("customization", {})
+
+ manager_config.update(
+ {
+ "timezone": timezone_str,
+ "display": display_config,
+ "customization": customization_config,
+ }
+ )
+
+ return manager_config
+
+ def _get_available_modes(self) -> list:
+ """Get list of available display modes based on enabled leagues using league registry."""
+ modes = []
+
+ # Use league registry to build mode list in priority order
+ # Iterate through leagues in priority order (lower priority number = higher priority)
+ sorted_leagues = sorted(
+ self._league_registry.items(),
+ key=lambda item: item[1].get('priority', 999)
+ )
+
+ for league_id, league_data in sorted_leagues:
+ # Check if league is enabled
+ if not league_data.get('enabled', False):
+ continue
+
+ # Get league config to check display_modes settings
+ league_config = self.config.get(league_id, {})
+ display_modes_config = league_config.get("display_modes", {})
+
+ # Check each mode type
+ for mode_type in ['recent', 'upcoming', 'live']: # Order: recent, upcoming, live
+ mode_enabled = True # Default to enabled if not specified
+ if mode_type == 'live':
+ mode_enabled = display_modes_config.get("live", display_modes_config.get("show_live", True))
+ elif mode_type == 'recent':
+ mode_enabled = display_modes_config.get("recent", display_modes_config.get("show_recent", True))
+ elif mode_type == 'upcoming':
+ mode_enabled = display_modes_config.get("upcoming", display_modes_config.get("show_upcoming", True))
+
+ if mode_enabled:
+ modes.append(f"{league_id}_{mode_type}")
+
+ # Default to NCAA Men's Lacrosse if no leagues enabled
+ if not modes:
+ modes = ["ncaa_mens_recent", "ncaa_mens_upcoming", "ncaa_mens_live"]
+
+ return modes
+
+ def _get_current_manager(self):
+ """Get the current manager based on the current mode (like football plugin)."""
+ if not self.modes:
+ return None
+
+ current_mode = self.modes[self.current_mode_index]
+
+ if current_mode.startswith("ncaa_mens_"):
+ if not self.ncaa_mens_enabled:
+ return None
+ mode_type = current_mode.split("_", 2)[2] # "live", "recent", "upcoming"
+ if mode_type == "live":
+ return self.ncaa_mens_live
+ elif mode_type == "recent":
+ return self.ncaa_mens_recent
+ elif mode_type == "upcoming":
+ return self.ncaa_mens_upcoming
+
+ elif current_mode.startswith("ncaa_womens_"):
+ if not self.ncaa_womens_enabled:
+ return None
+ mode_type = current_mode.split("_", 2)[2] # "live", "recent", "upcoming"
+ if mode_type == "live":
+ return self.ncaa_womens_live
+ elif mode_type == "recent":
+ return self.ncaa_womens_recent
+ elif mode_type == "upcoming":
+ return self.ncaa_womens_upcoming
+
+ return None
+
+ def _ensure_manager_updated(self, manager) -> None:
+ """Trigger an update when the delegated manager is stale."""
+ last_update = getattr(manager, "last_update", None)
+ update_interval = getattr(manager, "update_interval", None)
+ if last_update is None or update_interval is None:
+ return
+
+ interval = update_interval
+ no_data_interval = getattr(manager, "no_data_interval", None)
+ live_games = getattr(manager, "live_games", None)
+ if no_data_interval and not live_games:
+ interval = no_data_interval
+
+ try:
+ if interval and time.time() - last_update >= interval:
+ manager.update()
+ except Exception as exc:
+ self.logger.debug(f"Auto-refresh failed for manager {manager}: {exc}")
+
+ def update(self) -> None:
+ """Update lacrosse game data."""
+ if not self.is_enabled:
+ return
+
+ current_time = time.time()
+ # Log plugin update calls for debugging (every 5 minutes)
+ if not hasattr(self, '_last_plugin_update_log') or current_time - self._last_plugin_update_log >= 300:
+ self.logger.info(f"Plugin update() called at {current_time}")
+ self._last_plugin_update_log = current_time
+
+ try:
+ # Update NCAA Men's managers if enabled
+ if self.ncaa_mens_enabled:
+ for attr in ("ncaa_mens_live", "ncaa_mens_recent", "ncaa_mens_upcoming"):
+ manager = getattr(self, attr, None)
+ if manager:
+ manager.update()
+
+ # Update NCAA Women's managers if enabled
+ if self.ncaa_womens_enabled:
+ for attr in (
+ "ncaa_womens_live",
+ "ncaa_womens_recent",
+ "ncaa_womens_upcoming",
+ ):
+ manager = getattr(self, attr, None)
+ if manager:
+ manager.update()
+
+ except Exception as e:
+ self.logger.error(f"Error updating managers: {e}", exc_info=True)
+
+ def display(self, display_mode: str = None, force_clear: bool = False) -> bool:
+ """Display lacrosse games for a specific granular mode.
+
+ The plugin now uses granular modes directly (ncaa_mens_recent, ncaa_womens_live,
+ ncaa_mens_recent, ncaa_mens_upcoming, ncaa_mens_live, etc.) registered in manifest.json.
+ The display controller handles rotation between these modes.
+
+ Args:
+ display_mode: Granular mode name (e.g., 'ncaa_mens_recent', 'ncaa_womens_upcoming', 'ncaa_mens_live')
+ Format: {league}_{mode_type}
+ If None, uses internal mode cycling (legacy support).
+ force_clear: If True, clear display before rendering
+ """
+ if not self.is_enabled:
+ return False
+
+ try:
+ # Track the current active display mode for use in is_cycle_complete()
+ if display_mode:
+ self._current_active_display_mode = display_mode
+
+ # Route to appropriate display handler
+ if display_mode:
+ # Handle legacy combined modes (lacrosse_live, lacrosse_recent, lacrosse_upcoming)
+ # These should not be called with new architecture, but handle gracefully
+ # for backward compatibility during transition
+ if display_mode.startswith("lacrosse_"):
+ # Legacy combined mode - extract mode_type and show all enabled leagues
+ mode_type_str = display_mode.replace("lacrosse_", "")
+ if mode_type_str not in ['live', 'recent', 'upcoming']:
+ self.logger.warning(
+ f"Invalid legacy combined mode: {display_mode}"
+ )
+ return False
+
+ # Show all enabled leagues for this mode type (sequential block)
+ # This maintains backward compatibility during transition
+ enabled_leagues = self._get_enabled_leagues_for_mode(mode_type_str)
+ if not enabled_leagues:
+ self.logger.debug(
+ f"No enabled leagues for legacy mode {display_mode}"
+ )
+ return False
+
+ # Try to display from first enabled league (simplified fallback)
+ # Sequential block display would show all leagues, but for legacy
+ # mode support we just try the first one
+ for league_id in enabled_leagues:
+ success = self._display_league_mode(league_id, mode_type_str, force_clear)
+ if success:
+ return True
+
+ # No content from any league
+ return False
+
+ # Parse granular mode name: {league}_{mode_type}
+ # e.g., "ncaa_mens_recent" -> league="ncaa_mens", mode_type="recent"
+ # e.g., "ncaa_mens_recent" -> league="ncaa_mens", mode_type="recent"
+ #
+ # Scalable approach: Check league registry first, then extract mode type
+ # This works for any league naming convention (underscores, dots, etc.)
+ mode_type_str = None
+ league = None
+
+ # Known mode type suffixes (standardized across all sports plugins)
+ mode_suffixes = ['_live', '_recent', '_upcoming']
+
+ # Try to match against league registry first (most reliable)
+ # Check each league ID in registry to see if display_mode starts with it
+ for league_id in self._league_registry.keys():
+ for mode_suffix in mode_suffixes:
+ expected_mode = f"{league_id}{mode_suffix}"
+ if display_mode == expected_mode:
+ league = league_id
+ mode_type_str = mode_suffix[1:] # Remove leading underscore
+ break
+ if league:
+ break
+
+ # Fallback: If no registry match, parse from the end (for backward compatibility)
+ if not league:
+ for mode_suffix in mode_suffixes:
+ if display_mode.endswith(mode_suffix):
+ mode_type_str = mode_suffix[1:] # Remove leading underscore
+ league = display_mode[:-len(mode_suffix)] # Everything before the suffix
+ # Validate it's a known league
+ if league in self._league_registry:
+ break
+ else:
+ # Not a known league, try next suffix
+ league = None
+ mode_type_str = None
+
+ if not mode_type_str or not league:
+ self.logger.warning(
+ f"Invalid granular display_mode format: {display_mode} "
+ f"(expected format: {{league}}_{{mode_type}}, e.g., 'ncaa_mens_recent' or 'ncaa_womens_recent'). "
+ f"Valid leagues: {list(self._league_registry.keys())}"
+ )
+ return False
+
+ # Validate league exists in registry (double-check)
+ if league not in self._league_registry:
+ self.logger.warning(
+ f"Invalid league in display_mode: {league} (mode: {display_mode}). "
+ f"Valid leagues: {list(self._league_registry.keys())}"
+ )
+ return False
+
+ # Check if league is enabled
+ if not self._league_registry[league].get('enabled', False):
+ self.logger.debug(
+ f"League {league} is disabled, skipping {display_mode}"
+ )
+ return False
+
+ # Check if mode is enabled for this league
+ league_config = self.config.get(league, {})
+ display_modes_config = league_config.get("display_modes", {})
+
+ mode_enabled = True
+ if mode_type_str == 'live':
+ mode_enabled = display_modes_config.get("live", display_modes_config.get("show_live", True))
+ elif mode_type_str == 'recent':
+ mode_enabled = display_modes_config.get("recent", display_modes_config.get("show_recent", True))
+ elif mode_type_str == 'upcoming':
+ mode_enabled = display_modes_config.get("upcoming", display_modes_config.get("show_upcoming", True))
+
+ if not mode_enabled:
+ self.logger.debug(
+ f"Mode {mode_type_str} is disabled for league {league}, skipping {display_mode}"
+ )
+ return False
+
+ # Display this specific league/mode combination
+ return self._display_league_mode(league, mode_type_str, force_clear)
+ else:
+ # No display_mode provided - use internal cycling (legacy support)
+ return self._display_internal_cycling(force_clear)
+
+ except Exception as e:
+ self.logger.error(f"Error in display method: {e}", exc_info=True)
+ return False
+
+ def is_cycle_complete(self) -> bool:
+ """Report whether the plugin has shown a full cycle of content."""
+ if not self._dynamic_feature_enabled():
+ return True
+
+ # Pass the current active display mode to evaluate completion for the right mode
+ self._evaluate_dynamic_cycle_completion(display_mode=self._current_active_display_mode)
+ self.logger.info(f"is_cycle_complete() called: display_mode={self._current_active_display_mode}, returning {self._dynamic_cycle_complete}")
+ return self._dynamic_cycle_complete
+
+ def _set_display_context_from_manager(self, manager, mode_type: str) -> None:
+ """Set current display league and mode type based on manager instance.
+
+ Args:
+ manager: Manager instance
+ mode_type: 'live', 'recent', or 'upcoming'
+ """
+ self._current_display_mode_type = mode_type
+
+ # Check NCAA Men's managers
+ if manager in (getattr(self, 'ncaa_mens_live', None),
+ getattr(self, 'ncaa_mens_recent', None),
+ getattr(self, 'ncaa_mens_upcoming', None)):
+ self._current_display_league = 'ncaa_mens'
+ # Check NCAA Women's managers
+ elif manager in (getattr(self, 'ncaa_womens_live', None),
+ getattr(self, 'ncaa_womens_recent', None),
+ getattr(self, 'ncaa_womens_upcoming', None)):
+ self._current_display_league = 'ncaa_womens'
+
+ @staticmethod
+ def _build_manager_key(mode_name: str, manager) -> str:
+ """Build a unique key for tracking a manager instance.
+
+ Args:
+ mode_name: Display mode name (e.g., 'ncaa_mens_recent')
+ manager: Manager instance
+
+ Returns:
+ Manager key string (e.g., 'ncaa_mens_recent:NCAAMLacrosseLiveManager')
+ """
+ manager_name = manager.__class__.__name__ if manager else "None"
+ return f"{mode_name}:{manager_name}"
+
+ @staticmethod
+ def _get_total_games_for_manager(manager) -> int:
+ """Get total number of games for a manager.
+
+ Args:
+ manager: Manager instance
+
+ Returns:
+ Number of games (0 if no games or manager is None)
+ """
+ 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
+
+ @staticmethod
+ def _get_all_game_ids_for_manager(manager) -> set:
+ """Get all game IDs from a manager's game list.
+
+ Args:
+ manager: Manager instance
+
+ Returns:
+ Set of game ID strings
+ """
+ if manager is None:
+ return set()
+ game_ids = set()
+ for attr in ("live_games", "games_list", "recent_games", "upcoming_games"):
+ game_list = getattr(manager, attr, None)
+ if isinstance(game_list, list) and game_list:
+ for i, game in enumerate(game_list):
+ game_id = game.get('id')
+ if game_id:
+ game_ids.add(str(game_id))
+ else:
+ # Fallback to index-based identifier if ID missing
+ away_abbr = game.get('away_abbr', '')
+ home_abbr = game.get('home_abbr', '')
+ if away_abbr and home_abbr:
+ game_ids.add(f"{away_abbr}@{home_abbr}-{i}")
+ else:
+ game_ids.add(f"index-{i}")
+ break
+ return game_ids
+
+ def _get_rankings_cache(self) -> Dict[str, int]:
+ """Get combined team rankings cache from all managers.
+
+ Returns:
+ Dictionary mapping team abbreviations to their rankings/positions
+ Format: {'TB': 1, 'BOS': 2, ...}
+ Empty dict if no rankings available
+ """
+ rankings = {}
+
+ # Try to get rankings from each manager
+ for manager_attr in ['ncaa_mens_live', 'ncaa_mens_recent', 'ncaa_mens_upcoming',
+ 'ncaa_womens_live', 'ncaa_womens_recent', 'ncaa_womens_upcoming']:
+ manager = getattr(self, manager_attr, None)
+ if manager:
+ manager_rankings = getattr(manager, '_team_rankings_cache', {})
+ if manager_rankings:
+ rankings.update(manager_rankings)
+
+ return rankings
+
+ def _get_manager_for_league_mode(self, league: str, mode_type: str):
+ """Get manager instance for a league and mode type combination.
+
+ This is a convenience method that calls _get_league_manager_for_mode()
+ for consistency with football-scoreboard naming.
+
+ Args:
+ league: 'ncaa_mens' or 'ncaa_womens'
+ mode_type: 'live', 'recent', or 'upcoming'
+
+ Returns:
+ Manager instance or None if not available/enabled
+ """
+ return self._get_league_manager_for_mode(league, mode_type)
+
+ def _get_games_from_manager(self, manager, mode_type: str) -> List[Dict]:
+ """Get games list from a manager based on mode type.
+
+ Args:
+ manager: Manager instance
+ mode_type: 'live', 'recent', or 'upcoming'
+
+ Returns:
+ List of game dictionaries
+ """
+ if mode_type == 'live':
+ return list(getattr(manager, 'live_games', []) or [])
+ elif mode_type == 'recent':
+ # Try games_list first (used by recent managers), then recent_games
+ games = getattr(manager, 'games_list', None)
+ if games is None:
+ games = getattr(manager, 'recent_games', [])
+ return list(games or [])
+ elif mode_type == 'upcoming':
+ # Try games_list first (used by upcoming managers), then upcoming_games
+ games = getattr(manager, 'games_list', None)
+ if games is None:
+ games = getattr(manager, 'upcoming_games', [])
+ return list(games or [])
+ return []
+
+ def _has_live_games_for_manager(self, manager) -> bool:
+ """Check if a manager has valid live games (for favorite teams if configured).
+
+ Args:
+ manager: Manager instance to check
+
+ Returns:
+ True if manager has live games that should be displayed
+ """
+ if not manager:
+ return False
+
+ live_games = getattr(manager, 'live_games', [])
+ if not live_games:
+ return False
+
+ # Filter out games that are final or appear over
+ live_games = [g for g in live_games if not g.get('is_final', False)]
+ if hasattr(manager, '_is_game_really_over'):
+ live_games = [g for g in live_games if not manager._is_game_really_over(g)]
+
+ if not live_games:
+ return False
+
+ # If favorite teams are configured, only return True if there are live games for favorite teams
+ favorite_teams = getattr(manager, 'favorite_teams', [])
+ if favorite_teams:
+ has_favorite_live = any(
+ game.get('home_abbr') in favorite_teams
+ or game.get('away_abbr') in favorite_teams
+ for game in live_games
+ )
+ return has_favorite_live
+
+ # No favorite teams configured, any live game counts
+ return True
+
+ def _filter_managers_by_live_content(self, managers: list, mode_type: str) -> list:
+ """Filter managers based on live content when in live mode.
+
+ Args:
+ managers: List of manager instances
+ mode_type: 'live', 'recent', or 'upcoming'
+
+ Returns:
+ Filtered list of managers with live content (for live mode) or original list
+ """
+ if mode_type != 'live':
+ return managers
+
+ # For live mode, only include managers with actual live games
+ filtered = []
+ for manager in managers:
+ if self._has_live_games_for_manager(manager):
+ filtered.append(manager)
+
+ return filtered
+
+ def _apply_sticky_manager_logic(self, display_mode: str, managers_to_try: list) -> list:
+ """Apply sticky manager logic to filter managers list.
+
+ Args:
+ display_mode: External display mode name
+ managers_to_try: List of managers to try
+
+ Returns:
+ Filtered list of managers (only sticky manager if exists and available)
+ """
+ sticky_manager = self._sticky_manager_per_mode.get(display_mode)
+
+ self.logger.info(
+ f"Sticky manager check for {display_mode}: "
+ f"sticky={sticky_manager.__class__.__name__ if sticky_manager else None}, "
+ f"available_managers={[m.__class__.__name__ for m in managers_to_try if m]}"
+ )
+
+ if sticky_manager and sticky_manager in managers_to_try:
+ self.logger.info(
+ f"Using sticky manager {sticky_manager.__class__.__name__} for {display_mode} - "
+ "RESTRICTING to this manager only"
+ )
+ return [sticky_manager]
+
+ # No sticky manager or not in list - clean up if needed
+ if sticky_manager:
+ self.logger.info(
+ f"Sticky manager {sticky_manager.__class__.__name__} no longer available for {display_mode}, "
+ f"selecting new one from {len(managers_to_try)} options"
+ )
+ self._sticky_manager_per_mode.pop(display_mode, None)
+ self._sticky_manager_start_time.pop(display_mode, None)
+ else:
+ self.logger.info(
+ f"No sticky manager yet for {display_mode}, will select from {len(managers_to_try)} available managers"
+ )
+
+ return managers_to_try
+
+ def _resolve_managers_for_mode(self, mode_type: str) -> list:
+ """
+ Resolve ordered list of managers to try for a given mode type.
+
+ This method uses the league registry to get managers in priority order,
+ respecting both league-level and mode-level enabling/disabling.
+
+ For live mode, it also respects live_priority settings and filters
+ to only include managers with actual live games.
+
+ Args:
+ mode_type: 'live', 'recent', or 'upcoming'
+
+ Returns:
+ Ordered list of manager instances to try (in priority order)
+ Managers are filtered based on:
+ - League enabled state
+ - Mode enabled state for that league (live, recent, upcoming)
+ - For live mode: live_priority and actual live games availability
+ """
+ managers_to_try = []
+
+ # Get enabled leagues for this mode type in priority order
+ # This already respects league-level and mode-level enabling
+ enabled_leagues = self._get_enabled_leagues_for_mode(mode_type)
+
+ if mode_type == 'live':
+ # For live mode, update managers first to get current live games
+ # This ensures we have fresh data before checking for live content
+ for league_id in enabled_leagues:
+ manager = self._get_league_manager_for_mode(league_id, 'live')
+ if manager:
+ try:
+ manager.update()
+ except Exception as e:
+ self.logger.debug(f"Error updating {league_id} live manager: {e}")
+
+ # For live mode, respect live_priority settings
+ # Only include managers with live_priority enabled AND actual live games
+ for league_id in enabled_leagues:
+ league_data = self._league_registry.get(league_id, {})
+ live_priority = league_data.get('live_priority', False)
+
+ manager = self._get_league_manager_for_mode(league_id, 'live')
+ if not manager:
+ continue
+
+ # If live_priority is enabled, only include if manager has live games
+ if live_priority:
+ if self._has_live_games_for_manager(manager):
+ managers_to_try.append(manager)
+ self.logger.debug(
+ f"{league_id} has live games and live_priority - adding to list"
+ )
+ else:
+ # No live_priority - include manager anyway (fallback)
+ managers_to_try.append(manager)
+ self.logger.debug(
+ f"{league_id} live manager added (no live_priority requirement)"
+ )
+
+ # If no managers found with live_priority, fall back to all enabled managers
+ # This ensures we always have something to show if leagues are enabled
+ if not managers_to_try:
+ for league_id in enabled_leagues:
+ manager = self._get_league_manager_for_mode(league_id, 'live')
+ if manager:
+ managers_to_try.append(manager)
+ self.logger.debug(
+ f"Fallback: added {league_id} live manager (no live_priority managers found)"
+ )
+ else:
+ # For recent and upcoming modes, use standard priority order
+ # Get managers for each enabled league in priority order
+ for league_id in enabled_leagues:
+ manager = self._get_league_manager_for_mode(league_id, mode_type)
+ if manager:
+ managers_to_try.append(manager)
+ self.logger.debug(
+ f"Added {league_id} {mode_type} manager to list "
+ f"(priority: {self._league_registry[league_id].get('priority', 999)})"
+ )
+
+ self.logger.debug(
+ f"Resolved {len(managers_to_try)} manager(s) for {mode_type} mode: "
+ f"{[m.__class__.__name__ for m in managers_to_try]}"
+ )
+
+ return managers_to_try
+
+ def _get_manager_for_mode(self, mode_name: str):
+ """Resolve manager instance for a given display mode.
+
+ Args:
+ mode_name: Display mode name (e.g., 'ncaa_mens_recent', 'ncaa_womens_live')
+
+ Returns:
+ Manager instance or None if not found/disabled
+ """
+ if mode_name.startswith("ncaa_mens_"):
+ if not self.ncaa_mens_enabled:
+ return None
+ suffix = mode_name[len("ncaa_mens_"):]
+ if suffix == "live":
+ return getattr(self, "ncaa_mens_live", None)
+ if suffix == "recent":
+ return getattr(self, "ncaa_mens_recent", None)
+ if suffix == "upcoming":
+ return getattr(self, "ncaa_mens_upcoming", None)
+ elif mode_name.startswith("ncaa_womens_"):
+ if not self.ncaa_womens_enabled:
+ return None
+ suffix = mode_name[len("ncaa_womens_"):]
+ if suffix == "live":
+ return getattr(self, "ncaa_womens_live", None)
+ if suffix == "recent":
+ return getattr(self, "ncaa_womens_recent", None)
+ if suffix == "upcoming":
+ return getattr(self, "ncaa_womens_upcoming", None)
+ return None
+
+ def _track_single_game_progress(self, manager_key: str, manager, league: str, mode_type: str) -> None:
+ """Track progress for a manager with a single game (or no games).
+
+ Args:
+ manager_key: Unique key identifying this manager
+ manager: Manager instance
+ league: League name ('ncaa_mens' or 'ncaa_womens')
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+ """
+ current_time = time.time()
+
+ if manager_key not in self._single_game_manager_start_times:
+ # First time seeing this single-game manager (in this cycle) - record start time
+ self._single_game_manager_start_times[manager_key] = current_time
+ game_duration = self._get_game_duration(league, mode_type, manager) if league and mode_type else getattr(manager, 'game_display_duration', 15)
+ self.logger.info(f"Single-game manager {manager_key} first seen at {current_time:.2f}, will complete after {game_duration}s")
+ else:
+ # Check if enough time has passed
+ start_time = self._single_game_manager_start_times[manager_key]
+ game_duration = self._get_game_duration(league, mode_type, manager) if league and mode_type else getattr(manager, 'game_display_duration', 15)
+ elapsed = current_time - start_time
+ if elapsed >= game_duration:
+ # Enough time has passed - mark as complete
+ if manager_key not in self._dynamic_managers_completed:
+ self._dynamic_managers_completed.add(manager_key)
+ self.logger.info(f"Single-game manager {manager_key} completed after {elapsed:.2f}s (required: {game_duration}s)")
+ # Clean up start time now that manager has completed
+ if manager_key in self._single_game_manager_start_times:
+ del self._single_game_manager_start_times[manager_key]
+ else:
+ # Still waiting
+ self.logger.debug(f"Single-game manager {manager_key} waiting: {elapsed:.2f}s/{game_duration}s (start_time={start_time:.2f}, current_time={current_time:.2f})")
+
+ def _record_dynamic_progress(self, current_manager, actual_mode: str = None, display_mode: str = None) -> None:
+ """Track progress through managers/games for dynamic duration."""
+ if not self._dynamic_feature_enabled() or not self.modes:
+ self._dynamic_cycle_complete = True
+ return
+
+ # Use actual_mode if provided (when display_mode is specified), otherwise use internal mode cycling
+ if actual_mode:
+ current_mode = actual_mode
+ else:
+ current_mode = self.modes[self.current_mode_index] if self.modes else None
+ if current_mode is None:
+ return
+
+ # Track both the internal mode and the external display mode if provided
+ self._dynamic_cycle_seen_modes.add(current_mode)
+ if display_mode and display_mode != current_mode:
+ # Also track the external display mode for proper completion checking
+ self._dynamic_cycle_seen_modes.add(display_mode)
+
+ manager_key = self._build_manager_key(current_mode, current_manager)
+ self._dynamic_mode_to_manager_key[current_mode] = manager_key
+
+ # Extract league and mode_type from current_mode for duration lookups
+ league = None
+ mode_type = None
+ if current_mode:
+ if current_mode.startswith('ncaa_mens_'):
+ league = 'ncaa_mens'
+ mode_type = current_mode.split('_', 2)[2]
+ elif current_mode.startswith('ncaa_womens_'):
+ league = 'ncaa_womens'
+ mode_type = current_mode.split('_', 2)[2]
+
+ # Log for debugging
+ self.logger.debug(f"_record_dynamic_progress: current_mode={current_mode}, display_mode={display_mode}, manager={current_manager.__class__.__name__}, manager_key={manager_key}, _last_display_mode={self._last_display_mode}")
+
+ total_games = self._get_total_games_for_manager(current_manager)
+
+ # Check if this is a new cycle for this display mode BEFORE adding to tracking
+ # A "new cycle" means we're returning to a mode after having been away (different mode)
+ # Only track external display_mode (from display controller), not internal mode cycling
+ is_new_cycle = False
+ current_time = time.time()
+
+ # Only track mode changes for external calls (where display_mode differs from actual_mode)
+ # This prevents internal mode cycling from triggering new cycle detection
+ is_external_call = (display_mode and actual_mode and display_mode != actual_mode)
+
+ if is_external_call:
+ # External call from display controller - check for mode switches
+ # Only treat as "new cycle" if we've been away for a while (> 10s)
+ # This allows cycling through recent→upcoming→live→recent without clearing state
+ NEW_CYCLE_THRESHOLD = 10.0 # seconds
+
+ if display_mode != self._last_display_mode:
+ # Switched to a different external mode
+ time_since_last = current_time - self._last_display_mode_time if self._last_display_mode_time > 0 else 999
+
+ # Only treat as new cycle if we've been away for a while OR this is the first time
+ if time_since_last >= NEW_CYCLE_THRESHOLD:
+ is_new_cycle = True
+ self.logger.info(f"New cycle detected for {display_mode}: switched from {self._last_display_mode} (last seen {time_since_last:.1f}s ago)")
+ else:
+ # Quick mode switch within same overall cycle - don't reset
+ self.logger.debug(f"Quick mode switch to {display_mode} from {self._last_display_mode} ({time_since_last:.1f}s ago) - continuing cycle")
+ elif manager_key not in self._display_mode_to_managers.get(display_mode, set()):
+ # Same external mode but manager not tracked yet - could be multi-league setup
+ self.logger.debug(f"Manager {manager_key} not yet tracked for current mode {display_mode}")
+ else:
+ # Same mode and manager already tracked - continue within current cycle
+ self.logger.debug(f"Continuing cycle for {display_mode}: manager {manager_key} already tracked")
+
+ # Update last display mode tracking (only for external calls)
+ self._last_display_mode = display_mode
+ self._last_display_mode_time = current_time
+
+ # ONLY reset state if this is truly a new cycle (after threshold)
+ if is_new_cycle:
+ # New cycle starting - reset ALL state for this manager to start completely fresh
+ if manager_key in self._single_game_manager_start_times:
+ old_start = self._single_game_manager_start_times[manager_key]
+ self.logger.info(f"New cycle for {display_mode}: resetting start time for {manager_key} (old: {old_start:.2f})")
+ del self._single_game_manager_start_times[manager_key]
+ # Also remove from completed set so it can be tracked fresh in this cycle
+ if manager_key in self._dynamic_managers_completed:
+ self.logger.info(f"New cycle for {display_mode}: removing {manager_key} from completed set")
+ self._dynamic_managers_completed.discard(manager_key)
+ # Also clear any game ID start times for this manager
+ if manager_key in self._game_id_start_times:
+ self.logger.info(f"New cycle for {display_mode}: clearing game ID start times for {manager_key}")
+ del self._game_id_start_times[manager_key]
+ # Clear progress tracking for this manager
+ if manager_key in self._dynamic_manager_progress:
+ self.logger.info(f"New cycle for {display_mode}: clearing progress for {manager_key}")
+ self._dynamic_manager_progress[manager_key].clear()
+
+ # Now add to tracking AFTER checking for new cycle
+ if display_mode and display_mode != current_mode:
+ # Store mapping from display_mode to manager_key for completion checking
+ self._display_mode_to_managers.setdefault(display_mode, set()).add(manager_key)
+
+ if total_games <= 1:
+ # Single (or no) game - wait for full game display duration before marking complete
+ self._track_single_game_progress(manager_key, current_manager, league, mode_type)
+ return
+
+ # Get current game to extract its ID for tracking
+ current_game = getattr(current_manager, "current_game", None)
+ if not current_game:
+ # No current game - can't track progress, but this is valid (empty game list)
+ self.logger.debug(f"No current_game in manager {manager_key}, skipping progress tracking")
+ # Still mark the mode as seen even if no content
+ return
+
+ # Use game ID for tracking instead of index to persist across game order changes
+ game_id = current_game.get('id')
+ if not game_id:
+ # Fallback to index if game ID not available (shouldn't happen, but safety first)
+ current_index = getattr(current_manager, "current_game_index", 0)
+ # Also try to get a unique identifier from game data
+ away_abbr = current_game.get('away_abbr', '')
+ home_abbr = current_game.get('home_abbr', '')
+ if away_abbr and home_abbr:
+ game_id = f"{away_abbr}@{home_abbr}-{current_index}"
+ else:
+ game_id = f"index-{current_index}"
+ self.logger.warning(f"Game ID not found for manager {manager_key}, using fallback: {game_id}")
+
+ # Ensure game_id is a string for consistent tracking
+ game_id = str(game_id)
+
+ progress_set = self._dynamic_manager_progress.setdefault(manager_key, set())
+
+ # Track when this game ID was first seen
+ game_times = self._game_id_start_times.setdefault(manager_key, {})
+ if game_id not in game_times:
+ # First time seeing this game - record start time
+ game_times[game_id] = time.time()
+ game_duration = self._get_game_duration(league, mode_type, current_manager) if league and mode_type else getattr(current_manager, 'game_display_duration', 15)
+ game_display = f"{current_game.get('away_abbr', '?')}@{current_game.get('home_abbr', '?')}"
+ self.logger.info(f"Game {game_display} (ID: {game_id}) in manager {manager_key} first seen, will complete after {game_duration}s")
+
+ # Check if this game has been shown for full duration
+ start_time = game_times[game_id]
+ game_duration = self._get_game_duration(league, mode_type, current_manager) if league and mode_type else getattr(current_manager, 'game_display_duration', 15)
+ elapsed = time.time() - start_time
+
+ if elapsed >= game_duration:
+ # This game has been shown for full duration - add to progress set
+ if game_id not in progress_set:
+ progress_set.add(game_id)
+ game_display = f"{current_game.get('away_abbr', '?')}@{current_game.get('home_abbr', '?')}"
+ self.logger.info(f"Game {game_display} (ID: {game_id}) in manager {manager_key} completed after {elapsed:.2f}s (required: {game_duration}s)")
+ else:
+ # Still waiting for this game to complete its duration
+ self.logger.debug(f"Game ID {game_id} in manager {manager_key} waiting: {elapsed:.2f}s/{game_duration}s")
+
+ # Get all valid game IDs from current game list to clean up stale entries
+ valid_game_ids = self._get_all_game_ids_for_manager(current_manager)
+
+ # Clean up progress set and start times for games that no longer exist
+ if valid_game_ids:
+ # Remove game IDs from progress set that are no longer in the game list
+ progress_set.intersection_update(valid_game_ids)
+ # Also clean up start times for games that no longer exist
+ game_times = {k: v for k, v in game_times.items() if k in valid_game_ids}
+ self._game_id_start_times[manager_key] = game_times
+ elif total_games == 0:
+ # No games in list - clear all tracking for this manager
+ progress_set.clear()
+ game_times.clear()
+ self._game_id_start_times[manager_key] = {}
+
+ # Only mark manager complete when all current games have been shown for their full duration
+ # Use the actual current game IDs, not just the count, to handle dynamic game lists
+ current_game_ids = self._get_all_game_ids_for_manager(current_manager)
+
+ if current_game_ids:
+ # Check if all current games have been shown for full duration
+ if current_game_ids.issubset(progress_set):
+ if manager_key not in self._dynamic_managers_completed:
+ self._dynamic_managers_completed.add(manager_key)
+ self.logger.info(f"Manager {manager_key} completed - all {len(current_game_ids)} games shown for full duration (progress: {len(progress_set)} game IDs)")
+ else:
+ missing_count = len(current_game_ids - progress_set)
+ self.logger.debug(f"Manager {manager_key} incomplete - {missing_count} of {len(current_game_ids)} games not yet shown for full duration")
+ elif total_games == 0:
+ # Empty game list - mark as complete immediately
+ if manager_key not in self._dynamic_managers_completed:
+ self._dynamic_managers_completed.add(manager_key)
+ self.logger.debug(f"Manager {manager_key} completed - no games to display")
+
+ def _evaluate_dynamic_cycle_completion(self, display_mode: str = None) -> None:
+ """
+ Determine whether all enabled leagues have completed their cycles for a display mode.
+
+ For sequential block display, a display mode cycle is complete when:
+ - All enabled leagues for that mode type have completed showing all their games
+ - Each league is tracked separately via manager keys
+
+ This method checks completion status for all leagues that were used for
+ the given display mode, ensuring all enabled leagues have completed
+ before marking the cycle as complete.
+
+ Args:
+ display_mode: External display mode name (e.g., 'ncaa_mens_recent')
+ If None, checks internal mode cycling completion
+ """
+ if not self._dynamic_feature_enabled():
+ self._dynamic_cycle_complete = True
+ return
+
+ if not self.modes:
+ self._dynamic_cycle_complete = True
+ return
+
+ # If display_mode is provided, check all managers used for that display mode
+ # This handles multi-league scenarios where we need all leagues to complete
+ if display_mode and display_mode in self._display_mode_to_managers:
+ used_manager_keys = self._display_mode_to_managers[display_mode]
+ if not used_manager_keys:
+ # No managers were used for this display mode yet - cycle not complete
+ self._dynamic_cycle_complete = False
+ self.logger.debug(f"Display mode {display_mode} has no managers tracked yet - cycle incomplete")
+ return
+
+ # Extract mode type to get enabled leagues for comparison
+ mode_type = self._extract_mode_type(display_mode)
+ enabled_leagues = self._get_enabled_leagues_for_mode(mode_type) if mode_type else []
+
+ self.logger.info(
+ f"_evaluate_dynamic_cycle_completion for {display_mode}: "
+ f"checking {len(used_manager_keys)} manager(s): {used_manager_keys}, "
+ f"enabled leagues: {enabled_leagues}"
+ )
+
+ # Check if all managers used for this display mode have completed
+ incomplete_managers = []
+ for manager_key in used_manager_keys:
+ if manager_key not in self._dynamic_managers_completed:
+ incomplete_managers.append(manager_key)
+ # Get the manager to check its state for logging and potential completion
+ # Extract mode and manager class from manager_key (format: "mode:ManagerClass")
+ parts = manager_key.split(':', 1)
+ if len(parts) == 2:
+ mode_name, manager_class_name = parts
+ manager = self._get_manager_for_mode(mode_name)
+ if manager and manager.__class__.__name__ == manager_class_name:
+ total_games = self._get_total_games_for_manager(manager)
+ if total_games <= 1:
+ # Single-game manager - check time
+ if manager_key in self._single_game_manager_start_times:
+ start_time = self._single_game_manager_start_times[manager_key]
+ # Extract league and mode_type from mode_name
+ league = 'ncaa_mens' if mode_name.startswith('ncaa_mens_') else ('ncaa_womens' if mode_name.startswith('ncaa_womens_') else None)
+ mode_type_str = mode_name.split('_')[-1] if mode_name else None
+ game_duration = self._get_game_duration(league, mode_type_str, manager) if league and mode_type_str else getattr(manager, 'game_display_duration', 15)
+ current_time = time.time()
+ elapsed = current_time - start_time
+ if elapsed >= game_duration:
+ self._dynamic_managers_completed.add(manager_key)
+ incomplete_managers.remove(manager_key)
+ self.logger.info(f"Manager {manager_key} marked complete in completion check: {elapsed:.2f}s >= {game_duration}s")
+ # Clean up start time now that manager has completed
+ if manager_key in self._single_game_manager_start_times:
+ del self._single_game_manager_start_times[manager_key]
+ else:
+ self.logger.debug(f"Manager {manager_key} waiting in completion check: {elapsed:.2f}s/{game_duration}s (start_time={start_time:.2f}, current_time={current_time:.2f})")
+ else:
+ # Manager not yet seen - keep it incomplete
+ # This means _record_dynamic_progress hasn't been called yet for this manager
+ # or the state was reset, so we can't determine completion
+ self.logger.debug(f"Manager {manager_key} not yet seen in completion check (not in start_times) - keeping incomplete")
+
+ if incomplete_managers:
+ self._dynamic_cycle_complete = False
+ self.logger.debug(f"Display mode {display_mode} cycle incomplete - {len(incomplete_managers)} manager(s) still in progress: {incomplete_managers}")
+ return
+
+ # All managers completed - verify they truly completed
+ # Double-check that single-game managers have truly finished their duration
+ all_truly_completed = True
+ for manager_key in used_manager_keys:
+ # If manager has a start time, it hasn't completed yet (or just completed)
+ if manager_key in self._single_game_manager_start_times:
+ # Still has start time - check if it should be completed
+ parts = manager_key.split(':', 1)
+ if len(parts) == 2:
+ mode_name, manager_class_name = parts
+ manager = self._get_manager_for_mode(mode_name)
+ if manager and manager.__class__.__name__ == manager_class_name:
+ start_time = self._single_game_manager_start_times[manager_key]
+ # Extract league and mode_type from mode_name
+ league = 'ncaa_mens' if mode_name.startswith('ncaa_mens_') else ('ncaa_womens' if mode_name.startswith('ncaa_womens_') else None)
+ mode_type_str = mode_name.split('_')[-1] if mode_name else None
+ game_duration = self._get_game_duration(league, mode_type_str, manager) if league and mode_type_str else getattr(manager, 'game_display_duration', 15)
+ elapsed = time.time() - start_time
+ if elapsed < game_duration:
+ # Not enough time has passed - not truly completed
+ all_truly_completed = False
+ self.logger.debug(f"Manager {manager_key} in completed set but still has start time with {elapsed:.2f}s < {game_duration}s")
+ break
+
+ if all_truly_completed:
+ self._dynamic_cycle_complete = True
+ self.logger.info(f"Display mode {display_mode} cycle complete - all {len(used_manager_keys)} manager(s) completed")
+ else:
+ # Some managers aren't truly completed - keep cycle incomplete
+ self._dynamic_cycle_complete = False
+ self.logger.debug(f"Display mode {display_mode} cycle incomplete - some managers not truly completed yet")
+ return
+
+ # Standard mode checking (for internal mode cycling)
+ required_modes = [mode for mode in self.modes if mode]
+ if not required_modes:
+ self._dynamic_cycle_complete = True
+ return
+
+ for mode_name in required_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:
+ manager = self._get_manager_for_mode(mode_name)
+ total_games = self._get_total_games_for_manager(manager)
+ if total_games <= 1:
+ # For single-game managers, check if enough time has passed
+ if manager_key in self._single_game_manager_start_times:
+ start_time = self._single_game_manager_start_times[manager_key]
+ game_duration = getattr(manager, 'game_display_duration', 15) if manager else 15
+ elapsed = time.time() - start_time
+ if elapsed >= game_duration:
+ self._dynamic_managers_completed.add(manager_key)
+ else:
+ # Not enough time yet
+ self._dynamic_cycle_complete = False
+ return
+ else:
+ # Haven't seen this manager yet in _record_dynamic_progress
+ self._dynamic_cycle_complete = False
+ return
+ else:
+ # Multi-game manager - check if all current games have been shown for full duration
+ progress_set = self._dynamic_manager_progress.get(manager_key, set())
+ current_game_ids = self._get_all_game_ids_for_manager(manager)
+
+ # Check if all current games are in the progress set (shown for full duration)
+ if current_game_ids and current_game_ids.issubset(progress_set):
+ self._dynamic_managers_completed.add(manager_key)
+ # Continue to check other modes
+ else:
+ missing_games = current_game_ids - progress_set if current_game_ids else set()
+ self.logger.debug(f"Manager {manager_key} progress: {len(progress_set)}/{len(current_game_ids)} games completed, missing: {len(missing_games)}")
+ self._dynamic_cycle_complete = False
+ return
+
+ self._dynamic_cycle_complete = True
+
+ def supports_dynamic_duration(self) -> bool:
+ """
+ Check if dynamic duration is enabled for the current display context.
+ Checks granular settings: per-league/per-mode > per-mode > per-league > global.
+ """
+ if not self.is_enabled:
+ return False
+
+ # If no current display context, return False (no global fallback)
+ if not self._current_display_league or not self._current_display_mode_type:
+ return False
+
+ league = self._current_display_league
+ mode_type = self._current_display_mode_type
+
+ # Check per-league/per-mode setting first (most specific)
+ league_config = self.config.get(league, {})
+ league_dynamic = league_config.get("dynamic_duration", {})
+ league_modes = league_dynamic.get("modes", {})
+ mode_config = league_modes.get(mode_type, {})
+ if "enabled" in mode_config:
+ return bool(mode_config.get("enabled", False))
+
+ # Check per-league setting
+ if "enabled" in league_dynamic:
+ return bool(league_dynamic.get("enabled", False))
+
+ # No global fallback - return False
+ return False
+
+ def get_dynamic_duration_cap(self) -> Optional[float]:
+ """
+ Get dynamic duration cap for the current display context.
+ Checks granular settings: per-league/per-mode > per-mode > per-league > global.
+ """
+ if not self.is_enabled:
+ return None
+
+ # If no current display context, return None (no global fallback)
+ if not self._current_display_league or not self._current_display_mode_type:
+ return None
+
+ league = self._current_display_league
+ mode_type = self._current_display_mode_type
+
+ # Check per-league/per-mode setting first (most specific)
+ league_config = self.config.get(league, {})
+ league_dynamic = league_config.get("dynamic_duration", {})
+ league_modes = league_dynamic.get("modes", {})
+ mode_config = league_modes.get(mode_type, {})
+ if "max_duration_seconds" in mode_config:
+ try:
+ cap = float(mode_config.get("max_duration_seconds"))
+ if cap > 0:
+ return cap
+ except (TypeError, ValueError):
+ pass
+
+ # Check per-league setting
+ if "max_duration_seconds" in league_dynamic:
+ try:
+ cap = float(league_dynamic.get("max_duration_seconds"))
+ if cap > 0:
+ return cap
+ except (TypeError, ValueError):
+ pass
+
+ # No global fallback - return None
+ return None
+
+ def has_live_priority(self) -> bool:
+ if not self.is_enabled:
+ return False
+
+ return any(
+ [
+ self.ncaa_mens_enabled and self.ncaa_mens_live_priority,
+ self.ncaa_womens_enabled and self.ncaa_womens_live_priority,
+ ]
+ )
+
+ def has_live_content(self) -> bool:
+ if not self.is_enabled:
+ return False
+
+ # Check NCAA Men's live content
+ ncaa_mens_live = False
+ if (
+ self.ncaa_mens_enabled
+ and self.ncaa_mens_live_priority
+ and hasattr(self, "ncaa_mens_live")
+ ):
+ live_games = getattr(self.ncaa_mens_live, "live_games", [])
+ if live_games:
+ # Filter out any games that are final or appear over
+ live_games = [g for g in live_games if not g.get("is_final", False)]
+ # Additional validation using helper method if available
+ if hasattr(self.ncaa_mens_live, "_is_game_really_over"):
+ live_games = [g for g in live_games if not self.ncaa_mens_live._is_game_really_over(g)]
+
+ if live_games:
+ # If favorite teams are configured, only return True if there are live games for favorite teams
+ favorite_teams = getattr(self.ncaa_mens_live, "favorite_teams", [])
+ if favorite_teams:
+ # Check if any live game involves a favorite team
+ ncaa_mens_live = any(
+ game.get("home_abbr") in favorite_teams
+ or game.get("away_abbr") in favorite_teams
+ for game in live_games
+ )
+ else:
+ # No favorite teams configured, return True if any live games exist
+ ncaa_mens_live = True
+
+ # Check NCAA Women's live content
+ ncaa_womens_live = False
+ if (
+ self.ncaa_womens_enabled
+ and self.ncaa_womens_live_priority
+ and hasattr(self, "ncaa_womens_live")
+ ):
+ live_games = getattr(self.ncaa_womens_live, "live_games", [])
+ if live_games:
+ # Filter out any games that are final or appear over
+ live_games = [g for g in live_games if not g.get("is_final", False)]
+ # Additional validation using helper method if available
+ if hasattr(self.ncaa_womens_live, "_is_game_really_over"):
+ live_games = [g for g in live_games if not self.ncaa_womens_live._is_game_really_over(g)]
+
+ if live_games:
+ # If favorite teams are configured, only return True if there are live games for favorite teams
+ favorite_teams = getattr(self.ncaa_womens_live, "favorite_teams", [])
+ if favorite_teams:
+ # Check if any live game involves a favorite team
+ ncaa_womens_live = any(
+ game.get("home_abbr") in favorite_teams
+ or game.get("away_abbr") in favorite_teams
+ for game in live_games
+ )
+ else:
+ # No favorite teams configured, return True if any live games exist
+ ncaa_womens_live = True
+
+ result = ncaa_mens_live or ncaa_womens_live
+
+ # Throttle logging when returning False to reduce log noise
+ # Always log True immediately (important), but only log False every 60 seconds
+ current_time = time.time()
+ should_log = result or (current_time - self._last_live_content_false_log >= self._live_content_log_interval)
+
+ if should_log:
+ self.logger.info(
+ f"has_live_content() returning {result}: "
+ f"ncaa_mens_live={ncaa_mens_live}, ncaa_womens_live={ncaa_womens_live}"
+ )
+ if not result:
+ self._last_live_content_false_log = current_time
+
+ return result
+
+ def get_live_modes(self) -> list:
+ """
+ Return the registered plugin mode name(s) that have live content.
+
+ Returns granular live modes (ncaa_mens_live, ncaa_womens_live) that have live content.
+ The plugin is registered with granular modes in manifest.json.
+ """
+ if not self.is_enabled:
+ return []
+
+ live_modes = []
+
+ # Check NCAA Men's live content
+ if (
+ self.ncaa_mens_enabled
+ and self.ncaa_mens_live_priority
+ and hasattr(self, "ncaa_mens_live")
+ ):
+ live_games = getattr(self.ncaa_mens_live, "live_games", [])
+ if live_games:
+ # Filter out any games that are final or appear over
+ live_games = [g for g in live_games if not g.get("is_final", False)]
+ # Additional validation using helper method if available
+ if hasattr(self.ncaa_mens_live, "_is_game_really_over"):
+ live_games = [g for g in live_games if not self.ncaa_mens_live._is_game_really_over(g)]
+
+ if live_games:
+ # Check if favorite teams filter applies
+ favorite_teams = getattr(self.ncaa_mens_live, "favorite_teams", [])
+ if favorite_teams:
+ # Only include if there are live games for favorite teams
+ if any(
+ game.get("home_abbr") in favorite_teams
+ or game.get("away_abbr") in favorite_teams
+ for game in live_games
+ ):
+ live_modes.append("ncaa_mens_live")
+ else:
+ # No favorite teams configured, include if any live games exist
+ live_modes.append("ncaa_mens_live")
+
+ # Check NCAA Women's live content
+ if (
+ self.ncaa_womens_enabled
+ and self.ncaa_womens_live_priority
+ and hasattr(self, "ncaa_womens_live")
+ ):
+ live_games = getattr(self.ncaa_womens_live, "live_games", [])
+ if live_games:
+ # Filter out any games that are final or appear over
+ live_games = [g for g in live_games if not g.get("is_final", False)]
+ # Additional validation using helper method if available
+ if hasattr(self.ncaa_womens_live, "_is_game_really_over"):
+ live_games = [g for g in live_games if not self.ncaa_womens_live._is_game_really_over(g)]
+
+ if live_games:
+ # Check if favorite teams filter applies
+ favorite_teams = getattr(self.ncaa_womens_live, "favorite_teams", [])
+ if favorite_teams:
+ # Only include if there are live games for favorite teams
+ if any(
+ game.get("home_abbr") in favorite_teams
+ or game.get("away_abbr") in favorite_teams
+ for game in live_games
+ ):
+ live_modes.append("ncaa_womens_live")
+ else:
+ # No favorite teams configured, include if any live games exist
+ live_modes.append("ncaa_womens_live")
+
+ return live_modes
+
+ def _should_use_scroll_mode(self, league: str, mode_type: str) -> bool:
+ """
+ Check if a specific league should use scroll mode for this game type.
+
+ Args:
+ league: League ID ('ncaa_mens' or 'ncaa_womens')
+ mode_type: 'live', 'recent', or 'upcoming'
+
+ Returns:
+ True if this league uses scroll mode for this game type
+ """
+ return self._get_display_mode(league, mode_type) == 'scroll'
+
+ def _display_scroll_mode(self, display_mode: str, league: str, mode_type: str, force_clear: bool) -> bool:
+ """Handle display for scroll mode (single league).
+
+ Args:
+ display_mode: External mode name (e.g., 'ncaa_mens_recent')
+ league: League ID ('ncaa_mens' or 'ncaa_womens')
+ mode_type: Game type ('live', 'recent', 'upcoming')
+ force_clear: Whether to force clear display
+
+ Returns:
+ True if content was displayed, False otherwise
+ """
+ if not self._scroll_manager:
+ self.logger.warning("Scroll mode requested but scroll manager not available")
+ # Fall back to switch mode
+ return self._try_manager_display(
+ self._get_league_manager_for_mode(league, mode_type),
+ force_clear,
+ display_mode,
+ mode_type,
+ None
+ )[0]
+
+ # Check if we need to prepare new scroll content
+ scroll_key = f"{display_mode}_{mode_type}"
+
+ if not self._scroll_prepared.get(scroll_key, False):
+ # Get manager and update it
+ manager = self._get_league_manager_for_mode(league, mode_type)
+ if not manager:
+ self.logger.debug(f"No manager available for {league} {mode_type}")
+ return False
+
+ self._ensure_manager_updated(manager)
+
+ # Get games from this manager
+ games = self._get_games_from_manager(manager, mode_type)
+
+ 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
+
+ # Add league info to each game
+ for game in games:
+ game['league'] = league
+
+ # Get rankings cache for display
+ rankings = self._get_rankings_cache()
+
+ # Prepare scroll content (single league)
+ success = self._scroll_manager.prepare_and_display(
+ games, mode_type, [league], rankings
+ )
+
+ if success:
+ self._scroll_prepared[scroll_key] = True
+ self._scroll_active[scroll_key] = True
+ self.logger.info(
+ f"[Lacrosse Scroll] Started scrolling {len(games)} {league} {mode_type} games"
+ )
+ else:
+ self._scroll_prepared[scroll_key] = False
+ self._scroll_active[scroll_key] = False
+ return False
+
+ # Display the next scroll frame
+ if self._scroll_active.get(scroll_key, False):
+ displayed = self._scroll_manager.display_frame(mode_type)
+
+ if displayed:
+ # Check if scroll is complete
+ if self._scroll_manager.is_complete(mode_type):
+ self.logger.info(f"[Lacrosse Scroll] Cycle complete for {display_mode}")
+ # Reset for next cycle
+ self._scroll_prepared[scroll_key] = False
+ self._scroll_active[scroll_key] = False
+ # Mark cycle as complete for dynamic duration
+ self._dynamic_cycle_complete = True
+
+ return True
+ else:
+ # Scroll display failed
+ self._scroll_active[scroll_key] = False
+ return False
+
+ return False
+
+ def _display_league_mode(self, league: str, mode_type: str, force_clear: bool) -> bool:
+ """
+ Display a specific league/mode combination (e.g., NCAA Men's Recent, NCAA Women's Upcoming).
+
+ This method displays content from a single league and mode type, used when
+ rotation_order specifies granular modes like 'ncaa_mens_recent' or 'ncaa_womens_upcoming'.
+
+ Args:
+ league: League ID ('ncaa_mens' or 'ncaa_womens')
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+ force_clear: Whether to force clear display
+
+ Returns:
+ True if content was displayed, False otherwise
+ """
+ # Validate league
+ if league not in self._league_registry:
+ self.logger.warning(f"Invalid league in _display_league_mode: {league}")
+ return False
+
+ # Check if league is enabled
+ if not self._league_registry[league].get('enabled', False):
+ self.logger.debug(f"League {league} is disabled, skipping")
+ return False
+
+ # Get manager for this league/mode combination
+ manager = self._get_league_manager_for_mode(league, mode_type)
+ if not manager:
+ self.logger.debug(f"No manager available for {league} {mode_type}")
+ return False
+
+ # Create display mode name for tracking
+ display_mode = f"{league}_{mode_type}"
+
+ # Check if this league uses scroll mode
+ if self._should_use_scroll_mode(league, mode_type):
+ return self._display_scroll_mode(display_mode, league, mode_type, force_clear)
+
+ # Set display context for dynamic duration tracking
+ self._current_display_league = league
+ self._current_display_mode_type = mode_type
+
+ # Try to display content from this league's manager (switch mode)
+ success, _ = self._try_manager_display(
+ manager, force_clear, display_mode, mode_type, None
+ )
+
+ # Only track mode start time and check duration if we actually have content to display
+ if success:
+ # Track mode start time for per-mode duration enforcement (only when content exists)
+ if display_mode not in self._mode_start_time:
+ self._mode_start_time[display_mode] = time.time()
+ self.logger.debug(f"Started tracking time for {display_mode}")
+
+ # Check if mode-level duration has expired (only check if we have content)
+ effective_mode_duration = self._get_effective_mode_duration(display_mode, mode_type)
+ if effective_mode_duration is not None:
+ elapsed_time = time.time() - self._mode_start_time[display_mode]
+ if elapsed_time >= effective_mode_duration:
+ # Mode duration expired - time to rotate
+ self.logger.info(
+ f"Mode duration expired for {display_mode}: "
+ f"{elapsed_time:.1f}s >= {effective_mode_duration}s. "
+ f"Rotating to next mode (progress preserved for resume)."
+ )
+ # Reset mode start time for next cycle
+ self._mode_start_time[display_mode] = time.time()
+ return False
+
+ self.logger.debug(
+ f"Displayed content from {league} {mode_type} (mode: {display_mode})"
+ )
+ else:
+ # No content - clear any existing start time so mode can start fresh when content becomes available
+ if display_mode in self._mode_start_time:
+ del self._mode_start_time[display_mode]
+ self.logger.debug(f"Cleared mode start time for {display_mode} (no content available)")
+
+ self.logger.debug(
+ f"No content available for {league} {mode_type} (mode: {display_mode})"
+ )
+
+ return success
+
+ def _display_internal_cycling(self, force_clear: bool) -> bool:
+ """Handle display for internal mode cycling (when no display_mode provided).
+
+ Args:
+ force_clear: Whether to force clear display
+
+ Returns:
+ True if content was displayed, False otherwise
+ """
+ current_time = time.time()
+
+ # Check if we should stay on live mode
+ should_stay_on_live = False
+ if self.has_live_content():
+ # Get current mode name
+ current_mode = self.modes[self.current_mode_index] if self.modes else None
+ # If we're on a live mode, stay there
+ if current_mode and current_mode.endswith('_live'):
+ should_stay_on_live = True
+ # If we're not on a live mode but have live content, switch to it
+ elif not (current_mode and current_mode.endswith('_live')):
+ # Find the first live mode
+ for i, mode in enumerate(self.modes):
+ if mode.endswith('_live'):
+ 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
+
+ # Handle mode cycling only if not staying on live
+ if not should_stay_on_live and current_time - self.last_mode_switch >= self.display_duration:
+ self.current_mode_index = (self.current_mode_index + 1) % len(self.modes)
+ self.last_mode_switch = current_time
+ force_clear = True
+
+ current_mode = self.modes[self.current_mode_index]
+ self.logger.info(f"Switching to display mode: {current_mode}")
+
+ # Get current manager and display
+ current_manager = self._get_current_manager()
+ if not current_manager:
+ self.logger.warning("No manager available for current mode")
+ return False
+
+ # Track which league/mode we're displaying for granular dynamic duration
+ current_mode = self.modes[self.current_mode_index] if self.modes else None
+ if current_mode:
+ # Extract mode type from mode name
+ mode_type = self._extract_mode_type(current_mode)
+ if mode_type:
+ self._set_display_context_from_manager(current_manager, mode_type)
+
+ result = current_manager.display(force_clear)
+ if result is not False:
+ try:
+ # Build the actual mode name from league and mode_type for accurate tracking
+ current_mode = self.modes[self.current_mode_index] if self.modes else None
+ if current_mode:
+ manager_key = self._build_manager_key(current_mode, current_manager)
+ # Track which managers were used for internal mode cycling
+ # For internal cycling, the mode itself is the display_mode
+ self._display_mode_to_managers.setdefault(current_mode, set()).add(manager_key)
+ self._record_dynamic_progress(
+ current_manager, actual_mode=current_mode, display_mode=current_mode
+ )
+ except Exception as progress_err: # pylint: disable=broad-except
+ self.logger.debug(f"Dynamic progress tracking failed: {progress_err}")
+ else:
+ # Manager returned False (no content) - ensure display is cleared
+ # This is a safety measure in case the manager didn't clear it
+ if force_clear:
+ try:
+ self.display_manager.clear()
+ self.display_manager.update_display()
+ except Exception as clear_err:
+ self.logger.debug(f"Error clearing display when manager returned False: {clear_err}")
+
+ current_mode = self.modes[self.current_mode_index] if self.modes else None
+ self._evaluate_dynamic_cycle_completion(display_mode=current_mode)
+ return result
+
+ def _try_manager_display(
+ self,
+ manager,
+ force_clear: bool,
+ display_mode: str,
+ mode_type: str,
+ sticky_manager=None
+ ) -> Tuple[bool, Optional[str]]:
+ """
+ Try to display content from a single manager.
+
+ This method handles displaying content from a manager and tracking progress
+ for dynamic duration. It uses sticky manager logic to ensure all games from
+ one league are displayed before switching to another.
+
+ Args:
+ manager: Manager instance to try
+ force_clear: Whether to force clear display
+ display_mode: External display mode name (e.g., 'ncaa_mens_recent')
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+ sticky_manager: Deprecated parameter (kept for compatibility, ignored)
+
+ Returns:
+ Tuple of (success: bool, actual_mode: Optional[str])
+ - success: True if manager displayed content, False otherwise
+ - actual_mode: The actual mode name used for tracking (e.g., 'ncaa_mens_recent')
+ """
+ if not manager:
+ return False, None
+
+ # Track which league we're displaying for granular dynamic duration
+ # This sets _current_display_league and _current_display_mode_type
+ # which are used for progress tracking and duration calculations
+ self._set_display_context_from_manager(manager, mode_type)
+
+ # Ensure manager is updated before displaying
+ # This fetches fresh data if needed based on update intervals
+ self._ensure_manager_updated(manager)
+
+ # Attempt to display content from this manager
+ # Manager returns True if it has content to show, False if no content
+ result = manager.display(force_clear)
+
+ # Build the actual mode name from league and mode_type for accurate tracking
+ # This is used to track progress per league separately
+ # Example: 'ncaa_mens_recent' or 'ncaa_womens_live'
+ actual_mode = (
+ f"{self._current_display_league}_{mode_type}"
+ if self._current_display_league and mode_type
+ else display_mode
+ )
+
+ # Track game transitions for logging
+ # Only log at DEBUG level for frequent calls, INFO for game transitions
+ manager_class_name = manager.__class__.__name__
+ has_current_game = hasattr(manager, 'current_game') and manager.current_game is not None
+ current_game = getattr(manager, 'current_game', None) if has_current_game else None
+
+ # Get current game ID for transition detection
+ current_game_id = None
+ if current_game:
+ current_game_id = current_game.get('id') or current_game.get('game_id')
+ if not current_game_id:
+ # Fallback: create ID from team abbreviations
+ away = current_game.get('away_abbr', '')
+ home = current_game.get('home_abbr', '')
+ if away and home:
+ current_game_id = f"{away}@{home}"
+
+ # Check for game transition
+ game_tracking = self._current_game_tracking.get(display_mode, {})
+ last_game_id = game_tracking.get('game_id')
+ last_league = game_tracking.get('league')
+ last_log_time = game_tracking.get('last_log_time', 0.0)
+ current_time = time.time()
+
+ # Detect game transition or league change
+ game_changed = (current_game_id and current_game_id != last_game_id)
+ league_changed = (self._current_display_league and self._current_display_league != last_league)
+ time_since_last_log = current_time - last_log_time
+
+ # Log game transitions at INFO level (but throttle to avoid spam)
+ if (game_changed or league_changed) and time_since_last_log >= self._game_transition_log_interval:
+ if game_changed and current_game_id:
+ away_abbr = current_game.get('away_abbr', '?') if current_game else '?'
+ home_abbr = current_game.get('home_abbr', '?') if current_game else '?'
+ self.logger.info(
+ f"Game transition in {display_mode}: "
+ f"{away_abbr} @ {home_abbr} "
+ f"({self._current_display_league or 'unknown'} {mode_type})"
+ )
+ elif league_changed and self._current_display_league:
+ self.logger.info(
+ f"League transition in {display_mode}: "
+ f"switched to {self._current_display_league} {mode_type}"
+ )
+
+ # Update tracking
+ self._current_game_tracking[display_mode] = {
+ 'game_id': current_game_id,
+ 'league': self._current_display_league,
+ 'last_log_time': current_time
+ }
+ else:
+ # Frequent calls - only log at DEBUG level
+ self.logger.debug(
+ f"Manager {manager_class_name} display() returned {result}, "
+ f"has_current_game={has_current_game}, game_id={current_game_id}"
+ )
+
+ if result is True:
+ # Success - track progress and set sticky manager
+ manager_key = self._build_manager_key(actual_mode, manager)
+
+ try:
+ self._record_dynamic_progress(manager, actual_mode=actual_mode, display_mode=display_mode)
+ except Exception as progress_err: # pylint: disable=broad-except
+ self.logger.debug(f"Dynamic progress tracking failed: {progress_err}")
+
+ # Set as sticky manager AFTER progress tracking (which may clear it on new cycle)
+ if display_mode not in self._sticky_manager_per_mode:
+ self._sticky_manager_per_mode[display_mode] = manager
+ self._sticky_manager_start_time[display_mode] = time.time()
+ self.logger.info(f"Set sticky manager {manager_class_name} for {display_mode}")
+
+ # Track which managers were used for this display mode
+ if display_mode:
+ self._display_mode_to_managers.setdefault(display_mode, set()).add(manager_key)
+
+ self._evaluate_dynamic_cycle_completion(display_mode=display_mode)
+ return True, actual_mode
+
+ elif result is False and manager == sticky_manager:
+ # Sticky manager returned False - check if completed
+ manager_key = self._build_manager_key(actual_mode, manager)
+
+ if manager_key in self._dynamic_managers_completed:
+ self.logger.info(
+ f"Sticky manager {manager_class_name} completed all games, switching to next manager"
+ )
+ self._sticky_manager_per_mode.pop(display_mode, None)
+ self._sticky_manager_start_time.pop(display_mode, None)
+ # Signal to break out of loop and try next manager
+ return False, None
+ else:
+ # Manager not done yet, just returning False temporarily (between game switches)
+ self.logger.debug(
+ f"Sticky manager {manager_class_name} returned False (between games), continuing"
+ )
+ return False, None
+
+ elif result is False:
+ # Non-sticky manager returned False - try next
+ return False, None
+
+ else:
+ # Result is None or other - assume success
+ manager_key = self._build_manager_key(actual_mode, manager)
+
+ try:
+ self._record_dynamic_progress(manager, actual_mode=actual_mode, display_mode=display_mode)
+ except Exception as progress_err: # pylint: disable=broad-except
+ self.logger.debug(f"Dynamic progress tracking failed: {progress_err}")
+
+ # Track which managers were used for this display mode
+ if display_mode:
+ self._display_mode_to_managers.setdefault(display_mode, set()).add(manager_key)
+
+ self._evaluate_dynamic_cycle_completion(display_mode=display_mode)
+ return True, actual_mode
+
+ def _get_effective_mode_duration(self, display_mode: str, mode_type: str) -> Optional[float]:
+ """
+ Get effective mode duration for a display mode.
+
+ Checks per-mode duration settings first, then falls back to dynamic calculation.
+
+ Args:
+ display_mode: Display mode name (e.g., 'ncaa_mens_recent')
+ mode_type: Mode type ('live', 'recent', or 'upcoming')
+
+ Returns:
+ Mode duration in seconds (float) or None to use dynamic calculation
+ """
+ if not self._current_display_league:
+ return None
+
+ # Get mode duration from config
+ mode_duration = self._get_mode_duration(self._current_display_league, mode_type)
+ if mode_duration is not None:
+ return mode_duration
+
+ # No per-mode duration configured - use dynamic calculation
+ return None
+
+ def validate_config(self) -> bool:
+ """Validate plugin configuration."""
+ try:
+ # Check that at least one league is enabled
+ if not (self.ncaa_mens_enabled or self.ncaa_womens_enabled):
+ self.logger.warning("No leagues enabled in lacrosse scoreboard plugin")
+ return False
+
+ return True
+ except Exception as e:
+ self.logger.error(f"Error validating config: {e}")
+ return False
+
+ def get_display_duration(self) -> float:
+ """Get the display duration for this plugin."""
+ return float(self.display_duration)
+
+ def get_cycle_duration(self, display_mode: str = None) -> Optional[float]:
+ """
+ Calculate the expected cycle duration for a display mode based on the number of games.
+
+ This implements dynamic duration scaling with support for mode-level durations:
+ - Mode-level duration: Fixed total time for mode (recent_mode_duration, upcoming_mode_duration, live_mode_duration)
+ - Dynamic calculation: Total duration = num_games × per_game_duration
+
+ Priority order:
+ 1. Mode-level duration (if configured)
+ 2. Dynamic calculation (if no mode-level duration)
+ 3. Dynamic duration cap applies to both if enabled
+
+ Args:
+ display_mode: The display mode to calculate duration for (e.g., 'ncaa_mens_live', 'ncaa_mens_recent', 'ncaa_womens_upcoming')
+
+ Returns:
+ Total expected duration in seconds, or None if not applicable
+ """
+ self.logger.info(f"get_cycle_duration() called with display_mode={display_mode}, is_enabled={self.is_enabled}")
+ if not self.is_enabled or not display_mode:
+ self.logger.info(f"get_cycle_duration() returning None: is_enabled={self.is_enabled}, display_mode={display_mode}")
+ return None
+
+ # Extract mode type and league (if granular mode)
+ mode_type = self._extract_mode_type(display_mode)
+ if not mode_type:
+ return None
+
+ # Parse granular mode name if applicable (e.g., "ncaa_mens_recent", "ncaa_womens_upcoming")
+ league = None
+ if "_" in display_mode and not display_mode.startswith("lacrosse_"):
+ # Granular mode: extract league
+ # Handle ncaa_mens and ncaa_womens with multiple underscores
+ if display_mode.startswith("ncaa_mens_"):
+ league = "ncaa_mens"
+ elif display_mode.startswith("ncaa_womens_"):
+ league = "ncaa_womens"
+ else:
+ # Try standard split
+ parts = display_mode.split("_", 1)
+ if len(parts) == 2:
+ potential_league, potential_mode_type = parts
+ if potential_league in self._league_registry and potential_mode_type == mode_type:
+ league = potential_league
+
+ # Check for mode-level duration first (priority 1)
+ # Extract league if not already determined
+ if not league and mode_type:
+ # Try to get league from current display context or parse from display_mode
+ if self._current_display_league:
+ league = self._current_display_league
+ else:
+ # Try to parse from display_mode
+ if display_mode.startswith("ncaa_mens_"):
+ league = "ncaa_mens"
+ elif display_mode.startswith("ncaa_womens_"):
+ league = "ncaa_womens"
+
+ if league:
+ effective_mode_duration = self._get_mode_duration(league, mode_type)
+ if effective_mode_duration is not None:
+ self.logger.info(
+ f"get_cycle_duration: using mode-level duration for {display_mode} = {effective_mode_duration}s"
+ )
+ return effective_mode_duration
+
+ # Fall through to dynamic calculation based on game count (priority 2)
+
+ try:
+ self.logger.info(f"get_cycle_duration: extracted mode_type={mode_type}, league={league} from display_mode={display_mode}")
+
+ total_games = 0
+ total_duration = 0.0 # Accumulate duration per-league to handle different per_game_durations
+
+ # Collect managers for this mode and count their games
+ managers_to_check = []
+
+ # If granular mode (specific league), only check that league
+ if league:
+ manager = self._get_league_manager_for_mode(league, mode_type)
+ if manager:
+ managers_to_check.append((league, manager))
+ else:
+ # Combined mode - check all enabled leagues for this mode_type
+ for lg in ('ncaa_mens', 'ncaa_womens'):
+ if self._league_registry.get(lg, {}).get('enabled'):
+ mgr = self._get_league_manager_for_mode(lg, mode_type)
+ if mgr:
+ managers_to_check.append((lg, mgr))
+
+ # CRITICAL: Update managers BEFORE checking game counts!
+ self.logger.info(f"get_cycle_duration: updating {len(managers_to_check)} manager(s) before counting games")
+ for league_name, manager in managers_to_check:
+ if manager:
+ self._ensure_manager_updated(manager)
+
+ # Count games from all applicable managers and get duration
+ for league_name, manager in managers_to_check:
+ if not manager:
+ continue
+
+ # Get the appropriate game list based on mode type
+ if mode_type == 'live':
+ games = getattr(manager, 'live_games', [])
+ elif mode_type == 'recent':
+ # Try games_list first (used by recent managers), then recent_games
+ games = getattr(manager, 'games_list', None)
+ if games is None:
+ games = getattr(manager, 'recent_games', [])
+ else:
+ games = list(games) if games else []
+ elif mode_type == 'upcoming':
+ # Try games_list first (used by upcoming managers), then upcoming_games
+ games = getattr(manager, 'games_list', None)
+ if games is None:
+ games = getattr(manager, 'upcoming_games', [])
+ else:
+ games = list(games) if games else []
+ else:
+ games = []
+
+ # Get duration for this league/mode combination
+ per_game_duration = self._get_game_duration(league_name, mode_type, manager)
+
+ # Filter out invalid games
+ if games:
+ # For live games, filter out final games
+ if mode_type == 'live':
+ games = [g for g in games if not g.get('is_final', False)]
+ if hasattr(manager, '_is_game_really_over'):
+ games = [g for g in games if not manager._is_game_really_over(g)]
+
+ game_count = len(games)
+ total_games += game_count
+ # Accumulate duration per-league to correctly handle different per_game_durations
+ total_duration += game_count * per_game_duration
+
+ self.logger.debug(
+ f"get_cycle_duration: {league_name} {mode_type} has {game_count} games × "
+ f"{per_game_duration}s = {game_count * per_game_duration}s"
+ )
+
+ self.logger.info(f"get_cycle_duration: found {total_games} total games for {display_mode}")
+
+ if total_games == 0:
+ # If no games found yet (managers still fetching data), return a default duration
+ # This allows the display to start while data is loading
+ default_duration = 45.0 # 3 games × 15s per game (reasonable default)
+ self.logger.info(f"get_cycle_duration: {display_mode} has no games yet, returning default {default_duration}s")
+ return default_duration
+
+ self.logger.info(
+ f"get_cycle_duration({display_mode}): {total_games} total games = {total_duration}s"
+ )
+
+ return total_duration
+
+ except Exception as e:
+ self.logger.error(f"Error calculating cycle duration for {display_mode}: {e}", exc_info=True)
+ return None
+
+ def get_info(self) -> Dict[str, Any]:
+ """Get plugin information."""
+ try:
+ current_manager = self._get_current_manager()
+ current_mode = self.modes[self.current_mode_index] if self.modes else "none"
+
+ info = {
+ "plugin_id": self.plugin_id,
+ "name": "Lacrosse Scoreboard",
+ "version": "1.0.1",
+ "enabled": self.is_enabled,
+ "display_size": f"{self.display_width}x{self.display_height}",
+ "ncaa_mens_enabled": self.ncaa_mens_enabled,
+ "ncaa_womens_enabled": self.ncaa_womens_enabled,
+ "current_mode": current_mode,
+ "available_modes": self.modes,
+ "display_duration": self.display_duration,
+ "game_display_duration": self.game_display_duration,
+ "show_records": getattr(self, 'show_records', False),
+ "show_ranking": getattr(self, 'show_ranking', False),
+ "show_odds": getattr(self, 'show_odds', False),
+ "managers_initialized": {
+ "ncaa_mens_live": hasattr(self, "ncaa_mens_live"),
+ "ncaa_mens_recent": hasattr(self, "ncaa_mens_recent"),
+ "ncaa_mens_upcoming": hasattr(self, "ncaa_mens_upcoming"),
+ "ncaa_womens_live": hasattr(self, "ncaa_womens_live"),
+ "ncaa_womens_recent": hasattr(self, "ncaa_womens_recent"),
+ "ncaa_womens_upcoming": hasattr(self, "ncaa_womens_upcoming"),
+ },
+ "live_priority": {
+ "ncaa_mens": self.ncaa_mens_enabled
+ and self.ncaa_mens_live_priority,
+ "ncaa_womens": self.ncaa_womens_enabled
+ and self.ncaa_womens_live_priority,
+ },
+ }
+
+ # Add manager-specific info if available
+ if current_manager and hasattr(current_manager, "get_info"):
+ try:
+ manager_info = current_manager.get_info()
+ info["current_manager_info"] = manager_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": "Lacrosse Scoreboard",
+ "error": str(e),
+ }
+
+ # -------------------------------------------------------------------------
+ # Scroll mode helper methods
+ # -------------------------------------------------------------------------
+ def _is_scroll_mode_available(self) -> bool:
+ """
+ Check if scroll mode is available (scroll manager exists).
+
+ Returns:
+ True if scroll mode is available, False otherwise
+ """
+ return bool(self._scroll_manager)
+
+ def _collect_games_for_scroll(self) -> tuple:
+ """
+ Collect all games for scroll mode from enabled leagues.
+
+ Collects live, recent, and upcoming games organized by league.
+ Within each league, games are sorted: live first, then recent, then upcoming.
+
+ Returns:
+ Tuple of (games_list, leagues_list)
+ """
+ all_games = []
+ leagues = []
+
+ sport_key_for_league = {
+ 'ncaa_mens': 'ncaam_lacrosse',
+ 'ncaa_womens': 'ncaaw_lacrosse',
+ }
+
+ for league_id, sport_key in sport_key_for_league.items():
+ if not self._league_registry.get(league_id, {}).get('enabled'):
+ continue
+ league_games = []
+ for mode_type in ['live', 'recent', 'upcoming']:
+ manager = self._get_manager_for_league_mode(sport_key, mode_type)
+ if not manager:
+ continue
+ games = self._get_games_from_manager(manager, mode_type)
+ for game in games:
+ game['league'] = sport_key
+ if 'status' not in game:
+ 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')
+ league_games.extend(games)
+
+ if league_games:
+ all_games.extend(league_games)
+ leagues.append(sport_key)
+
+ return all_games, leagues
+
+ def _get_manager_for_league_mode(self, league: str, mode_type: str):
+ """Get manager for a specific league and mode type.
+
+ Accepts either plugin league IDs ('ncaa_mens', 'ncaa_womens') or
+ sport-key form ('ncaam_lacrosse', 'ncaaw_lacrosse').
+ """
+ if league in ('ncaa_mens', 'ncaam_lacrosse'):
+ if mode_type == 'live':
+ return getattr(self, 'ncaa_mens_live', None)
+ if mode_type == 'recent':
+ return getattr(self, 'ncaa_mens_recent', None)
+ if mode_type == 'upcoming':
+ return getattr(self, 'ncaa_mens_upcoming', None)
+ elif league in ('ncaa_womens', 'ncaaw_lacrosse'):
+ if mode_type == 'live':
+ return getattr(self, 'ncaa_womens_live', None)
+ if mode_type == 'recent':
+ return getattr(self, 'ncaa_womens_recent', None)
+ if mode_type == 'upcoming':
+ return getattr(self, 'ncaa_womens_upcoming', None)
+ return None
+
+ # -------------------------------------------------------------------------
+ # Vegas scroll mode support
+ # -------------------------------------------------------------------------
+ def get_vegas_content(self) -> Optional[Any]:
+ """
+ Get content for Vegas-style continuous scroll mode.
+
+ Triggers scroll content generation if cache is empty, then returns
+ the cached scroll image(s) for Vegas to compose into its scroll strip.
+
+ Returns:
+ List of PIL Images from scroll displays, or None if no content
+ """
+ if not hasattr(self, '_scroll_manager') or not self._scroll_manager:
+ return None
+
+ images = self._scroll_manager.get_all_vegas_content_items()
+
+ if not images:
+ self.logger.info("[Lacrosse 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(
+ "[Lacrosse Vegas] Returning %d image(s), %dpx total",
+ len(images), total_width
+ )
+ return images
+
+ return None
+
+ def get_vegas_content_type(self) -> str:
+ """
+ Indicate the type of content this plugin provides for Vegas scroll.
+
+ Returns:
+ 'multi' - Plugin has multiple scrollable items (games)
+ """
+ return 'multi'
+
+ def get_vegas_display_mode(self) -> 'VegasDisplayMode':
+ """
+ Get the display mode for Vegas scroll integration.
+
+ Returns:
+ VegasDisplayMode.SCROLL - Content scrolls continuously
+ """
+ if VegasDisplayMode:
+ # Check for config override
+ 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
+ # Fallback if VegasDisplayMode not available
+ return "scroll"
+
+ def _ensure_scroll_content_for_vegas(self) -> None:
+ """
+ Ensure scroll content is generated for Vegas mode.
+
+ This method is called by get_vegas_content() when the scroll cache is empty.
+ It collects all game types (live, recent, upcoming) organized by league.
+ """
+ if not hasattr(self, '_scroll_manager') or not self._scroll_manager:
+ self.logger.debug("[Lacrosse Vegas] No scroll manager available")
+ return
+
+ # Collect all games (live, recent, upcoming) organized by league
+ games, leagues = self._collect_games_for_scroll()
+
+ if not games:
+ self.logger.debug("[Lacrosse Vegas] No games available")
+ return
+
+ # Count games by type for logging
+ game_type_counts = {'live': 0, 'recent': 0, 'upcoming': 0}
+ for game in games:
+ state = game.get('status', {}).get('state', '')
+ if state == 'in':
+ game_type_counts['live'] += 1
+ elif state == 'post':
+ game_type_counts['recent'] += 1
+ elif state == 'pre':
+ game_type_counts['upcoming'] += 1
+
+ # Prepare scroll content with mixed game types
+ # Note: Using 'mixed' as game_type indicator for scroll config
+ success = self._scroll_manager.prepare_and_display(
+ games, 'mixed', leagues, None
+ )
+
+ if success:
+ type_summary = ', '.join(
+ f"{count} {gtype}" for gtype, count in game_type_counts.items() if count > 0
+ )
+ self.logger.info(
+ f"[Lacrosse Vegas] Successfully generated scroll content: "
+ f"{len(games)} games ({type_summary}) from {', '.join(leagues)}"
+ )
+ else:
+ self.logger.warning("[Lacrosse Vegas] Failed to generate scroll content")
+
+ def cleanup(self) -> None:
+ """Clean up resources."""
+ try:
+ if hasattr(self, "background_service") and self.background_service:
+ # Clean up background service if needed
+ pass
+ except Exception as e:
+ self.logger.error(f"Error during cleanup: {e}")
diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json
new file mode 100644
index 00000000..803caffc
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/manifest.json
@@ -0,0 +1,75 @@
+{
+ "id": "lacrosse-scoreboard",
+ "name": "Lacrosse Scoreboard",
+ "version": "1.0.3",
+ "author": "ChuckBuilds",
+ "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules",
+ "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard",
+ "entry_point": "manager.py",
+ "class_name": "LacrosseScoreboardPlugin",
+ "category": "sports",
+ "tags": [
+ "lacrosse",
+ "ncaa",
+ "sports",
+ "scoreboard",
+ "live-scores"
+ ],
+ "icon": "fas fa-baseball-ball",
+ "compatible_versions": [
+ ">=2.0.0"
+ ],
+ "ledmatrix_version": "2.0.0",
+ "requires": {
+ "python": ">=3.9",
+ "display_size": {
+ "min_width": 64,
+ "min_height": 32
+ }
+ },
+ "config_schema": "config_schema.json",
+ "assets": {
+ "logos": "Uses shared LEDMatrix assets/sports/ncaa_logos directory"
+ },
+ "update_interval": 60,
+ "default_duration": 15,
+ "display_modes": [
+ "ncaa_mens_recent",
+ "ncaa_mens_upcoming",
+ "ncaa_mens_live",
+ "ncaa_womens_recent",
+ "ncaa_womens_upcoming",
+ "ncaa_womens_live"
+ ],
+ "api_requirements": [
+ {
+ "name": "ESPN API",
+ "required": true,
+ "description": "ESPN public API for NCAA lacrosse scores and schedules",
+ "url": "https://site.api.espn.com/apis/site/v2/sports/lacrosse/",
+ "rate_limit": "No official rate limit, but please use responsibly"
+ }
+ ],
+ "versions": [
+ {
+ "version": "1.0.3",
+ "ledmatrix_min": "2.0.0",
+ "released": "2026-04-06"
+ },
+ {
+ "version": "1.0.2",
+ "ledmatrix_min": "2.0.0",
+ "released": "2026-04-06"
+ },
+ {
+ "version": "1.0.1",
+ "ledmatrix_min": "2.0.0",
+ "released": "2026-04-06"
+ }
+ ],
+ "last_updated": "2026-04-06",
+ "stars": 0,
+ "downloads": 0,
+ "verified": true,
+ "screenshot": ""
+}
diff --git a/plugins/lacrosse-scoreboard/ncaam_lacrosse_managers.py b/plugins/lacrosse-scoreboard/ncaam_lacrosse_managers.py
new file mode 100644
index 00000000..d1b4eae1
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/ncaam_lacrosse_managers.py
@@ -0,0 +1,142 @@
+import logging
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from lacrosse import Lacrosse, LacrosseLive
+from sports import SportsRecent, SportsUpcoming
+
+# Constants
+ESPN_NCAAMLAX_SCOREBOARD_URL = (
+ "https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/scoreboard"
+)
+
+
+class BaseNCAAMLacrosseManager(Lacrosse):
+ """Base class for NCAA Men's Lacrosse managers with common functionality."""
+
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ ):
+ self.logger = logging.getLogger("NCAAMLAX")
+ super().__init__(
+ config=config,
+ display_manager=display_manager,
+ cache_manager=cache_manager,
+ logger=self.logger,
+ sport_key="ncaam_lacrosse",
+ )
+
+ # Check display modes to determine what data to fetch.
+ # Keys match the adapter output in manager.py::_adapt_config_for_manager
+ # and the plain "live"/"recent"/"upcoming" names used in
+ # config_schema.json.
+ display_modes = self.mode_config.get("display_modes", {})
+ self.recent_enabled = display_modes.get("recent", False)
+ self.upcoming_enabled = display_modes.get("upcoming", False)
+ self.live_enabled = display_modes.get("live", False)
+ self.league = "mens-college-lacrosse"
+
+ self.logger.info(
+ f"Initialized NCAAMLacrosse 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_ncaa_lacrosse_api_data(self, use_cache: bool = True) -> Optional[Dict]:
+ """Fetch the men's NCAA lacrosse season schedule (January through May)."""
+ return self._fetch_season_schedule(
+ sport="ncaa_mens_lacrosse",
+ cache_key_prefix="ncaa_mens_lacrosse_schedule",
+ scoreboard_url=ESPN_NCAAMLAX_SCOREBOARD_URL,
+ season_start_mmdd="0101",
+ use_cache=use_cache,
+ )
+
+ def _fetch_data(self) -> Optional[Dict]:
+ """Default fetch: pull the cached season schedule.
+
+ Live managers override this to query only today's games.
+ """
+ return self._fetch_ncaa_lacrosse_api_data(use_cache=True)
+
+
+class NCAAMLacrosseLiveManager(BaseNCAAMLacrosseManager, LacrosseLive):
+ """Manager for live NCAA Men's Lacrosse games."""
+
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ ):
+ super().__init__(config, display_manager, cache_manager)
+ self.logger = logging.getLogger("NCAAMLacrosseLiveManager")
+
+ # Initialize with test game only if test mode is enabled
+ if self.test_mode:
+ self.current_game = {
+ "id": "401712345",
+ "home_abbr": "MD",
+ "away_abbr": "JHU",
+ "home_score": "7",
+ "away_score": "5",
+ "period": 2,
+ "period_text": "Q2",
+ "home_id": "120",
+ "away_id": "228",
+ "clock": "08:42",
+ "home_logo_path": Path(self.logo_dir, "MD.png"),
+ "away_logo_path": Path(self.logo_dir, "JHU.png"),
+ "game_time": "7:00 PM",
+ "game_date": "Apr 12",
+ "is_live": True,
+ "is_final": False,
+ "is_upcoming": False,
+ }
+ self.live_games = [self.current_game]
+ self.logger.info(
+ "Initialized NCAAMLacrosseLiveManager with test game: MD vs JHU"
+ )
+ else:
+ self.logger.info("Initialized NCAAMLacrosseLiveManager in live mode")
+
+ def _fetch_data(self) -> Optional[Dict]:
+ """Live fetch: pull today's games directly rather than the full season."""
+ return self._fetch_todays_games()
+
+
+class NCAAMLacrosseRecentManager(BaseNCAAMLacrosseManager, SportsRecent):
+ """Manager for recently completed NCAA Men's Lacrosse games."""
+
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ ):
+ super().__init__(config, display_manager, cache_manager)
+ self.logger = logging.getLogger("NCAAMLacrosseRecentManager")
+ self.logger.info(
+ f"Initialized NCAAMLacrosseRecentManager with {len(self.favorite_teams)} favorite teams"
+ )
+
+
+class NCAAMLacrosseUpcomingManager(BaseNCAAMLacrosseManager, SportsUpcoming):
+ """Manager for upcoming NCAA Men's Lacrosse games."""
+
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ ):
+ super().__init__(config, display_manager, cache_manager)
+ self.logger = logging.getLogger("NCAAMLacrosseUpcomingManager")
+ self.logger.info(
+ f"Initialized NCAAMLacrosseUpcomingManager with {len(self.favorite_teams)} favorite teams"
+ )
diff --git a/plugins/lacrosse-scoreboard/ncaaw_lacrosse_managers.py b/plugins/lacrosse-scoreboard/ncaaw_lacrosse_managers.py
new file mode 100644
index 00000000..6cd2659b
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/ncaaw_lacrosse_managers.py
@@ -0,0 +1,142 @@
+import logging
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+from lacrosse import Lacrosse, LacrosseLive
+from sports import SportsRecent, SportsUpcoming
+
+# Constants
+ESPN_NCAAWLAX_SCOREBOARD_URL = (
+ "https://site.api.espn.com/apis/site/v2/sports/lacrosse/womens-college-lacrosse/scoreboard"
+)
+
+
+class BaseNCAAWLacrosseManager(Lacrosse):
+ """Base class for NCAA Women's Lacrosse managers with common functionality."""
+
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ ):
+ self.logger = logging.getLogger("NCAAWLAX")
+ super().__init__(
+ config=config,
+ display_manager=display_manager,
+ cache_manager=cache_manager,
+ logger=self.logger,
+ sport_key="ncaaw_lacrosse",
+ )
+
+ # Check display modes to determine what data to fetch.
+ # Keys match the adapter output in manager.py::_adapt_config_for_manager
+ # and the plain "live"/"recent"/"upcoming" names used in
+ # config_schema.json.
+ display_modes = self.mode_config.get("display_modes", {})
+ self.recent_enabled = display_modes.get("recent", False)
+ self.upcoming_enabled = display_modes.get("upcoming", False)
+ self.live_enabled = display_modes.get("live", False)
+ self.league = "womens-college-lacrosse"
+
+ self.logger.info(
+ f"Initialized NCAAWLacrosse 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_ncaa_lacrosse_api_data(self, use_cache: bool = True) -> Optional[Dict]:
+ """Fetch the women's NCAA lacrosse season schedule (February through May)."""
+ return self._fetch_season_schedule(
+ sport="ncaa_womens_lacrosse",
+ cache_key_prefix="ncaa_womens_lacrosse_schedule",
+ scoreboard_url=ESPN_NCAAWLAX_SCOREBOARD_URL,
+ season_start_mmdd="0201",
+ use_cache=use_cache,
+ )
+
+ def _fetch_data(self) -> Optional[Dict]:
+ """Default fetch: pull the cached season schedule.
+
+ Live managers override this to query only today's games.
+ """
+ return self._fetch_ncaa_lacrosse_api_data(use_cache=True)
+
+
+class NCAAWLacrosseLiveManager(BaseNCAAWLacrosseManager, LacrosseLive):
+ """Manager for live NCAA Women's Lacrosse games."""
+
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ ):
+ super().__init__(config, display_manager, cache_manager)
+ self.logger = logging.getLogger("NCAAWLacrosseLiveManager")
+
+ # Initialize with test game only if test mode is enabled
+ if self.test_mode:
+ self.current_game = {
+ "id": "401712999",
+ "home_abbr": "BC",
+ "away_abbr": "NU",
+ "home_score": "11",
+ "away_score": "9",
+ "period": 3,
+ "period_text": "Q3",
+ "home_id": "103",
+ "away_id": "111",
+ "clock": "06:15",
+ "home_logo_path": Path(self.logo_dir, "BC.png"),
+ "away_logo_path": Path(self.logo_dir, "NU.png"),
+ "game_time": "6:00 PM",
+ "game_date": "Apr 13",
+ "is_live": True,
+ "is_final": False,
+ "is_upcoming": False,
+ }
+ self.live_games = [self.current_game]
+ self.logger.info(
+ "Initialized NCAAWLacrosseLiveManager with test game: BC vs NU"
+ )
+ else:
+ self.logger.info("Initialized NCAAWLacrosseLiveManager in live mode")
+
+ def _fetch_data(self) -> Optional[Dict]:
+ """Live fetch: pull today's games directly rather than the full season."""
+ return self._fetch_todays_games()
+
+
+class NCAAWLacrosseRecentManager(BaseNCAAWLacrosseManager, SportsRecent):
+ """Manager for recently completed NCAA Women's Lacrosse games."""
+
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ ):
+ super().__init__(config, display_manager, cache_manager)
+ self.logger = logging.getLogger("NCAAWLacrosseRecentManager")
+ self.logger.info(
+ f"Initialized NCAAWLacrosseRecentManager with {len(self.favorite_teams)} favorite teams"
+ )
+
+
+class NCAAWLacrosseUpcomingManager(BaseNCAAWLacrosseManager, SportsUpcoming):
+ """Manager for upcoming NCAA Women's Lacrosse games."""
+
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ display_manager,
+ cache_manager,
+ ):
+ super().__init__(config, display_manager, cache_manager)
+ self.logger = logging.getLogger("NCAAWLacrosseUpcomingManager")
+ self.logger.info(
+ f"Initialized NCAAWLacrosseUpcomingManager with {len(self.favorite_teams)} favorite teams"
+ )
diff --git a/plugins/lacrosse-scoreboard/requirements.txt b/plugins/lacrosse-scoreboard/requirements.txt
new file mode 100644
index 00000000..642c65fc
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/requirements.txt
@@ -0,0 +1,23 @@
+# Lacrosse Scoreboard Plugin Dependencies
+
+# Image processing
+Pillow>=9.0.0
+
+# HTTP requests for ESPN API
+requests>=2.28.0
+
+# Timezone handling
+pytz>=2022.1
+
+# URL parsing and retry logic
+urllib3>=1.26.0
+
+# Required LEDMatrix base classes
+# These are imported from the main LEDMatrix installation:
+# - src.plugin_system.base_plugin (BasePlugin)
+# - src.cache_manager (CacheManager)
+# - src.display_manager (DisplayManager)
+
+# Note: The plugin includes its own core modules (sports.py, lacrosse.py)
+# to be self-contained and not depend on the main LEDMatrix installation
+
diff --git a/plugins/lacrosse-scoreboard/scroll_display.py b/plugins/lacrosse-scoreboard/scroll_display.py
new file mode 100644
index 00000000..8aed0ffe
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/scroll_display.py
@@ -0,0 +1,651 @@
+"""
+Scroll Display Handler for Lacrosse 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 (NCAA lacrosse logos) between different leagues
+- Dynamic duration based on total content width
+- FPS logging and performance monitoring
+- Live priority support for scroll mode
+- Support for mixed game types in a single scroll
+"""
+
+import logging
+import time
+import os
+from pathlib import Path
+from typing import Dict, Any, List, Optional, Tuple
+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__)
+
+# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1
+# Fall back to Image.LANCZOS for older versions
+try:
+ RESAMPLE_FILTER = Image.Resampling.LANCZOS
+except AttributeError:
+ RESAMPLE_FILTER = Image.LANCZOS
+
+
+class ScrollDisplay:
+ """
+ Handles scroll display mode for the lacrosse scoreboard plugin.
+
+ This class:
+ - Collects all games matching criteria (respecting live priority)
+ - Pre-renders each game using GameRenderer
+ - Adds league separator icons between different leagues
+ - Composes a single wide image using ScrollHelper
+ - Implements dynamic duration based on total content width
+ - Logs FPS and game count during scrolling
+ """
+
+ # Paths to league separator icons. Lacrosse uses a single NCAA lacrosse
+ # logo for both men's and women's since ESPN does not ship separate
+ # gendered marks for the sport.
+ NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/NCAA.png"
+ NCAA_LACROSSE_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_lacrosse.png"
+
+ def __init__(
+ self,
+ display_manager,
+ config: Dict[str, Any],
+ custom_logger: Optional[logging.Logger] = None
+ ):
+ """
+ Initialize the ScrollDisplay handler.
+
+ 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
+
+ # Get display dimensions
+ if hasattr(display_manager, 'matrix') and display_manager.matrix is not None:
+ self.display_width = display_manager.matrix.width
+ self.display_height = display_manager.matrix.height
+ else:
+ self.display_width = getattr(display_manager, "width", 128)
+ self.display_height = getattr(display_manager, "height", 32)
+
+ # Initialize ScrollHelper
+ if ScrollHelper:
+ self.scroll_helper = ScrollHelper(
+ self.display_width,
+ self.display_height,
+ self.logger
+ )
+ # Configure scroll settings
+ self._configure_scroll_helper()
+ else:
+ self.scroll_helper = None
+ self.logger.error("ScrollHelper not available - scroll mode will not work")
+
+ # Shared logo cache for game renderer
+ self._logo_cache: Dict[str, Image.Image] = {}
+
+ # League separator icons cache
+ self._separator_icons: Dict[str, Image.Image] = {}
+ self._load_separator_icons()
+
+ # Tracking state
+ self._current_games: List[Dict] = []
+ self._current_game_type: str = ""
+ self._current_leagues: List[str] = []
+ self._vegas_content_items: List[Image.Image] = []
+ self._is_scrolling = False
+ self._scroll_start_time: Optional[float] = None
+ self._last_log_time: float = 0
+ self._log_interval: float = 5.0 # Log every 5 seconds
+
+ # Performance tracking
+ self._frame_count: int = 0
+ self._fps_sample_start: float = time.time()
+
+ def _configure_scroll_helper(self) -> None:
+ """Configure scroll helper with settings from config."""
+ if not self.scroll_helper:
+ return
+
+ # Get global scroll settings, then per-league overrides
+ scroll_settings = self._get_scroll_settings()
+
+ # Set scroll speed (pixels per second in time-based mode)
+ scroll_speed = scroll_settings.get("scroll_speed", 50.0)
+ self.scroll_helper.set_scroll_speed(scroll_speed)
+
+ # Set scroll delay
+ scroll_delay = scroll_settings.get("scroll_delay", 0.01)
+ self.scroll_helper.set_scroll_delay(scroll_delay)
+
+ # Enable dynamic duration
+ dynamic_duration = scroll_settings.get("dynamic_duration", True)
+ self.scroll_helper.set_dynamic_duration_settings(
+ enabled=dynamic_duration,
+ min_duration=30,
+ max_duration=600, # 10 minutes max
+ buffer=0.2 # 20% buffer to ensure scroll completes fully off screen
+ )
+
+ # Use frame-based scrolling for better FPS control
+ self.scroll_helper.set_frame_based_scrolling(True)
+
+ # Convert scroll_speed from pixels/second to pixels/frame for frame-based mode
+ # Formula: pixels_per_frame = (pixels/second) * (seconds/frame)
+ if scroll_delay > 0:
+ pixels_per_frame = scroll_speed * scroll_delay
+ else:
+ # Fallback: assume 100 FPS if delay is 0
+ pixels_per_frame = scroll_speed / 100.0
+
+ # Clamp to reasonable range (0.1 to 5 pixels per frame for smooth scrolling)
+ pixels_per_frame = max(0.1, min(5.0, pixels_per_frame))
+ self.scroll_helper.set_scroll_speed(pixels_per_frame)
+
+ # Calculate effective pixels per second for logging
+ effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100
+
+ self.logger.info(
+ f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, delay={scroll_delay}s "
+ f"(effective {effective_pps:.1f} px/s from {scroll_speed} px/s config), dynamic_duration={dynamic_duration}"
+ )
+
+ def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]:
+ """Get scroll settings, optionally for a specific league."""
+ # Default scroll settings
+ defaults = {
+ "scroll_speed": 50.0,
+ "scroll_delay": 0.01,
+ "gap_between_games": 48,
+ "show_league_separators": True,
+ "dynamic_duration": True,
+ "game_card_width": 128,
+ }
+
+ # Try to get league-specific settings first
+ if league:
+ league_config = self.config.get(league, {})
+ league_scroll = league_config.get("scroll_settings", {})
+ if league_scroll:
+ return {**defaults, **league_scroll}
+
+ # Fall back to NCAA Men's settings (try both naming conventions)
+ for league_key in ["ncaa_mens", "ncaam_lacrosse"]:
+ ncaa_config = self.config.get(league_key, {})
+ ncaa_scroll = ncaa_config.get("scroll_settings", {})
+ if ncaa_scroll:
+ return {**defaults, **ncaa_scroll}
+
+ # Fall back to NCAA Women's settings (try both naming conventions)
+ for league_key in ["ncaa_womens", "ncaaw_lacrosse"]:
+ ncaa_config = self.config.get(league_key, {})
+ ncaa_scroll = ncaa_config.get("scroll_settings", {})
+ if ncaa_scroll:
+ return {**defaults, **ncaa_scroll}
+
+ return defaults
+
+ def _load_separator_icons(self) -> None:
+ """Load and resize league separator icons."""
+ separator_height = self.display_height - 4 # Leave some padding
+
+ # Load NCAA icon (try sport-specific first, then generic). Both
+ # entries register under the lacrosse league keys so the generic
+ # NCAA.png acts as a real fallback when ncaa_lacrosse.png is missing —
+ # otherwise separator lookups for "ncaam_lacrosse" / "ncaaw_lacrosse"
+ # would silently return None.
+ lacrosse_keys = ["ncaam_lacrosse", "ncaa_mens",
+ "ncaaw_lacrosse", "ncaa_womens"]
+ ncaa_icon_paths = [
+ (self.NCAA_LACROSSE_SEPARATOR_ICON, lacrosse_keys),
+ (self.NCAA_SEPARATOR_ICON, [*lacrosse_keys, "ncaa"]),
+ ]
+
+ for icon_path, league_keys in ncaa_icon_paths:
+ if os.path.exists(icon_path):
+ try:
+ # Use context manager to ensure file handle is closed
+ with Image.open(icon_path) as ncaa_file:
+ # Convert creates a copy; if already RGBA, use copy() to detach from file
+ if ncaa_file.mode != "RGBA":
+ ncaa_icon = ncaa_file.convert("RGBA")
+ else:
+ ncaa_icon = ncaa_file.copy()
+ # Resize to fit height while maintaining aspect ratio (after file is closed)
+ aspect = ncaa_icon.width / ncaa_icon.height
+ new_width = int(separator_height * aspect)
+ ncaa_icon = ncaa_icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER)
+ # Only populate keys that haven't been set yet so the
+ # sport-specific icon (iterated first) always wins over
+ # the generic NCAA fallback.
+ for key in league_keys:
+ self._separator_icons.setdefault(key, ncaa_icon)
+ self.logger.debug(f"Loaded NCAA separator icon from {icon_path}: {new_width}x{separator_height}")
+ except Exception:
+ self.logger.exception(f"Error loading NCAA separator icon from {icon_path}")
+
+ def _determine_game_type(self, game: Dict) -> str:
+ """
+ Determine the game type from the game's status.
+
+ Args:
+ game: Game dictionary
+
+ Returns:
+ Game type: 'live', 'recent', or 'upcoming'
+ """
+ state = game.get('status', {}).get('state', '')
+ if state == 'in':
+ return 'live'
+ elif state == 'post':
+ return 'recent'
+ elif state == 'pre':
+ return 'upcoming'
+ else:
+ # Default to upcoming if state is unknown
+ return 'upcoming'
+
+ def prepare_scroll_content(
+ self,
+ games: List[Dict],
+ game_type: str,
+ leagues: List[str],
+ rankings_cache: Optional[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., ['ncaam_lacrosse', 'ncaaw_lacrosse'])
+ 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.clear() # Reset all scroll state, not just cache
+ return False
+
+ self._current_games = games
+ self._current_game_type = game_type
+ self._current_leagues = leagues
+
+ # Get scroll settings using primary league from the provided leagues list
+ primary_league = leagues[0] if leagues else None
+ scroll_settings = self._get_scroll_settings(primary_league)
+ 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", "ncaam_lacrosse") # Default to NCAA Men's Lacrosse if not specified
+
+ # Add league separator if switching leagues OR if this is the first league
+ if show_separators:
+ if current_league is None:
+ # First league - add separator at the start
+ separator = self._separator_icons.get(game_league)
+ if separator:
+ # Create a separator image with proper background
+ sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0))
+ # Center the separator vertically
+ y_offset = (self.display_height - separator.height) // 2
+ sep_img.paste(separator, (4, y_offset), separator)
+ content_items.append(sep_img)
+ self.logger.debug(f"Added {game_league} separator icon at start")
+ elif game_league != current_league:
+ # Switching leagues - add separator
+ separator = self._separator_icons.get(game_league)
+ if separator:
+ # Create a separator image with proper background
+ sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0))
+ # Center the separator vertically
+ y_offset = (self.display_height - separator.height) // 2
+ sep_img.paste(separator, (4, y_offset), separator)
+ content_items.append(sep_img)
+ self.logger.debug(f"Added {game_league} separator icon")
+
+ current_league = game_league
+
+ # Render game card
+ # Only determine type from game state when in 'mixed' mode; otherwise use the passed game_type
+ try:
+ if game_type == 'mixed':
+ individual_game_type = self._determine_game_type(game)
+ else:
+ individual_game_type = game_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:
+ self.logger.exception("Error rendering game card")
+ continue
+
+ if not content_items:
+ self.logger.warning("No game cards rendered")
+ return False
+
+ # Store individual items for Vegas mode (avoids scroll_helper padding)
+ self._vegas_content_items = list(content_items)
+
+ # Create scrolling image using ScrollHelper
+ self.scroll_helper.create_scrolling_image(
+ content_items,
+ item_gap=gap_between_games,
+ element_gap=0 # No element gap - each item is a complete game card
+ )
+
+ # Log what we loaded
+ league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()])
+ self.logger.info(
+ f"[Lacrosse Scroll] Prepared {game_count} games for scrolling: {league_summary}"
+ )
+ self.logger.info(
+ f"[Lacrosse Scroll] Total scroll width: {self.scroll_helper.total_scroll_width}px, "
+ f"Dynamic duration: {self.scroll_helper.calculated_duration}s"
+ )
+
+ # Reset tracking state
+ self._is_scrolling = True
+ self._scroll_start_time = time.time()
+ self._frame_count = 0
+ self._fps_sample_start = time.time()
+
+ return True
+
+ def display_scroll_frame(self) -> bool:
+ """
+ Display the next frame of the scrolling content.
+
+ Returns:
+ True if a frame was displayed, False if scroll is complete or no content
+ """
+ if not self.scroll_helper or not self.scroll_helper.cached_image:
+ return False
+
+ # Update scroll position
+ self.scroll_helper.update_scroll_position()
+
+ # Get visible portion
+ visible = self.scroll_helper.get_visible_portion()
+ if not visible:
+ return False
+
+ # Display the visible portion
+ try:
+ self.display_manager.image = visible
+ self.display_manager.update_display()
+
+ # Track frame rate
+ self._frame_count += 1
+ self.scroll_helper.log_frame_rate()
+
+ # Periodic logging
+ self._log_scroll_progress()
+ except Exception:
+ self.logger.exception("Error displaying scroll frame")
+ return False
+ else:
+ return True
+
+ def _log_scroll_progress(self) -> None:
+ """Log scroll progress and FPS periodically."""
+ current_time = time.time()
+
+ if current_time - self._last_log_time >= self._log_interval:
+ # Calculate FPS
+ elapsed = current_time - self._fps_sample_start
+ if elapsed > 0:
+ fps = self._frame_count / elapsed
+
+ # Get scroll info
+ scroll_info = self.scroll_helper.get_scroll_info()
+
+ self.logger.info(
+ f"[Lacrosse Scroll] FPS: {fps:.1f}, "
+ f"Position: {scroll_info['scroll_position']:.0f}/{scroll_info['total_width']}px, "
+ f"Elapsed: {scroll_info.get('elapsed_time', 0):.1f}s/{scroll_info['dynamic_duration']}s"
+ )
+
+ # Reset FPS tracking
+ self._frame_count = 0
+ self._fps_sample_start = current_time
+ self._last_log_time = current_time
+
+ def is_scroll_complete(self) -> bool:
+ """Check if the scroll cycle is complete."""
+ if not self.scroll_helper:
+ return True
+ return self.scroll_helper.is_scroll_complete()
+
+ def reset_scroll(self) -> None:
+ """Reset the scroll position to the beginning."""
+ if self.scroll_helper:
+ self.scroll_helper.reset_scroll()
+ self._frame_count = 0
+ self._fps_sample_start = time.time()
+ self.logger.debug("Scroll position reset")
+
+ def get_scroll_info(self) -> Dict[str, Any]:
+ """Get current scroll state information."""
+ if not self.scroll_helper:
+ return {"error": "ScrollHelper not available"}
+
+ info = self.scroll_helper.get_scroll_info()
+ info.update({
+ "game_count": len(self._current_games),
+ "game_type": self._current_game_type,
+ "leagues": self._current_leagues,
+ "is_scrolling": self._is_scrolling
+ })
+ return info
+
+ def get_dynamic_duration(self) -> int:
+ """Get the calculated dynamic duration for this scroll content."""
+ if self.scroll_helper:
+ return self.scroll_helper.get_dynamic_duration()
+ return 60 # Default fallback
+
+ def clear(self) -> None:
+ """Clear scroll content and reset state."""
+ if self.scroll_helper:
+ self.scroll_helper.clear_cache()
+ self._current_games = []
+ self._current_game_type = ""
+ self._current_leagues = []
+ self._vegas_content_items = []
+ self._is_scrolling = False
+ self._scroll_start_time = None
+ self.logger.debug("Scroll display cleared")
+
+
+class ScrollDisplayManager:
+ """
+ Manages scroll display instances for different game types.
+
+ This class provides a higher-level interface for the lacrosse 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
+
+ # Create scroll displays for each game type
+ self._scroll_displays: Dict[str, ScrollDisplay] = {}
+ self._current_game_type: Optional[str] = None
+
+ def get_scroll_display(self, game_type: str) -> ScrollDisplay:
+ """
+ Get or create a scroll display for a game type.
+
+ Args:
+ game_type: Type of games ('live', 'recent', 'upcoming')
+
+ Returns:
+ ScrollDisplay instance for the game type
+ """
+ if game_type not in self._scroll_displays:
+ self._scroll_displays[game_type] = ScrollDisplay(
+ self.display_manager,
+ self.config,
+ self.logger
+ )
+ return self._scroll_displays[game_type]
+
+ def prepare_and_display(
+ self,
+ games: List[Dict],
+ game_type: str,
+ leagues: List[str],
+ rankings_cache: Optional[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: Optional[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: Optional[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: Optional[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 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/lacrosse-scoreboard/sports.py b/plugins/lacrosse-scoreboard/sports.py
new file mode 100644
index 00000000..bdb31936
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/sports.py
@@ -0,0 +1,2327 @@
+import logging
+import os
+import threading
+import time
+from abc import ABC, abstractmethod
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional
+
+import pytz
+import requests
+from PIL import Image, ImageDraw, ImageFont
+from requests.adapters import HTTPAdapter
+from urllib3.util.retry import Retry
+
+# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1
+# Fall back to Image.LANCZOS for older versions
+try:
+ RESAMPLE_FILTER = Image.Resampling.LANCZOS
+except AttributeError:
+ RESAMPLE_FILTER = Image.LANCZOS
+
+# Import simplified dependencies for plugin use
+from dynamic_team_resolver import DynamicTeamResolver
+from logo_downloader import LogoDownloader, download_missing_logo
+from base_odds_manager import BaseOddsManager
+from data_sources import ESPNDataSource
+
+
+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 this sport
+ # Import from src to ensure we get the full LogoDownloader with get_logo_directory
+ from src.logo_downloader import LogoDownloader as MainLogoDownloader
+ try:
+ logo_downloader = MainLogoDownloader()
+ default_logo_dir = Path(logo_downloader.get_logo_directory(sport_key))
+ self.logger.info(f"Logo directory for sport_key='{sport_key}': {default_logo_dir}")
+ except Exception as e:
+ # Fallback to default directory structure
+ self.logger.warning(f"Failed to get logo directory for sport_key='{sport_key}': {e}, using fallback")
+ default_logo_dir = Path(f"assets/sports/{sport_key}_logos")
+ self.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
+ self.show_favorite_teams_only: bool = self.mode_config.get(
+ "show_favorite_teams_only", False
+ )
+ self.show_all_live: bool = self.mode_config.get("show_all_live", False)
+
+ 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
+ # 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
+ )
+
+ # 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}")
+
+ 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 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 _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) -> None:
+ """Common display method for all NCAA FB managers""" # Updated docstring
+ if not self.is_enabled: # Check if module is enabled
+ return
+
+ if not self.current_game:
+ # Clear the display so old content doesn't persist
+ if force_clear:
+ self.display_manager.clear()
+ self.display_manager.update_display()
+ 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
+
+ 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.
+ except Exception as e:
+ self.logger.error(
+ f"Error during display call in {self.__class__.__name__}: {e}",
+ exc_info=True,
+ )
+
+ def _load_custom_font_from_element_config(
+ self,
+ element_config: Dict[str, Any],
+ default_size: int = 8,
+ default_font: Optional[str] = None,
+ ) -> 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
+ default_font: Default font filename when not specified in config (e.g. '4x6-font.ttf' for odds)
+
+ Returns:
+ PIL ImageFont object
+ """
+ base_default = default_font or "PressStart2P-Regular.ttf"
+ font_name = element_config.get("font", base_default)
+ 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'):
+ # BDF fonts are not supported by ImageFont.truetype()
+ # To use BDF fonts, convert to PILfont format using pilfont.py:
+ # python -m PIL.pilfont font.bdf
+ # This creates .pil and .pbm files that can be loaded with ImageFont.load()
+ self.logger.warning(
+ f"BDF font '{font_name}' not supported; convert to PILfont format "
+ f"using 'python -m PIL.pilfont {font_path}' then use the .pil file. "
+ f"Falling back to default font."
+ )
+ # 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", base_default)
+ 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 to convert string to int
+ try:
+ return int(float(offset_value))
+ except (ValueError, TypeError):
+ self.logger.warning(
+ f"Invalid layout offset value for {element}.{axis}: '{offset_value}', using default {default}"
+ )
+ return default
+ else:
+ return default
+ except Exception as e:
+ # Gracefully handle any config access errors
+ self.logger.debug(f"Error reading 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, default_font="4x6-font.ttf"
+ )
+ 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."
+ )
+
+ # Try to download the logo from ESPN API (this will create placeholder if download fails)
+ download_missing_logo(
+ self.sport_key, 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), RESAMPLE_FILTER)
+ 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 the new architecture."""
+ 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)
+ )
+
+ # Fetch odds using OddsManager
+ odds_data = self.odds_manager.get_odds(
+ sport=self.sport,
+ league=self.league,
+ event_id=game["id"],
+ update_interval_seconds=update_interval,
+ )
+
+ 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']}")
+
+ except Exception as e:
+ self.logger.error(
+ f"Error fetching odds for game {game.get('id', 'N/A')}: {e}"
+ )
+
+ def _get_timezone(self):
+ timezone_name = None
+
+ # Allow plugin-specific override first
+ timezone_name = self.config.get("timezone")
+
+ if not timezone_name and self.config_manager:
+ try:
+ timezone_name = self.config_manager.get_timezone()
+ except Exception as exc:
+ self.logger.warning(
+ f"Error retrieving timezone from ConfigManager: {exc}"
+ )
+
+ if not timezone_name:
+ timezone_name = "UTC"
+
+ try:
+ return pytz.timezone(timezone_name)
+ except pytz.UnknownTimeZoneError:
+ self.logger.warning(
+ f"Unknown timezone '{timezone_name}' - 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 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 = {}
+ 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
+
+ # Cache the results
+ self._team_rankings_cache = rankings
+ self._rankings_cache_timestamp = current_time
+
+ self.logger.debug(f"Fetched rankings for {len(rankings)} teams")
+ return rankings
+
+ except Exception as e:
+ self.logger.error(f"Error fetching team rankings: {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, ESPN API typically returns times in Eastern Time for NHL/NFL
+ # Assume Eastern Time and convert to UTC
+ eastern = pytz.timezone('America/New_York')
+ start_time_utc = eastern.localize(dt).astimezone(pytz.UTC)
+ else:
+ # Convert to pytz.UTC for consistency
+ start_time_utc = dt.astimezone(pytz.UTC)
+ except ValueError:
+ self.logger.warning("Could not parse game date: %s", 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 = ""
+
+ 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_team.get("score", "0"),
+ "home_logo_path": self.logo_dir
+ / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"),
+ "home_logo_url": home_team["team"].get("logo"),
+ "home_record": home_record,
+ "away_record": away_record,
+ "away_abbr": away_abbr,
+ "away_id": away_team["id"],
+ "away_score": away_team.get("score", "0"),
+ "away_logo_path": self.logo_dir
+ / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"),
+ "away_logo_url": away_team["team"].get("logo"),
+ "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 only today's games for live updates (not entire season)."""
+ try:
+ # 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")
+ # Fetch todays games only
+ url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
+ response = self.session.get(
+ url,
+ params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000},
+ headers=self.headers,
+ timeout=10,
+ )
+ response.raise_for_status()
+ data = response.json()
+ events = data.get("events", [])
+
+ self.logger.info(
+ f"Fetched {len(events)} games for the last 2 days for {self.sport} - {self.league}"
+ )
+ return {"events": events}
+ except requests.exceptions.RequestException as e:
+ self.logger.error(
+ f"API error fetching todays 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 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
+ 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:
+ self.current_game = self.games_list[
+ self.current_game_index
+ ] # Update data
+
+ if not self.games_list:
+ self.logger.info(
+ "No relevant upcoming games found to display."
+ ) # Changed log prefix
+ 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:
+ 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)
+
+ 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_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.paste(main_img.convert("RGB"), (0, 0))
+ self.display_manager.update_display()
+ return
+
+ center_y = self.display_height // 2
+
+ # MLB-style logo positions with layout offsets
+ home_x = self.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
+
+ # "Next Game" at the top (use smaller status font) with layout offsets
+ status_font = self.fonts["status"]
+ if self.display_width > 128:
+ status_font = self.fonts["time"]
+ status_text = "Next Game"
+ status_width = draw_overlay.textlength(status_text, font=status_font)
+ status_x = (self.display_width - status_width) // 2 + self._get_layout_offset('status_text', 'x_offset')
+ status_y = 1 + self._get_layout_offset('status_text', 'y_offset') # Changed from 2
+ 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 = (self.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') # Raise date slightly
+ 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 = (self.display_width - time_width) // 2 + self._get_layout_offset('time', 'x_offset')
+ time_y = date_y + 9 + self._get_layout_offset('time', 'y_offset') # Place time below date
+ 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"], self.display_width, self.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(f"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):
+ """Display upcoming games, handling switching."""
+ if not self.is_enabled:
+ return
+
+ 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 # 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:
+ self._draw_scorebug_layout(self.current_game, force_clear)
+ # update_display() is called within _draw_scorebug_layout for upcoming
+
+ except Exception as e:
+ self.logger.error(
+ f"Error in display loop: {e}", exc_info=True
+ ) # Changed log prefix
+
+
+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):
+ clock = game.get("clock", "")
+ period = game.get("period", 0)
+ period_text = game.get("period_text", "").lower()
+
+ if "final" in period_text:
+ appears_finished = True
+ self._clear_zero_clock_tracking(game_id)
+ elif period >= 4: # Lacrosse: 4 quarters (Q4 or OT)
+ clock_normalized = clock.replace(":", "").strip() if isinstance(clock, str) else ""
+ if clock_normalized in ("000", "00", "") or clock in ("0:00", ":00"):
+ zero_clock_duration = self._get_zero_clock_duration(game_id)
+ if zero_clock_duration >= 120:
+ appears_finished = True
+ self.logger.debug(
+ f"Game {game.get('away_abbr')}@{game.get('home_abbr')} "
+ f"appears finished after {zero_clock_duration:.0f}s at 0:00"
+ )
+ else:
+ 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:
+ 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
+ )
+ # 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:
+ # No favorites or show_favorite_teams_only disabled: 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]
+ self.logger.info(
+ f"No favorites configured: showing {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
+ # 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:
+ # List content is same, just update data for current game
+ self.current_game = self.games_list[self.current_game_index]
+
+ if not self.games_list:
+ self.logger.info(
+ "No relevant recent games found to display."
+ ) # Changed log prefix
+ self.current_game = None # Ensure display clears if no games
+
+ 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:
+ 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)
+
+ 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.paste(main_img.convert("RGB"), (0, 0))
+ self.display_manager.update_display()
+ return
+
+ center_y = self.display_height // 2
+
+ # MLB-style logo positioning (closer to edges) with layout offsets
+ home_x = self.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
+ 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"])
+ score_x = (self.display_width - score_width) // 2 + self._get_layout_offset('score', 'x_offset')
+ score_y = self.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"]
+ )
+
+ # "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 = (self.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"], self.display_width, self.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(f"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,
+ )
+
+ self._custom_scorebug_layout(game, draw_overlay)
+ # 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 recent game: {e}", exc_info=True
+ ) # Changed log prefix
+
+ def display(self, force_clear=False):
+ """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
+
+ 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:
+ self._draw_scorebug_layout(self.current_game, force_clear)
+ # update_display() is called within _draw_scorebug_layout for recent
+
+ except Exception as e:
+ self.logger.error(
+ f"Error in display loop: {e}", exc_info=True
+ ) # Changed log prefix
+
+
+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
+ try:
+ mode_config_keys = list(self.mode_config.keys()) if isinstance(self.mode_config, dict) else "N/A"
+ 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={mode_config_keys}"
+ )
+ except Exception as e:
+ self.logger.warning(f"Error logging SportsLive initialization: {e}")
+ 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)
+ 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
+
+ def _is_game_really_over(self, game: Dict) -> bool:
+ """Check if a game appears to be over even if API says it's live.
+
+ Lacrosse: Games end in Q4 or OT when clock hits 0:00 (period >= 4).
+ """
+ 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
+
+ # Check if clock is 0:00 in P3 or OT (period >= 3)
+ raw_clock = game.get("clock")
+ period = game.get("period", 0)
+
+ # Only check clock-based finish if we have a valid clock string
+ if isinstance(raw_clock, str) and raw_clock.strip() and period >= 4:
+ clock = raw_clock
+ clock_normalized = clock.replace(":", "").strip()
+ if clock_normalized in ("000", "00") or clock in ("0:00", ":00"):
+ self.logger.debug(
+ f"_is_game_really_over({game_str}): "
+ f"returning True - clock at 0:00 (clock='{clock}', period={period})"
+ )
+ return True
+
+ self.logger.debug(
+ f"_is_game_really_over({game_str}): returning False"
+ )
+ 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
+
+ # Debug logging for interval selection (log every 5 minutes or when interval changes)
+ if current_time - self.last_log_time >= 300: # Log every 5 minutes
+ self.logger.info(
+ f"Update check: live_games={len(_live_games_attr) if _live_games_attr else 0}, "
+ f"update_interval={_update_interval_attr}, no_data_interval={_no_data_interval_attr}, "
+ f"selected_interval={interval}, time_since_last_update={time_since_last_update:.1f}s, "
+ f"has_recently_checked={has_recently_checked}"
+ )
+ self.last_log_time = current_time
+
+ # 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()
+
+ if self.test_mode:
+ # Simulate clock running down in test mode
+ self._test_mode_update()
+ else:
+ # Fetch live game data
+ data = self._fetch_data()
+ new_live_games = []
+ if data and "events" in data:
+ live_or_halftime_count = 0
+ filtered_out_count = 0
+
+ for game in data["events"]:
+ details = self._extract_game_details(game)
+ if details:
+ # Filter out final games and games that appear to be over
+ if details.get("is_final", False):
+ continue
+
+ if self._is_game_really_over(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 not (details["is_live"] or details["is_halftime"]):
+ continue
+
+ live_or_halftime_count += 1
+
+ # Filtering logic matching SportsUpcoming:
+ # - If show_all_live = True → show all games
+ # - If show_favorite_teams_only = False → show all games
+ # - If show_favorite_teams_only = True but favorite_teams is empty → show all games (fallback)
+ # - If show_favorite_teams_only = True and favorite_teams has teams → only show games with those teams
+ if self.show_all_live:
+ # Always show all live games if show_all_live is enabled
+ should_include = True
+ elif not self.show_favorite_teams_only:
+ # If favorite teams filtering is disabled, show all games
+ should_include = True
+ elif not self.favorite_teams:
+ # If favorite teams filtering is enabled but no favorites are configured,
+ # show all games (same behavior as SportsUpcoming)
+ should_include = True
+ else:
+ # Favorite teams filtering is enabled AND favorites are configured
+ # Only show games involving favorite teams
+ should_include = (
+ details["home_abbr"] in self.favorite_teams
+ or details["away_abbr"] in self.favorite_teams
+ )
+
+ 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()
+
+ 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)
+
+ # Log filtering configuration
+ self.logger.info(
+ f"Live game filtering: {len(data['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:
+ # Sort with favorites first, then by start time
+ def sort_key(g):
+ is_favorite = self.favorite_teams and (g["home_abbr"] in self.favorite_teams or g["away_abbr"] in self.favorite_teams)
+ start_time = g.get("start_time_utc") or datetime.now(timezone.utc)
+ # Favorites first (0), non-favorites second (1), then by start time
+ return (0 if is_favorite else 1, start_time)
+
+ self.live_games = sorted(new_live_games, key=sort_key)
+ # Reset index if current game is gone or list is new
+ if (
+ not self.current_game
+ or self.current_game["id"] not in new_game_ids
+ ):
+ self.current_game_index = 0
+ self.current_game = (
+ self.live_games[0] if self.live_games else None
+ )
+ 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
+ }
+
+ 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)
+ # Fix: Don't check for switching if last_game_switch is still 0 (games haven't been loaded yet)
+ # This prevents immediate switching when the system has been running for a while before games load
+ with self._games_lock:
+ if (
+ not self.test_mode
+ and len(self.live_games) > 1
+ and self.last_game_switch > 0
+ and (current_time - self.last_game_switch) >= self.game_display_duration
+ ):
+ self.current_game_index = (self.current_game_index + 1) % len(
+ self.live_games
+ )
+ self.current_game = self.live_games[self.current_game_index]
+ 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/lacrosse-scoreboard/test_lacrosse_plugin.py b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py
new file mode 100644
index 00000000..851f3c63
--- /dev/null
+++ b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py
@@ -0,0 +1,272 @@
+#!/usr/bin/env python3
+"""
+Smoke test for the Lacrosse Scoreboard plugin.
+
+Run standalone from the plugin directory:
+
+ cd plugins/lacrosse-scoreboard
+ python test_lacrosse_plugin.py
+
+The test:
+ 1. Stubs the LEDMatrix host modules so plugin imports resolve.
+ 2. Imports every Python module in the plugin to catch syntax / import errors.
+ 3. Instantiates the dynamic team resolver and verifies the NCAA Men's Top 5 /
+ NCAA Women's Top 5 shortcuts resolve against the live ESPN ranking feeds.
+ 4. Fetches a recent window of events from both ESPN lacrosse scoreboard
+ endpoints and pushes each event through Lacrosse._extract_game_details,
+ asserting that required fields (abbreviations, IDs, scores, period,
+ logo URLs, records) are populated.
+
+No external test framework is required — the script exits non-zero on the
+first failure and prints a summary on success.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import sys
+import types
+import urllib.error
+import urllib.request
+from datetime import datetime
+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))
+
+
+# ---------------------------------------------------------------------------
+# Stub LEDMatrix host modules before importing the plugin.
+# ---------------------------------------------------------------------------
+def _install_host_stubs() -> None:
+ stub_modules = [
+ "src",
+ "src.plugin_system",
+ "src.plugin_system.base_plugin",
+ "src.background_data_service",
+ "src.common",
+ "src.common.scroll_helper",
+ "src.api_counter",
+ ]
+ for name in stub_modules:
+ 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.api_counter"].increment_api_counter = lambda *a, **k: None
+
+
+_install_host_stubs()
+
+logging.basicConfig(level=logging.CRITICAL)
+
+
+# ---------------------------------------------------------------------------
+# Test 1: import every plugin module.
+# ---------------------------------------------------------------------------
+def test_imports() -> None:
+ import data_sources # noqa: F401
+ import logo_downloader # noqa: F401
+ import base_odds_manager # noqa: F401
+ import dynamic_team_resolver # noqa: F401
+ import game_renderer # noqa: F401
+ import scroll_display # noqa: F401
+ import sports # noqa: F401
+ import lacrosse # noqa: F401
+ import ncaam_lacrosse_managers # noqa: F401
+ import ncaaw_lacrosse_managers # noqa: F401
+ import manager
+
+ assert hasattr(manager, "LacrosseScoreboardPlugin"), "plugin class missing"
+ print(" [ok] imports")
+
+
+# ---------------------------------------------------------------------------
+# Test 2: dynamic team resolver talks to live ESPN rankings.
+# ---------------------------------------------------------------------------
+def test_rankings_resolver() -> None:
+ from dynamic_team_resolver import DynamicTeamResolver
+
+ resolver = DynamicTeamResolver()
+ men = resolver.resolve_teams(["NCAA_MENS_TOP_5"], "ncaam_lacrosse")
+ women = resolver.resolve_teams(["NCAA_WOMENS_TOP_5"], "ncaaw_lacrosse")
+
+ # Treat partial outages as skips rather than failures — if we can confirm
+ # at least one endpoint returned real data, the resolver is working; we
+ # just can't fully validate the other side right now.
+ if not men and not women:
+ raise _NetworkUnavailable("both rankings endpoints returned no data")
+ if not men:
+ raise _NetworkUnavailable("men's rankings endpoint returned no data")
+ if not women:
+ raise _NetworkUnavailable("women's rankings endpoint returned no data")
+
+ assert len(men) == 5, f"expected 5 men's teams, got {len(men)}: {men}"
+ assert len(women) == 5, f"expected 5 women's teams, got {len(women)}: {women}"
+ assert all(isinstance(t, str) and t for t in men + women), "empty team entry"
+ print(f" [ok] dynamic resolver — men={men[:3]}..., women={women[:3]}...")
+
+
+class _NetworkUnavailable(Exception):
+ """Raised by a test when it detects the network/external feed is down."""
+
+
+# ---------------------------------------------------------------------------
+# Test 3: extraction pipeline against live ESPN data.
+# ---------------------------------------------------------------------------
+REQUIRED_FIELDS = [
+ "id",
+ "home_abbr",
+ "away_abbr",
+ "home_id",
+ "away_id",
+ "period",
+ "period_text",
+]
+
+
+def _fetch(url: str) -> dict:
+ req = urllib.request.Request(url, headers={"User-Agent": "LEDMatrix/1.0"})
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ return json.loads(resp.read())
+
+
+def _make_test_instance():
+ """Build a minimal Lacrosse instance without touching the host framework."""
+ import pytz
+ from lacrosse import Lacrosse
+ from sports import SportsCore
+
+ class _TestLacrosse(Lacrosse):
+ def _fetch_data(self):
+ return None
+
+ inst = object.__new__(_TestLacrosse)
+ inst.logger = logging.getLogger("test")
+ inst.logger.setLevel(logging.CRITICAL)
+ inst.sport = "lacrosse"
+ inst.config = {"timezone": "America/New_York"}
+ inst.timezone = pytz.timezone("America/New_York")
+ inst.favorite_teams = []
+ inst.show_favorite_teams_only = False
+ inst.show_records = True
+ inst.show_ranking = True
+ inst.show_odds = False
+ inst.logo_dir = Path("assets/sports/ncaa_logos")
+ inst._team_rankings_cache = {}
+ inst._extract_game_details_common = SportsCore._extract_game_details_common.__get__(
+ inst, _TestLacrosse
+ )
+ inst._get_timezone = SportsCore._get_timezone.__get__(inst, _TestLacrosse)
+ return inst
+
+
+def test_extraction(label: str, league_slug: str, date_window: str) -> None:
+ url = (
+ f"https://site.api.espn.com/apis/site/v2/sports/lacrosse/"
+ f"{league_slug}/scoreboard?dates={date_window}&limit=50"
+ )
+ data = _fetch(url)
+ events = data.get("events", [])
+ assert events, f"{label}: no events returned by ESPN for {date_window}"
+
+ inst = _make_test_instance()
+ extracted = 0
+ for ev in events:
+ details = inst._extract_game_details(ev)
+ if not details:
+ continue
+ extracted += 1
+ missing = [f for f in REQUIRED_FIELDS if details.get(f) in (None, "")]
+ assert not missing, (
+ f"{label}: event {ev.get('id')} missing fields {missing}: {details}"
+ )
+ # Logo URL pattern check — ESPN NCAA CDN
+ for side in ("home", "away"):
+ logo = details.get(f"{side}_logo_url") or ""
+ if logo:
+ assert logo.startswith("https://a.espncdn.com/"), (
+ f"{label}: unexpected logo host {logo}"
+ )
+
+ assert extracted == len(events), (
+ f"{label}: extracted {extracted}/{len(events)} events "
+ f"(expected all events to parse cleanly)"
+ )
+ print(f" [ok] {label} — {extracted}/{len(events)} events parsed cleanly")
+
+
+# ---------------------------------------------------------------------------
+# Runner
+# ---------------------------------------------------------------------------
+def _build_season_window() -> str:
+ """Build a rolling scoreboard date window for the current season.
+
+ NCAA lacrosse runs January through late May. From January through June we
+ query the current calendar year; from July onward we query the upcoming
+ season. Returned format is 'YYYYMMDD-YYYYMMDD' as ESPN expects.
+ """
+ now = datetime.now()
+ year = now.year if now.month < 7 else now.year + 1
+ return f"{year}0101-{year}0601"
+
+
+def main() -> int:
+ print("Lacrosse Scoreboard plugin — smoke test")
+
+ season_window = _build_season_window()
+
+ tests = [
+ ("imports", test_imports, ()),
+ ("rankings resolver", test_rankings_resolver, ()),
+ (
+ "men's extraction",
+ test_extraction,
+ ("men's", "mens-college-lacrosse", season_window),
+ ),
+ (
+ "women's extraction",
+ test_extraction,
+ ("women's", "womens-college-lacrosse", season_window),
+ ),
+ ]
+
+ # Import requests lazily so the test can still import when requests is
+ # unavailable — we only need it to recognise network errors.
+ try:
+ import requests.exceptions as _rexc
+ network_errors: tuple = (urllib.error.URLError, _rexc.RequestException)
+ except ImportError:
+ network_errors = (urllib.error.URLError,)
+
+ failed = 0
+ for name, fn, args in tests:
+ try:
+ fn(*args)
+ except AssertionError as e:
+ print(f" [FAIL] {name}: {e}")
+ failed += 1
+ except _NetworkUnavailable as e:
+ print(f" [skip] {name}: {e}")
+ except network_errors as e:
+ # Network failures are non-fatal — skip with a warning so the
+ # test can still run on air-gapped CI.
+ print(f" [skip] {name}: network unavailable ({e})")
+ except Exception as e: # noqa: BLE001 — we want every failure surfaced
+ print(f" [FAIL] {name}: {type(e).__name__}: {e}")
+ failed += 1
+
+ if failed:
+ print(f"\n{failed} test(s) failed.")
+ return 1
+ print("\nAll smoke tests passed.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())