Merge missing PRs from main: NCAA Hockey (#36), Emulator Support (#35), NCAA FB AP rankings (#17), NCAA FB logos (#15)

- Added NCAA Hockey support with new manager and logos
- Added emulator support with requirements file
- Added NCAA FB AP top 25 rankings functionality
- Added NCAA FB logo download capability
- Resolved conflicts by keeping development branch improvements while adding missing features
This commit is contained in:
Chuck
2025-09-17 10:00:27 -04:00
27 changed files with 1324 additions and 21 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -180,6 +180,11 @@
"ncaam_basketball": {
"enabled": false,
"top_teams": 25
},
"ncaam_hockey": {
"enabled": true,
"top_teams": 10,
"show_ranking": true
}
},
"update_interval": 3600,
@@ -354,6 +359,32 @@
"ncaam_basketball_upcoming": true
}
},
"ncaam_hockey_scoreboard": {
"enabled": true,
"live_priority": true,
"live_game_duration": 20,
"show_odds": true,
"test_mode": false,
"update_interval_seconds": 3600,
"live_update_interval": 30,
"live_odds_update_interval": 3600,
"odds_update_interval": 3600,
"season_cache_duration_seconds": 86400,
"recent_games_to_show": 1,
"upcoming_games_to_show": 1,
"show_favorite_teams_only": true,
"favorite_teams": [
"RIT"
],
"logo_dir": "assets/sports/ncaa_logos",
"show_records": true,
"show_ranking": true,
"display_modes": {
"ncaam_hockey_live": true,
"ncaam_hockey_recent": true ,
"ncaam_hockey_upcoming": true
}
},
"youtube": {
"enabled": false,
"update_interval": 3600

View File

@@ -0,0 +1 @@
RGBMatrixEmulator

View File

@@ -9,7 +9,6 @@ from googleapiclient.discovery import build
import pickle
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from rgbmatrix import graphics
import pytz
from src.config_manager import ConfigManager
import time

View File

@@ -30,6 +30,7 @@ from src.nfl_managers import NFLLiveManager, NFLRecentManager, NFLUpcomingManage
from src.ncaa_fb_managers import NCAAFBLiveManager, NCAAFBRecentManager, NCAAFBUpcomingManager
from src.ncaa_baseball_managers import NCAABaseballLiveManager, NCAABaseballRecentManager, NCAABaseballUpcomingManager
from src.ncaam_basketball_managers import NCAAMBasketballLiveManager, NCAAMBasketballRecentManager, NCAAMBasketballUpcomingManager
from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentManager, NCAAMHockeyUpcomingManager
from src.youtube_display import YouTubeDisplay
from src.calendar_manager import CalendarManager
from src.text_display import TextDisplay
@@ -236,6 +237,21 @@ class DisplayController:
self.ncaam_basketball_upcoming = None
logger.info("NCAA Men's Basketball managers initialized in %.3f seconds", time.time() - ncaam_basketball_time)
# Initialize NCAA Men's Hockey managers if enabled
ncaam_hockey_time = time.time()
ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False)
ncaam_hockey_display_modes = self.config.get('ncaam_hockey_scoreboard', {}).get('display_modes', {})
if ncaam_hockey_enabled:
self.ncaam_hockey_live = NCAAMHockeyLiveManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_live', True) else None
self.ncaam_hockey_recent = NCAAMHockeyRecentManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_recent', True) else None
self.ncaam_hockey_upcoming = NCAAMHockeyUpcomingManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_upcoming', True) else None
else:
self.ncaam_hockey_live = None
self.ncaam_hockey_recent = None
self.ncaam_hockey_upcoming = None
logger.info("NCAA Men's Hockey managers initialized in %.3f seconds", time.time() - ncaam_hockey_time)
# Track MLB rotation state
self.mlb_current_team_index = 0
self.mlb_showing_recent = True
@@ -252,6 +268,7 @@ class DisplayController:
self.ncaa_fb_live_priority = self.config.get('ncaa_fb_scoreboard', {}).get('live_priority', True)
self.ncaa_baseball_live_priority = self.config.get('ncaa_baseball_scoreboard', {}).get('live_priority', True)
self.ncaam_basketball_live_priority = self.config.get('ncaam_basketball_scoreboard', {}).get('live_priority', True)
self.ncaam_hockey_live_priority = self.config.get('ncaam_hockey_scoreboard', {}).get('live_priority', True)
# List of available display modes (adjust order as desired)
self.available_modes = []
@@ -297,6 +314,9 @@ class DisplayController:
if ncaam_basketball_enabled:
if self.ncaam_basketball_recent: self.available_modes.append('ncaam_basketball_recent')
if self.ncaam_basketball_upcoming: self.available_modes.append('ncaam_basketball_upcoming')
if ncaam_hockey_enabled:
if self.ncaam_hockey_recent: self.available_modes.append('ncaam_hockey_recent')
if self.ncaam_hockey_upcoming: self.available_modes.append('ncaam_hockey_upcoming')
# Add live modes to rotation if live_priority is False and there are live games
self._update_live_modes_in_rotation()
@@ -399,7 +419,10 @@ class DisplayController:
'ncaa_baseball_upcoming': 15,
'ncaam_basketball_live': 30, # Added NCAA Men's Basketball durations
'ncaam_basketball_recent': 15,
'ncaam_basketball_upcoming': 15
'ncaam_basketball_upcoming': 15,
'ncaam_hockey_live': 30, # Added NCAA Men's Hockey durations
'ncaam_hockey_recent': 15,
'ncaam_hockey_upcoming': 15
}
# Merge loaded durations with defaults
for key, value in default_durations.items():
@@ -627,6 +650,10 @@ class DisplayController:
if self.ncaam_basketball_live: self.ncaam_basketball_live.update()
if self.ncaam_basketball_recent: self.ncaam_basketball_recent.update()
if self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.update()
elif current_sport == 'ncaam_hockey':
if self.ncaam_hockey_live: self.ncaam_hockey_live.update()
if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update()
if self.ncaam_hockey_upcoming: self.ncaam_hockey_upcoming.update()
else:
# If no specific sport is active, update all managers (fallback behavior)
# This ensures data is available when switching to a sport
@@ -666,6 +693,10 @@ class DisplayController:
if self.ncaam_basketball_recent: self.ncaam_basketball_recent.update()
if self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.update()
if self.ncaam_hockey_live: self.ncaam_hockey_live.update()
if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update()
if self.ncaam_hockey_upcoming: self.ncaam_hockey_upcoming.update()
def _check_live_games(self) -> tuple:
"""
Check if there are any live games available.
@@ -693,6 +724,8 @@ class DisplayController:
live_checks['ncaa_baseball'] = self.ncaa_baseball_live and self.ncaa_baseball_live.live_games
if 'ncaam_basketball_scoreboard' in self.config and self.config['ncaam_basketball_scoreboard'].get('enabled', False):
live_checks['ncaam_basketball'] = self.ncaam_basketball_live and self.ncaam_basketball_live.live_games
if 'ncaam_hockey_scoreboard' in self.config and self.config['ncaam_hockey_scoreboard'].get('enabled', False):
live_checks['ncaam_hockey'] = self.ncaam_hockey_live and self.ncaam_hockey_live.live_games
for sport, has_live_games in live_checks.items():
if has_live_games:
@@ -943,6 +976,7 @@ class DisplayController:
ncaa_fb_enabled = self.config.get('ncaa_fb_scoreboard', {}).get('enabled', False)
ncaa_baseball_enabled = self.config.get('ncaa_baseball_scoreboard', {}).get('enabled', False)
ncaam_basketball_enabled = self.config.get('ncaam_basketball_scoreboard', {}).get('enabled', False)
ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False)
update_mode('nhl_live', getattr(self, 'nhl_live', None), self.nhl_live_priority, nhl_enabled)
update_mode('nba_live', getattr(self, 'nba_live', None), self.nba_live_priority, nba_enabled)
@@ -953,6 +987,7 @@ class DisplayController:
update_mode('ncaa_fb_live', getattr(self, 'ncaa_fb_live', None), self.ncaa_fb_live_priority, ncaa_fb_enabled)
update_mode('ncaa_baseball_live', getattr(self, 'ncaa_baseball_live', None), self.ncaa_baseball_live_priority, ncaa_baseball_enabled)
update_mode('ncaam_basketball_live', getattr(self, 'ncaam_basketball_live', None), self.ncaam_basketball_live_priority, ncaam_basketball_enabled)
update_mode('ncaam_hockey_live', getattr(self, 'ncaam_hockey_live', None), self.ncaam_hockey_live_priority, ncaam_hockey_enabled)
def run(self):
"""Run the display controller, switching between displays."""
@@ -995,7 +1030,8 @@ class DisplayController:
('nfl', 'nfl_live', self.nfl_live_priority),
('ncaa_fb', 'ncaa_fb_live', self.ncaa_fb_live_priority),
('ncaa_baseball', 'ncaa_baseball_live', self.ncaa_baseball_live_priority),
('ncaam_basketball', 'ncaam_basketball_live', self.ncaam_basketball_live_priority)
('ncaam_basketball', 'ncaam_basketball_live', self.ncaam_basketball_live_priority),
('ncaam_hockey', 'ncaam_hockey_live', self.ncaam_hockey_live_priority)
]:
manager = getattr(self, attr, None)
# Only consider sports that are enabled (manager is not None) and have actual live games
@@ -1196,6 +1232,12 @@ class DisplayController:
manager_to_display = self.ncaa_baseball_live
elif self.current_display_mode == 'ncaam_basketball_live' and self.ncaam_basketball_live:
manager_to_display = self.ncaam_basketball_live
elif self.current_display_mode == 'ncaam_hockey_live' and self.ncaam_hockey_live:
manager_to_display = self.ncaam_hockey_live
elif self.current_display_mode == 'ncaam_hockey_recent' and self.ncaam_hockey_recent:
manager_to_display = self.ncaam_hockey_recent
elif self.current_display_mode == 'ncaam_hockey_upcoming' and self.ncaam_hockey_upcoming:
manager_to_display = self.ncaam_hockey_upcoming
elif self.current_display_mode == 'mlb_live' and self.mlb_live:
manager_to_display = self.mlb_live
elif self.current_display_mode == 'milb_live' and self.milb_live:
@@ -1260,6 +1302,10 @@ class DisplayController:
self.ncaa_baseball_recent.display(force_clear=self.force_clear)
elif self.current_display_mode == 'ncaa_baseball_upcoming' and self.ncaa_baseball_upcoming:
self.ncaa_baseball_upcoming.display(force_clear=self.force_clear)
elif self.current_display_mode == 'ncaam_hockey_recent' and self.ncaam_hockey_recent:
self.ncaam_hockey_recent.display(force_clear=self.force_clear)
elif self.current_display_mode == 'ncaam_hockey_upcoming' and self.ncaam_hockey_upcoming:
self.ncaam_hockey_upcoming.display(force_clear=self.force_clear)
elif self.current_display_mode == 'milb_live' and self.milb_live and len(self.milb_live.live_games) > 0:
logger.debug(f"[DisplayController] Calling MiLB live display with {len(self.milb_live.live_games)} live games")
# Update data before displaying for live managers

View File

@@ -1,4 +1,8 @@
from rgbmatrix import RGBMatrix, RGBMatrixOptions
import os
if os.getenv("EMULATOR", "false") == "true":
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
else:
from rgbmatrix import RGBMatrix, RGBMatrixOptions
from PIL import Image, ImageDraw, ImageFont
import time
from typing import Dict, Any, List, Tuple

View File

@@ -1,22 +1,17 @@
import time
import logging
import requests
import json
from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta, timezone
import os
from PIL import Image, ImageDraw, ImageFont
import pytz
try:
from .display_manager import DisplayManager
from .cache_manager import CacheManager
from .config_manager import ConfigManager
from .logo_downloader import download_missing_logo
except ImportError:
# Fallback for direct imports
from display_manager import DisplayManager
from cache_manager import CacheManager
from config_manager import ConfigManager
from logo_downloader import download_missing_logo
# Import the API counter function from web interface
@@ -149,7 +144,16 @@ class LeaderboardManager:
'season': self.enabled_sports.get('ncaa_baseball', {}).get('season', 2025),
'level': self.enabled_sports.get('ncaa_baseball', {}).get('level', 1),
'sort': self.enabled_sports.get('ncaa_baseball', {}).get('sort', 'winpercent:desc,gamesbehind:asc')
}
},
'ncaam_hockey': {
'sport': 'hockey',
'league': 'mens-college-hockey',
'logo_dir': 'assets/sports/ncaa_logos',
'league_logo': 'assets/sports/ncaa_logos/ncaah.png',
'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-hockey/teams',
'enabled': self.enabled_sports.get('ncaam_hockey', {}).get('enabled', False),
'top_teams': self.enabled_sports.get('ncaam_hockey', {}).get('top_teams', 25)
},
}
logger.info(f"LeaderboardManager initialized with enabled sports: {[k for k, v in self.league_configs.items() if v['enabled']]}")
@@ -290,6 +294,9 @@ class LeaderboardManager:
if league_key == 'college-football':
return self._fetch_ncaa_fb_rankings(league_config)
if league_key == 'mens-college-hockey':
return self._fetch_ncaam_hockey_rankings(league_config)
# Use standings endpoint for NFL, MLB, NHL, and NCAA Baseball
if league_key in ['nfl', 'mlb', 'nhl', 'college-baseball']:
return self._fetch_standings_data(league_config)
@@ -472,6 +479,111 @@ class LeaderboardManager:
logger.error(f"Error fetching rankings for {league_key}: {e}")
return []
def _fetch_ncaam_hockey_rankings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Fetch NCAA Hockey rankings from ESPN API using the rankings endpoint."""
league_key = league_config['league']
cache_key = f"leaderboard_{league_key}_rankings"
# Try to get cached data first
cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard')
if cached_data:
logger.info(f"Using cached rankings data for {league_key}")
return cached_data.get('standings', [])
try:
logger.info(f"Fetching fresh rankings data for {league_key}")
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/rankings"
# Get rankings data
response = requests.get(rankings_url, timeout=self.request_timeout)
response.raise_for_status()
data = response.json()
# Increment API counter for sports data
increment_api_counter('sports', 1)
logger.info(f"Available rankings: {[rank['name'] for rank in data.get('availableRankings', [])]}")
logger.info(f"Latest season: {data.get('latestSeason', {})}")
logger.info(f"Latest week: {data.get('latestWeek', {})}")
rankings_data = data.get('rankings', [])
if not rankings_data:
logger.warning("No rankings data found")
return []
# Use the first ranking (usually AP Top 25)
first_ranking = rankings_data[0]
ranking_name = first_ranking.get('name', 'Unknown')
ranking_type = first_ranking.get('type', 'Unknown')
teams = first_ranking.get('ranks', [])
logger.info(f"Using ranking: {ranking_name} ({ranking_type})")
logger.info(f"Found {len(teams)} teams in ranking")
standings = []
# Process each team in the ranking
for team_data in teams:
team_info = team_data.get('team', {})
team_name = team_info.get('name', 'Unknown')
team_abbr = team_info.get('abbreviation', 'Unknown')
current_rank = team_data.get('current', 0)
record_summary = team_data.get('recordSummary', '0-0')
logger.debug(f" {current_rank}. {team_name} ({team_abbr}): {record_summary}")
# Parse the record string (e.g., "12-1", "8-4", "10-2-1")
wins = 0
losses = 0
ties = 0
win_percentage = 0
try:
parts = record_summary.split('-')
if len(parts) >= 2:
wins = int(parts[0])
losses = int(parts[1])
if len(parts) == 3:
ties = int(parts[2])
# Calculate win percentage
total_games = wins + losses + ties
win_percentage = wins / total_games if total_games > 0 else 0
except (ValueError, IndexError):
logger.warning(f"Could not parse record for {team_name}: {record_summary}")
continue
standings.append({
'name': team_name,
'abbreviation': team_abbr,
'rank': current_rank,
'wins': wins,
'losses': losses,
'ties': ties,
'win_percentage': win_percentage,
'record_summary': record_summary,
'ranking_name': ranking_name
})
# Limit to top teams (they're already ranked)
top_teams = standings[:league_config['top_teams']]
# Cache the results
cache_data = {
'standings': top_teams,
'timestamp': time.time(),
'league': league_key,
'ranking_name': ranking_name
}
self.cache_manager.save_cache(cache_key, cache_data)
logger.info(f"Fetched and cached {len(top_teams)} teams for {league_key} using {ranking_name}")
return top_teams
except Exception as e:
logger.error(f"Error fetching rankings for {league_key}: {e}")
return []
def _fetch_standings_data(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Fetch standings data from ESPN API using the standings endpoint."""
league_key = league_config['league']

View File

@@ -32,6 +32,7 @@ class LogoDownloader:
'fcs': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', # FCS teams from same endpoint
'ncaam_basketball': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams',
'ncaa_baseball': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/teams',
'ncaam_hockey': 'https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/teams',
# Soccer leagues
'soccer_eng.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/teams',
'soccer_esp.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/esp.1/teams',
@@ -55,6 +56,7 @@ class LogoDownloader:
'fcs': 'assets/sports/ncaa_logos', # FCS teams go in same directory
'ncaam_basketball': 'assets/sports/ncaa_logos',
'ncaa_baseball': 'assets/sports/ncaa_logos',
'ncaam_hockey': 'assets/sports/ncaa_logos',
# Soccer leagues - all use the same soccer_logos directory
'soccer_eng.1': 'assets/sports/soccer_logos',
'soccer_esp.1': 'assets/sports/soccer_logos',
@@ -181,7 +183,7 @@ class LogoDownloader:
try:
logger.info(f"Fetching team data for {league} from ESPN API...")
response = self.session.get(api_url, headers=self.headers, timeout=self.request_timeout)
response = self.session.get(api_url, params={'limit':1000},headers=self.headers, timeout=self.request_timeout)
response.raise_for_status()
data = response.json()

View File

@@ -103,6 +103,8 @@ class BaseNCAAFBManager: # Renamed class
self._rankings_cache_timestamp = 0
self._rankings_cache_duration = 3600 # Cache rankings for 1 hour
self.top_25_rankings = []
self.logger.info(f"Initialized NCAAFB manager with display dimensions: {self.display_width}x{self.display_height}")
self.logger.info(f"Logo directory: {self.logo_dir}")
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
@@ -190,7 +192,7 @@ class BaseNCAAFBManager: # Renamed class
odds_data = self.odds_manager.get_odds(
sport="football",
league="ncaa_fb",
league="college-football",
event_id=game['id'],
update_interval_seconds=update_interval
)
@@ -367,6 +369,39 @@ class BaseNCAAFBManager: # Renamed class
else:
return self._fetch_ncaa_fb_api_data(use_cache=True)
def _fetch_rankings(self):
self.logger.info(f"[NCAAFB] Fetching current AP Top 25 rankings from ESPN API...")
try:
url = "http://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings"
response = requests.get(url)
response.raise_for_status()
data = response.json()
# Grab rankings[0]
rankings_0 = data.get("rankings", [])[0]
# Extract top 25 team abbreviations
self.top_25_rankings = [
entry["team"]["abbreviation"]
for entry in rankings_0.get("ranks", [])[:25]
]
except requests.exceptions.RequestException as e:
self.logger.error(f"[NCAAFB] Error retrieving AP Top 25 rankings: {e}")
def _get_rank(self, team_to_check):
i = 1
if self.top_25_rankings:
for team in self.top_25_rankings:
if team == team_to_check:
return i
i += 1
else:
return 0
else:
return 0
def _load_fonts(self):
"""Load fonts used by the scoreboard."""
fonts = {}
@@ -376,6 +411,7 @@ class BaseNCAAFBManager: # Renamed class
fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Using 4x6 for status
fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Added detail font
fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
logging.info("[NCAAFB] Successfully loaded fonts") # Changed log prefix
except IOError:
logging.warning("[NCAAFB] Fonts not found, using default PIL font.") # Changed log prefix
@@ -384,6 +420,7 @@ class BaseNCAAFBManager: # Renamed class
fonts['team'] = ImageFont.load_default()
fonts['status'] = ImageFont.load_default()
fonts['detail'] = ImageFont.load_default()
fonts['rank'] = ImageFont.load_default()
return fonts
def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None:
@@ -833,6 +870,9 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class
self.logger.warning("[NCAAFB] Test mode: Could not parse clock") # Changed log prefix
# No actual display call here, let main loop handle it
else:
# Fetch rankings
self._fetch_rankings()
# Fetch live game data
data = self._fetch_data()
new_live_games = []
@@ -960,6 +1000,24 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class
main_img.paste(away_logo, (away_x, away_y), away_logo)
# --- Draw Text Elements on Overlay ---
# Ranking (if ranked)
home_rank = self._get_rank(game["home_abbr"])
away_rank = self._get_rank(game["away_abbr"])
if home_rank > 0:
rank_text = str(home_rank)
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
rank_x = home_x - 8
rank_y = 2
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
if away_rank > 0:
rank_text = str(away_rank)
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
rank_x = away_x + away_logo.width + 8
rank_y = 2
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
# Scores (centered, slightly above bottom)
home_score = str(game.get("home_score", "0"))
away_score = str(game.get("away_score", "0"))
@@ -1103,6 +1161,9 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class
self.last_update = current_time # Update time even if fetch fails
try:
# Fetch rankings
self._fetch_rankings()
data = self._fetch_data() # Uses shared cache
if not data or 'events' not in data:
self.logger.warning("[NCAAFB Recent] No events found in shared data.") # Changed log prefix
@@ -1236,6 +1297,24 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class
main_img.paste(away_logo, (away_x, away_y), away_logo)
# Draw Text Elements on Overlay
# Ranking (if ranked)
home_rank = self._get_rank(game["home_abbr"])
away_rank = self._get_rank(game["away_abbr"])
if home_rank > 0:
rank_text = str(home_rank)
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
rank_x = home_x - 8
rank_y = 2
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
if away_rank > 0:
rank_text = str(away_rank)
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
rank_x = away_x + away_logo.width - 8
rank_y = 2
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
# Final Scores (Centered, same position as live)
home_score = str(game.get("home_score", "0"))
away_score = str(game.get("away_score", "0"))
@@ -1400,6 +1479,9 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class
self.last_update = current_time
try:
# Fetch rankings
self._fetch_rankings()
data = self._fetch_data() # Uses shared cache
if not data or 'events' not in data:
self.logger.warning("[NCAAFB Upcoming] No events found in shared data.") # Changed log prefix
@@ -1587,6 +1669,25 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class
game_date = game.get("game_date", "")
game_time = game.get("game_time", "")
# Ranking (if ranked)
home_rank = self._get_rank(game["home_abbr"])
away_rank = self._get_rank(game["away_abbr"])
if home_rank > 0:
rank_text = str(home_rank)
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
rank_x = home_x - 8
rank_y = 2
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
if away_rank > 0:
rank_text = str(away_rank)
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
rank_x = away_x + away_logo.width - 8
rank_y = 2
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
# "Next Game" at the top (use smaller status font)
status_text = "Next Game"
status_width = draw_overlay.textlength(status_text, font=self.fonts['status'])

View File

@@ -0,0 +1,954 @@
import os
import time
import logging
import requests
import json
from typing import Dict, Any, Optional
from PIL import Image, ImageDraw, ImageFont
from datetime import datetime, timezone
from src.display_manager import DisplayManager
from src.cache_manager import CacheManager # Keep CacheManager import
from src.odds_manager import OddsManager
from src.logo_downloader import download_missing_logo
import pytz
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Constants
ESPN_NCAAMH_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/scoreboard" # Changed URL for NCAA FB
# Configure logging to match main configuration
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
class BaseNCAAMHockeyManager: # Renamed class
"""Base class for NCAA Mens Hockey managers with common functionality.""" # Updated docstring
# Class variables for warning tracking
_no_data_warning_logged = False
_last_warning_time = 0
_warning_cooldown = 60 # Only log warnings once per minute
_shared_data = None
_last_shared_update = 0
_processed_games_cache = {} # Cache for processed game data
_processed_games_timestamp = 0
logger = logging.getLogger('NCAAMH') # Changed logger name
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
self.display_manager = display_manager
self.config = config
self.cache_manager = cache_manager
self.config_manager = self.cache_manager.config_manager
self.odds_manager = OddsManager(self.cache_manager, self.config_manager)
self.ncaam_hockey_config = config.get("ncaam_hockey_scoreboard", {}) # Changed config key
self.is_enabled = self.ncaam_hockey_config.get("enabled", False)
self.show_odds = self.ncaam_hockey_config.get("show_odds", False)
self.test_mode = self.ncaam_hockey_config.get("test_mode", False)
self.logo_dir = self.ncaam_hockey_config.get("logo_dir", "assets/sports/ncaa_logos") # Changed logo dir
self.update_interval = self.ncaam_hockey_config.get("update_interval_seconds", 60)
self.show_records = self.ncaam_hockey_config.get('show_records', False)
self.show_ranking = self.ncaam_hockey_config.get('show_ranking', False)
self.season_cache_duration = self.ncaam_hockey_config.get("season_cache_duration_seconds", 86400) # 24 hours default
# Number of games to show (instead of time-based windows)
self.recent_games_to_show = self.ncaam_hockey_config.get("recent_games_to_show", 5) # Show last 5 games
self.upcoming_games_to_show = self.ncaam_hockey_config.get("upcoming_games_to_show", 10) # Show next 10 games
# Set up session with retry logic
self.session = requests.Session()
retry_strategy = Retry(
total=5, # increased number of retries
backoff_factor=1, # increased backoff factor
status_forcelist=[429, 500, 502, 503, 504], # added 429 to retry list
allowed_methods=["GET", "HEAD", "OPTIONS"]
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
# Set up headers
self.headers = {
'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive'
}
self.last_update = 0
self.current_game = None
self.fonts = self._load_fonts()
self.favorite_teams = self.ncaam_hockey_config.get("favorite_teams", [])
# Check display modes to determine what data to fetch
display_modes = self.ncaam_hockey_config.get("display_modes", {})
self.recent_enabled = display_modes.get("ncaam_hockey_recent", False)
self.upcoming_enabled = display_modes.get("ncaam_hockey_upcoming", False)
self.live_enabled = display_modes.get("ncaam_hockey_live", False)
self.logger.setLevel(logging.INFO)
self.display_width = self.display_manager.matrix.width
self.display_height = self.display_manager.matrix.height
self._logo_cache = {}
# Initialize team rankings cache
self._team_rankings_cache = {}
self._rankings_cache_timestamp = 0
self._rankings_cache_duration = 3600 # Cache rankings for 1 hour
self.logger.info(f"Initialized NCAAMHockey manager with display dimensions: {self.display_width}x{self.display_height}")
self.logger.info(f"Logo directory: {self.logo_dir}")
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
def _fetch_team_rankings(self) -> Dict[str, int]:
"""Fetch current team rankings from ESPN API."""
current_time = time.time()
# Check if we have cached rankings that are still valid
if (self._team_rankings_cache and
current_time - self._rankings_cache_timestamp < self._rankings_cache_duration):
return self._team_rankings_cache
try:
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/rankings"
response = self.session.get(rankings_url, headers=self.headers, timeout=30)
response.raise_for_status()
data = response.json()
rankings = {}
rankings_data = data.get('rankings', [])
if rankings_data:
# Use the first ranking (usually AP Top 25)
first_ranking = rankings_data[0]
teams = first_ranking.get('ranks', [])
for team_data in teams:
team_info = team_data.get('team', {})
team_abbr = team_info.get('abbreviation', '')
current_rank = team_data.get('current', 0)
if team_abbr and current_rank > 0:
rankings[team_abbr] = current_rank
# Cache the results
self._team_rankings_cache = rankings
self._rankings_cache_timestamp = current_time
self.logger.debug(f"Fetched rankings for {len(rankings)} teams")
return rankings
except Exception as e:
self.logger.error(f"Error fetching team rankings: {e}")
return {}
def _get_timezone(self):
try:
timezone_str = self.config.get('timezone', 'UTC')
return pytz.timezone(timezone_str)
except pytz.UnknownTimeZoneError:
return pytz.utc
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
"""Check if we should log a warning based on cooldown period."""
current_time = time.time()
if current_time - self._last_warning_time > cooldown:
self._last_warning_time = current_time
return True
return False
def _fetch_odds(self, game: Dict) -> None:
"""Fetch odds for a specific game if conditions are met."""
# Check if odds should be shown for this sport
if not self.show_odds:
return
# Check if we should only fetch for favorite teams
is_favorites_only = self.ncaam_hockey_config.get("show_favorite_teams_only", False)
if is_favorites_only:
home_abbr = game.get('home_abbr')
away_abbr = game.get('away_abbr')
if not (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams):
self.logger.debug(f"Skipping odds fetch for non-favorite game in favorites-only mode: {away_abbr}@{home_abbr}")
return
self.logger.debug(f"Proceeding with odds fetch for game: {game.get('id', 'N/A')}")
# Fetch odds using OddsManager (ESPN API)
try:
# Determine update interval based on game state
is_live = game.get('status', '').lower() == 'in'
update_interval = self.ncaam_hockey_config.get("live_odds_update_interval", 60) if is_live \
else self.ncaam_hockey_config.get("odds_update_interval", 3600)
odds_data = self.odds_manager.get_odds(
sport="hockey",
league="mens-college-hockey",
event_id=game['id'],
update_interval_seconds=update_interval
)
if odds_data:
game['odds'] = odds_data
self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}")
else:
self.logger.debug(f"No odds data returned for game {game['id']}")
except Exception as e:
self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}")
def _fetch_ncaa_fb_api_data(self, use_cache: bool = True) -> Optional[Dict]:
"""
Fetches the full season schedule for NCAAMH, caches it, and then filters
for relevant games based on the current configuration.
"""
now = datetime.now(pytz.utc)
current_year = now.year
years_to_check = [current_year]
if now.month < 8:
years_to_check.append(current_year - 1)
all_events = []
for year in years_to_check:
cache_key = f"ncaamh_schedule_{year}"
if use_cache:
cached_data = self.cache_manager.get(cache_key, max_age=self.season_cache_duration)
if cached_data:
self.logger.info(f"[NCAAMH] Using cached schedule for {year}")
all_events.extend(cached_data)
continue
self.logger.info(f"[NCAAMH] Fetching full {year} season schedule from ESPN API...")
try:
response = self.session.get(ESPN_NCAAMH_SCOREBOARD_URL, params={"dates": year,"limit":1000},headers=self.headers, timeout=15)
response.raise_for_status()
data = response.json()
events = data.get('events', [])
if use_cache:
self.cache_manager.set(cache_key, events)
self.logger.info(f"[NCAAMH] Successfully fetched and cached {len(events)} events for {year} season.")
all_events.extend(events)
except requests.exceptions.RequestException as e:
self.logger.error(f"[NCAAMH] API error fetching full schedule for {year}: {e}")
continue
if not all_events:
self.logger.warning("[NCAAMH] No events found in schedule data.")
return None
return {'events': all_events}
def _fetch_data(self, date_str: str = None) -> Optional[Dict]:
"""Fetch data using shared data mechanism or direct fetch for live."""
if isinstance(self, NCAAMHockeyLiveManager):
return self._fetch_ncaa_fb_api_data(use_cache=False)
else:
return self._fetch_ncaa_fb_api_data(use_cache=True)
def _load_fonts(self):
"""Load fonts used by the scoreboard."""
fonts = {}
try:
fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
logging.info("[NCAAMH] Successfully loaded Press Start 2P font for all text elements")
except IOError:
logging.warning("[NCAAMH] Press Start 2P font not found, trying 4x6 font.")
try:
# Try to load the 4x6 font as a fallback
fonts['score'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 12)
fonts['time'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8)
fonts['team'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8)
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 9)
logging.info("[NCAAMH] Successfully loaded 4x6 font for all text elements")
except IOError:
logging.warning("[NCAAMH] 4x6 font not found, using default PIL font.")
# Use default PIL font as a last resort
fonts['score'] = ImageFont.load_default()
fonts['time'] = ImageFont.load_default()
fonts['team'] = ImageFont.load_default()
fonts['status'] = ImageFont.load_default()
return fonts
def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)):
"""
Draw text with a black outline for better readability.
Args:
draw: ImageDraw object
text: Text to draw
position: (x, y) position to draw the text
font: Font to use
fill: Text color (default: white)
outline_color: Outline color (default: black)
"""
x, y = position
# Draw the outline by drawing the text in black at 8 positions around the text
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
# Draw the text in the specified color
draw.text((x, y), text, font=font, fill=fill)
def _load_and_resize_logo(self, team_abbrev: str, team_name: str = None) -> Optional[Image.Image]:
"""Load and resize a team logo, with caching and automatic download if missing."""
if team_abbrev in self._logo_cache:
return self._logo_cache[team_abbrev]
logo_path = os.path.join(self.logo_dir, f"{team_abbrev}.png")
self.logger.debug(f"Logo path: {logo_path}")
try:
# Try to download missing logo first
if not os.path.exists(logo_path):
self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.")
# Try to download the logo from ESPN API
success = download_missing_logo(team_abbrev, 'ncaam_hockey', team_name)
if not success:
# Create placeholder if download fails
self.logger.warning(f"Failed to download logo for {team_abbrev}. Creating placeholder.")
os.makedirs(os.path.dirname(logo_path), exist_ok=True)
logo = Image.new('RGBA', (32, 32), (200, 200, 200, 255)) # Gray placeholder
draw = ImageDraw.Draw(logo)
draw.text((2, 10), team_abbrev, fill=(0, 0, 0, 255))
logo.save(logo_path)
self.logger.info(f"Created placeholder logo at {logo_path}")
logo = Image.open(logo_path)
if logo.mode != 'RGBA':
logo = logo.convert('RGBA')
max_width = int(self.display_width * 1.5)
max_height = int(self.display_height * 1.5)
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
self._logo_cache[team_abbrev] = logo
return logo
except Exception as e:
self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True)
return None
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract relevant game details from ESPN API response."""
if not game_event:
return None
try:
competition = game_event["competitions"][0]
status = competition["status"]
competitors = competition["competitors"]
game_date_str = game_event["date"]
# Parse game date/time
try:
start_time_utc = datetime.fromisoformat(game_date_str.replace("Z", "+00:00"))
self.logger.debug(f"[NCAAMH] Parsed game time: {start_time_utc}")
except ValueError:
logging.warning(f"[NCAAMH] Could not parse game date: {game_date_str}")
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")
home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else ''
away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else ''
# Don't show "0-0" records - set to blank instead
if home_record == "0-0":
home_record = ''
if away_record == "0-0":
away_record = ''
# Format game time and date for display
game_time = ""
game_date = ""
if start_time_utc:
# Convert to local time
local_time = start_time_utc.astimezone(self._get_timezone())
game_time = local_time.strftime("%-I:%M%p")
# Check date format from config
use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False)
if use_short_date_format:
game_date = local_time.strftime("%-m/%-d")
else:
game_date = self.display_manager.format_date_with_ordinal(local_time)
details = {
"start_time_utc": start_time_utc,
"status_text": status["type"]["shortDetail"],
"period": status.get("period", 0),
"clock": status.get("displayClock", "0:00"),
"is_live": status["type"]["state"] in ("in", "halftime"),
"is_final": status["type"]["state"] == "post",
"is_upcoming": status["type"]["state"] == "pre",
"home_abbr": home_team["team"]["abbreviation"],
"home_score": home_team.get("score", "0"),
"home_record": home_record,
"home_logo_path": os.path.join(self.logo_dir, f"{home_team['team']['abbreviation']}.png"),
"away_abbr": away_team["team"]["abbreviation"],
"away_score": away_team.get("score", "0"),
"away_record": away_record,
"away_logo_path": os.path.join(self.logo_dir, f"{away_team['team']['abbreviation']}.png"),
"game_time": game_time,
"game_date": game_date,
"id": game_event.get("id")
}
# Log game details for debugging
self.logger.debug(f"[NCAAMH] Extracted game details: {details['away_abbr']} vs {details['home_abbr']}")
# Use .get() to avoid KeyError if optional keys are missing
self.logger.debug(
f"[NCAAMH] Game status: is_final={details.get('is_final')}, "
f"is_upcoming={details.get('is_upcoming')}, is_live={details.get('is_live')}"
)
# Validate logo files
for team in ["home", "away"]:
logo_path = details[f"{team}_logo_path"]
if not os.path.isfile(logo_path):
# logging.warning(f"[NCAAMH] {team.title()} logo not found: {logo_path}")
details[f"{team}_logo_path"] = None
else:
try:
with Image.open(logo_path) as img:
logging.debug(f"[NCAAMH] {team.title()} logo is valid: {img.format}, size: {img.size}")
except Exception as e:
logging.error(f"[NCAAMH] {team.title()} logo file exists but is not valid: {e}")
details[f"{team}_logo_path"] = None
return details
except Exception as e:
logging.error(f"[NCAAMH] Error extracting game details: {e}")
return None
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Draw the scorebug layout for the current game."""
try:
# Create a new black image for the main display
main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255))
# Load logos once
home_logo = self._load_and_resize_logo(game["home_abbr"])
away_logo = self._load_and_resize_logo(game["away_abbr"])
if not home_logo or not away_logo:
self.logger.error("Failed to load one or both team logos")
return
# Create a single overlay for both logos
overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0))
# Calculate vertical center line for alignment
center_y = self.display_height // 2
# Draw home team logo (far right, extending beyond screen)
home_x = self.display_width - home_logo.width + 2
home_y = center_y - (home_logo.height // 2)
# Paste the home logo onto the overlay
overlay.paste(home_logo, (home_x, home_y), home_logo)
# Draw away team logo (far left, extending beyond screen)
away_x = -2
away_y = center_y - (away_logo.height // 2)
# Paste the away logo onto the overlay
overlay.paste(away_logo, (away_x, away_y), away_logo)
# Composite the overlay with the main image
main_img = Image.alpha_composite(main_img, overlay)
# Convert to RGB for final display
main_img = main_img.convert('RGB')
draw = ImageDraw.Draw(main_img)
# Check if this is an upcoming game
is_upcoming = game.get("is_upcoming", False)
if is_upcoming:
# For upcoming games, show date and time stacked in the center
game_date = game.get("game_date", "")
game_time = game.get("game_time", "")
# Show "Next Game" at the top
status_text = "Next Game"
status_width = draw.textlength(status_text, font=self.fonts['status'])
status_x = (self.display_width - status_width) // 2
status_y = 2
self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['status'])
# Calculate position for the date text (centered horizontally, below "Next Game")
date_width = draw.textlength(game_date, font=self.fonts['time'])
date_x = (self.display_width - date_width) // 2
date_y = center_y - 5 # Position in center
self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time'])
# Calculate position for the time text (centered horizontally, in center)
time_width = draw.textlength(game_time, font=self.fonts['time'])
time_x = (self.display_width - time_width) // 2
time_y = date_y + 10 # Position below date
self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time'])
else:
# For live/final games, show scores and period/time
home_score = str(game.get("home_score", "0"))
away_score = str(game.get("away_score", "0"))
score_text = f"{away_score}-{home_score}"
# Calculate position for the score text (centered at the bottom)
score_width = draw.textlength(score_text, font=self.fonts['score'])
score_x = (self.display_width - score_width) // 2
score_y = self.display_height - 15
self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score'])
# Draw period and time or Final
if game.get("is_final", False):
status_text = "Final"
else:
period = game.get("period", 0)
clock = game.get("clock", "0:00")
# Format period text
if period > 3:
period_text = "OT"
else:
period_text = f"{period}{'st' if period == 1 else 'nd' if period == 2 else 'rd'}"
status_text = f"{period_text} {clock}"
# Calculate position for the status text (centered at the top)
status_width = draw.textlength(status_text, font=self.fonts['time'])
status_x = (self.display_width - status_width) // 2
status_y = 5
self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time'])
# Display odds if available
if 'odds' in game:
odds = game['odds']
spread = odds.get('spread', {}).get('point', None)
if spread is not None:
# Format spread text
spread_text = f"{spread:+.1f}" if spread > 0 else f"{spread:.1f}"
# Choose color and position based on which team has the spread
if odds.get('spread', {}).get('team') == game['home_abbr']:
text_color = (255, 100, 100) # Reddish
spread_x = self.display_width - draw.textlength(spread_text, font=self.fonts['status']) - 2
else:
text_color = (100, 255, 100) # Greenish
spread_x = 2
spread_y = 0
self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), self.fonts['status'], fill=text_color)
# Draw records if enabled
if self.show_records:
try:
record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
except IOError:
record_font = ImageFont.load_default()
away_record = game.get('away_record', '')
home_record = game.get('home_record', '')
record_bbox = draw.textbbox((0,0), "0-0", font=record_font)
record_height = record_bbox[3] - record_bbox[1]
record_y = self.display_height - record_height
if away_record:
away_record_x = 2
self._draw_text_with_outline(draw, away_record, (away_record_x, record_y), record_font)
if home_record:
home_record_bbox = draw.textbbox((0,0), home_record, font=record_font)
home_record_width = home_record_bbox[2] - home_record_bbox[0]
home_record_x = self.display_width - home_record_width - 2
self._draw_text_with_outline(draw, home_record, (home_record_x, record_y), record_font)
# Display the image
self.display_manager.image.paste(main_img, (0, 0))
self.display_manager.update_display()
except Exception as e:
self.logger.error(f"Error displaying game: {e}", exc_info=True)
def display(self, force_clear: bool = False) -> None:
"""Common display method for all NCAAMH managers"""
if not self.current_game:
current_time = time.time()
if not hasattr(self, '_last_warning_time'):
self._last_warning_time = 0
if current_time - self._last_warning_time > 300: # 5 minutes cooldown
self.logger.warning("[NCAAMH] No game data available to display")
self._last_warning_time = current_time
return
self._draw_scorebug_layout(self.current_game, force_clear)
class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager): # Renamed class
"""Manager for live NCAA Mens Hockey games."""
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
super().__init__(config, display_manager, cache_manager)
self.update_interval = self.ncaam_hockey_config.get("live_update_interval", 15) # 15 seconds for live games
self.no_data_interval = 300 # 5 minutes when no live games
self.last_update = 0
self.logger.info("Initialized NCAA Mens Hockey Live Manager")
self.live_games = [] # List to store all live games
self.current_game_index = 0 # Index to track which game to show
self.last_game_switch = 0 # Track when we last switched games
self.game_display_duration = self.ncaam_hockey_config.get("live_game_duration", 20) # Display each live game for 20 seconds
self.last_display_update = 0 # Track when we last updated the display
self.last_log_time = 0
self.log_interval = 300 # Only log status every 5 minutes
# Initialize with test game only if test mode is enabled
if self.test_mode:
self.current_game = {
"home_abbr": "RIT",
"away_abbr": "PU",
"home_score": "3",
"away_score": "2",
"period": 2,
"clock": "12:34",
"home_logo_path": os.path.join(self.logo_dir, "RIT.png"),
"away_logo_path": os.path.join(self.logo_dir, "PU.png"),
"game_time": "7:30 PM",
"game_date": "Apr 17"
}
self.live_games = [self.current_game]
logging.info("[NCAAMH] Initialized NCAAMHockeyLiveManager with test game: RIT vs PU")
else:
logging.info("[NCAAMH] Initialized NCAAMHockeyLiveManager in live mode")
def update(self):
"""Update live game data."""
if not self.is_enabled: return
current_time = time.time()
interval = self.no_data_interval if not self.live_games else self.update_interval
if current_time - self.last_update >= interval:
self.last_update = current_time
if self.test_mode:
# For testing, we'll just update the clock to show it's working
if self.current_game:
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
self.display(force_clear=True)
else:
# Fetch live game data from ESPN API
data = self._fetch_data()
if data and "events" in data:
# Find all live games involving favorite teams
new_live_games = []
for event in data["events"]:
details = self._extract_game_details(event)
if details and details["is_live"]:
self._fetch_odds(details)
new_live_games.append(details)
# Filter for favorite teams only if the config is set
if self.ncaam_hockey_config.get("show_favorite_teams_only", False):
new_live_games = [game for game in new_live_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
# Only log if there's a change in games or enough time has passed
should_log = (
current_time - self.last_log_time >= self.log_interval or
len(new_live_games) != len(self.live_games) or
not self.live_games # Log if we had no games before
)
if should_log:
if new_live_games:
filter_text = "favorite teams" if self.ncaam_hockey_config.get("show_favorite_teams_only", False) else "all teams"
self.logger.info(f"[NCAAMH] Found {len(new_live_games)} live games involving {filter_text}")
for game in new_live_games:
self.logger.info(f"[NCAAMH] Live game: {game['away_abbr']} vs {game['home_abbr']} - Period {game['period']}, {game['clock']}")
else:
filter_text = "favorite teams" if self.ncaam_hockey_config.get("show_favorite_teams_only", False) else "criteria"
self.logger.info(f"[NCAAMH] No live games found matching {filter_text}")
self.last_log_time = current_time
if new_live_games:
# Update the current game with the latest data
for new_game in new_live_games:
if self.current_game and (
(new_game["home_abbr"] == self.current_game["home_abbr"] and
new_game["away_abbr"] == self.current_game["away_abbr"]) or
(new_game["home_abbr"] == self.current_game["away_abbr"] and
new_game["away_abbr"] == self.current_game["home_abbr"])
):
self.current_game = new_game
break
# Only update the games list if we have new games
if not self.live_games or set(game["away_abbr"] + game["home_abbr"] for game in new_live_games) != set(game["away_abbr"] + game["home_abbr"] for game in self.live_games):
self.live_games = new_live_games
# If we don't have a current game or it's not in the new list, start from the beginning
if not self.current_game or self.current_game not in self.live_games:
self.current_game_index = 0
self.current_game = self.live_games[0]
self.last_game_switch = current_time
# Update display if data changed, limit rate
if current_time - self.last_display_update >= 1.0:
# self.display(force_clear=True) # REMOVED: DisplayController handles this
self.last_display_update = current_time
else:
# No live games found
self.live_games = []
self.current_game = None
# Check if it's time to switch games
if len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration:
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
self.current_game = self.live_games[self.current_game_index]
self.last_game_switch = current_time
# self.display(force_clear=True) # REMOVED: DisplayController handles this
self.last_display_update = current_time # Track time for potential display update
def display(self, force_clear=False):
"""Display live game information."""
if not self.current_game:
return
super().display(force_clear) # Call parent class's display method
class NCAAMHockeyRecentManager(BaseNCAAMHockeyManager):
"""Manager for recently completed NCAAMH games."""
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
super().__init__(config, display_manager, cache_manager)
self.recent_games = []
self.current_game_index = 0
self.last_update = 0
self.update_interval = 300 # 5 minutes
self.recent_games_to_show = self.ncaam_hockey_config.get("recent_games_to_show", 5) # Number of most recent games to display
self.last_game_switch = 0
self.game_display_duration = 15 # Display each game for 15 seconds
self.logger.info(f"Initialized NCAAMHRecentManager with {len(self.favorite_teams)} favorite teams")
def update(self):
"""Update recent games data."""
current_time = time.time()
if current_time - self.last_update < self.update_interval:
return
self.last_update = current_time
try:
# Fetch data from ESPN API
data = self._fetch_data()
if not data or 'events' not in data:
self.logger.warning("[NCAAMH] No events found in ESPN API response")
return
events = data['events']
self.logger.info(f"[NCAAMH] Successfully fetched {len(events)} events from ESPN API")
# Process games
processed_games = []
for event in events:
game = self._extract_game_details(event)
if game and game['is_final']:
# Fetch odds if enabled
self._fetch_odds(game)
processed_games.append(game)
# Filter for favorite teams only if the config is set
if self.ncaam_hockey_config.get("show_favorite_teams_only", False):
team_games = [game for game in processed_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
else:
team_games = processed_games
# Sort games by start time, most recent first, then limit to recent_games_to_show
team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
team_games = team_games[:self.recent_games_to_show]
self.logger.info(f"[NCAAMH] Found {len(team_games)} recent games for favorite teams (limited to {self.recent_games_to_show})")
new_game_ids = {g['id'] for g in team_games}
current_game_ids = {g['id'] for g in getattr(self, 'games_list', [])}
if new_game_ids != current_game_ids:
self.games_list = team_games
self.current_game_index = 0
self.current_game = self.games_list[0] if self.games_list else None
self.last_game_switch = current_time
elif self.games_list:
self.current_game = self.games_list[self.current_game_index]
if not self.games_list:
self.current_game = None
except Exception as e:
self.logger.error(f"[NCAAMH] Error updating recent games: {e}", exc_info=True)
def display(self, force_clear=False):
"""Display recent games."""
if not self.games_list:
self.logger.info("[NCAAMH] No recent games to display")
return # Skip display update entirely
try:
current_time = time.time()
# Check if it's time to switch games
if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration:
# Move to next game
self.current_game_index = (self.current_game_index + 1) % len(self.games_list)
self.current_game = self.games_list[self.current_game_index]
self.last_game_switch = current_time
force_clear = True # Force clear when switching games
# Draw the scorebug layout
self._draw_scorebug_layout(self.current_game, force_clear)
# Update display
self.display_manager.update_display()
except Exception as e:
self.logger.error(f"[NCAAMH] Error displaying recent game: {e}", exc_info=True)
class NCAAMHockeyUpcomingManager(BaseNCAAMHockeyManager):
"""Manager for upcoming NCAA Mens Hockey games."""
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
super().__init__(config, display_manager, cache_manager)
self.upcoming_games = []
self.current_game_index = 0
self.last_update = 0
self.update_interval = 300 # 5 minutes
self.upcoming_games_to_show = self.ncaam_hockey_config.get("upcoming_games_to_show", 5) # Number of upcoming games to display
self.last_log_time = 0
self.log_interval = 300 # Only log status every 5 minutes
self.last_warning_time = 0
self.warning_cooldown = 300 # Only show warning every 5 minutes
self.last_game_switch = 0 # Track when we last switched games
self.game_display_duration = 15 # Display each game for 15 seconds
self.logger.info(f"Initialized NCAAMHUpcomingManager with {len(self.favorite_teams)} favorite teams")
def update(self):
"""Update upcoming games data."""
current_time = time.time()
if current_time - self.last_update < self.update_interval:
return
self.last_update = current_time
try:
# Fetch data from ESPN API
data = self._fetch_data()
if not data or 'events' not in data:
self.logger.warning("[NCAAMH] No events found in ESPN API response")
return
events = data['events']
self.logger.info(f"[NCAAMH] Successfully fetched {len(events)} events from ESPN API")
# Process games
new_upcoming_games = []
for event in events:
game = self._extract_game_details(event)
if game and game['is_upcoming']:
# Only fetch odds for games that will be displayed
if self.ncaam_hockey_config.get("show_favorite_teams_only", False):
if not self.favorite_teams or (game['home_abbr'] not in self.favorite_teams and game['away_abbr'] not in self.favorite_teams):
continue
self._fetch_odds(game)
new_upcoming_games.append(game)
# Filter for favorite teams only if the config is set
if self.ncaam_hockey_config.get("show_favorite_teams_only", False):
team_games = [game for game in new_upcoming_games
if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
else:
team_games = new_upcoming_games
# Sort games by start time, soonest first, then limit to configured count
team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
team_games = team_games[:self.upcoming_games_to_show]
# Only log if there's a change in games or enough time has passed
should_log = (
current_time - self.last_log_time >= self.log_interval or
len(team_games) != len(self.upcoming_games) or
not self.upcoming_games # Log if we had no games before
)
if should_log:
if team_games:
self.logger.info(f"[NCAAMH] Found {len(team_games)} upcoming games for favorite teams (limited to {self.upcoming_games_to_show})")
for game in team_games:
self.logger.info(f"[NCAAMH] Upcoming game: {game['away_abbr']} vs {game['home_abbr']} - {game['game_date']} {game['game_time']}")
else:
self.logger.info("[NCAAMH] No upcoming games found for favorite teams")
self.logger.debug(f"[NCAAMH] Favorite teams: {self.favorite_teams}")
self.last_log_time = current_time
self.upcoming_games = team_games
if self.upcoming_games:
if not self.current_game or self.current_game['id'] not in {g['id'] for g in self.upcoming_games}:
self.current_game_index = 0
self.current_game = self.upcoming_games[0]
self.last_game_switch = current_time
else:
self.current_game = None
except Exception as e:
self.logger.error(f"[NCAAMH] Error updating upcoming games: {e}", exc_info=True)
def display(self, force_clear=False):
"""Display upcoming games."""
if not self.upcoming_games:
current_time = time.time()
if current_time - self.last_warning_time > self.warning_cooldown:
self.logger.info("[NCAAMH] No upcoming games to display")
self.last_warning_time = current_time
return # Skip display update entirely
try:
current_time = time.time()
# Check if it's time to switch games
if len(self.upcoming_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration:
# Move to next game
self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games)
self.current_game = self.upcoming_games[self.current_game_index]
self.last_game_switch = current_time
force_clear = True # Force clear when switching games
# Draw the scorebug layout
self._draw_scorebug_layout(self.current_game, force_clear)
# Update display
self.display_manager.update_display()
except Exception as e:
self.logger.error(f"[NCAAMH] Error displaying upcoming game: {e}", exc_info=True)

View File

@@ -154,8 +154,8 @@ class BaseNFLManager: # Renamed class
self.logger.info(f"[NFL] Fetching full {current_year} season schedule from ESPN API (cache_enabled={use_cache})...")
try:
url = f"https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard?dates={current_year}"
response = self.session.get(url, headers=self.headers, timeout=15)
url = f"https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
response = self.session.get(url, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15)
response.raise_for_status()
data = response.json()
events = data.get('events', [])

View File

@@ -1,11 +1,8 @@
import os
import json
import logging
from datetime import datetime, date
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from rgbmatrix import graphics
import pytz
from datetime import date
from PIL import ImageDraw, ImageFont
from src.config_manager import ConfigManager
import time
try:

View File

@@ -4,8 +4,6 @@ import time
import logging
from PIL import Image, ImageDraw, ImageFont
import requests
from rgbmatrix import RGBMatrix, RGBMatrixOptions
import os
from typing import Dict, Any
# Import the API counter function from web interface