mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-29 12:03:00 +00:00
* feat: add error detection, monitoring, and code quality improvements This comprehensive update addresses automatic error detection, code quality, and plugin development experience: ## Error Detection & Monitoring - Add ErrorAggregator service for centralized error tracking - Add pattern detection for recurring errors (5+ in 60 min) - Add error dashboard API endpoints (/api/v3/errors/*) - Integrate error recording into plugin executor ## Code Quality - Remove 10 silent `except: pass` blocks in sports.py and football.py - Remove hardcoded debug log paths - Add pre-commit hooks to prevent future bare except clauses ## Validation & Type Safety - Add warnings when plugins lack config_schema.json - Add config key collision detection for plugins - Improve type coercion logging in BasePlugin ## Testing - Add test_config_validation_edge_cases.py - Add test_plugin_loading_failures.py - Add test_error_aggregator.py ## Documentation - Add PLUGIN_ERROR_HANDLING.md guide - Add CONFIG_DEBUGGING.md guide Note: GitHub Actions CI workflow is available in the plan but requires workflow scope to push. Add .github/workflows/ci.yml manually. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address code review issues - Fix GitHub issues URL in CONFIG_DEBUGGING.md - Use RLock in error_aggregator.py to prevent deadlock in export_to_file - Distinguish missing vs invalid schema files in plugin_manager.py - Add assertions to test_null_value_for_required_field test - Remove unused initial_count variable in test_plugin_load_error_recorded - Add validation for max_age_hours in clear_old_errors API endpoint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
395 lines
22 KiB
Python
395 lines
22 KiB
Python
from typing import Dict, Any, Optional, List
|
|
from src.display_manager import DisplayManager
|
|
from src.cache_manager import CacheManager
|
|
from datetime import datetime, timezone, timedelta
|
|
import logging
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import time
|
|
from src.base_classes.data_sources import ESPNDataSource
|
|
from src.base_classes.sports import SportsCore, SportsLive
|
|
|
|
class Football(SportsCore):
|
|
"""Base class for football 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 = "football"
|
|
|
|
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
|
"""Extract relevant game details from ESPN NCAA FB API response."""
|
|
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"]
|
|
|
|
# --- Football Specific Details (Likely same for NFL/NCAAFB) ---
|
|
down_distance_text = ""
|
|
down_distance_text_long = ""
|
|
possession_indicator = None # Default to None
|
|
scoring_event = "" # Track scoring events
|
|
home_timeouts = 0
|
|
away_timeouts = 0
|
|
is_redzone = False
|
|
posession = None
|
|
|
|
if situation and status["type"]["state"] == "in":
|
|
# down = situation.get("down")
|
|
down_distance_text = situation.get("shortDownDistanceText")
|
|
down_distance_text_long = situation.get("downDistanceText")
|
|
# distance = situation.get("distance")
|
|
|
|
# Detect scoring events from status detail
|
|
status_detail = status["type"].get("detail", "").lower()
|
|
status_short = status["type"].get("shortDetail", "").lower()
|
|
is_redzone = situation.get("isRedZone")
|
|
posession = situation.get("possession")
|
|
|
|
# Check for scoring events in status text
|
|
if any(keyword in status_detail for keyword in ["touchdown", "td"]):
|
|
scoring_event = "TOUCHDOWN"
|
|
elif any(keyword in status_detail for keyword in ["field goal", "fg"]):
|
|
scoring_event = "FIELD GOAL"
|
|
elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]):
|
|
scoring_event = "PAT"
|
|
elif any(keyword in status_short for keyword in ["touchdown", "td"]):
|
|
scoring_event = "TOUCHDOWN"
|
|
elif any(keyword in status_short for keyword in ["field goal", "fg"]):
|
|
scoring_event = "FIELD GOAL"
|
|
elif any(keyword in status_short for keyword in ["extra point", "pat"]):
|
|
scoring_event = "PAT"
|
|
|
|
# Determine possession based on team ID
|
|
possession_team_id = situation.get("possession")
|
|
if possession_team_id:
|
|
if possession_team_id == home_team.get("id"):
|
|
possession_indicator = "home"
|
|
elif possession_team_id == away_team.get("id"):
|
|
possession_indicator = "away"
|
|
|
|
home_timeouts = situation.get("homeTimeouts", 3) # Default to 3 if not specified
|
|
away_timeouts = situation.get("awayTimeouts", 3) # Default to 3 if not specified
|
|
|
|
|
|
# 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"),
|
|
"home_timeouts": home_timeouts,
|
|
"away_timeouts": away_timeouts,
|
|
"down_distance_text": down_distance_text, # Added Down/Distance
|
|
"down_distance_text_long": down_distance_text_long,
|
|
"is_redzone": is_redzone,
|
|
"possession": posession, # ID of team with possession
|
|
"possession_indicator": possession_indicator, # Added for easy home/away check
|
|
"scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT)
|
|
})
|
|
|
|
# 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
|
|
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
|
|
return None
|
|
|
|
class FootballLive(Football, 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"]:
|
|
try:
|
|
minutes, seconds = map(int, self.current_game["clock"].split(':'))
|
|
seconds -= 1
|
|
if seconds < 0:
|
|
seconds = 59
|
|
minutes -= 1
|
|
if minutes < 0:
|
|
# Simulate end of quarter/game
|
|
if self.current_game["period"] < 4: # Q4 is period 4
|
|
self.current_game["period"] += 1
|
|
# Update period_text based on new period
|
|
if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1"
|
|
elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2"
|
|
elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3"
|
|
elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4"
|
|
# Reset clock for next quarter (e.g., 15:00)
|
|
minutes, seconds = 15, 0
|
|
else:
|
|
# Simulate game end
|
|
self.current_game["is_live"] = False
|
|
self.current_game["is_final"] = True
|
|
self.current_game["period_text"] = "Final"
|
|
minutes, seconds = 0, 0
|
|
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
|
# Simulate down change occasionally
|
|
if seconds % 15 == 0:
|
|
self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}"
|
|
self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}"
|
|
|
|
# Display update handled by main loop or explicit call if needed immediately
|
|
# self.display(force_clear=True) # Only if immediate update is desired here
|
|
|
|
except ValueError:
|
|
self.logger.warning("Test mode: Could not parse clock") # Changed log prefix
|
|
# No actual display call here, let main loop handle it
|
|
|
|
|
|
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
|
|
|
|
# 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'])
|
|
|
|
# Period/Quarter and Clock (Top center)
|
|
period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
|
if game.get("is_halftime"): \
|
|
period_clock_text = "Halftime" # Override for halftime
|
|
elif 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'])
|
|
|
|
# Down & Distance or Scoring Event (Below Period/Clock)
|
|
scoring_event = game.get("scoring_event", "")
|
|
down_distance = game.get("down_distance_text", "")
|
|
if self.display_width > 128:
|
|
down_distance = game.get("down_distance_text_long", "")
|
|
|
|
# Show scoring event if detected, otherwise show down & distance
|
|
if scoring_event and game.get("is_live"):
|
|
# Display scoring event with special formatting
|
|
event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail'])
|
|
event_x = (self.display_width - event_width) // 2
|
|
event_y = (self.display_height) - 7
|
|
|
|
# Color coding for different scoring events
|
|
if scoring_event == "TOUCHDOWN":
|
|
event_color = (255, 215, 0) # Gold
|
|
elif scoring_event == "FIELD GOAL":
|
|
event_color = (0, 255, 0) # Green
|
|
elif scoring_event == "PAT":
|
|
event_color = (255, 165, 0) # Orange
|
|
else:
|
|
event_color = (255, 255, 255) # White
|
|
|
|
self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color)
|
|
elif down_distance and game.get("is_live"): # Only show if live and available
|
|
dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail'])
|
|
dd_x = (self.display_width - dd_width) // 2
|
|
dd_y = (self.display_height)- 7 # Top of D&D text
|
|
down_color = (200, 200, 0) if not game.get("is_redzone", False) else (255,0,0) # Yellowish text
|
|
self._draw_text_with_outline(draw_overlay, down_distance, (dd_x, dd_y), self.fonts['detail'], fill=down_color)
|
|
|
|
# Possession Indicator (small football icon)
|
|
possession = game.get("possession_indicator")
|
|
if possession: # Only draw if possession is known
|
|
ball_radius_x = 3 # Wider for football shape
|
|
ball_radius_y = 2 # Shorter for football shape
|
|
ball_color = (139, 69, 19) # Brown color for the football
|
|
lace_color = (255, 255, 255) # White for laces
|
|
|
|
# Approximate height of the detail font (4x6 font at size 6 is roughly 6px tall)
|
|
detail_font_height_approx = 6
|
|
ball_y_center = dd_y + (detail_font_height_approx // 2) # Center ball vertically with D&D text
|
|
|
|
possession_ball_padding = 3 # Pixels between D&D text and ball
|
|
|
|
if possession == "away":
|
|
# Position ball to the left of D&D text
|
|
ball_x_center = dd_x - possession_ball_padding - ball_radius_x
|
|
elif possession == "home":
|
|
# Position ball to the right of D&D text
|
|
ball_x_center = dd_x + dd_width + possession_ball_padding + ball_radius_x
|
|
else:
|
|
ball_x_center = 0 # Should not happen / no indicator
|
|
|
|
if ball_x_center > 0: # Draw if position is valid
|
|
# Draw the football shape (ellipse)
|
|
draw_overlay.ellipse(
|
|
(ball_x_center - ball_radius_x, ball_y_center - ball_radius_y, # x0, y0
|
|
ball_x_center + ball_radius_x, ball_y_center + ball_radius_y), # x1, y1
|
|
fill=ball_color, outline=(0,0,0)
|
|
)
|
|
# Draw a simple horizontal lace
|
|
draw_overlay.line(
|
|
(ball_x_center - 1, ball_y_center, ball_x_center + 1, ball_y_center),
|
|
fill=lace_color, width=1
|
|
)
|
|
|
|
# Timeouts (Bottom corners) - 3 small bars per team
|
|
timeout_bar_width = 4
|
|
timeout_bar_height = 2
|
|
timeout_spacing = 1
|
|
timeout_y = self.display_height - timeout_bar_height - 1 # Bottom edge
|
|
|
|
# Away Timeouts (Bottom Left)
|
|
away_timeouts_remaining = game.get("away_timeouts", 0)
|
|
for i in range(3):
|
|
to_x = 2 + i * (timeout_bar_width + timeout_spacing)
|
|
color = (255, 255, 255) if i < away_timeouts_remaining else (80, 80, 80) # White if available, gray if used
|
|
draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0))
|
|
|
|
# Home Timeouts (Bottom Right)
|
|
home_timeouts_remaining = game.get("home_timeouts", 0)
|
|
for i in range(3):
|
|
to_x = self.display_width - 2 - timeout_bar_width - (2-i) * (timeout_bar_width + timeout_spacing)
|
|
color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used
|
|
draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0))
|
|
|
|
# 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 - 4
|
|
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 Football game: {e}", exc_info=True) # Changed log prefix
|