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()