mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 05:13:01 +00:00
* fix(perf): cache fonts in sport base classes to avoid disk I/O per frame Replace 7 ImageFont.truetype() calls in display methods with cached self.fonts['detail'] lookups. The 4x6-font.ttf at size 6 is already loaded once in _load_fonts() — loading it again on every display() call causes unnecessary disk I/O on each render frame (~30-50 FPS). Files: sports.py (2), football.py (1), hockey.py (2), basketball.py (1), baseball.py (1) Co-Authored-By: 5ymb01 <noreply@github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: trigger CodeRabbit review --------- Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com> Co-authored-by: 5ymb01 <noreply@github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
303 lines
13 KiB
Python
303 lines
13 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 Basketball(SportsCore):
|
|
"""Base class for basketball 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 = "basketball"
|
|
|
|
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:
|
|
# 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 <= 4:
|
|
period_text = f"Q{period}" # OT starts after Q4
|
|
elif period > 4:
|
|
period_text = f"OT{period - 4}" # OT starts after Q4
|
|
elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state
|
|
period_text = "HALF"
|
|
elif status["type"]["state"] == "post":
|
|
if period > 4 : period_text = "Final/OT"
|
|
else: period_text = "Final"
|
|
elif status["type"]["state"] == "pre":
|
|
period_text = details.get("game_time", "") # Show time for upcoming
|
|
|
|
details.update({
|
|
"period": period,
|
|
"period_text": period_text, # Formatted quarter/status
|
|
"clock": status.get("displayClock", "0:00"),
|
|
})
|
|
|
|
# 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 BasketballLive(Basketball, 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 Basketball 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()
|
|
)
|
|
|
|
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"]
|
|
)
|
|
|
|
# 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:
|
|
record_font = self.fonts.get('detail', ImageFont.load_default())
|
|
|
|
# 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
|