mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
* Add NCAA Womens Hockey * Fix status text --------- Co-authored-by: Alex Resnick <adr8282@gmail.com>
385 lines
16 KiB
Python
385 lines
16 KiB
Python
import logging
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Optional
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from src.base_classes.data_sources import ESPNDataSource
|
|
from src.base_classes.sports import SportsCore, SportsLive
|
|
from src.cache_manager import CacheManager
|
|
from src.display_manager import DisplayManager
|
|
|
|
|
|
class Hockey(SportsCore):
|
|
"""Base class for hockey sports with common functionality."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: Dict[str, Any],
|
|
display_manager: DisplayManager,
|
|
cache_manager: CacheManager,
|
|
logger: logging.Logger,
|
|
sport_key: str,
|
|
):
|
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
self.data_source = ESPNDataSource(logger)
|
|
self.sport = "hockey"
|
|
self.show_shots_on_goal = self.mode_config.get("show_shots_on_goal", False)
|
|
|
|
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
"""Extract relevant game details from ESPN NCAA FB API response."""
|
|
# --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES ---
|
|
details, home_team, away_team, status, situation = (
|
|
self._extract_game_details_common(game_event)
|
|
)
|
|
if details is None or home_team is None or away_team is None or status is None:
|
|
return
|
|
try:
|
|
competition = game_event["competitions"][0]
|
|
status = competition["status"]
|
|
powerplay = False
|
|
penalties = ""
|
|
home_team_saves = next(
|
|
(
|
|
int(c["displayValue"])
|
|
for c in home_team["statistics"]
|
|
if c.get("name") == "saves"
|
|
),
|
|
0,
|
|
)
|
|
home_team_saves_per = next(
|
|
(
|
|
float(c["displayValue"])
|
|
for c in home_team["statistics"]
|
|
if c.get("name") == "savePct"
|
|
),
|
|
0.0,
|
|
)
|
|
away_team_saves = next(
|
|
(
|
|
int(c["displayValue"])
|
|
for c in away_team["statistics"]
|
|
if c.get("name") == "saves"
|
|
),
|
|
0,
|
|
)
|
|
away_team_saves_per = next(
|
|
(
|
|
float(c["displayValue"])
|
|
for c in away_team["statistics"]
|
|
if c.get("name") == "savePct"
|
|
),
|
|
0.0,
|
|
)
|
|
|
|
home_shots = 0
|
|
away_shots = 0
|
|
if home_team_saves_per > 0:
|
|
away_shots = round(home_team_saves / home_team_saves_per)
|
|
if away_team_saves_per > 0:
|
|
home_shots = round(away_team_saves / away_team_saves_per)
|
|
status_short = status["type"].get("shortDetail", "")
|
|
|
|
if situation and status["type"]["state"] == "in":
|
|
# Detect scoring events from status detail
|
|
# status_detail = status["type"].get("detail", "")
|
|
powerplay = situation.get("isPowerPlay", False)
|
|
penalties = situation.get("penalties", "")
|
|
|
|
# Format period/quarter
|
|
period = status.get("period", 0)
|
|
period_text = ""
|
|
if status["type"]["state"] == "in":
|
|
if period == 0:
|
|
period_text = "Start" # Before kickoff
|
|
elif period >= 1 and period <= 3:
|
|
period_text = f"P{period}" # OT starts after Q4
|
|
elif period > 3:
|
|
period_text = f"OT{period - 3}" # OT starts after Q4
|
|
elif status["type"]["state"] == "post":
|
|
if period > 3:
|
|
period_text = "Final/OT"
|
|
else:
|
|
period_text = "Final"
|
|
elif status["type"]["state"] == "pre":
|
|
period_text = details.get("game_time", "") # Show time for upcoming
|
|
|
|
details.update(
|
|
{
|
|
"period": period,
|
|
"period_text": period_text, # Formatted quarter/status
|
|
"clock": status.get("displayClock", "0:00"),
|
|
"power_play": powerplay,
|
|
"penalties": penalties,
|
|
"home_shots": home_shots,
|
|
"away_shots": away_shots,
|
|
}
|
|
)
|
|
|
|
# Basic validation (can be expanded)
|
|
if not details["home_abbr"] or not details["away_abbr"]:
|
|
self.logger.warning(
|
|
f"Missing team abbreviation in event: {details['id']}"
|
|
)
|
|
return None
|
|
|
|
self.logger.debug(
|
|
f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}"
|
|
)
|
|
|
|
return details
|
|
except Exception as e:
|
|
# Log the problematic event structure if possible
|
|
self.logger.error(
|
|
f"Error extracting game details: {e} from event: {game_event.get('id')}",
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
|
|
|
|
class HockeyLive(Hockey, SportsLive):
|
|
def __init__(
|
|
self,
|
|
config: Dict[str, Any],
|
|
display_manager: DisplayManager,
|
|
cache_manager: CacheManager,
|
|
logger: logging.Logger,
|
|
sport_key: str,
|
|
):
|
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
|
|
|
def _test_mode_update(self):
|
|
if self.current_game and self.current_game["is_live"]:
|
|
# For testing, we'll just update the clock to show it's working
|
|
minutes = int(self.current_game["clock"].split(":")[0])
|
|
seconds = int(self.current_game["clock"].split(":")[1])
|
|
seconds -= 1
|
|
if seconds < 0:
|
|
seconds = 59
|
|
minutes -= 1
|
|
if minutes < 0:
|
|
minutes = 19
|
|
if self.current_game["period"] < 3:
|
|
self.current_game["period"] += 1
|
|
else:
|
|
self.current_game["period"] = 1
|
|
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
|
# Always update display in test mode
|
|
|
|
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring
|
|
try:
|
|
main_img = Image.new(
|
|
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 255)
|
|
)
|
|
overlay = Image.new(
|
|
"RGBA", (self.display_width, self.display_height), (0, 0, 0, 0)
|
|
)
|
|
draw_overlay = ImageDraw.Draw(
|
|
overlay
|
|
) # Draw text elements on overlay first
|
|
home_logo = self._load_and_resize_logo(
|
|
game["home_id"],
|
|
game["home_abbr"],
|
|
game["home_logo_path"],
|
|
game.get("home_logo_url"),
|
|
)
|
|
away_logo = self._load_and_resize_logo(
|
|
game["away_id"],
|
|
game["away_abbr"],
|
|
game["away_logo_path"],
|
|
game.get("away_logo_url"),
|
|
)
|
|
|
|
if not home_logo or not away_logo:
|
|
self.logger.error(
|
|
f"Failed to load logos for live game: {game.get('id')}"
|
|
) # Changed log prefix
|
|
# Draw placeholder text if logos fail
|
|
draw_final = ImageDraw.Draw(main_img.convert("RGB"))
|
|
self._draw_text_with_outline(
|
|
draw_final, "Logo Error", (5, 5), self.fonts["status"]
|
|
)
|
|
self.display_manager.image.paste(main_img.convert("RGB"), (0, 0))
|
|
self.display_manager.update_display()
|
|
return
|
|
|
|
center_y = self.display_height // 2
|
|
|
|
# Draw logos (shifted slightly more inward than NHL perhaps)
|
|
home_x = (
|
|
self.display_width - home_logo.width + 10
|
|
) # adjusted from 18 # Adjust position as needed
|
|
home_y = center_y - (home_logo.height // 2)
|
|
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
|
|
|
away_x = -10 # adjusted from 18 # Adjust position as needed
|
|
away_y = center_y - (away_logo.height // 2)
|
|
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
|
|
|
# --- Draw Text Elements on Overlay ---
|
|
# Note: Rankings are now handled in the records/rankings section below
|
|
|
|
# Period/Quarter and Clock (Top center)
|
|
period_clock_text = (
|
|
f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
|
)
|
|
if game.get("is_period_break"):
|
|
period_clock_text = game.get("status_text", "Period Break")
|
|
|
|
status_width = draw_overlay.textlength(
|
|
period_clock_text, font=self.fonts["time"]
|
|
)
|
|
status_x = (self.display_width - status_width) // 2
|
|
status_y = 1 # Position at top
|
|
self._draw_text_with_outline(
|
|
draw_overlay,
|
|
period_clock_text,
|
|
(status_x, status_y),
|
|
self.fonts["time"],
|
|
)
|
|
|
|
# Scores (centered, slightly above bottom)
|
|
home_score = str(game.get("home_score", "0"))
|
|
away_score = str(game.get("away_score", "0"))
|
|
score_text = f"{away_score}-{home_score}"
|
|
score_width = draw_overlay.textlength(score_text, font=self.fonts["score"])
|
|
score_x = (self.display_width - score_width) // 2
|
|
score_y = (
|
|
self.display_height // 2
|
|
) - 3 # centered #from 14 # Position score higher
|
|
self._draw_text_with_outline(
|
|
draw_overlay, score_text, (score_x, score_y), self.fonts["score"]
|
|
)
|
|
|
|
# Shots on Goal
|
|
if self.show_shots_on_goal:
|
|
shots_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
|
home_shots = str(game.get("home_shots", "0"))
|
|
away_shots = str(game.get("away_shots", "0"))
|
|
shots_text = f"{away_shots} SHOTS {home_shots}"
|
|
shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font)
|
|
shots_height = shots_bbox[3] - shots_bbox[1]
|
|
shots_y = self.display_height - shots_height - 1
|
|
shots_width = draw_overlay.textlength(shots_text, font=shots_font)
|
|
shots_x = (self.display_width - shots_width) // 2
|
|
self._draw_text_with_outline(
|
|
draw_overlay, shots_text, (shots_x, shots_y), shots_font
|
|
)
|
|
|
|
# Draw odds if available
|
|
if "odds" in game and game["odds"]:
|
|
self._draw_dynamic_odds(
|
|
draw_overlay, game["odds"], self.display_width, self.display_height
|
|
)
|
|
|
|
# Draw records or rankings if enabled
|
|
if self.show_records or self.show_ranking:
|
|
try:
|
|
record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
|
self.logger.debug(f"Loaded 6px record font successfully")
|
|
except IOError:
|
|
record_font = ImageFont.load_default()
|
|
self.logger.warning(
|
|
f"Failed to load 6px font, using default font (size: {record_font.size})"
|
|
)
|
|
|
|
# Get team abbreviations
|
|
away_abbr = game.get("away_abbr", "")
|
|
home_abbr = game.get("home_abbr", "")
|
|
|
|
record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font)
|
|
record_height = record_bbox[3] - record_bbox[1]
|
|
record_y = self.display_height - record_height - 1
|
|
self.logger.debug(
|
|
f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}"
|
|
)
|
|
|
|
# Display away team info
|
|
if away_abbr:
|
|
if self.show_ranking and self.show_records:
|
|
# When both rankings and records are enabled, rankings replace records completely
|
|
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
if away_rank > 0:
|
|
away_text = f"#{away_rank}"
|
|
else:
|
|
# Show nothing for unranked teams when rankings are prioritized
|
|
away_text = ""
|
|
elif self.show_ranking:
|
|
# Show ranking only if available
|
|
away_rank = self._team_rankings_cache.get(away_abbr, 0)
|
|
if away_rank > 0:
|
|
away_text = f"#{away_rank}"
|
|
else:
|
|
away_text = ""
|
|
elif self.show_records:
|
|
# Show record only when rankings are disabled
|
|
away_text = game.get("away_record", "")
|
|
else:
|
|
away_text = ""
|
|
|
|
if away_text:
|
|
away_record_x = 3
|
|
self.logger.debug(
|
|
f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}"
|
|
)
|
|
self._draw_text_with_outline(
|
|
draw_overlay,
|
|
away_text,
|
|
(away_record_x, record_y),
|
|
record_font,
|
|
)
|
|
|
|
# Display home team info
|
|
if home_abbr:
|
|
if self.show_ranking and self.show_records:
|
|
# When both rankings and records are enabled, rankings replace records completely
|
|
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
if home_rank > 0:
|
|
home_text = f"#{home_rank}"
|
|
else:
|
|
# Show nothing for unranked teams when rankings are prioritized
|
|
home_text = ""
|
|
elif self.show_ranking:
|
|
# Show ranking only if available
|
|
home_rank = self._team_rankings_cache.get(home_abbr, 0)
|
|
if home_rank > 0:
|
|
home_text = f"#{home_rank}"
|
|
else:
|
|
home_text = ""
|
|
elif self.show_records:
|
|
# Show record only when rankings are disabled
|
|
home_text = game.get("home_record", "")
|
|
else:
|
|
home_text = ""
|
|
|
|
if home_text:
|
|
home_record_bbox = draw_overlay.textbbox(
|
|
(0, 0), home_text, font=record_font
|
|
)
|
|
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
|
home_record_x = self.display_width - home_record_width - 3
|
|
self.logger.debug(
|
|
f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}"
|
|
)
|
|
self._draw_text_with_outline(
|
|
draw_overlay,
|
|
home_text,
|
|
(home_record_x, record_y),
|
|
record_font,
|
|
)
|
|
|
|
# Composite the text overlay onto the main image
|
|
main_img = Image.alpha_composite(main_img, overlay)
|
|
main_img = main_img.convert("RGB") # Convert for display
|
|
|
|
# Display the final image
|
|
self.display_manager.image.paste(main_img, (0, 0))
|
|
self.display_manager.update_display() # Update display here for live
|
|
|
|
except Exception as e:
|
|
self.logger.error(
|
|
f"Error displaying live Hockey game: {e}", exc_info=True
|
|
) # Changed log prefix
|