mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
Merge branch 'working' of https://github.com/ChuckBuilds/LEDMatrix into working
This commit is contained in:
76
README.md
76
README.md
@@ -3,9 +3,10 @@
|
||||
A modular LED matrix display system for sports information using Raspberry Pi and RGB LED matrices.
|
||||
|
||||
## Hardware Requirements
|
||||
- Raspberry Pi 3 or newer
|
||||
- Raspberry Pi 4 or older
|
||||
- Adafruit RGB Matrix Bonnet/HAT
|
||||
- LED Matrix panels (64x32)
|
||||
- 2x LED Matrix panels (64x32)
|
||||
- DC Power Supply for Adafruit RGB HAT
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -31,6 +32,72 @@ cp config/config.example.json config/config.json
|
||||
|
||||
2. Edit `config/config.json` with your preferences
|
||||
|
||||
### YouTube Display Configuration
|
||||
|
||||
The YouTube display module shows channel statistics for a specified YouTube channel. To configure it:
|
||||
|
||||
1. In `config/config.json`, add the following section:
|
||||
```json
|
||||
{
|
||||
"youtube": {
|
||||
"enabled": true,
|
||||
"update_interval": 300 // Update interval in seconds (default: 300)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. In `config/config_secrets.json`, add your YouTube API credentials:
|
||||
```json
|
||||
{
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_CHANNEL_ID"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To get these credentials:
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the YouTube Data API v3
|
||||
4. Create credentials (API key)
|
||||
5. For the channel ID, you can find it in your YouTube channel URL or use the YouTube Data API to look it up
|
||||
|
||||
### Calendar Display Configuration
|
||||
|
||||
The calendar display module shows upcoming events from your Google Calendar. To configure it:
|
||||
|
||||
1. In `config/config.json`, add the following section:
|
||||
```json
|
||||
{
|
||||
"calendar": {
|
||||
"enabled": true,
|
||||
"update_interval": 300, // Update interval in seconds (default: 300)
|
||||
"max_events": 3, // Maximum number of events to display
|
||||
"calendars": ["primary"] // List of calendar IDs to display
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Set up Google Calendar API access:
|
||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the Google Calendar API
|
||||
4. Create OAuth 2.0 credentials:
|
||||
- Application type: Desktop app
|
||||
- Download the credentials file as `credentials.json`
|
||||
5. Place the `credentials.json` file in your project root directory
|
||||
|
||||
3. On first run, the application will:
|
||||
- Open a browser window for Google authentication
|
||||
- Request calendar read-only access
|
||||
- Save the authentication token as `token.pickle`
|
||||
|
||||
The calendar display will show:
|
||||
- Event date and time
|
||||
- Event title (wrapped to fit the display)
|
||||
- Up to 3 upcoming events (configurable)
|
||||
|
||||
## API Keys
|
||||
|
||||
For sensitive settings like API keys:
|
||||
@@ -96,6 +163,7 @@ The LEDMatrix system includes a robust caching mechanism to optimize API calls a
|
||||
- Stock prices and market data
|
||||
- Stock news headlines
|
||||
- NHL game information
|
||||
- NBA game information
|
||||
|
||||
### Cache Behavior
|
||||
- Data is cached based on update intervals defined in `config.json`
|
||||
@@ -113,9 +181,9 @@ The LEDMatrix system includes a robust caching mechanism to optimize API calls a
|
||||
- Temporary files are used for safe updates
|
||||
- JSON serialization handles all data types including timestamps
|
||||
|
||||
## NHL Scoreboard Display
|
||||
## NHL, NBA Scoreboard Display
|
||||
|
||||
The LEDMatrix system includes a comprehensive NHL scoreboard display system with three display modes:
|
||||
The LEDMatrix system includes a comprehensive NHL, NBA scoreboard display system with three display modes:
|
||||
|
||||
### Display Modes
|
||||
- **Live Games**: Shows currently playing games with live scores and game status
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"nba_live": 30,
|
||||
"nba_recent": 20,
|
||||
"nba_upcoming": 20,
|
||||
"calendar": 30
|
||||
"calendar": 30,
|
||||
"youtube": 20
|
||||
}
|
||||
},
|
||||
"clock": {
|
||||
@@ -109,5 +110,9 @@
|
||||
"nba_upcoming": true
|
||||
},
|
||||
"live_game_duration": 30
|
||||
},
|
||||
"youtube": {
|
||||
"enabled": true,
|
||||
"update_interval": 3600
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"weather": {
|
||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||
},
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,16 @@ import numpy as np
|
||||
from rgbmatrix import graphics
|
||||
import pytz
|
||||
from src.config_manager import ConfigManager
|
||||
import time
|
||||
|
||||
# Configure logger for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO to reduce noise
|
||||
|
||||
class CalendarManager:
|
||||
def __init__(self, matrix, canvas, config):
|
||||
self.matrix = matrix
|
||||
self.canvas = canvas
|
||||
def __init__(self, display_manager, config):
|
||||
logger.info("Initializing CalendarManager")
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.calendar_config = config.get('calendar', {})
|
||||
self.enabled = self.calendar_config.get('enabled', False)
|
||||
@@ -24,12 +29,11 @@ class CalendarManager:
|
||||
self.max_events = self.calendar_config.get('max_events', 3)
|
||||
self.calendars = self.calendar_config.get('calendars', ['birthdays'])
|
||||
self.last_update = 0
|
||||
self.last_debug_log = 0 # Add timestamp for debug message throttling
|
||||
self.events = []
|
||||
self.service = None
|
||||
|
||||
# Get display manager instance
|
||||
from src.display_manager import DisplayManager
|
||||
self.display_manager = DisplayManager._instance
|
||||
logger.info(f"Calendar configuration: enabled={self.enabled}, update_interval={self.update_interval}, max_events={self.max_events}, calendars={self.calendars}")
|
||||
|
||||
# Get timezone from config
|
||||
self.config_manager = ConfigManager()
|
||||
@@ -37,11 +41,13 @@ class CalendarManager:
|
||||
try:
|
||||
self.timezone = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logging.warning(f"Unknown timezone '{timezone_str}' in config, defaulting to UTC.")
|
||||
logger.warning(f"Unknown timezone '{timezone_str}' in config, defaulting to UTC.")
|
||||
self.timezone = pytz.utc
|
||||
|
||||
if self.enabled:
|
||||
self.authenticate()
|
||||
else:
|
||||
logger.warning("Calendar manager is disabled in configuration")
|
||||
|
||||
# Display properties
|
||||
self.text_color = (255, 255, 255) # White
|
||||
@@ -50,30 +56,37 @@ class CalendarManager:
|
||||
|
||||
# State management
|
||||
self.current_event_index = 0
|
||||
self.force_clear = False
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticate with Google Calendar API."""
|
||||
logger.info("Starting calendar authentication")
|
||||
creds = None
|
||||
token_file = self.calendar_config.get('token_file', 'token.pickle')
|
||||
|
||||
if os.path.exists(token_file):
|
||||
logger.info(f"Loading credentials from {token_file}")
|
||||
with open(token_file, 'rb') as token:
|
||||
creds = pickle.load(token)
|
||||
|
||||
|
||||
if not creds or not creds.valid:
|
||||
logger.info("Credentials not found or invalid")
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
logger.info("Refreshing expired credentials")
|
||||
creds.refresh(Request())
|
||||
else:
|
||||
logging.error("Calendar credentials not found or invalid. Please run calendar_registration.py first.")
|
||||
self.enabled = False
|
||||
return
|
||||
|
||||
try:
|
||||
self.service = build('calendar', 'v3', credentials=creds)
|
||||
logging.info("Successfully authenticated with Google Calendar")
|
||||
except Exception as e:
|
||||
logging.error(f"Error building calendar service: {str(e)}")
|
||||
self.enabled = False
|
||||
logger.info("Requesting new credentials")
|
||||
flow = InstalledAppFlow.from_client_secrets_file(
|
||||
self.calendar_config.get('credentials_file', 'credentials.json'),
|
||||
['https://www.googleapis.com/auth/calendar.readonly'])
|
||||
creds = flow.run_local_server(port=0)
|
||||
|
||||
logger.info(f"Saving credentials to {token_file}")
|
||||
with open(token_file, 'wb') as token:
|
||||
pickle.dump(creds, token)
|
||||
|
||||
self.service = build('calendar', 'v3', credentials=creds)
|
||||
logger.info("Calendar service built successfully")
|
||||
|
||||
def get_events(self):
|
||||
"""Fetch upcoming calendar events."""
|
||||
@@ -99,6 +112,13 @@ class CalendarManager:
|
||||
def draw_event(self, event, y_start=1):
|
||||
"""Draw a single calendar event on the canvas. Returns True on success, False on error."""
|
||||
try:
|
||||
# Only log event details at INFO level when first switching to calendar display
|
||||
if self.force_clear:
|
||||
logger.info(f"CalendarManager displaying event: {event.get('summary', 'No title')}")
|
||||
logger.info(f"Event details - Date: {self._format_event_date(event)}, Time: {self._format_event_time(event)}, Summary: {event.get('summary', 'No Title')}")
|
||||
else:
|
||||
logger.debug(f"Drawing event: {event.get('summary', 'No title')}")
|
||||
|
||||
# Get event details
|
||||
summary = event.get('summary', 'No Title')
|
||||
time_str = self._format_event_time(event)
|
||||
@@ -108,89 +128,105 @@ class CalendarManager:
|
||||
font = self.display_manager.small_font
|
||||
available_width = self.display_manager.matrix.width - 4 # Leave 2 pixel margin on each side
|
||||
|
||||
# Wrap title text
|
||||
title_lines = self._wrap_text(summary, available_width, font)
|
||||
|
||||
# Calculate total height needed
|
||||
date_height = 8 # Approximate height for date string
|
||||
time_height = 8 # Approximate height for time string
|
||||
title_height = len(title_lines) * 8 # Approximate height for title lines
|
||||
# Height = date + time + title + spacing between each
|
||||
total_height = date_height + time_height + title_height + ( (1 + len(title_lines)) * 2 )
|
||||
# Draw date and time on top line
|
||||
datetime_str = f"{date_str} {time_str}"
|
||||
self.display_manager.draw_text(datetime_str, y=2, color=self.text_color, small_font=True)
|
||||
|
||||
# Calculate starting y position to center vertically
|
||||
y_pos = (self.display_manager.matrix.height - total_height) // 2
|
||||
y_pos = max(1, y_pos) # Ensure it doesn't start above the top edge
|
||||
|
||||
# Draw date in grey
|
||||
self.display_manager.draw_text(date_str, y=y_pos, color=self.date_color, small_font=True)
|
||||
y_pos += date_height + 2 # Move down for the time
|
||||
|
||||
# Draw time in green
|
||||
self.display_manager.draw_text(time_str, y=y_pos, color=self.time_color, small_font=True)
|
||||
y_pos += time_height + 2 # Move down for the title
|
||||
# Wrap summary text for two lines
|
||||
title_lines = self._wrap_text(summary, available_width, font, max_lines=2)
|
||||
|
||||
# Draw title lines
|
||||
# Draw summary lines
|
||||
y_pos = 12 # Start position for summary (below date/time)
|
||||
for line in title_lines:
|
||||
if y_pos >= self.display_manager.matrix.height - 8: # Stop if we run out of space
|
||||
break
|
||||
self.display_manager.draw_text(line, y=y_pos, color=self.text_color, small_font=True)
|
||||
y_pos += 8 + 2 # Move down for the next line, add 2px spacing
|
||||
return True # Return True on successful drawing
|
||||
y_pos += 8 # Move down for next line
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error drawing calendar event: {str(e)}", exc_info=True)
|
||||
return False # Return False on error
|
||||
logger.error(f"Error drawing calendar event: {str(e)}", exc_info=True)
|
||||
return False
|
||||
|
||||
def _wrap_text(self, text, max_width, font):
|
||||
def _wrap_text(self, text, max_width, font, max_lines=2):
|
||||
"""Wrap text to fit within max_width using the provided font."""
|
||||
if not text:
|
||||
return [""]
|
||||
|
||||
lines = []
|
||||
words = text.split()
|
||||
current_line = []
|
||||
|
||||
words = text.split()
|
||||
|
||||
for word in words:
|
||||
test_line = ' '.join(current_line + [word])
|
||||
# Use textlength for accurate width calculation
|
||||
text_width = self.display_manager.draw.textlength(test_line, font=font)
|
||||
# Try adding the word to the current line
|
||||
test_line = ' '.join(current_line + [word]) if current_line else word
|
||||
bbox = self.display_manager.draw.textbbox((0, 0), test_line, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
|
||||
if text_width <= max_width:
|
||||
# Word fits, add it to current line
|
||||
current_line.append(word)
|
||||
else:
|
||||
# If the word itself is too long, add it on its own line (or handle differently if needed)
|
||||
if not current_line:
|
||||
lines.append(word)
|
||||
else:
|
||||
# Word doesn't fit, start a new line
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
current_line = [word]
|
||||
# Recheck if the new line with just this word is too long
|
||||
if self.display_manager.draw.textlength(word, font=font) > max_width:
|
||||
# Handle very long words if necessary (e.g., truncate)
|
||||
pass
|
||||
|
||||
if current_line:
|
||||
lines.append(' '.join(current_line))
|
||||
current_line = [word]
|
||||
else:
|
||||
# Single word too long, truncate it
|
||||
truncated = word
|
||||
while len(truncated) > 0:
|
||||
bbox = self.display_manager.draw.textbbox((0, 0), truncated + "...", font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
lines.append(truncated + "...")
|
||||
break
|
||||
truncated = truncated[:-1]
|
||||
if not truncated:
|
||||
lines.append(word[:10] + "...")
|
||||
|
||||
return lines
|
||||
# Check if we've filled all lines
|
||||
if len(lines) >= max_lines:
|
||||
break
|
||||
|
||||
# Handle any remaining text in current_line
|
||||
if current_line and len(lines) < max_lines:
|
||||
remaining_text = ' '.join(current_line)
|
||||
if len(words) > len(current_line): # More words remain
|
||||
# Try to fit with ellipsis
|
||||
while len(remaining_text) > 0:
|
||||
bbox = self.display_manager.draw.textbbox((0, 0), remaining_text + "...", font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
lines.append(remaining_text + "...")
|
||||
break
|
||||
remaining_text = remaining_text[:-1]
|
||||
else:
|
||||
lines.append(remaining_text)
|
||||
|
||||
# Ensure we have exactly max_lines
|
||||
while len(lines) < max_lines:
|
||||
lines.append("")
|
||||
|
||||
return lines[:max_lines]
|
||||
|
||||
def update(self, current_time):
|
||||
"""Update calendar events if needed."""
|
||||
if not self.enabled:
|
||||
logger.debug("Calendar manager is disabled, skipping update")
|
||||
return
|
||||
|
||||
# Only fetch new events if the update interval has passed
|
||||
if current_time - self.last_update >= self.update_interval:
|
||||
logging.info("Fetching new calendar events...")
|
||||
if current_time - self.last_update > self.update_interval:
|
||||
logger.info("Updating calendar events")
|
||||
self.events = self.get_events()
|
||||
self.last_update = current_time
|
||||
if not self.events:
|
||||
logging.info("No upcoming calendar events found.")
|
||||
logger.info("No upcoming calendar events found.")
|
||||
else:
|
||||
logging.info(f"Fetched {len(self.events)} calendar events.")
|
||||
logger.info(f"Fetched {len(self.events)} calendar events.")
|
||||
# Reset index if events change
|
||||
self.current_event_index = 0
|
||||
else:
|
||||
# Only log debug message every 5 seconds
|
||||
if current_time - self.last_debug_log > 5:
|
||||
logger.debug("Skipping calendar update - not enough time has passed")
|
||||
self.last_debug_log = current_time
|
||||
|
||||
def _format_event_date(self, event):
|
||||
"""Format event date for display"""
|
||||
@@ -227,18 +263,20 @@ class CalendarManager:
|
||||
logging.error(f"Could not parse time string: {start} - {e}")
|
||||
return "Invalid Time"
|
||||
|
||||
def display(self):
|
||||
def display(self, force_clear=False):
|
||||
"""Display the current calendar event on the matrix"""
|
||||
logging.debug(f"CalendarManager display called. Enabled: {self.enabled}, Events count: {len(self.events) if self.events is not None else 'None'}")
|
||||
if not self.enabled:
|
||||
logging.debug("CalendarManager display returning because not enabled.")
|
||||
logger.debug("Calendar manager is disabled, skipping display")
|
||||
return
|
||||
|
||||
# Only clear if force_clear is True (mode switch) or no events are drawn
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
if not self.events:
|
||||
# Display "No Events" message if the list is empty
|
||||
logging.info("--> CalendarManager: Attempting to draw DEBUG (no events).")
|
||||
self.display_manager.clear()
|
||||
self.display_manager.draw_text("Calendar DEBUG", small_font=True, color=self.text_color)
|
||||
logger.debug("No calendar events to display")
|
||||
self.display_manager.draw_text("No Events", small_font=True, color=self.text_color)
|
||||
self.display_manager.update_display()
|
||||
return
|
||||
|
||||
@@ -246,31 +284,29 @@ class CalendarManager:
|
||||
if self.current_event_index >= len(self.events):
|
||||
self.current_event_index = 0 # Wrap around
|
||||
event_to_display = self.events[self.current_event_index]
|
||||
logging.debug(f"CalendarManager displaying event index {self.current_event_index}: {event_to_display.get('summary')}")
|
||||
|
||||
# Clear the display before drawing the current event
|
||||
logging.debug("CalendarManager clearing display for event.")
|
||||
self.display_manager.clear()
|
||||
|
||||
# Set force_clear flag for logging
|
||||
self.force_clear = force_clear
|
||||
|
||||
# Draw the event
|
||||
draw_successful = self.draw_event(event_to_display)
|
||||
|
||||
if draw_successful:
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
logging.debug("CalendarManager event display updated.")
|
||||
logger.debug("CalendarManager event display updated.")
|
||||
else:
|
||||
# Draw failed (error logged in draw_event), show debug message
|
||||
logging.info("--> CalendarManager: Attempting to draw DEBUG (draw_event failed).")
|
||||
self.display_manager.clear() # Clear any partial drawing
|
||||
self.display_manager.draw_text("Calendar DEBUG", small_font=True, color=self.text_color)
|
||||
logger.warning("Failed to draw calendar event")
|
||||
self.display_manager.draw_text("Calendar Error", small_font=True, color=self.text_color)
|
||||
self.display_manager.update_display()
|
||||
|
||||
def advance_event(self):
|
||||
"""Advance to the next event. Called by DisplayManager when calendar display time is up."""
|
||||
if not self.events:
|
||||
if not self.enabled:
|
||||
logger.debug("Calendar manager is disabled, skipping event advance")
|
||||
return
|
||||
self.current_event_index += 1
|
||||
if self.current_event_index >= len(self.events):
|
||||
self.current_event_index = 0
|
||||
logging.debug(f"CalendarManager advanced to event index {self.current_event_index}")
|
||||
logger.debug(f"CalendarManager advanced to event index {self.current_event_index}")
|
||||
@@ -9,6 +9,8 @@ from src.stock_manager import StockManager
|
||||
from src.stock_news_manager import StockNewsManager
|
||||
from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager
|
||||
from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager
|
||||
from src.youtube_display import YouTubeDisplay
|
||||
from src.calendar_manager import CalendarManager
|
||||
|
||||
# Get logger without configuring
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,7 +34,8 @@ class DisplayController:
|
||||
self.weather = WeatherManager(self.config, self.display_manager) if self.config.get('weather', {}).get('enabled', False) else None
|
||||
self.stocks = StockManager(self.config, self.display_manager) if self.config.get('stocks', {}).get('enabled', False) else None
|
||||
self.news = StockNewsManager(self.config, self.display_manager) if self.config.get('stock_news', {}).get('enabled', False) else None
|
||||
self.calendar = self.display_manager.calendar_manager if self.config.get('calendar', {}).get('enabled', False) else None
|
||||
self.calendar = CalendarManager(self.display_manager, self.config) if self.config.get('calendar', {}).get('enabled', False) else None
|
||||
self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None
|
||||
logger.info(f"Calendar Manager initialized: {'Object' if self.calendar else 'None'}")
|
||||
logger.info("Display modes initialized in %.3f seconds", time.time() - init_time)
|
||||
|
||||
@@ -70,6 +73,7 @@ class DisplayController:
|
||||
if self.stocks: self.available_modes.append('stocks')
|
||||
if self.news: self.available_modes.append('stock_news')
|
||||
if self.calendar: self.available_modes.append('calendar')
|
||||
if self.youtube: self.available_modes.append('youtube')
|
||||
|
||||
# Add NHL display modes if enabled
|
||||
if nhl_enabled:
|
||||
@@ -110,7 +114,8 @@ class DisplayController:
|
||||
'weather_daily': 15,
|
||||
'stocks': 45,
|
||||
'stock_news': 30,
|
||||
'calendar': 30
|
||||
'calendar': 30,
|
||||
'youtube': 30
|
||||
})
|
||||
logger.info("DisplayController initialized with display_manager: %s", id(self.display_manager))
|
||||
logger.info(f"Available display modes: {self.available_modes}")
|
||||
@@ -137,6 +142,7 @@ class DisplayController:
|
||||
if self.stocks: self.stocks.update_stock_data()
|
||||
if self.news: self.news.update_news_data()
|
||||
if self.calendar: self.calendar.update(time.time())
|
||||
if self.youtube: self.youtube.update()
|
||||
|
||||
# Update NHL managers
|
||||
if self.nhl_live: self.nhl_live.update()
|
||||
@@ -341,13 +347,15 @@ class DisplayController:
|
||||
# Only proceed with mode switching if no live games
|
||||
if current_time - self.last_switch > self.get_current_duration():
|
||||
# No live games, continue with regular rotation
|
||||
# If we're currently on calendar, advance to next event before switching modes
|
||||
if self.current_display_mode == 'calendar' and self.calendar:
|
||||
self.calendar.advance_event()
|
||||
|
||||
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
logger.info(f"Switching to: {self.current_display_mode}")
|
||||
self.force_clear = True
|
||||
self.last_switch = current_time
|
||||
if self.current_display_mode != 'calendar' and self.calendar:
|
||||
self.calendar.advance_event()
|
||||
|
||||
# Display current mode frame (only for non-live modes)
|
||||
try:
|
||||
@@ -368,7 +376,10 @@ class DisplayController:
|
||||
self.news.display_news()
|
||||
|
||||
elif self.current_display_mode == 'calendar' and self.calendar:
|
||||
self.calendar.display()
|
||||
# Update calendar data if needed
|
||||
self.calendar.update(current_time)
|
||||
# Always display the calendar, with force_clear only on mode switch
|
||||
self.calendar.display(force_clear=self.force_clear)
|
||||
|
||||
elif self.current_display_mode == 'nhl_recent' and self.nhl_recent:
|
||||
self.nhl_recent.display(force_clear=self.force_clear)
|
||||
@@ -380,12 +391,16 @@ class DisplayController:
|
||||
elif self.current_display_mode == 'nba_upcoming' and self.nba_upcoming:
|
||||
self.nba_upcoming.display(force_clear=self.force_clear)
|
||||
|
||||
elif self.current_display_mode == 'youtube' and self.youtube:
|
||||
self.youtube.display(force_clear=self.force_clear)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating display for mode {self.current_display_mode}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
self.force_clear = False
|
||||
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Display controller stopped by user")
|
||||
except Exception as e:
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Dict, Any, List, Tuple
|
||||
import logging
|
||||
import math
|
||||
from .weather_icons import WeatherIcons
|
||||
from .calendar_manager import CalendarManager
|
||||
import os
|
||||
|
||||
# Get logger without configuring
|
||||
@@ -31,7 +30,7 @@ class DisplayManager:
|
||||
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
||||
|
||||
# Initialize managers
|
||||
self.calendar_manager = CalendarManager(self.matrix, self.current_canvas, self.config)
|
||||
# Calendar manager is now initialized by DisplayController
|
||||
|
||||
def _setup_matrix(self):
|
||||
"""Initialize the RGB matrix with configuration settings."""
|
||||
|
||||
@@ -241,6 +241,27 @@ class BaseNHLManager:
|
||||
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) -> Optional[Image.Image]:
|
||||
"""Load and resize a team logo, with caching."""
|
||||
self.logger.debug(f"Loading logo for {team_abbrev}")
|
||||
@@ -430,19 +451,19 @@ class BaseNHLManager:
|
||||
status_width = draw.textlength(status_text, font=self.fonts['status'])
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 2
|
||||
draw.text((status_x, status_y), status_text, font=self.fonts['status'], fill=(255, 255, 255))
|
||||
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
|
||||
draw.text((date_x, date_y), game_date, font=self.fonts['time'], fill=(255, 255, 255))
|
||||
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
|
||||
draw.text((time_x, time_y), game_time, font=self.fonts['time'], fill=(255, 255, 255))
|
||||
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"))
|
||||
@@ -453,7 +474,7 @@ class BaseNHLManager:
|
||||
score_width = draw.textlength(score_text, font=self.fonts['score'])
|
||||
score_x = (self.display_width - score_width) // 2
|
||||
score_y = self.display_height - 15
|
||||
draw.text((score_x, score_y), score_text, font=self.fonts['score'], fill=(255, 255, 255))
|
||||
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):
|
||||
@@ -474,7 +495,7 @@ class BaseNHLManager:
|
||||
status_width = draw.textlength(status_text, font=self.fonts['time'])
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 5
|
||||
draw.text((status_x, status_y), status_text, font=self.fonts['time'], fill=(255, 255, 255))
|
||||
self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time'])
|
||||
|
||||
# Display the image
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
|
||||
@@ -99,20 +99,33 @@ class WeatherManager:
|
||||
lat = geo_data[0]['lat']
|
||||
lon = geo_data[0]['lon']
|
||||
|
||||
# Get current weather and forecast using coordinates
|
||||
weather_url = f"https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={api_key}&units={units}"
|
||||
forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?lat={lat}&lon={lon}&appid={api_key}&units={units}"
|
||||
# Get current weather and daily forecast using One Call API
|
||||
one_call_url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&exclude=minutely,alerts&appid={api_key}&units={units}"
|
||||
|
||||
# Fetch current weather
|
||||
response = requests.get(weather_url)
|
||||
# Fetch current weather and daily forecast
|
||||
response = requests.get(one_call_url)
|
||||
response.raise_for_status()
|
||||
self.weather_data = response.json()
|
||||
|
||||
# Fetch forecast
|
||||
response = requests.get(forecast_url)
|
||||
response.raise_for_status()
|
||||
self.forecast_data = response.json()
|
||||
one_call_data = response.json()
|
||||
|
||||
# Store current weather data
|
||||
self.weather_data = {
|
||||
'main': {
|
||||
'temp': one_call_data['current']['temp'],
|
||||
'temp_max': one_call_data['daily'][0]['temp']['max'],
|
||||
'temp_min': one_call_data['daily'][0]['temp']['min'],
|
||||
'humidity': one_call_data['current']['humidity'],
|
||||
'pressure': one_call_data['current']['pressure']
|
||||
},
|
||||
'weather': one_call_data['current']['weather'],
|
||||
'wind': {
|
||||
'speed': one_call_data['current'].get('wind_speed', 0),
|
||||
'deg': one_call_data['current'].get('wind_deg', 0)
|
||||
}
|
||||
}
|
||||
|
||||
# Store forecast data (for hourly and daily forecasts)
|
||||
self.forecast_data = one_call_data
|
||||
|
||||
# Process forecast data
|
||||
self._process_forecast_data(self.forecast_data)
|
||||
|
||||
@@ -144,12 +157,12 @@ class WeatherManager:
|
||||
return
|
||||
|
||||
# Process hourly forecast (next 5 hours)
|
||||
hourly_list = forecast_data.get('list', [])[:5] # Changed from 6 to 5 to match image
|
||||
hourly_list = forecast_data.get('hourly', [])[:5] # Get next 5 hours
|
||||
self.hourly_forecast = []
|
||||
|
||||
for hour_data in hourly_list:
|
||||
dt = datetime.fromtimestamp(hour_data['dt'])
|
||||
temp = round(hour_data['main']['temp'])
|
||||
temp = round(hour_data['temp'])
|
||||
condition = hour_data['weather'][0]['main']
|
||||
self.hourly_forecast.append({
|
||||
'hour': dt.strftime('%I:00 %p').lstrip('0'), # Format as "2:00 PM"
|
||||
@@ -158,38 +171,18 @@ class WeatherManager:
|
||||
})
|
||||
|
||||
# Process daily forecast
|
||||
daily_data = {}
|
||||
full_forecast_list = forecast_data.get('list', []) # Use the full list
|
||||
for item in full_forecast_list: # Iterate over the full list
|
||||
date = datetime.fromtimestamp(item['dt']).strftime('%Y-%m-%d')
|
||||
if date not in daily_data:
|
||||
daily_data[date] = {
|
||||
'temps': [],
|
||||
'conditions': [],
|
||||
'date': datetime.fromtimestamp(item['dt'])
|
||||
}
|
||||
daily_data[date]['temps'].append(item['main']['temp'])
|
||||
daily_data[date]['conditions'].append(item['weather'][0]['main'])
|
||||
|
||||
# Calculate daily summaries, excluding today
|
||||
daily_list = forecast_data.get('daily', [])[1:4] # Skip today (index 0) and get next 3 days
|
||||
self.daily_forecast = []
|
||||
today_str = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# Sort data by date to ensure chronological order
|
||||
sorted_daily_items = sorted(daily_data.items(), key=lambda item: item[1]['date'])
|
||||
|
||||
# Filter out today's data and take the next 3 days
|
||||
future_days_data = [item for item in sorted_daily_items if item[0] != today_str][:3]
|
||||
|
||||
for date_str, data in future_days_data:
|
||||
temps = data['temps']
|
||||
temp_high = round(max(temps))
|
||||
temp_low = round(min(temps))
|
||||
condition = max(set(data['conditions']), key=data['conditions'].count)
|
||||
for day_data in daily_list:
|
||||
dt = datetime.fromtimestamp(day_data['dt'])
|
||||
temp_high = round(day_data['temp']['max'])
|
||||
temp_low = round(day_data['temp']['min'])
|
||||
condition = day_data['weather'][0]['main']
|
||||
|
||||
self.daily_forecast.append({
|
||||
'date': data['date'].strftime('%a'), # Day name (Mon, Tue, etc.)
|
||||
'date_str': data['date'].strftime('%m/%d'), # Date (4/8, 4/9, etc.)
|
||||
'date': dt.strftime('%a'), # Day name (Mon, Tue, etc.)
|
||||
'date_str': dt.strftime('%m/%d'), # Date (4/8, 4/9, etc.)
|
||||
'temp_high': temp_high,
|
||||
'temp_low': temp_low,
|
||||
'condition': condition
|
||||
@@ -340,7 +333,7 @@ class WeatherManager:
|
||||
|
||||
# --- Wind (Section 3) ---
|
||||
wind_speed = weather_data['wind']['speed']
|
||||
wind_deg = weather_data.get('wind', {}).get('deg', 0)
|
||||
wind_deg = weather_data['wind']['deg']
|
||||
wind_dir = self._get_wind_direction(wind_deg)
|
||||
wind_text = f"W:{wind_speed:.0f}{wind_dir}"
|
||||
wind_width = draw.textlength(wind_text, font=font)
|
||||
|
||||
168
src/youtube_display.py
Normal file
168
src/youtube_display.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import requests
|
||||
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
# Get logger without configuring
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class YouTubeDisplay:
|
||||
def __init__(self, display_manager, config: Dict[str, Any]):
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.youtube_config = config.get('youtube', {})
|
||||
self.enabled = self.youtube_config.get('enabled', False)
|
||||
self.update_interval = self.youtube_config.get('update_interval', 300)
|
||||
self.last_update = 0
|
||||
self.channel_stats = None
|
||||
|
||||
# Load secrets file
|
||||
try:
|
||||
with open('config/config_secrets.json', 'r') as f:
|
||||
self.secrets = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading secrets file: {e}")
|
||||
self.secrets = {}
|
||||
self.enabled = False
|
||||
|
||||
if self.enabled:
|
||||
logger.info("YouTube display enabled")
|
||||
self._initialize_display()
|
||||
else:
|
||||
logger.info("YouTube display disabled")
|
||||
|
||||
def _initialize_display(self):
|
||||
"""Initialize display components."""
|
||||
self.font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
try:
|
||||
self.youtube_logo = Image.open("assets/youtube_logo.png")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading YouTube logo: {e}")
|
||||
self.enabled = False
|
||||
|
||||
def _get_channel_stats(self, channel_id):
|
||||
"""Fetch channel statistics from YouTube API."""
|
||||
api_key = self.secrets.get('youtube', {}).get('api_key')
|
||||
if not api_key:
|
||||
logger.error("YouTube API key not configured in secrets file")
|
||||
return None
|
||||
|
||||
url = f"https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet&id={channel_id}&key={api_key}"
|
||||
|
||||
try:
|
||||
response = requests.get(url)
|
||||
data = response.json()
|
||||
if data['items']:
|
||||
channel = data['items'][0]
|
||||
return {
|
||||
'title': channel['snippet']['title'],
|
||||
'subscribers': int(channel['statistics']['subscriberCount']),
|
||||
'views': int(channel['statistics']['viewCount'])
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching YouTube stats: {e}")
|
||||
return None
|
||||
|
||||
def _create_display(self, channel_stats):
|
||||
"""Create the display image with channel statistics."""
|
||||
if not channel_stats:
|
||||
return None
|
||||
|
||||
# Create a new image with the matrix dimensions
|
||||
image = Image.new('RGB', (self.display_manager.matrix.width, self.display_manager.matrix.height))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Calculate logo dimensions - 60% of display height to ensure text fits
|
||||
logo_height = int(self.display_manager.matrix.height * 0.6)
|
||||
logo_width = int(self.youtube_logo.width * (logo_height / self.youtube_logo.height))
|
||||
resized_logo = self.youtube_logo.resize((logo_width, logo_height))
|
||||
|
||||
# Position logo on the left with padding
|
||||
logo_x = 2 # Small padding from left edge
|
||||
logo_y = (self.display_manager.matrix.height - logo_height) // 2 # Center vertically
|
||||
|
||||
# Paste the logo
|
||||
image.paste(resized_logo, (logo_x, logo_y))
|
||||
|
||||
# Calculate right section width and starting position
|
||||
right_section_x = logo_x + logo_width + 4 # Start after logo with some padding
|
||||
|
||||
# Calculate text positions
|
||||
line_height = 10 # Approximate line height for PressStart2P font at size 8
|
||||
total_text_height = line_height * 3 # 3 lines of text
|
||||
start_y = (self.display_manager.matrix.height - total_text_height) // 2
|
||||
|
||||
# Draw channel name (top)
|
||||
channel_name = channel_stats['title']
|
||||
# Truncate channel name if too long
|
||||
max_chars = (self.display_manager.matrix.width - right_section_x - 4) // 8 # 8 pixels per character
|
||||
if len(channel_name) > max_chars:
|
||||
channel_name = channel_name[:max_chars-3] + "..."
|
||||
name_bbox = draw.textbbox((0, 0), channel_name, font=self.font)
|
||||
name_width = name_bbox[2] - name_bbox[0]
|
||||
name_x = right_section_x + ((self.display_manager.matrix.width - right_section_x - name_width) // 2)
|
||||
draw.text((name_x, start_y), channel_name, font=self.font, fill=(255, 255, 255))
|
||||
|
||||
# Draw subscriber count (middle)
|
||||
subs_text = f"{channel_stats['subscribers']:,} subs"
|
||||
subs_bbox = draw.textbbox((0, 0), subs_text, font=self.font)
|
||||
subs_width = subs_bbox[2] - subs_bbox[0]
|
||||
subs_x = right_section_x + ((self.display_manager.matrix.width - right_section_x - subs_width) // 2)
|
||||
draw.text((subs_x, start_y + line_height), subs_text, font=self.font, fill=(255, 255, 255))
|
||||
|
||||
# Draw view count (bottom)
|
||||
views_text = f"{channel_stats['views']:,} views"
|
||||
views_bbox = draw.textbbox((0, 0), views_text, font=self.font)
|
||||
views_width = views_bbox[2] - views_bbox[0]
|
||||
views_x = right_section_x + ((self.display_manager.matrix.width - right_section_x - views_width) // 2)
|
||||
draw.text((views_x, start_y + (line_height * 2)), views_text, font=self.font, fill=(255, 255, 255))
|
||||
|
||||
return image
|
||||
|
||||
def update(self):
|
||||
"""Update YouTube channel stats if needed."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - self.last_update >= self.update_interval:
|
||||
channel_id = self.config.get('youtube', {}).get('channel_id')
|
||||
if not channel_id:
|
||||
logger.error("YouTube channel ID not configured")
|
||||
return
|
||||
|
||||
self.channel_stats = self._get_channel_stats(channel_id)
|
||||
self.last_update = current_time
|
||||
|
||||
def display(self, force_clear: bool = False):
|
||||
"""Display YouTube channel stats."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if not self.channel_stats:
|
||||
self.update()
|
||||
|
||||
if self.channel_stats:
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
display_image = self._create_display(self.channel_stats)
|
||||
if display_image:
|
||||
self.display_manager.image = display_image
|
||||
self.display_manager.update_display()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
if self.enabled:
|
||||
self.display_manager.clear()
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
youtube_display = YouTubeDisplay()
|
||||
youtube_display.display()
|
||||
youtube_display.cleanup()
|
||||
Reference in New Issue
Block a user