diff --git a/plugin-repos/march-madness/config_schema.json b/plugin-repos/march-madness/config_schema.json deleted file mode 100644 index bccfe0a8..00000000 --- a/plugin-repos/march-madness/config_schema.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "March Madness Plugin Configuration", - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "default": false, - "description": "Enable the March Madness tournament display" - }, - "leagues": { - "type": "object", - "title": "Tournament Leagues", - "description": "Which NCAA tournaments to display", - "properties": { - "ncaam": { - "type": "boolean", - "default": true, - "description": "Show NCAA Men's Tournament games" - }, - "ncaaw": { - "type": "boolean", - "default": true, - "description": "Show NCAA Women's Tournament games" - } - }, - "additionalProperties": false - }, - "favorite_teams": { - "type": "array", - "title": "Favorite Teams", - "description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.", - "items": { - "type": "string" - }, - "uniqueItems": true, - "default": [] - }, - "display_options": { - "type": "object", - "title": "Display Options", - "x-collapsed": true, - "properties": { - "show_seeds": { - "type": "boolean", - "default": true, - "description": "Show tournament seeds (1-16) next to team names" - }, - "show_round_logos": { - "type": "boolean", - "default": true, - "description": "Show round logo separators between game groups" - }, - "highlight_upsets": { - "type": "boolean", - "default": true, - "description": "Highlight upset winners (higher seed beating lower seed) in gold" - }, - "show_bracket_progress": { - "type": "boolean", - "default": true, - "description": "Show which teams are still alive in each region" - }, - "scroll_speed": { - "type": "number", - "default": 1.0, - "minimum": 0.5, - "maximum": 5.0, - "description": "Scroll speed (pixels per frame)" - }, - "scroll_delay": { - "type": "number", - "default": 0.02, - "minimum": 0.001, - "maximum": 0.1, - "description": "Delay between scroll frames (seconds)" - }, - "target_fps": { - "type": "integer", - "default": 120, - "minimum": 30, - "maximum": 200, - "description": "Target frames per second" - }, - "loop": { - "type": "boolean", - "default": true, - "description": "Loop the scroll continuously" - }, - "dynamic_duration": { - "type": "boolean", - "default": true, - "description": "Automatically adjust display duration based on content width" - }, - "min_duration": { - "type": "integer", - "default": 30, - "minimum": 10, - "maximum": 300, - "description": "Minimum display duration in seconds" - }, - "max_duration": { - "type": "integer", - "default": 300, - "minimum": 30, - "maximum": 600, - "description": "Maximum display duration in seconds" - } - }, - "additionalProperties": false - }, - "data_settings": { - "type": "object", - "title": "Data Settings", - "x-collapsed": true, - "properties": { - "update_interval": { - "type": "integer", - "default": 300, - "minimum": 60, - "maximum": 3600, - "description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected." - }, - "request_timeout": { - "type": "integer", - "default": 30, - "minimum": 5, - "maximum": 60, - "description": "API request timeout in seconds" - } - }, - "additionalProperties": false - } - }, - "required": ["enabled"], - "additionalProperties": false, - "x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"] -} diff --git a/plugin-repos/march-madness/manager.py b/plugin-repos/march-madness/manager.py deleted file mode 100644 index 49661e0f..00000000 --- a/plugin-repos/march-madness/manager.py +++ /dev/null @@ -1,910 +0,0 @@ -"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix. - -Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by -round, with seeds, round logos, live scores, and upset highlighting. -""" - -import re -import threading -import time -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional - -import numpy as np -import pytz -import requests -from PIL import Image, ImageDraw, ImageFont -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -from src.plugin_system.base_plugin import BasePlugin - -try: - from src.common.scroll_helper import ScrollHelper -except ImportError: - ScrollHelper = None - - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -SCOREBOARD_URLS = { - "ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard", - "ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard", -} - -ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6} - -ROUND_DISPLAY_NAMES = { - "NCG": "Championship", - "F4": "Final Four", - "E8": "Elite Eight", - "S16": "Sweet Sixteen", - "R32": "Round of 32", - "R64": "Round of 64", -} - -ROUND_LOGO_FILES = { - "NCG": "CHAMPIONSHIP.png", - "F4": "FINAL_4.png", - "E8": "ELITE_8.png", - "S16": "SWEET_16.png", - "R32": "ROUND_32.png", - "R64": "ROUND_64.png", -} - -REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4} - -# Colors -COLOR_WHITE = (255, 255, 255) -COLOR_GOLD = (255, 215, 0) -COLOR_GRAY = (160, 160, 160) -COLOR_DIM = (100, 100, 100) -COLOR_RED = (255, 60, 60) -COLOR_GREEN = (60, 200, 60) -COLOR_BLACK = (0, 0, 0) -COLOR_DARK_BG = (20, 20, 20) - - -# --------------------------------------------------------------------------- -# Plugin Class -# --------------------------------------------------------------------------- - -class MarchMadnessPlugin(BasePlugin): - """NCAA March Madness tournament bracket tracker.""" - - def __init__( - self, - plugin_id: str, - config: Dict[str, Any], - display_manager: Any, - cache_manager: Any, - plugin_manager: Any, - ): - super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) - - # Config - leagues_config = config.get("leagues", {}) - self.show_ncaam: bool = leagues_config.get("ncaam", True) - self.show_ncaaw: bool = leagues_config.get("ncaaw", True) - self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])] - - display_options = config.get("display_options", {}) - self.show_seeds: bool = display_options.get("show_seeds", True) - self.show_round_logos: bool = display_options.get("show_round_logos", True) - self.highlight_upsets: bool = display_options.get("highlight_upsets", True) - self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True) - self.scroll_speed: float = display_options.get("scroll_speed", 1.0) - self.scroll_delay: float = display_options.get("scroll_delay", 0.02) - self.target_fps: int = display_options.get("target_fps", 120) - self.loop: bool = display_options.get("loop", True) - self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True) - self.min_duration: int = display_options.get("min_duration", 30) - self.max_duration: int = display_options.get("max_duration", 300) - if self.min_duration > self.max_duration: - self.logger.warning( - f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values" - ) - self.min_duration, self.max_duration = self.max_duration, self.min_duration - - data_settings = config.get("data_settings", {}) - self.update_interval: int = data_settings.get("update_interval", 300) - self.request_timeout: int = data_settings.get("request_timeout", 30) - - # Scrolling flag for display controller - self.enable_scrolling = True - - # State - self.games_data: List[Dict] = [] - self.ticker_image: Optional[Image.Image] = None - self.last_update: float = 0 - self.dynamic_duration: float = 60 - self.total_scroll_width: int = 0 - self._display_start_time: Optional[float] = None - self._end_reached_logged: bool = False - self._update_lock = threading.Lock() - self._has_live_games: bool = False - self._cached_dynamic_duration: Optional[float] = None - self._duration_cache_time: float = 0 - - # Display dimensions - self.display_width: int = self.display_manager.matrix.width - self.display_height: int = self.display_manager.matrix.height - - # HTTP session with retry - self.session = requests.Session() - retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]) - self.session.mount("https://", HTTPAdapter(max_retries=retry)) - self.headers = {"User-Agent": "LEDMatrix/2.0"} - - # ScrollHelper - if ScrollHelper: - self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger) - if hasattr(self.scroll_helper, "set_frame_based_scrolling"): - self.scroll_helper.set_frame_based_scrolling(True) - self.scroll_helper.set_scroll_speed(self.scroll_speed) - self.scroll_helper.set_scroll_delay(self.scroll_delay) - if hasattr(self.scroll_helper, "set_target_fps"): - self.scroll_helper.set_target_fps(self.target_fps) - self.scroll_helper.set_dynamic_duration_settings( - enabled=self.dynamic_duration_enabled, - min_duration=self.min_duration, - max_duration=self.max_duration, - buffer=0.1, - ) - else: - self.scroll_helper = None - self.logger.warning("ScrollHelper not available") - - # Fonts - self.fonts = self._load_fonts() - - # Logos - self._round_logos: Dict[str, Image.Image] = {} - self._team_logo_cache: Dict[str, Optional[Image.Image]] = {} - self._march_madness_logo: Optional[Image.Image] = None - self._load_round_logos() - - self.logger.info( - f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, " - f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}" - ) - - # ------------------------------------------------------------------ - # Fonts - # ------------------------------------------------------------------ - - def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: - fonts = {} - try: - fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - except IOError: - fonts["score"] = ImageFont.load_default() - try: - fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - except IOError: - fonts["time"] = ImageFont.load_default() - try: - fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - fonts["detail"] = ImageFont.load_default() - return fonts - - # ------------------------------------------------------------------ - # Logo loading - # ------------------------------------------------------------------ - - def _load_round_logos(self) -> None: - logo_dir = Path("assets/sports/ncaa_logos") - for round_key, filename in ROUND_LOGO_FILES.items(): - path = logo_dir / filename - try: - img = Image.open(path).convert("RGBA") - # Resize to fit display height - target_h = self.display_height - 4 - ratio = target_h / img.height - target_w = int(img.width * ratio) - self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS) - except (OSError, ValueError) as e: - self.logger.warning(f"Could not load round logo {filename}: {e}") - except Exception: - self.logger.exception(f"Unexpected error loading round logo {filename}") - - # March Madness logo - mm_path = logo_dir / "MARCH_MADNESS.png" - try: - img = Image.open(mm_path).convert("RGBA") - target_h = self.display_height - 4 - ratio = target_h / img.height - target_w = int(img.width * ratio) - self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS) - except (OSError, ValueError) as e: - self.logger.warning(f"Could not load March Madness logo: {e}") - except Exception: - self.logger.exception("Unexpected error loading March Madness logo") - - def _get_team_logo(self, abbr: str) -> Optional[Image.Image]: - if abbr in self._team_logo_cache: - return self._team_logo_cache[abbr] - logo_dir = Path("assets/sports/ncaa_logos") - path = logo_dir / f"{abbr}.png" - try: - img = Image.open(path).convert("RGBA") - target_h = self.display_height - 6 - ratio = target_h / img.height - target_w = int(img.width * ratio) - img = img.resize((target_w, target_h), Image.Resampling.LANCZOS) - self._team_logo_cache[abbr] = img - return img - except (FileNotFoundError, OSError, ValueError): - self._team_logo_cache[abbr] = None - return None - except Exception: - self.logger.exception(f"Unexpected error loading team logo for {abbr}") - self._team_logo_cache[abbr] = None - return None - - # ------------------------------------------------------------------ - # Data fetching - # ------------------------------------------------------------------ - - def _is_tournament_window(self) -> bool: - today = datetime.now(pytz.utc) - return (3, 10) <= (today.month, today.day) <= (4, 10) - - def _fetch_tournament_data(self) -> List[Dict]: - """Fetch tournament games from ESPN scoreboard API.""" - all_games: List[Dict] = [] - - leagues = [] - if self.show_ncaam: - leagues.append("ncaam") - if self.show_ncaaw: - leagues.append("ncaaw") - - for league_key in leagues: - url = SCOREBOARD_URLS.get(league_key) - if not url: - continue - - cache_key = f"march_madness_{league_key}_scoreboard" - cache_max_age = 60 if self._has_live_games else self.update_interval - cached = self.cache_manager.get(cache_key, max_age=cache_max_age) - if cached: - all_games.extend(cached) - continue - - try: - # NCAA basketball scoreboard without dates param returns current games - params = {"limit": 1000, "groups": 100} - resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout) - resp.raise_for_status() - data = resp.json() - events = data.get("events", []) - - league_games = [] - for event in events: - game = self._parse_event(event, league_key) - if game: - league_games.append(game) - - self.cache_manager.set(cache_key, league_games) - self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games") - all_games.extend(league_games) - - except Exception: - self.logger.exception(f"Error fetching {league_key} tournament data") - - return all_games - - def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]: - """Parse an ESPN event into a game dict.""" - competitions = event.get("competitions", []) - if not competitions: - return None - comp = competitions[0] - - # Confirm tournament game - comp_type = comp.get("type", {}) - is_tournament = comp_type.get("abbreviation") == "TRNMNT" - notes = comp.get("notes", []) - headline = "" - if notes: - headline = notes[0].get("headline", "") - if not is_tournament and "Championship" in headline: - is_tournament = True - if not is_tournament: - return None - - # Status - status = comp.get("status", {}).get("type", {}) - state = status.get("state", "pre") - status_detail = status.get("shortDetail", "") - - # Teams - competitors = comp.get("competitors", []) - 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: - return None - - home_abbr = home_team.get("team", {}).get("abbreviation", "???") - away_abbr = away_team.get("team", {}).get("abbreviation", "???") - home_score = home_team.get("score", "0") - away_score = away_team.get("score", "0") - - # Seeds - home_seed = home_team.get("curatedRank", {}).get("current", 0) - away_seed = away_team.get("curatedRank", {}).get("current", 0) - if home_seed >= 99: - home_seed = 0 - if away_seed >= 99: - away_seed = 0 - - # Round and region - tournament_round = self._parse_round(headline) - tournament_region = self._parse_region(headline) - - # Date/time - date_str = event.get("date", "") - start_time_utc = None - game_date = "" - game_time = "" - try: - if date_str.endswith("Z"): - date_str = date_str.replace("Z", "+00:00") - dt = datetime.fromisoformat(date_str) - if dt.tzinfo is None: - start_time_utc = dt.replace(tzinfo=pytz.UTC) - else: - start_time_utc = dt.astimezone(pytz.UTC) - local = start_time_utc.astimezone(pytz.timezone("US/Eastern")) - game_date = local.strftime("%-m/%-d") - game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm") - except (ValueError, AttributeError): - pass - - # Period / clock for live games - period = 0 - clock = "" - period_text = "" - is_halftime = False - if state == "in": - status_obj = comp.get("status", {}) - period = status_obj.get("period", 0) - clock = status_obj.get("displayClock", "") - detail_lower = status_detail.lower() - uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q") - if period <= (4 if uses_quarters else 2): - period_text = f"Q{period}" if uses_quarters else f"H{period}" - else: - ot_num = period - (4 if uses_quarters else 2) - period_text = f"OT{ot_num}" if ot_num > 1 else "OT" - if "halftime" in detail_lower: - is_halftime = True - elif state == "post": - period_text = status.get("shortDetail", "Final") - if "Final" not in period_text: - period_text = "Final" - - # Determine winner and upset - is_final = state == "post" - is_upset = False - winner_side = "" - if is_final: - try: - h = int(float(home_score)) - a = int(float(away_score)) - if h > a: - winner_side = "home" - if home_seed > away_seed > 0: - is_upset = True - elif a > h: - winner_side = "away" - if away_seed > home_seed > 0: - is_upset = True - except (ValueError, TypeError): - pass - - return { - "id": event.get("id", ""), - "league": league_key, - "home_abbr": home_abbr, - "away_abbr": away_abbr, - "home_score": str(home_score), - "away_score": str(away_score), - "home_seed": home_seed, - "away_seed": away_seed, - "tournament_round": tournament_round, - "tournament_region": tournament_region, - "state": state, - "is_final": is_final, - "is_live": state == "in", - "is_upcoming": state == "pre", - "is_halftime": is_halftime, - "period": period, - "period_text": period_text, - "clock": clock, - "status_detail": status_detail, - "game_date": game_date, - "game_time": game_time, - "start_time_utc": start_time_utc, - "is_upset": is_upset, - "winner_side": winner_side, - "headline": headline, - } - - @staticmethod - def _parse_round(headline: str) -> str: - hl = headline.lower() - if "national championship" in hl: - return "NCG" - if "final four" in hl: - return "F4" - if "elite 8" in hl or "elite eight" in hl: - return "E8" - if "sweet 16" in hl or "sweet sixteen" in hl: - return "S16" - if "2nd round" in hl or "second round" in hl: - return "R32" - if "1st round" in hl or "first round" in hl: - return "R64" - return "" - - @staticmethod - def _parse_region(headline: str) -> str: - if "East Region" in headline: - return "E" - if "West Region" in headline: - return "W" - if "South Region" in headline: - return "S" - if "Midwest Region" in headline: - return "MW" - m = re.search(r"Regional (\d+)", headline) - if m: - return f"R{m.group(1)}" - return "" - - # ------------------------------------------------------------------ - # Game processing - # ------------------------------------------------------------------ - - def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]: - """Group games by round, sorted by round significance then region/seed.""" - grouped: Dict[str, List[Dict]] = {} - for game in games: - rnd = game.get("tournament_round", "") - grouped.setdefault(rnd, []).append(game) - - # Sort each round's games by region then seed matchup - for rnd, round_games in grouped.items(): - round_games.sort( - key=lambda g: ( - REGION_ORDER.get(g.get("tournament_region", ""), 4), - min(g.get("away_seed", 99), g.get("home_seed", 99)), - ) - ) - - return grouped - - # ------------------------------------------------------------------ - # Rendering - # ------------------------------------------------------------------ - - def _draw_text_with_outline( - self, - draw: ImageDraw.Draw, - text: str, - xy: tuple, - font: ImageFont.FreeTypeFont, - fill: tuple = COLOR_WHITE, - outline: tuple = COLOR_BLACK, - ) -> None: - x, y = xy - for dx in (-1, 0, 1): - for dy in (-1, 0, 1): - if dx or dy: - draw.text((x + dx, y + dy), text, font=font, fill=outline) - draw.text((x, y), text, font=font, fill=fill) - - def _create_round_separator(self, round_key: str) -> Image.Image: - """Create a separator tile for a tournament round.""" - height = self.display_height - name = ROUND_DISPLAY_NAMES.get(round_key, round_key) - font = self.fonts["time"] - - # Measure text - tmp = Image.new("RGB", (1, 1)) - tmp_draw = ImageDraw.Draw(tmp) - text_width = int(tmp_draw.textlength(name, font=font)) - - # Logo on each side - logo = self._round_logos.get(round_key, self._march_madness_logo) - logo_w = logo.width if logo else 0 - padding = 6 - - total_w = padding + logo_w + padding + text_width + padding + logo_w + padding - total_w = max(total_w, 80) - - img = Image.new("RGB", (total_w, height), COLOR_DARK_BG) - draw = ImageDraw.Draw(img) - - # Draw logos - x = padding - if logo: - logo_y = (height - logo.height) // 2 - img.paste(logo, (x, logo_y), logo) - x += logo_w + padding - - # Draw round name - text_y = (height - 8) // 2 # 8px font - self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD) - x += text_width + padding - - if logo: - logo_y = (height - logo.height) // 2 - img.paste(logo, (x, logo_y), logo) - - return img - - def _create_game_tile(self, game: Dict) -> Image.Image: - """Create a single game tile for the scrolling ticker.""" - height = self.display_height - font_score = self.fonts["score"] - font_time = self.fonts["time"] - font_detail = self.fonts["detail"] - - # Load team logos - away_logo = self._get_team_logo(game["away_abbr"]) - home_logo = self._get_team_logo(game["home_abbr"]) - logo_w = 0 - if away_logo: - logo_w = max(logo_w, away_logo.width) - if home_logo: - logo_w = max(logo_w, home_logo.width) - if logo_w == 0: - logo_w = 24 - - # Build text elements - away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else "" - home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else "" - away_text = f"{away_seed_str}{game['away_abbr']}" - home_text = f"{game['home_abbr']}{home_seed_str}" - - # Measure text widths - tmp = Image.new("RGB", (1, 1)) - tmp_draw = ImageDraw.Draw(tmp) - away_text_w = int(tmp_draw.textlength(away_text, font=font_detail)) - home_text_w = int(tmp_draw.textlength(home_text, font=font_detail)) - - # Center content: status line - if game["is_live"]: - if game["is_halftime"]: - status_text = "Halftime" - else: - status_text = f"{game['period_text']} {game['clock']}".strip() - elif game["is_final"]: - status_text = game.get("period_text", "Final") - else: - status_text = f"{game['game_date']} {game['game_time']}".strip() - - status_w = int(tmp_draw.textlength(status_text, font=font_time)) - - # Score line (for live/final) - score_text = "" - if game["is_live"] or game["is_final"]: - score_text = f"{game['away_score']}-{game['home_score']}" - score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0 - - # Calculate tile width - h_pad = 4 - center_w = max(status_w, score_w, 40) - tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad - - img = Image.new("RGB", (tile_w, height), COLOR_BLACK) - draw = ImageDraw.Draw(img) - - # Paste away logo - x = h_pad - if away_logo: - logo_y = (height - away_logo.height) // 2 - img.paste(away_logo, (x, logo_y), away_logo) - x += logo_w + h_pad - - # Away team text (seed + abbr) - is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False - away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE - if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]: - away_color = COLOR_GOLD - team_text_y = (height - 6) // 2 - 5 # Upper half - self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color) - x += away_text_w + h_pad - - # Center block - center_x = x - center_mid = center_x + center_w // 2 - - # Status text (top center of center block) - status_x = center_mid - status_w // 2 - status_y = 2 - status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY - self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color) - - # Score (bottom center of center block, for live/final) - if score_text: - score_x = center_mid - score_w // 2 - score_y = height - 13 - # Upset highlighting - if game["is_final"] and game["is_upset"] and self.highlight_upsets: - score_color = COLOR_GOLD - elif game["is_live"]: - score_color = COLOR_WHITE - else: - score_color = COLOR_WHITE - self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color) - - # Date for final games (below score) - if game["is_final"] and game.get("game_date"): - date_w = int(draw.textlength(game["game_date"], font=font_detail)) - date_x = center_mid - date_w // 2 - date_y = height - 6 - self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM) - - x = center_x + center_w + h_pad - - # Home team text - is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False - home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE - if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]: - home_color = COLOR_GOLD - self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color) - x += home_text_w + h_pad - - # Paste home logo - if home_logo: - logo_y = (height - home_logo.height) // 2 - img.paste(home_logo, (x, logo_y), home_logo) - - return img - - def _create_ticker_image(self) -> None: - """Build the full scrolling ticker image from game tiles.""" - if not self.games_data: - self.ticker_image = None - if self.scroll_helper: - self.scroll_helper.clear_cache() - return - - grouped = self._process_games(self.games_data) - content_items: List[Image.Image] = [] - - # Order rounds by significance (most important first) - sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6)) - - for rnd in sorted_rounds: - games = grouped[rnd] - if not games: - continue - - # Add round separator - if self.show_round_logos and rnd: - separator = self._create_round_separator(rnd) - content_items.append(separator) - - # Add game tiles - for game in games: - tile = self._create_game_tile(game) - content_items.append(tile) - - if not content_items: - self.ticker_image = None - if self.scroll_helper: - self.scroll_helper.clear_cache() - return - - if not self.scroll_helper: - self.ticker_image = None - return - - gap_width = 16 - - # Use ScrollHelper to create the scrolling image - self.ticker_image = self.scroll_helper.create_scrolling_image( - content_items=content_items, - item_gap=gap_width, - element_gap=0, - ) - - self.total_scroll_width = self.scroll_helper.total_scroll_width - self.dynamic_duration = self.scroll_helper.get_dynamic_duration() - - self.logger.info( - f"Ticker image created: {self.ticker_image.width}px wide, " - f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s" - ) - - # ------------------------------------------------------------------ - # Plugin lifecycle - # ------------------------------------------------------------------ - - def update(self) -> None: - """Fetch and process tournament data.""" - if not self.enabled: - return - - current_time = time.time() - # Use shorter interval if live games detected - interval = 60 if self._has_live_games else self.update_interval - if current_time - self.last_update < interval: - return - - with self._update_lock: - self.last_update = current_time - - if not self._is_tournament_window(): - self.logger.debug("Outside tournament window, skipping fetch") - self.games_data = [] - self.ticker_image = None - if self.scroll_helper: - self.scroll_helper.clear_cache() - return - - try: - games = self._fetch_tournament_data() - self._has_live_games = any(g["is_live"] for g in games) - self.games_data = games - self._create_ticker_image() - self.logger.info( - f"Updated: {len(games)} games, " - f"live={self._has_live_games}" - ) - except Exception as e: - self.logger.error(f"Update error: {e}", exc_info=True) - - def display(self, force_clear: bool = False) -> None: - """Render one scroll frame.""" - if not self.enabled: - return - - if force_clear or self._display_start_time is None: - self._display_start_time = time.time() - if self.scroll_helper: - self.scroll_helper.reset_scroll() - self._end_reached_logged = False - - if not self.games_data or self.ticker_image is None: - self._display_fallback() - return - - if not self.scroll_helper: - self._display_fallback() - return - - try: - if self.loop or not self.scroll_helper.is_scroll_complete(): - self.scroll_helper.update_scroll_position() - elif not self._end_reached_logged: - self.logger.info("Scroll complete") - self._end_reached_logged = True - - visible = self.scroll_helper.get_visible_portion() - if visible is None: - self._display_fallback() - return - - self.dynamic_duration = self.scroll_helper.get_dynamic_duration() - - matrix_w = self.display_manager.matrix.width - matrix_h = self.display_manager.matrix.height - if not hasattr(self.display_manager, "image") or self.display_manager.image is None: - self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK) - self.display_manager.image.paste(visible, (0, 0)) - self.display_manager.update_display() - self.scroll_helper.log_frame_rate() - - except Exception as e: - self.logger.error(f"Display error: {e}", exc_info=True) - self._display_fallback() - - def _display_fallback(self) -> None: - w = self.display_manager.matrix.width - h = self.display_manager.matrix.height - img = Image.new("RGB", (w, h), COLOR_BLACK) - draw = ImageDraw.Draw(img) - - if self._is_tournament_window(): - text = "No games" - else: - text = "Off-season" - - text_w = int(draw.textlength(text, font=self.fonts["time"])) - text_x = (w - text_w) // 2 - text_y = (h - 8) // 2 - draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY) - - # Show March Madness logo if available - if self._march_madness_logo: - logo_y = (h - self._march_madness_logo.height) // 2 - img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo) - - self.display_manager.image = img - self.display_manager.update_display() - - # ------------------------------------------------------------------ - # Duration / cycle management - # ------------------------------------------------------------------ - - def get_display_duration(self) -> float: - current_time = time.time() - if self._cached_dynamic_duration is not None: - cache_age = current_time - self._duration_cache_time - if cache_age < 5.0: - return self._cached_dynamic_duration - - self._cached_dynamic_duration = self.dynamic_duration - self._duration_cache_time = current_time - return self.dynamic_duration - - def supports_dynamic_duration(self) -> bool: - if not self.enabled: - return False - return self.dynamic_duration_enabled - - def is_cycle_complete(self) -> bool: - if not self.supports_dynamic_duration(): - return True - if self._display_start_time is not None and self.dynamic_duration > 0: - elapsed = time.time() - self._display_start_time - if elapsed >= self.dynamic_duration: - return True - if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete(): - return True - return False - - def reset_cycle_state(self) -> None: - super().reset_cycle_state() - self._display_start_time = None - self._end_reached_logged = False - if self.scroll_helper: - self.scroll_helper.reset_scroll() - - # ------------------------------------------------------------------ - # Vegas mode - # ------------------------------------------------------------------ - - def get_vegas_content(self): - if not self.games_data: - return None - tiles = [] - for game in self.games_data: - tiles.append(self._create_game_tile(game)) - return tiles if tiles else None - - def get_vegas_content_type(self) -> str: - return "multi" - - # ------------------------------------------------------------------ - # Info / cleanup - # ------------------------------------------------------------------ - - def get_info(self) -> Dict: - info = super().get_info() - info["total_games"] = len(self.games_data) - info["has_live_games"] = self._has_live_games - info["dynamic_duration"] = self.dynamic_duration - info["tournament_window"] = self._is_tournament_window() - return info - - def cleanup(self) -> None: - self.games_data = [] - self.ticker_image = None - if self.scroll_helper: - self.scroll_helper.clear_cache() - self._team_logo_cache.clear() - if self.session: - self.session.close() - self.session = None - super().cleanup() diff --git a/plugin-repos/march-madness/manifest.json b/plugin-repos/march-madness/manifest.json deleted file mode 100644 index 3f0d16f6..00000000 --- a/plugin-repos/march-madness/manifest.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "id": "march-madness", - "name": "March Madness", - "version": "1.0.0", - "description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting", - "author": "ChuckBuilds", - "category": "sports", - "tags": [ - "ncaa", - "basketball", - "march-madness", - "tournament", - "bracket", - "scrolling" - ], - "repo": "https://github.com/ChuckBuilds/ledmatrix-plugins", - "branch": "main", - "plugin_path": "plugins/march-madness", - "versions": [ - { - "version": "1.0.0", - "ledmatrix_min": "2.0.0", - "released": "2026-02-16" - } - ], - "stars": 0, - "downloads": 0, - "last_updated": "2026-02-16", - "verified": true, - "screenshot": "", - "display_modes": [ - "march_madness" - ], - "dependencies": {}, - "entry_point": "manager.py", - "class_name": "MarchMadnessPlugin" -} diff --git a/plugin-repos/march-madness/requirements.txt b/plugin-repos/march-madness/requirements.txt deleted file mode 100644 index d1d90878..00000000 --- a/plugin-repos/march-madness/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -requests>=2.33.0 -urllib3>=2.6.3 -Pillow>=12.2.0 -pytz>=2022.1 -numpy>=1.24.0