diff --git a/src/nhl_scoreboard.py b/src/nhl_scoreboard.py deleted file mode 100644 index 052e32f4..00000000 --- a/src/nhl_scoreboard.py +++ /dev/null @@ -1,1272 +0,0 @@ -import requests -import json -import time -from pathlib import Path -from PIL import Image, ImageDraw, ImageFont -import logging -from datetime import datetime, timedelta, timezone -from typing import List, Dict, Optional, Any -import os -try: - from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -except ImportError: - # Fallback for Python < 3.9 (requires pytz install: pip install pytz) - from pytz import timezone as ZoneInfo, UnknownTimeZoneError as ZoneInfoNotFoundError - -# --- Get Project Root --- -# Assuming the script is in /path/to/project/src/ -SCRIPT_DIR = Path(__file__).parent -PROJECT_ROOT = SCRIPT_DIR.parent - -# --- Constants --- -CONFIG_FILE = PROJECT_ROOT / "config" / "config.json" # Absolute path -# Default values in case config loading fails -DEFAULT_DISPLAY_WIDTH = 64 -DEFAULT_DISPLAY_HEIGHT = 32 -DEFAULT_NHL_ENABLED = False -DEFAULT_FAVORITE_TEAMS = [] -DEFAULT_NHL_TEST_MODE = False -DEFAULT_UPDATE_INTERVAL = 60 -DEFAULT_IDLE_UPDATE_INTERVAL = 3600 # Default 1 hour (was 300) -DEFAULT_CYCLE_GAME_DURATION = 10 # Cycle duration for multiple live games -DEFAULT_LOGO_DIR = PROJECT_ROOT / "assets" / "sports" / "nhl_logos" # Absolute path -DEFAULT_TEST_DATA_FILE = PROJECT_ROOT / "test_nhl_data.json" # Absolute path -DEFAULT_OUTPUT_IMAGE_FILE = PROJECT_ROOT / "nhl_scorebug_output.png" # Absolute path -DEFAULT_TIMEZONE = "UTC" -DEFAULT_NHL_SHOW_ONLY_FAVORITES = False -RECENT_GAME_HOURS = 48 # Updated lookback window - -ESPN_NHL_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/scoreboard" - -# --- Global Config Variables --- -# These will be populated by load_config() -DISPLAY_WIDTH = DEFAULT_DISPLAY_WIDTH -DISPLAY_HEIGHT = DEFAULT_DISPLAY_HEIGHT -NHL_ENABLED = DEFAULT_NHL_ENABLED -FAVORITE_TEAMS = DEFAULT_FAVORITE_TEAMS -TEST_MODE = DEFAULT_NHL_TEST_MODE -UPDATE_INTERVAL_SECONDS = DEFAULT_UPDATE_INTERVAL -IDLE_UPDATE_INTERVAL_SECONDS = DEFAULT_IDLE_UPDATE_INTERVAL -LOGO_DIR = DEFAULT_LOGO_DIR -TEST_DATA_FILE = DEFAULT_TEST_DATA_FILE -OUTPUT_IMAGE_FILE = DEFAULT_OUTPUT_IMAGE_FILE -LOCAL_TIMEZONE = None # Will be ZoneInfo object -SHOW_ONLY_FAVORITES = DEFAULT_NHL_SHOW_ONLY_FAVORITES - -# --- Logging Setup --- -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - -# --- Configuration Loading --- -def load_config(): - """Loads configuration from config.json.""" - global DISPLAY_WIDTH, DISPLAY_HEIGHT, NHL_ENABLED, FAVORITE_TEAMS, TEST_MODE, UPDATE_INTERVAL_SECONDS, IDLE_UPDATE_INTERVAL_SECONDS, LOGO_DIR, TEST_DATA_FILE, OUTPUT_IMAGE_FILE, LOCAL_TIMEZONE, SHOW_ONLY_FAVORITES - - try: - with open(CONFIG_FILE, 'r') as f: - config_data = json.load(f) - - # Read display dimensions from the 'display' -> 'hardware' section - display_config = config_data.get("display", {}) - hardware_config = display_config.get("hardware", {}) - # Calculate total width: cols * chain_length - cols = hardware_config.get("cols", DEFAULT_DISPLAY_WIDTH / hardware_config.get("chain_length", 1) if hardware_config.get("chain_length") else DEFAULT_DISPLAY_WIDTH) # Default handling needs care - chain = hardware_config.get("chain_length", 1) - DISPLAY_WIDTH = int(cols * chain) # Ensure integer - DISPLAY_HEIGHT = hardware_config.get("rows", DEFAULT_DISPLAY_HEIGHT) - - # Load timezone - tz_string = config_data.get("timezone", DEFAULT_TIMEZONE) - try: - LOCAL_TIMEZONE = ZoneInfo(tz_string) - logging.info(f"Timezone loaded: {tz_string}") - except ZoneInfoNotFoundError: - logging.warning(f"Timezone '{tz_string}' not found. Defaulting to {DEFAULT_TIMEZONE}.") - LOCAL_TIMEZONE = ZoneInfo(DEFAULT_TIMEZONE) - - nhl_config = config_data.get("nhl_scoreboard", {}) - NHL_ENABLED = nhl_config.get("enabled", DEFAULT_NHL_ENABLED) - FAVORITE_TEAMS = nhl_config.get("favorite_teams", DEFAULT_FAVORITE_TEAMS) - TEST_MODE = nhl_config.get("test_mode", DEFAULT_NHL_TEST_MODE) - UPDATE_INTERVAL_SECONDS = nhl_config.get("update_interval_seconds", DEFAULT_UPDATE_INTERVAL) - IDLE_UPDATE_INTERVAL_SECONDS = nhl_config.get("idle_update_interval_seconds", DEFAULT_IDLE_UPDATE_INTERVAL) - SHOW_ONLY_FAVORITES = nhl_config.get("show_only_favorites", DEFAULT_NHL_SHOW_ONLY_FAVORITES) - - logging.info("Configuration loaded successfully.") - logging.info(f"Display: {DISPLAY_WIDTH}x{DISPLAY_HEIGHT}") - logging.info(f"NHL Enabled: {NHL_ENABLED}") - logging.info(f"Favorite Teams: {FAVORITE_TEAMS}") - logging.info(f"Test Mode: {TEST_MODE}") - logging.info(f"Update Interval: {UPDATE_INTERVAL_SECONDS}s (Active), {IDLE_UPDATE_INTERVAL_SECONDS}s (Idle)") - logging.info(f"Show Only Favorites: {SHOW_ONLY_FAVORITES}") - - except FileNotFoundError: - logging.warning(f"Configuration file {CONFIG_FILE} not found. Using default settings.") - except json.JSONDecodeError: - logging.error(f"Error decoding configuration file {CONFIG_FILE.name}. Using default settings.") # Use .name - except Exception as e: - logging.error(f"An unexpected error occurred loading config: {e}. Using default settings.") - -# --- Display Simulation (Uses global config) --- -# (Keep existing function, it now uses global width/height) - -# --- Helper Functions --- - -def get_espn_data(): - """Fetches scoreboard data from ESPN API or loads test data.""" - try: - response = requests.get(ESPN_NHL_SCOREBOARD_URL) - response.raise_for_status() # Raise an exception for bad status codes - data = response.json() - logging.info("Successfully fetched live data from ESPN.") - # Save live data for testing if needed - if TEST_MODE: - # Ensure TEST_DATA_FILE is used - with open(TEST_DATA_FILE, 'w') as f: - json.dump(data, f, indent=2) - logging.info(f"Saved live data to {TEST_DATA_FILE.name}") # Use .name for logging - return data - except requests.exceptions.RequestException as e: - logging.error(f"Error fetching data from ESPN: {e}") - if TEST_MODE: - logging.warning("Fetching failed, attempting to load test data.") - try: - # Ensure TEST_DATA_FILE is used - with open(TEST_DATA_FILE, 'r') as f: - data = json.load(f) - logging.info(f"Successfully loaded test data from {TEST_DATA_FILE.name}") - return data - except FileNotFoundError: - logging.error(f"Test data file {TEST_DATA_FILE.name} not found.") - return None - except json.JSONDecodeError: - logging.error(f"Error decoding test data file {TEST_DATA_FILE.name}.") - return None - return None - -def find_favorite_game(data): - """Finds the first game involving a favorite team.""" - if not data or "events" not in data: - return None - - for event in data["events"]: - competitions = event.get("competitions", []) - if not competitions: - continue - competition = competitions[0] - competitors = competition.get("competitors", []) - if len(competitors) == 2: - team1_abbr = competitors[0].get("team", {}).get("abbreviation") - team2_abbr = competitors[1].get("team", {}).get("abbreviation") - if team1_abbr in FAVORITE_TEAMS or team2_abbr in FAVORITE_TEAMS: - logging.info(f"Found favorite game: {team1_abbr} vs {team2_abbr}") - return event # Return the whole event data - logging.info("No games involving favorite teams found.") - return None - -def find_relevant_favorite_event(data): - """Finds the most relevant game for favorite teams: Live > Recent Final > Next Upcoming.""" - if not data or "events" not in data: - return None - - live_event = None - recent_final_event = None - next_upcoming_event = None - - now_utc = datetime.now(timezone.utc) - cutoff_time_utc = now_utc - timedelta(hours=RECENT_GAME_HOURS) - - for event in data["events"]: - competitions = event.get("competitions", []) - if not competitions: - continue - competition = competitions[0] - competitors = competition.get("competitors", []) - if len(competitors) == 2: - team1_abbr = competitors[0].get("team", {}).get("abbreviation") - team2_abbr = competitors[1].get("team", {}).get("abbreviation") - - is_favorite = team1_abbr in FAVORITE_TEAMS or team2_abbr in FAVORITE_TEAMS - - if is_favorite: - details = extract_game_details(event) # Use extract to get parsed date and states - if not details or not details["start_time_utc"]: - continue # Skip if details couldn't be parsed - - # --- Check Categories (Priority Order) --- - - # 1. Live Game? - if details["is_live"]: - logging.debug(f"Found live favorite game: {team1_abbr} vs {team2_abbr}") - live_event = event - break # Found the highest priority, no need to check further - - # 2. Recent Final? - if details["is_final"] and details["start_time_utc"] > cutoff_time_utc: - # Keep the *most* recent final game - if recent_final_event is None or details["start_time_utc"] > extract_game_details(recent_final_event)["start_time_utc"]: - logging.debug(f"Found potential recent final: {team1_abbr} vs {team2_abbr}") - recent_final_event = event - - # 3. Upcoming Game? - if details["is_upcoming"] and details["start_time_utc"] > now_utc: - # Keep the *soonest* upcoming game - if next_upcoming_event is None or details["start_time_utc"] < extract_game_details(next_upcoming_event)["start_time_utc"]: - logging.debug(f"Found potential upcoming game: {team1_abbr} vs {team2_abbr}") - next_upcoming_event = event - - # Return the highest priority event found - if live_event: - logging.info("Displaying live favorite game.") - return live_event - elif recent_final_event: - logging.info("Displaying recent final favorite game.") - return recent_final_event - elif next_upcoming_event: - logging.info("Displaying next upcoming favorite game.") - return next_upcoming_event - else: - logging.info("No relevant (live, recent final, or upcoming) favorite games found.") - return None - -def extract_game_details(game_event): - """Extracts relevant details for the score bug display.""" - if not game_event: - return None - - details = {} - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] # ISO 8601 format (UTC) - - # Parse game date/time - try: - details["start_time_utc"] = datetime.fromisoformat(game_date_str.replace("Z", "+00:00")) - except ValueError: - logging.warning(f"Could not parse game date: {game_date_str}") - details["start_time_utc"] = None - - home_team = next(c for c in competitors if c.get("homeAway") == "home") - away_team = next(c for c in competitors if c.get("homeAway") == "away") - - details["status_text"] = status["type"]["shortDetail"] # e.g., "7:30 - 1st" or "Final" - details["period"] = status.get("period", 0) - details["clock"] = status.get("displayClock", "0:00") - details["is_live"] = status["type"]["state"] in ("in", "halftime") # 'in' for ongoing - details["is_final"] = status["type"]["state"] == "post" - details["is_upcoming"] = status["type"]["state"] == "pre" - - details["home_abbr"] = home_team["team"]["abbreviation"] - details["home_score"] = home_team.get("score", "0") - details["home_logo_path"] = LOGO_DIR / f"{details['home_abbr']}.png" - logging.debug(f"[NHL] Constructed home logo path: {details['home_logo_path']}") - - details["away_abbr"] = away_team["team"]["abbreviation"] - details["away_score"] = away_team.get("score", "0") - details["away_logo_path"] = LOGO_DIR / f"{details['away_abbr']}.png" - logging.debug(f"[NHL] Constructed away logo path: {details['away_logo_path']}") - - # Check if logo files exist and log the results - if not details["home_logo_path"].is_file(): - logging.warning(f"Home logo not found: {details['home_logo_path']}") - details["home_logo_path"] = None - else: - logging.debug(f"Home logo file exists: {details['home_logo_path']}") - try: - # Try to open the image to verify it's valid - with Image.open(details["home_logo_path"]) as img: - logging.debug(f"[NHL] Home logo is valid image: {img.format}, size: {img.size}") - except Exception as e: - logging.error(f"Home logo file exists but is not a valid image: {e}") - details["home_logo_path"] = None - - if not details["away_logo_path"].is_file(): - logging.warning(f"Away logo not found: {details['away_logo_path']}") - details["away_logo_path"] = None - else: - logging.debug(f"Away logo file exists: {details['away_logo_path']}") - try: - # Try to open the image to verify it's valid - with Image.open(details["away_logo_path"]) as img: - logging.debug(f"[NHL] Away logo is valid image: {img.format}, size: {img.size}") - except Exception as e: - logging.error(f"Away logo file exists but is not a valid image: {e}") - details["away_logo_path"] = None - - return details - - except (KeyError, IndexError, StopIteration) as e: - logging.error(f"Error parsing game details: {e} - Data: {game_event}") - return None - -def create_scorebug_image(game_details): - """Creates an image simulating the NHL score bug.""" - if not game_details: - # Create a blank or placeholder image if no game data - img = Image.new('RGB', (DISPLAY_WIDTH, DISPLAY_HEIGHT), color='black') - draw = ImageDraw.Draw(img) - try: - font = ImageFont.truetype("arial.ttf", 10) # Adjust font path/size - except IOError: - font = ImageFont.load_default() - draw.text((5, 10), "No game data", font=font, fill='white') - return img - - # --- Basic Layout --- - # This is highly dependent on your desired look and display size. - # Adjust positions, sizes, fonts accordingly. - img = Image.new('RGB', (DISPLAY_WIDTH, DISPLAY_HEIGHT), color='black') - draw = ImageDraw.Draw(img) - - try: - # Use a common font or specify path if needed - score_font = ImageFont.truetype("arial.ttf", 12) - time_font = ImageFont.truetype("arial.ttf", 10) - team_font = ImageFont.truetype("arial.ttf", 8) - status_font = ImageFont.truetype("arial.ttf", 9) # For Final/Upcoming status - except IOError: - logging.warning("Arial font not found, using default.") - score_font = ImageFont.load_default() - time_font = ImageFont.load_default() - team_font = ImageFont.load_default() - - - # --- Element Positions (Example - Needs heavy tuning) --- - away_logo_pos = (2, 2) - away_score_pos = (36, 2) # Right of away logo - home_logo_pos = (DISPLAY_WIDTH - 34, 2) # Positioned from the right - home_score_pos = (DISPLAY_WIDTH - 34 - 25, 2) # Left of home logo - - time_pos = (DISPLAY_WIDTH // 2, 2) # Centered top - period_pos = (DISPLAY_WIDTH // 2, 15) # Centered below time - - logo_size = (30, 30) # Max logo size - - # --- Draw Away Team --- - if game_details["away_logo_path"]: - try: - away_logo_rgba = Image.open(game_details["away_logo_path"]).convert("RGBA") - # Resize and reassign, instead of in-place thumbnail - away_logo_rgba = away_logo_rgba.resize(logo_size, Image.Resampling.LANCZOS) - # --- Debugging --- - logging.debug(f"[NHL Debug] Away upcoming Type after resize: {type(away_logo_rgba)}") - logging.debug(f"[NHL Debug] Away upcoming Has width attr: {hasattr(away_logo_rgba, 'width')}") - logging.debug(f"[NHL Debug] Away upcoming Has height attr: {hasattr(away_logo_rgba, 'height')}") - # --- End Debugging --- - - paste_x = away_logo_pos[0] - paste_y = (DISPLAY_HEIGHT - away_logo_rgba.height) // 2 - - # Manual pixel paste (robust alternative) - try: - for x in range(away_logo_rgba.width): - for y in range(away_logo_rgba.height): - r, g, b, a = away_logo_rgba.getpixel((x, y)) - if a > 128: # Check alpha threshold - target_x = paste_x + x - target_y = paste_y + y - # Ensure target pixel is within image bounds - if 0 <= target_x < img.width and 0 <= target_y < img.height: - img.putpixel((target_x, target_y), (r, g, b)) - except AttributeError as ae: - logging.error(f"[NHL Debug] Away upcoming AttributeError accessing width/height in loop: {ae}") - - except Exception as e: - logging.error(f"Error loading/pasting away logo {game_details['away_logo_path']}: {e}") - # Draw placeholder text if logo fails - draw.text(away_logo_pos, game_details["away_abbr"], font=team_font, fill="white") - else: - # Draw abbreviation if no logo path - draw.text(away_logo_pos, game_details["away_abbr"], font=team_font, fill="white") - - draw.text(away_score_pos, str(game_details["away_score"]), font=score_font, fill='white') - - # --- Draw Home Team --- - if game_details["home_logo_path"]: - try: - home_logo_rgba = Image.open(game_details["home_logo_path"]).convert("RGBA") - # Resize and reassign, instead of in-place thumbnail - home_logo_rgba = home_logo_rgba.resize(logo_size, Image.Resampling.LANCZOS) - # --- Debugging --- - logging.debug(f"[NHL Debug] Home upcoming Type after resize: {type(home_logo_rgba)}") - logging.debug(f"[NHL Debug] Home upcoming Has width attr: {hasattr(home_logo_rgba, 'width')}") - logging.debug(f"[NHL Debug] Home upcoming Has height attr: {hasattr(home_logo_rgba, 'height')}") - # --- End Debugging --- - - paste_x = home_logo_pos[0] - paste_y = (DISPLAY_HEIGHT - home_logo_rgba.height) // 2 - - # Manual pixel paste (robust alternative) - try: - for x in range(home_logo_rgba.width): - for y in range(home_logo_rgba.height): - r, g, b, a = home_logo_rgba.getpixel((x, y)) - if a > 128: # Check alpha threshold - target_x = paste_x + x - target_y = paste_y + y - # Ensure target pixel is within image bounds - if 0 <= target_x < img.width and 0 <= target_y < img.height: - img.putpixel((target_x, target_y), (r, g, b)) - except AttributeError as ae: - logging.error(f"[NHL Debug] Home upcoming AttributeError accessing width/height in loop: {ae}") - - except Exception as e: - logging.error(f"Error loading/pasting home logo {game_details['home_logo_path']}: {e}") - draw.text(home_logo_pos, game_details["home_abbr"], font=team_font, fill="white") - else: - draw.text((home_logo_pos[0] + 5, home_logo_pos[1] + 5), game_details["home_abbr"], font=team_font, fill="white", anchor="lt") - - - draw.text(home_score_pos, str(game_details["home_score"]), font=score_font, fill='white') - - # --- Draw Time and Period / Status --- - if game_details["is_live"]: - period_str = f"{game_details['period']}{'st' if game_details['period']==1 else 'nd' if game_details['period']==2 else 'rd' if game_details['period']==3 else 'th'}".upper() if game_details['period'] > 0 else "OT" if game_details['period'] > 3 else "" # Basic period formatting - # Check for Intermission specifically (adjust key if needed based on API) - status_name = game_details.get("status_type_name", "") # Need status name if possible - if status_name == "STATUS_HALFTIME": - # if "intermission" in game_details["status_text"].lower(): # Alternative check - period_str = "INTER" # Or "INT" - game_details["clock"] = "" # No clock during intermission - - draw.text(time_pos, game_details["clock"], font=time_font, fill='yellow', anchor="mt") # anchor middle-top - draw.text(period_pos, period_str, font=time_font, fill='yellow', anchor="mt") - - elif game_details["is_final"]: - # Display Final score status - draw.text(time_pos, "FINAL", font=status_font, fill='red', anchor="mt") - # Optionally add final period if available (e.g., "FINAL/OT") - period_str = f"/{game_details['period']}{'st' if game_details['period']==1 else 'nd' if game_details['period']==2 else 'rd' if game_details['period']==3 else 'th'}".upper() if game_details['period'] > 3 else "/OT" if game_details['period'] > 3 else "" - if game_details['period'] > 3: - draw.text(period_pos, f"OT{game_details['period'] - 3 if game_details['period'] < 7 else ''}", font=time_font, fill='red', anchor="mt") # Display OT period number - elif game_details['period'] == 0: # Check if shootout indicated differently? - draw.text(period_pos, "SO", font=time_font, fill='red', anchor="mt") - - elif game_details["is_upcoming"] and game_details["start_time_utc"]: - # Display Upcoming game time/date - start_local = game_details["start_time_utc"].astimezone(LOCAL_TIMEZONE) - now_local = datetime.now(LOCAL_TIMEZONE) - today_local = now_local.date() - start_date_local = start_local.date() - - if start_date_local == today_local: - date_str = "Today" - elif start_date_local == today_local + timedelta(days=1): - date_str = "Tomorrow" - else: - date_str = start_local.strftime("%a %b %d") # e.g., "Mon Jan 15" - - time_str = start_local.strftime("%I:%M %p").lstrip('0') # e.g., "7:30 PM" - - draw.text(time_pos, date_str, font=status_font, fill='cyan', anchor="mt") - draw.text(period_pos, time_str, font=time_font, fill='cyan', anchor="mt") - - else: - # Fallback for other statuses (Scheduled, Postponed etc.) - draw.text(time_pos, game_details["status_text"], font=time_font, fill='grey', anchor="mt") - - return img - -# --- Main Loop --- -def main(): - """Main execution loop.""" - load_config() # Load config first - - if not NHL_ENABLED: - logging.info("NHL Scoreboard is disabled in the configuration. Exiting.") - return - - # --- Matrix Initialization --- - # options = RGBMatrixOptions() - - # Load options from config (with fallbacks just in case) - # Note: These need to match the attributes of RGBMatrixOptions - # try: - # # Reload config data here specifically for matrix options - # with open(CONFIG_FILE, 'r') as f: - # config_data = json.load(f) - # display_config = config_data.get("display", {}) - # hardware_config = display_config.get("hardware", {}) - # runtime_config = display_config.get("runtime", {}) - - # options.rows = hardware_config.get("rows", 32) - # options.cols = hardware_config.get("cols", 64) # Use single panel width - # options.chain_length = hardware_config.get("chain_length", 1) - # options.parallel = hardware_config.get("parallel", 1) - # options.brightness = hardware_config.get("brightness", 60) - # options.hardware_mapping = hardware_config.get("hardware_mapping", "adafruit-hat-pwm") - # options.scan_mode = 1 if hardware_config.get("scan_mode", "progressive").lower() == "progressive" else 0 # 0 for interlaced - # options.pwm_bits = hardware_config.get("pwm_bits", 11) - # options.pwm_dither_bits = hardware_config.get("pwm_dither_bits", 0) - # options.pwm_lsb_nanoseconds = hardware_config.get("pwm_lsb_nanoseconds", 130) - # options.disable_hardware_pulsing = hardware_config.get("disable_hardware_pulsing", False) - # options.inverse_colors = hardware_config.get("inverse_colors", False) - # options.show_refresh_rate = hardware_config.get("show_refresh_rate", False) - # options.limit_refresh_rate_hz = hardware_config.get("limit_refresh_rate_hz", 0) # 0 for no limit - - # # From runtime config - # options.gpio_slowdown = runtime_config.get("gpio_slowdown", 2) - - # # Set other options if they exist in your config (e.g., led_rgb_sequence, pixel_mapper_config, row_addr_type, multiplexing, panel_type) - # if "led_rgb_sequence" in hardware_config: - # options.led_rgb_sequence = hardware_config["led_rgb_sequence"] - # # Add other specific options as needed - - # logging.info("RGBMatrix Options configured from config file.") - - # except Exception as e: - # logging.error(f"Error reading matrix options from config: {e}. Using default options.") - # # Use some safe defaults if config loading fails badly - # options.rows = 32 - # options.cols = 64 - # options.chain_length = 1 - # options.parallel = 1 - # options.hardware_mapping = 'adafruit-hat-pwm' - # options.gpio_slowdown = 2 - - # # Create matrix instance - # try: - # matrix = RGBMatrix(options = options) - # logging.info("RGBMatrix initialized successfully.") - # except Exception as e: - # logging.error(f"Failed to initialize RGBMatrix: {e}") - # logging.error("Check hardware connections, configuration, and ensure script is run with sufficient permissions (e.g., sudo or user in gpio group).") - # return # Exit if matrix cannot be initialized - - logging.info("Starting NHL Scoreboard...") - # Logging moved to load_config - # logging.info(f"Favorite teams: {FAVORITE_TEAMS}") - logging.info(f"Checking logos in: {LOGO_DIR.resolve()}") - if not LOGO_DIR.is_dir(): - # Try creating the directory if it doesn't exist - logging.warning(f"Logo directory {LOGO_DIR} not found. Attempting to create it.") - try: - LOGO_DIR.mkdir(parents=True, exist_ok=True) - logging.info(f"Successfully created logo directory: {LOGO_DIR}") - except Exception as e: - logging.error(f"Failed to create logo directory {LOGO_DIR}: {e}. Please create it manually.") - return # Exit if we can't create it - - while True: - logging.debug("Fetching latest data...") - data = get_espn_data() - - game_event = None # Initialize game_event - if data: - # Find the most relevant game (Live > Recent Final > Upcoming) for favorites - game_event = find_relevant_favorite_event(data) - - # Fallback logic only if show_only_favorites is false - if not game_event and not SHOW_ONLY_FAVORITES and data.get("events"): - logging.debug("No relevant favorite game found, and show_only_favorites is false. Looking for any live/scheduled game.") - # Find *any* live game if no favorite is relevant - live_games = [e for e in data["events"] if e.get("competitions", [{}])[0].get("status", {}).get("type", {}).get("state") == "in"] - if live_games: - logging.info("No favorite game relevant, showing first available live game.") - game_event = live_games[0] - elif data["events"]: # Or just show the first game listed if none are live - logging.info("No favorite or live games, showing first scheduled/final game.") - game_event = data["events"][0] - elif not game_event and SHOW_ONLY_FAVORITES: - logging.info("No relevant favorite game found, and show_only_favorites is true. Skipping display.") - # game_event remains None - - # Proceed only if we found an event (either favorite or fallback) - if game_event: - game_details = extract_game_details(game_event) - scorebug_image = create_scorebug_image(game_details) - else: - # Handle case where no event should be shown (e.g., show_only_favorites is true and none found) - scorebug_image = create_scorebug_image(None) # Create the 'No game data' image - - # --- Display Output --- - try: - # Convert Pillow image to RGB format expected by matrix - rgb_image = scorebug_image.convert('RGB') - - # Send image to matrix - # matrix.SetImage(rgb_image) - logging.debug("Image sent to matrix.") - - # --- Optional: Using a Canvas for smoother updates --- - # canvas.SetImage(rgb_image) - # canvas = matrix.SwapOnVSync(canvas) - # logging.debug("Canvas swapped on VSync.") - - except Exception as e: - logging.error(f"Failed to set image on matrix: {e}") - - # Save simulation image (optional now) - try: - scorebug_image.save(OUTPUT_IMAGE_FILE) - logging.info(f"Scorebug image saved to {OUTPUT_IMAGE_FILE.name}") - except Exception as e: - logging.error(f"Failed to save scorebug image: {e}") - - else: - logging.warning("No data received, skipping update cycle.") - # Optionally display an error message on the matrix - error_image = create_scorebug_image(None) # Or a custom error message - try: - # matrix.SetImage(error_image.convert('RGB')) - error_image.save(OUTPUT_IMAGE_FILE) # Also save error state to file - except Exception as e: - logging.error(f"Failed to set/save error image: {e}") - - logging.debug(f"Sleeping for {UPDATE_INTERVAL_SECONDS} seconds...") - time.sleep(UPDATE_INTERVAL_SECONDS) - - -if __name__ == "__main__": - main() - -class NHLScoreboardManager: - def __init__(self, config: dict, display_manager): - """Initializes the NHLScoreboardManager.""" - self.display_manager = display_manager - self.config = config - self.nhl_config = config.get("nhl_scoreboard", {}) - self.is_enabled = self.nhl_config.get("enabled", DEFAULT_NHL_ENABLED) - - # Load settings - self.favorite_teams = self.nhl_config.get("favorite_teams", DEFAULT_FAVORITE_TEAMS) - self.test_mode = self.nhl_config.get("test_mode", DEFAULT_NHL_TEST_MODE) - self.update_interval = self.nhl_config.get("update_interval_seconds", DEFAULT_UPDATE_INTERVAL) - self.idle_update_interval = self.nhl_config.get("idle_update_interval_seconds", DEFAULT_IDLE_UPDATE_INTERVAL) - self.cycle_duration = self.nhl_config.get("cycle_game_duration_seconds", DEFAULT_CYCLE_GAME_DURATION) - self.show_only_favorites = self.nhl_config.get("show_only_favorites", DEFAULT_NHL_SHOW_ONLY_FAVORITES) - self.logo_dir = DEFAULT_LOGO_DIR - self.test_data_file = DEFAULT_TEST_DATA_FILE - - # Timezone handling - tz_string = config.get("timezone", DEFAULT_TIMEZONE) - try: - self.local_timezone = ZoneInfo(tz_string) - except ZoneInfoNotFoundError: - logging.warning(f"[NHL] Timezone '{tz_string}' not found. Defaulting to {DEFAULT_TIMEZONE}.") - self.local_timezone = ZoneInfo(DEFAULT_TIMEZONE) - - # State variables - self.last_data_fetch_time = 0 - self.last_logic_update_time = 0 - self.relevant_events: List[Dict[str, Any]] = [] # ALL relevant events (live, upcoming today, recent final) - self.current_event_index: int = 0 - self.last_cycle_time: float = 0 - self.current_display_details: Optional[Dict[str, Any]] = None - self.needs_redraw: bool = True - - # Get display dimensions - if hasattr(display_manager, 'width') and hasattr(display_manager, 'height'): - self.display_width = display_manager.width - self.display_height = display_manager.height - else: # Fallback - display_config = config.get("display", {}) - hardware_config = display_config.get("hardware", {}) - cols = hardware_config.get("cols", 64) - chain = hardware_config.get("chain_length", 1) - self.display_width = int(cols * chain) - self.display_height = hardware_config.get("rows", 32) - - self.fonts = self._load_fonts() - self._log_initial_settings() - - def _log_initial_settings(self): - logging.info("[NHL] NHLScoreboardManager Initialized.") - logging.info(f"[NHL] Enabled: {self.is_enabled}") - logging.info(f"[NHL] Favorite Teams: {self.favorite_teams}") - logging.info(f"[NHL] Test Mode: {self.test_mode}") - logging.info(f"[NHL] Show Only Favorites: {self.show_only_favorites}") - logging.info(f"[NHL] Update Interval: {self.update_interval}s (Active), {self.idle_update_interval}s (Idle)") - logging.info(f"[NHL] Live Game Cycle Duration: {self.cycle_duration}s") - logging.info(f"[NHL] Display Size: {self.display_width}x{self.display_height}") - - def _load_fonts(self): - """Loads fonts used by the scoreboard.""" - fonts = {} - # Basic font loading, adjust paths/sizes as needed - try: - fonts['score'] = ImageFont.truetype("arial.ttf", 12) - fonts['time'] = ImageFont.truetype("arial.ttf", 10) - fonts['team'] = ImageFont.truetype("arial.ttf", 8) - fonts['status'] = ImageFont.truetype("arial.ttf", 9) - fonts['upcoming_main'] = ImageFont.truetype("arial.ttf", 10) # Font for TODAY/TIME - fonts['upcoming_vs'] = ImageFont.truetype("arial.ttf", 9) # Font for VS - fonts['placeholder'] = ImageFont.truetype("arial.ttf", 10) # Font for No Game msg - fonts['default'] = fonts['time'] - except IOError: - logging.warning("[NHL] Arial font 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['upcoming_main'] = ImageFont.load_default() - fonts['upcoming_vs'] = ImageFont.load_default() - fonts['placeholder'] = ImageFont.load_default() - fonts['default'] = ImageFont.load_default() - return fonts - - def _extract_game_details(self, game_event): - """Extracts relevant details for the score bug display.""" - if not game_event: - return None - - details = {} - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] # ISO 8601 format (UTC) - - # Parse game date/time - try: - details["start_time_utc"] = datetime.fromisoformat(game_date_str.replace("Z", "+00:00")) - except ValueError: - logging.warning(f"[NHL] Could not parse game date: {game_date_str}") - details["start_time_utc"] = None - - home_team = next(c for c in competitors if c.get("homeAway") == "home") - away_team = next(c for c in competitors if c.get("homeAway") == "away") - - # Extract status name if possible for better logic later - details["status_type_name"] = status.get("type", {}).get("name") # e.g., STATUS_IN_PROGRESS - - details["status_text"] = status["type"]["shortDetail"] # e.g., "7:30 - 1st" or "Final" - details["period"] = status.get("period", 0) - details["clock"] = status.get("displayClock", "0:00") - details["is_live"] = status["type"]["state"] in ("in", "halftime") # 'in' for ongoing - details["is_final"] = status["type"]["state"] == "post" - details["is_upcoming"] = status["type"]["state"] == "pre" - - details["home_abbr"] = home_team["team"]["abbreviation"] - details["home_score"] = home_team.get("score", "0") - details["home_logo_path"] = self.logo_dir / f"{details['home_abbr']}.png" - logging.debug(f"[NHL] Constructed home logo path: {details['home_logo_path']}") - - details["away_abbr"] = away_team["team"]["abbreviation"] - details["away_score"] = away_team.get("score", "0") - details["away_logo_path"] = self.logo_dir / f"{details['away_abbr']}.png" - logging.debug(f"[NHL] Constructed away logo path: {details['away_logo_path']}") - - # Check if logo files exist and log the results - if not details["home_logo_path"].is_file(): - logging.warning(f"[NHL] Home logo not found: {details['home_logo_path']}") - details["home_logo_path"] = None - else: - logging.debug(f"[NHL] Home logo file exists: {details['home_logo_path']}") - try: - # Try to open the image to verify it's valid - with Image.open(details["home_logo_path"]) as img: - logging.debug(f"[NHL] Home logo is valid image: {img.format}, size: {img.size}") - except Exception as e: - logging.error(f"[NHL] Home logo file exists but is not a valid image: {e}") - details["home_logo_path"] = None - - if not details["away_logo_path"].is_file(): - logging.warning(f"[NHL] Away logo not found: {details['away_logo_path']}") - details["away_logo_path"] = None - else: - logging.debug(f"[NHL] Away logo file exists: {details['away_logo_path']}") - try: - # Try to open the image to verify it's valid - with Image.open(details["away_logo_path"]) as img: - logging.debug(f"[NHL] Away logo is valid image: {img.format}, size: {img.size}") - except Exception as e: - logging.error(f"[NHL] Away logo file exists but is not a valid image: {e}") - details["away_logo_path"] = None - - return details - - except (KeyError, IndexError, StopIteration, TypeError) as e: - logging.error(f"[NHL] Error parsing game details: {e} - Data: {game_event}") - return None - - def _fetch_data_for_dates(self, dates): - """Fetches and combines data for a list of dates (YYYYMMDD).""" - combined_events = [] - event_ids = set() - success = False - - for date_str in dates: - data = self._fetch_data(date_str=date_str) - if data and "events" in data: - success = True # Mark success if at least one fetch works - for event in data["events"]: - if event["id"] not in event_ids: - combined_events.append(event) - event_ids.add(event["id"]) - time.sleep(0.1) # Small delay between API calls - - if success: - self.last_data_fetch_time = time.time() # Update time only if some data was fetched - - # Sort combined events by date just in case - try: - combined_events.sort(key=lambda x: datetime.fromisoformat(x["date"].replace("Z", "+00:00"))) - except (KeyError, ValueError): - logging.warning("[NHL] Could not sort combined events by date during fetch.") - - logging.debug(f"[NHL] Fetched and combined {len(combined_events)} events for dates: {dates}") - return combined_events - - def _fetch_data(self, date_str: str = None) -> dict: - """Internal helper to fetch scoreboard data for one specific date or default.""" - url = ESPN_NHL_SCOREBOARD_URL - params = {} - log_prefix = "[NHL]" - fetch_description = "default (today's)" - if date_str: - params['dates'] = date_str - log_prefix = f"[NHL {date_str}]" - fetch_description = f"date {date_str}" - - logging.info(f"{log_prefix} Fetching data for {fetch_description}.") - - try: - response = requests.get(url, params=params, timeout=10) # Added timeout - response.raise_for_status() - data = response.json() - logging.info(f"{log_prefix} Successfully fetched data.") - - # Save test data only when fetching default/today's view successfully - if not date_str and self.test_mode: - try: - with open(self.test_data_file, 'w') as f: - json.dump(data, f, indent=2) - logging.info(f"[NHL] Saved today's live data to {self.test_data_file.name}") - except Exception as e: - logging.error(f"[NHL] Failed to save test data: {e}") - - return data - except requests.exceptions.RequestException as e: - logging.error(f"{log_prefix} Error fetching data from ESPN: {e}") - # Try loading test data only if fetching today's default failed - if not date_str and self.test_mode: - logging.warning("[NHL] Fetching default failed, attempting to load test data.") - try: - with open(self.test_data_file, 'r') as f: - test_data = json.load(f) - logging.info(f"[NHL] Successfully loaded test data from {self.test_data_file.name}") - return test_data - except Exception as load_e: - logging.error(f"[NHL] Failed to load test data: {load_e}") - return None # Return None if fetch fails - - def _find_events_by_criteria(self, all_events: List[Dict[str, Any]], - is_live: bool = False, is_upcoming_today: bool = False, - is_recent_final: bool = False) -> List[Dict[str, Any]]: - """Helper to find favorite team events matching specific criteria.""" - matches = [] - now_utc = datetime.now(timezone.utc) - today_local = datetime.now(self.local_timezone).date() - cutoff_time_utc = now_utc - timedelta(hours=RECENT_GAME_HOURS) - - for event in all_events: - competitors = event.get("competitions", [{}])[0].get("competitors", []) - if len(competitors) == 2: - team1_abbr = competitors[0].get("team", {}).get("abbreviation") - team2_abbr = competitors[1].get("team", {}).get("abbreviation") - is_favorite = team1_abbr in self.favorite_teams or team2_abbr in self.favorite_teams - # Skip non-favorites ONLY if show_only_favorites is true - if not is_favorite and self.show_only_favorites: - continue - - # Apply criteria ONLY if the event involves a favorite team - if is_favorite: - details = self._extract_game_details(event) - if not details: continue - - if is_live and details["is_live"]: - matches.append(event) - continue - - if is_upcoming_today and details["is_upcoming"] and details["start_time_utc"]: - start_local_date = details["start_time_utc"].astimezone(self.local_timezone).date() - if start_local_date == today_local and details["start_time_utc"] > now_utc: - matches.append(event) - continue - - if is_recent_final and details["is_final"] and details["start_time_utc"]: - if details["start_time_utc"] > cutoff_time_utc: - matches.append(event) - continue - # --- NOTE: No fallback logic here yet for non-favorites if show_only_favorites is false --- - - # Sort results appropriately - if is_live: - pass # Keep API order for now - elif is_upcoming_today: - matches.sort(key=lambda x: self._extract_game_details(x).get("start_time_utc") or datetime.max.replace(tzinfo=timezone.utc)) # Sort by soonest - elif is_recent_final: - matches.sort(key=lambda x: self._extract_game_details(x).get("start_time_utc") or datetime.min.replace(tzinfo=timezone.utc), reverse=True) # Sort by most recent - - return matches - - def update(self): - """Determines the list of events to cycle through based on priority.""" - if not self.is_enabled: - if self.relevant_events: # Clear if disabled - self.relevant_events = [] - self.current_event_index = 0 - self.needs_redraw = True - return - - now = time.time() - - # Determine check interval: Use faster interval ONLY if a live game is active. - is_live_game_active = False - if self.relevant_events and self.current_display_details: - # Check the currently displayed event first for efficiency - if self.current_display_details.get("is_live"): - is_live_game_active = True - else: - # If current isn't live, check the whole list (less common) - for event in self.relevant_events: - details = self._extract_game_details(event) - if details and details.get("is_live"): - is_live_game_active = True - break # Found a live game - - check_interval = self.update_interval if is_live_game_active else self.idle_update_interval - - if now - self.last_logic_update_time < check_interval: - return - - logging.info(f"[NHL] Running update logic (Check Interval: {check_interval}s)") - self.last_logic_update_time = now - - # Fetch data if interval passed - all_events: List[Dict] = [] - if now - self.last_data_fetch_time > self.update_interval: - today_local = datetime.now(self.local_timezone) - dates_to_fetch = { - (today_local - timedelta(days=2)).strftime('%Y%m%d'), - (today_local - timedelta(days=1)).strftime('%Y%m%d'), - today_local.strftime('%Y%m%d'), - (today_local + timedelta(days=1)).strftime('%Y%m%d') - } - all_events = self._fetch_data_for_dates(sorted(list(dates_to_fetch))) - if not all_events: - logging.warning("[NHL] No events found after fetching.") - # Decide how to handle fetch failure - clear existing or keep stale? - # Let's clear for now if fetch fails entirely - if self.relevant_events: # If we previously had events - self.relevant_events = [] - self.current_event_index = 0 - self.current_display_details = None - self.needs_redraw = True - return # Stop update if fetch failed - else: - # Data not stale enough for API call, but logic check proceeds. - # How do we get all_events? Need to cache it? - # --> Problem: Can't re-evaluate criteria without fetching. - # --> Solution: Always fetch data when logic runs, but use interval for logic run itself. - # --> Let's revert: Fetch data based on fetch interval, run logic based on logic interval. - # --> Requires caching the fetched `all_events`. Let's add a cache. - - # --- Let's stick to the previous version's combined logic/fetch interval for now --- - # --- and focus on combining the event lists --- - - today_local = datetime.now(self.local_timezone) - dates_to_fetch = { - (today_local - timedelta(days=2)).strftime('%Y%m%d'), - (today_local - timedelta(days=1)).strftime('%Y%m%d'), - today_local.strftime('%Y%m%d'), - (today_local + timedelta(days=1)).strftime('%Y%m%d') - } - all_events = self._fetch_data_for_dates(sorted(list(dates_to_fetch))) - if not all_events: - logging.warning("[NHL] No events found after fetching.") - if self.relevant_events: - self.relevant_events = [] - self.current_event_index = 0 - self.current_display_details = None - self.needs_redraw = True - return - - - # --- Determine Combined List of Relevant Events --- - live_events = self._find_events_by_criteria(all_events, is_live=True) - upcoming_events = self._find_events_by_criteria(all_events, is_upcoming_today=True) - recent_final_events = self._find_events_by_criteria(all_events, is_recent_final=True) - - new_relevant_events_combined = [] - added_ids = set() - - # Add in order of priority, avoiding duplicates - for event in live_events + upcoming_events + recent_final_events: - event_id = event.get("id") - if event_id and event_id not in added_ids: - new_relevant_events_combined.append(event) - added_ids.add(event_id) - - # --- TODO: Implement Fallback Logic if show_only_favorites is False --- - if not new_relevant_events_combined and not self.show_only_favorites: - logging.debug("[NHL] No relevant favorite games, show_only_favorites=false. Fallback needed.") - # Add logic here to find non-favorite games based on priority if desired - pass # No fallback implemented yet - - # --- Compare and Update State --- - old_event_ids = {e.get("id") for e in self.relevant_events if e} - new_event_ids = {e.get("id") for e in new_relevant_events_combined if e} - - if old_event_ids != new_event_ids: - logging.info(f"[NHL] Relevant events changed. New count: {len(new_relevant_events_combined)}") - self.relevant_events = new_relevant_events_combined - self.current_event_index = 0 - self.last_cycle_time = time.time() # Reset cycle timer - # Load details for the first item immediately - self.current_display_details = self._extract_game_details(self.relevant_events[0] if self.relevant_events else None) - self.needs_redraw = True - elif self.relevant_events: # List content is same, check if details of *current* item changed - current_event_in_list = self.relevant_events[self.current_event_index] - new_details_for_current = self._extract_game_details(current_event_in_list) - # Compare specifically relevant fields (score, clock, period, status) - if self._details_changed_significantly(self.current_display_details, new_details_for_current): - logging.debug(f"[NHL] Details updated for current event index {self.current_event_index}") - self.current_display_details = new_details_for_current - self.needs_redraw = True - else: - logging.debug("[NHL] No significant change in details for current event.") - # else: No relevant events before or after - - - def _details_changed_significantly(self, old_details, new_details) -> bool: - """Compare specific fields to see if a redraw is needed.""" - if old_details is None and new_details is None: return False - if old_details is None or new_details is None: return True # Changed from something to nothing or vice-versa - - fields_to_check = ['home_score', 'away_score', 'clock', 'period', 'is_live', 'is_final', 'is_upcoming'] - for field in fields_to_check: - if old_details.get(field) != new_details.get(field): - return True - return False - - - def display(self, force_clear: bool = False): - """Generates and displays the current frame, handling cycling.""" - if not self.is_enabled: - return - - now = time.time() - redraw_this_frame = force_clear or self.needs_redraw - - # --- Handle Cycling --- - if len(self.relevant_events) > 1: # Cycle if more than one relevant event exists - if now - self.last_cycle_time > self.cycle_duration: - self.current_event_index = (self.current_event_index + 1) % len(self.relevant_events) - # Get details for the *new* index - self.current_display_details = self._extract_game_details(self.relevant_events[self.current_event_index]) - self.last_cycle_time = now - redraw_this_frame = True # Force redraw on cycle - logging.debug(f"[NHL] Cycling to event index {self.current_event_index}") - elif self.current_display_details is None: # Ensure details loaded for index 0 initially - self.current_display_details = self._extract_game_details(self.relevant_events[0]) - redraw_this_frame = True # Force redraw if details were missing - elif len(self.relevant_events) == 1: - # If only one event, make sure its details are loaded - if self.current_display_details is None: - self.current_display_details = self._extract_game_details(self.relevant_events[0]) - redraw_this_frame = True - else: # No relevant events - if self.current_display_details is not None: # Clear details if list is empty now - self.current_display_details = None - redraw_this_frame = True - - - # --- Generate and Display Frame --- - if not redraw_this_frame: - return - - logging.debug(f"[NHL] Generating frame (Index: {self.current_event_index})") - frame = self._create_frame(self.current_display_details) # Pass the specific details - - try: - if hasattr(self.display_manager, 'display_image'): - self.display_manager.display_image(frame) - elif hasattr(self.display_manager, 'matrix') and hasattr(self.display_manager.matrix, 'SetImage'): - self.display_manager.matrix.SetImage(frame.convert('RGB')) - else: - logging.error("[NHL] DisplayManager missing display_image or matrix.SetImage method.") - - self.needs_redraw = False - - except Exception as e: - logging.error(f"[NHL] Error displaying frame via DisplayManager: {e}") - - - def _create_frame(self, game_details: Optional[Dict[str, Any]]) -> Image.Image: - """Creates a Pillow image frame based on game details.""" - # This method now simply renders the layout based on the details passed in - img = Image.new('RGB', (self.display_width, self.display_height), color='black') - draw = ImageDraw.Draw(img) - - if not game_details: - self._draw_placeholder_layout(draw) - elif game_details.get("is_upcoming"): - self._draw_upcoming_layout(draw, game_details) - elif game_details.get("is_live") or game_details.get("is_final"): - self._draw_scorebug_layout(draw, game_details) - else: # Fallback/Other states - self._draw_placeholder_layout(draw, msg=game_details.get("status_text", "NHL Status")) # Show status text - - return img - - def _draw_placeholder_layout(self, draw: ImageDraw.ImageDraw, msg: str = "No NHL Games"): - """Draws the 'No NHL Games' or other placeholder message.""" - font = self.fonts.get('placeholder', ImageFont.load_default()) - bbox = draw.textbbox((0,0), msg, font=font) - text_width = bbox[2] - bbox[0] - text_height = bbox[3] - bbox[1] - draw.text(((self.display_width - text_width) // 2, (self.display_height - text_height) // 2), - msg, font=font, fill='grey') - - def _draw_upcoming_layout(self, draw: ImageDraw.ImageDraw, game_details: Dict[str, Any]): - """Draws the upcoming game layout with team logos and game time.""" - try: - # Load and resize logos - home_logo_path = os.path.join(self.logo_dir, f"{game_details['home_abbr']}.png") - away_logo_path = os.path.join(self.logo_dir, f"{game_details['away_abbr']}.png") - - # Load and process home logo - home_logo = None - if os.path.exists(home_logo_path): - try: - home_logo = Image.open(home_logo_path) - if home_logo.mode != 'RGBA': - home_logo = home_logo.convert('RGBA') - # Resize maintaining aspect ratio - max_size = min(int(self.display_width / 3), int(self.display_height / 2)) - home_logo.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) - except Exception as e: - logging.error(f"Error loading home logo {home_logo_path}: {e}") - - # Load and process away logo - away_logo = None - if os.path.exists(away_logo_path): - try: - away_logo = Image.open(away_logo_path) - if away_logo.mode != 'RGBA': - away_logo = away_logo.convert('RGBA') - # Resize maintaining aspect ratio - max_size = min(int(self.display_width / 3), int(self.display_height / 2)) - away_logo.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) - except Exception as e: - logging.error(f"Error loading away logo {away_logo_path}: {e}") - - # Calculate positions - width = self.display_width - height = self.display_height - - # Draw team logos - if home_logo: - home_x = width // 4 - home_logo.width // 2 - home_y = height // 4 - home_logo.height // 2 - draw.im.paste(home_logo, (home_x, home_y), home_logo) - - if away_logo: - away_x = width // 4 - away_logo.width // 2 - away_y = 3 * height // 4 - away_logo.height // 2 - draw.im.paste(away_logo, (away_x, away_y), away_logo) - - # Draw game time - game_time = game_details.get('clock', '') - time_x = width // 2 - 20 - time_y = height // 2 - 8 - draw.text((time_x, time_y), game_time, font=self.fonts['time'], fill=(255, 255, 255)) - - except Exception as e: - logging.error(f"Error in _draw_upcoming_layout: {e}") - - def _draw_scorebug_layout(self, draw: ImageDraw.ImageDraw, game_details: Dict[str, Any]): - """Draw the scorebug layout with team logos, scores, and game status.""" - try: - # Load and resize logos - home_logo_path = os.path.join(self.logo_dir, f"{game_details['home_abbr']}.png") - away_logo_path = os.path.join(self.logo_dir, f"{game_details['away_abbr']}.png") - - # Load and process home logo - home_logo = None - if os.path.exists(home_logo_path): - try: - home_logo = Image.open(home_logo_path) - if home_logo.mode != 'RGBA': - home_logo = home_logo.convert('RGBA') - # Resize maintaining aspect ratio - max_size = min(int(self.display_width / 3), int(self.display_height / 2)) - home_logo.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) - logging.debug(f"[NHL] Successfully loaded home logo: {home_logo_path}") - except Exception as e: - logging.error(f"Error loading home logo {home_logo_path}: {e}") - - # Load and process away logo - away_logo = None - if os.path.exists(away_logo_path): - try: - away_logo = Image.open(away_logo_path) - if away_logo.mode != 'RGBA': - away_logo = away_logo.convert('RGBA') - # Resize maintaining aspect ratio - max_size = min(int(self.display_width / 3), int(self.display_height / 2)) - away_logo.thumbnail((max_size, max_size), Image.Resampling.LANCZOS) - logging.debug(f"[NHL] Successfully loaded away logo: {away_logo_path}") - except Exception as e: - logging.error(f"Error loading away logo {away_logo_path}: {e}") - - # Calculate positions - width = self.display_width - height = self.display_height - - # Draw team logos - if home_logo: - home_x = 3 * width // 4 - home_logo.width // 2 - home_y = height // 4 - home_logo.height // 2 - # Create a temporary RGB image for compositing - temp_img = Image.new('RGB', (width, height), 'black') - temp_draw = ImageDraw.Draw(temp_img) - temp_draw.im.paste(home_logo, (home_x, home_y), home_logo) - # Paste the result onto the main image - draw.im.paste(temp_img, (0, 0)) - - if away_logo: - away_x = width // 4 - away_logo.width // 2 - away_y = 3 * height // 4 - away_logo.height // 2 - # Create a temporary RGB image for compositing - temp_img = Image.new('RGB', (width, height), 'black') - temp_draw = ImageDraw.Draw(temp_img) - temp_draw.im.paste(away_logo, (away_x, away_y), away_logo) - # Paste the result onto the main image - draw.im.paste(temp_img, (0, 0)) - - # Draw scores - score_color = (255, 255, 255) - home_score = str(game_details['home_score']) - away_score = str(game_details['away_score']) - - # Draw home score - home_score_x = width // 2 - 10 - home_score_y = height // 4 - 8 - draw.text((home_score_x, home_score_y), home_score, font=self.fonts['score'], fill=score_color) - - # Draw away score - away_score_x = width // 2 - 10 - away_score_y = 3 * height // 4 - 8 - draw.text((away_score_x, away_score_y), away_score, font=self.fonts['score'], fill=score_color) - - # Draw game status - status_text = game_details.get('status_text', '') - status_x = width // 2 - 20 - status_y = height // 2 - 8 - draw.text((status_x, status_y), status_text, font=self.fonts['status'], fill=score_color) - - except Exception as e: - logging.error(f"Error in _draw_scorebug_layout: {e}") - # Draw fallback text if logo loading fails - if 'home_abbr' in game_details: - draw.text((width // 4, height // 4), game_details['home_abbr'], font=self.fonts['team'], fill='white') - if 'away_abbr' in game_details: - draw.text((width // 4, 3 * height // 4), game_details['away_abbr'], font=self.fonts['team'], fill='white') - - -if __name__ == "__main__": - main() \ No newline at end of file