mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 21:33:00 +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.
|
A modular LED matrix display system for sports information using Raspberry Pi and RGB LED matrices.
|
||||||
|
|
||||||
## Hardware Requirements
|
## Hardware Requirements
|
||||||
- Raspberry Pi 3 or newer
|
- Raspberry Pi 4 or older
|
||||||
- Adafruit RGB Matrix Bonnet/HAT
|
- Adafruit RGB Matrix Bonnet/HAT
|
||||||
- LED Matrix panels (64x32)
|
- 2x LED Matrix panels (64x32)
|
||||||
|
- DC Power Supply for Adafruit RGB HAT
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -31,6 +32,72 @@ cp config/config.example.json config/config.json
|
|||||||
|
|
||||||
2. Edit `config/config.json` with your preferences
|
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
|
## API Keys
|
||||||
|
|
||||||
For sensitive settings like 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 prices and market data
|
||||||
- Stock news headlines
|
- Stock news headlines
|
||||||
- NHL game information
|
- NHL game information
|
||||||
|
- NBA game information
|
||||||
|
|
||||||
### Cache Behavior
|
### Cache Behavior
|
||||||
- Data is cached based on update intervals defined in `config.json`
|
- 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
|
- Temporary files are used for safe updates
|
||||||
- JSON serialization handles all data types including timestamps
|
- 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
|
### Display Modes
|
||||||
- **Live Games**: Shows currently playing games with live scores and game status
|
- **Live Games**: Shows currently playing games with live scores and game status
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
"nba_live": 30,
|
"nba_live": 30,
|
||||||
"nba_recent": 20,
|
"nba_recent": 20,
|
||||||
"nba_upcoming": 20,
|
"nba_upcoming": 20,
|
||||||
"calendar": 30
|
"calendar": 30,
|
||||||
|
"youtube": 20
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clock": {
|
"clock": {
|
||||||
@@ -109,5 +110,9 @@
|
|||||||
"nba_upcoming": true
|
"nba_upcoming": true
|
||||||
},
|
},
|
||||||
"live_game_duration": 30
|
"live_game_duration": 30
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"enabled": true,
|
||||||
|
"update_interval": 3600
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"weather": {
|
"weather": {
|
||||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
"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
|
from rgbmatrix import graphics
|
||||||
import pytz
|
import pytz
|
||||||
from src.config_manager import ConfigManager
|
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:
|
class CalendarManager:
|
||||||
def __init__(self, matrix, canvas, config):
|
def __init__(self, display_manager, config):
|
||||||
self.matrix = matrix
|
logger.info("Initializing CalendarManager")
|
||||||
self.canvas = canvas
|
self.display_manager = display_manager
|
||||||
self.config = config
|
self.config = config
|
||||||
self.calendar_config = config.get('calendar', {})
|
self.calendar_config = config.get('calendar', {})
|
||||||
self.enabled = self.calendar_config.get('enabled', False)
|
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.max_events = self.calendar_config.get('max_events', 3)
|
||||||
self.calendars = self.calendar_config.get('calendars', ['birthdays'])
|
self.calendars = self.calendar_config.get('calendars', ['birthdays'])
|
||||||
self.last_update = 0
|
self.last_update = 0
|
||||||
|
self.last_debug_log = 0 # Add timestamp for debug message throttling
|
||||||
self.events = []
|
self.events = []
|
||||||
self.service = None
|
self.service = None
|
||||||
|
|
||||||
# Get display manager instance
|
logger.info(f"Calendar configuration: enabled={self.enabled}, update_interval={self.update_interval}, max_events={self.max_events}, calendars={self.calendars}")
|
||||||
from src.display_manager import DisplayManager
|
|
||||||
self.display_manager = DisplayManager._instance
|
|
||||||
|
|
||||||
# Get timezone from config
|
# Get timezone from config
|
||||||
self.config_manager = ConfigManager()
|
self.config_manager = ConfigManager()
|
||||||
@@ -37,11 +41,13 @@ class CalendarManager:
|
|||||||
try:
|
try:
|
||||||
self.timezone = pytz.timezone(timezone_str)
|
self.timezone = pytz.timezone(timezone_str)
|
||||||
except pytz.UnknownTimeZoneError:
|
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
|
self.timezone = pytz.utc
|
||||||
|
|
||||||
if self.enabled:
|
if self.enabled:
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
|
else:
|
||||||
|
logger.warning("Calendar manager is disabled in configuration")
|
||||||
|
|
||||||
# Display properties
|
# Display properties
|
||||||
self.text_color = (255, 255, 255) # White
|
self.text_color = (255, 255, 255) # White
|
||||||
@@ -50,30 +56,37 @@ class CalendarManager:
|
|||||||
|
|
||||||
# State management
|
# State management
|
||||||
self.current_event_index = 0
|
self.current_event_index = 0
|
||||||
|
self.force_clear = False
|
||||||
|
|
||||||
def authenticate(self):
|
def authenticate(self):
|
||||||
"""Authenticate with Google Calendar API."""
|
"""Authenticate with Google Calendar API."""
|
||||||
|
logger.info("Starting calendar authentication")
|
||||||
creds = None
|
creds = None
|
||||||
token_file = self.calendar_config.get('token_file', 'token.pickle')
|
token_file = self.calendar_config.get('token_file', 'token.pickle')
|
||||||
|
|
||||||
if os.path.exists(token_file):
|
if os.path.exists(token_file):
|
||||||
|
logger.info(f"Loading credentials from {token_file}")
|
||||||
with open(token_file, 'rb') as token:
|
with open(token_file, 'rb') as token:
|
||||||
creds = pickle.load(token)
|
creds = pickle.load(token)
|
||||||
|
|
||||||
if not creds or not creds.valid:
|
if not creds or not creds.valid:
|
||||||
|
logger.info("Credentials not found or invalid")
|
||||||
if creds and creds.expired and creds.refresh_token:
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
logger.info("Refreshing expired credentials")
|
||||||
creds.refresh(Request())
|
creds.refresh(Request())
|
||||||
else:
|
else:
|
||||||
logging.error("Calendar credentials not found or invalid. Please run calendar_registration.py first.")
|
logger.info("Requesting new credentials")
|
||||||
self.enabled = False
|
flow = InstalledAppFlow.from_client_secrets_file(
|
||||||
return
|
self.calendar_config.get('credentials_file', 'credentials.json'),
|
||||||
|
['https://www.googleapis.com/auth/calendar.readonly'])
|
||||||
try:
|
creds = flow.run_local_server(port=0)
|
||||||
self.service = build('calendar', 'v3', credentials=creds)
|
|
||||||
logging.info("Successfully authenticated with Google Calendar")
|
logger.info(f"Saving credentials to {token_file}")
|
||||||
except Exception as e:
|
with open(token_file, 'wb') as token:
|
||||||
logging.error(f"Error building calendar service: {str(e)}")
|
pickle.dump(creds, token)
|
||||||
self.enabled = False
|
|
||||||
|
self.service = build('calendar', 'v3', credentials=creds)
|
||||||
|
logger.info("Calendar service built successfully")
|
||||||
|
|
||||||
def get_events(self):
|
def get_events(self):
|
||||||
"""Fetch upcoming calendar events."""
|
"""Fetch upcoming calendar events."""
|
||||||
@@ -99,6 +112,13 @@ class CalendarManager:
|
|||||||
def draw_event(self, event, y_start=1):
|
def draw_event(self, event, y_start=1):
|
||||||
"""Draw a single calendar event on the canvas. Returns True on success, False on error."""
|
"""Draw a single calendar event on the canvas. Returns True on success, False on error."""
|
||||||
try:
|
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
|
# Get event details
|
||||||
summary = event.get('summary', 'No Title')
|
summary = event.get('summary', 'No Title')
|
||||||
time_str = self._format_event_time(event)
|
time_str = self._format_event_time(event)
|
||||||
@@ -108,89 +128,105 @@ class CalendarManager:
|
|||||||
font = self.display_manager.small_font
|
font = self.display_manager.small_font
|
||||||
available_width = self.display_manager.matrix.width - 4 # Leave 2 pixel margin on each side
|
available_width = self.display_manager.matrix.width - 4 # Leave 2 pixel margin on each side
|
||||||
|
|
||||||
# Wrap title text
|
# Draw date and time on top line
|
||||||
title_lines = self._wrap_text(summary, available_width, font)
|
datetime_str = f"{date_str} {time_str}"
|
||||||
|
self.display_manager.draw_text(datetime_str, y=2, color=self.text_color, small_font=True)
|
||||||
# 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 )
|
|
||||||
|
|
||||||
# Calculate starting y position to center vertically
|
# Wrap summary text for two lines
|
||||||
y_pos = (self.display_manager.matrix.height - total_height) // 2
|
title_lines = self._wrap_text(summary, available_width, font, max_lines=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
|
|
||||||
|
|
||||||
# Draw title lines
|
# Draw summary lines
|
||||||
|
y_pos = 12 # Start position for summary (below date/time)
|
||||||
for line in title_lines:
|
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)
|
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
|
y_pos += 8 # Move down for next line
|
||||||
return True # Return True on successful drawing
|
|
||||||
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error drawing calendar event: {str(e)}", exc_info=True)
|
logger.error(f"Error drawing calendar event: {str(e)}", exc_info=True)
|
||||||
return False # Return False on error
|
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."""
|
"""Wrap text to fit within max_width using the provided font."""
|
||||||
if not text:
|
if not text:
|
||||||
return [""]
|
return [""]
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
words = text.split()
|
|
||||||
current_line = []
|
current_line = []
|
||||||
|
words = text.split()
|
||||||
|
|
||||||
for word in words:
|
for word in words:
|
||||||
test_line = ' '.join(current_line + [word])
|
# Try adding the word to the current line
|
||||||
# Use textlength for accurate width calculation
|
test_line = ' '.join(current_line + [word]) if current_line else word
|
||||||
text_width = self.display_manager.draw.textlength(test_line, font=font)
|
bbox = self.display_manager.draw.textbbox((0, 0), test_line, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
|
||||||
if text_width <= max_width:
|
if text_width <= max_width:
|
||||||
|
# Word fits, add it to current line
|
||||||
current_line.append(word)
|
current_line.append(word)
|
||||||
else:
|
else:
|
||||||
# If the word itself is too long, add it on its own line (or handle differently if needed)
|
# Word doesn't fit, start a new line
|
||||||
if not current_line:
|
if current_line:
|
||||||
lines.append(word)
|
|
||||||
else:
|
|
||||||
lines.append(' '.join(current_line))
|
lines.append(' '.join(current_line))
|
||||||
current_line = [word]
|
current_line = [word]
|
||||||
# Recheck if the new line with just this word is too long
|
else:
|
||||||
if self.display_manager.draw.textlength(word, font=font) > max_width:
|
# Single word too long, truncate it
|
||||||
# Handle very long words if necessary (e.g., truncate)
|
truncated = word
|
||||||
pass
|
while len(truncated) > 0:
|
||||||
|
bbox = self.display_manager.draw.textbbox((0, 0), truncated + "...", font=font)
|
||||||
if current_line:
|
if bbox[2] - bbox[0] <= max_width:
|
||||||
lines.append(' '.join(current_line))
|
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):
|
def update(self, current_time):
|
||||||
"""Update calendar events if needed."""
|
"""Update calendar events if needed."""
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
|
logger.debug("Calendar manager is disabled, skipping update")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only fetch new events if the update interval has passed
|
if current_time - self.last_update > self.update_interval:
|
||||||
if current_time - self.last_update >= self.update_interval:
|
logger.info("Updating calendar events")
|
||||||
logging.info("Fetching new calendar events...")
|
|
||||||
self.events = self.get_events()
|
self.events = self.get_events()
|
||||||
self.last_update = current_time
|
self.last_update = current_time
|
||||||
if not self.events:
|
if not self.events:
|
||||||
logging.info("No upcoming calendar events found.")
|
logger.info("No upcoming calendar events found.")
|
||||||
else:
|
else:
|
||||||
logging.info(f"Fetched {len(self.events)} calendar events.")
|
logger.info(f"Fetched {len(self.events)} calendar events.")
|
||||||
# Reset index if events change
|
# Reset index if events change
|
||||||
self.current_event_index = 0
|
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):
|
def _format_event_date(self, event):
|
||||||
"""Format event date for display"""
|
"""Format event date for display"""
|
||||||
@@ -227,18 +263,20 @@ class CalendarManager:
|
|||||||
logging.error(f"Could not parse time string: {start} - {e}")
|
logging.error(f"Could not parse time string: {start} - {e}")
|
||||||
return "Invalid Time"
|
return "Invalid Time"
|
||||||
|
|
||||||
def display(self):
|
def display(self, force_clear=False):
|
||||||
"""Display the current calendar event on the matrix"""
|
"""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:
|
if not self.enabled:
|
||||||
logging.debug("CalendarManager display returning because not enabled.")
|
logger.debug("Calendar manager is disabled, skipping display")
|
||||||
return
|
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:
|
if not self.events:
|
||||||
# Display "No Events" message if the list is empty
|
# Display "No Events" message if the list is empty
|
||||||
logging.info("--> CalendarManager: Attempting to draw DEBUG (no events).")
|
logger.debug("No calendar events to display")
|
||||||
self.display_manager.clear()
|
self.display_manager.draw_text("No Events", small_font=True, color=self.text_color)
|
||||||
self.display_manager.draw_text("Calendar DEBUG", small_font=True, color=self.text_color)
|
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -246,31 +284,29 @@ class CalendarManager:
|
|||||||
if self.current_event_index >= len(self.events):
|
if self.current_event_index >= len(self.events):
|
||||||
self.current_event_index = 0 # Wrap around
|
self.current_event_index = 0 # Wrap around
|
||||||
event_to_display = self.events[self.current_event_index]
|
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
|
# Set force_clear flag for logging
|
||||||
logging.debug("CalendarManager clearing display for event.")
|
self.force_clear = force_clear
|
||||||
self.display_manager.clear()
|
|
||||||
|
|
||||||
# Draw the event
|
# Draw the event
|
||||||
draw_successful = self.draw_event(event_to_display)
|
draw_successful = self.draw_event(event_to_display)
|
||||||
|
|
||||||
if draw_successful:
|
if draw_successful:
|
||||||
# Update the display
|
# Update the display
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
logging.debug("CalendarManager event display updated.")
|
logger.debug("CalendarManager event display updated.")
|
||||||
else:
|
else:
|
||||||
# Draw failed (error logged in draw_event), show debug message
|
# Draw failed (error logged in draw_event), show debug message
|
||||||
logging.info("--> CalendarManager: Attempting to draw DEBUG (draw_event failed).")
|
logger.warning("Failed to draw calendar event")
|
||||||
self.display_manager.clear() # Clear any partial drawing
|
self.display_manager.draw_text("Calendar Error", small_font=True, color=self.text_color)
|
||||||
self.display_manager.draw_text("Calendar DEBUG", small_font=True, color=self.text_color)
|
|
||||||
self.display_manager.update_display()
|
self.display_manager.update_display()
|
||||||
|
|
||||||
def advance_event(self):
|
def advance_event(self):
|
||||||
"""Advance to the next event. Called by DisplayManager when calendar display time is up."""
|
"""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
|
return
|
||||||
self.current_event_index += 1
|
self.current_event_index += 1
|
||||||
if self.current_event_index >= len(self.events):
|
if self.current_event_index >= len(self.events):
|
||||||
self.current_event_index = 0
|
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.stock_news_manager import StockNewsManager
|
||||||
from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager
|
from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager
|
||||||
from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager
|
from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager
|
||||||
|
from src.youtube_display import YouTubeDisplay
|
||||||
|
from src.calendar_manager import CalendarManager
|
||||||
|
|
||||||
# Get logger without configuring
|
# Get logger without configuring
|
||||||
logger = logging.getLogger(__name__)
|
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.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.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.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(f"Calendar Manager initialized: {'Object' if self.calendar else 'None'}")
|
||||||
logger.info("Display modes initialized in %.3f seconds", time.time() - init_time)
|
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.stocks: self.available_modes.append('stocks')
|
||||||
if self.news: self.available_modes.append('stock_news')
|
if self.news: self.available_modes.append('stock_news')
|
||||||
if self.calendar: self.available_modes.append('calendar')
|
if self.calendar: self.available_modes.append('calendar')
|
||||||
|
if self.youtube: self.available_modes.append('youtube')
|
||||||
|
|
||||||
# Add NHL display modes if enabled
|
# Add NHL display modes if enabled
|
||||||
if nhl_enabled:
|
if nhl_enabled:
|
||||||
@@ -110,7 +114,8 @@ class DisplayController:
|
|||||||
'weather_daily': 15,
|
'weather_daily': 15,
|
||||||
'stocks': 45,
|
'stocks': 45,
|
||||||
'stock_news': 30,
|
'stock_news': 30,
|
||||||
'calendar': 30
|
'calendar': 30,
|
||||||
|
'youtube': 30
|
||||||
})
|
})
|
||||||
logger.info("DisplayController initialized with display_manager: %s", id(self.display_manager))
|
logger.info("DisplayController initialized with display_manager: %s", id(self.display_manager))
|
||||||
logger.info(f"Available display modes: {self.available_modes}")
|
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.stocks: self.stocks.update_stock_data()
|
||||||
if self.news: self.news.update_news_data()
|
if self.news: self.news.update_news_data()
|
||||||
if self.calendar: self.calendar.update(time.time())
|
if self.calendar: self.calendar.update(time.time())
|
||||||
|
if self.youtube: self.youtube.update()
|
||||||
|
|
||||||
# Update NHL managers
|
# Update NHL managers
|
||||||
if self.nhl_live: self.nhl_live.update()
|
if self.nhl_live: self.nhl_live.update()
|
||||||
@@ -341,13 +347,15 @@ class DisplayController:
|
|||||||
# Only proceed with mode switching if no live games
|
# Only proceed with mode switching if no live games
|
||||||
if current_time - self.last_switch > self.get_current_duration():
|
if current_time - self.last_switch > self.get_current_duration():
|
||||||
# No live games, continue with regular rotation
|
# 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_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
|
||||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||||
logger.info(f"Switching to: {self.current_display_mode}")
|
logger.info(f"Switching to: {self.current_display_mode}")
|
||||||
self.force_clear = True
|
self.force_clear = True
|
||||||
self.last_switch = current_time
|
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)
|
# Display current mode frame (only for non-live modes)
|
||||||
try:
|
try:
|
||||||
@@ -368,7 +376,10 @@ class DisplayController:
|
|||||||
self.news.display_news()
|
self.news.display_news()
|
||||||
|
|
||||||
elif self.current_display_mode == 'calendar' and self.calendar:
|
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:
|
elif self.current_display_mode == 'nhl_recent' and self.nhl_recent:
|
||||||
self.nhl_recent.display(force_clear=self.force_clear)
|
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:
|
elif self.current_display_mode == 'nba_upcoming' and self.nba_upcoming:
|
||||||
self.nba_upcoming.display(force_clear=self.force_clear)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error updating display for mode {self.current_display_mode}: {e}", exc_info=True)
|
logger.error(f"Error updating display for mode {self.current_display_mode}: {e}", exc_info=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.force_clear = False
|
self.force_clear = False
|
||||||
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Display controller stopped by user")
|
logger.info("Display controller stopped by user")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from typing import Dict, Any, List, Tuple
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from .weather_icons import WeatherIcons
|
from .weather_icons import WeatherIcons
|
||||||
from .calendar_manager import CalendarManager
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Get logger without configuring
|
# Get logger without configuring
|
||||||
@@ -31,7 +30,7 @@ class DisplayManager:
|
|||||||
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
logger.info("Font loading completed in %.3f seconds", time.time() - font_time)
|
||||||
|
|
||||||
# Initialize managers
|
# Initialize managers
|
||||||
self.calendar_manager = CalendarManager(self.matrix, self.current_canvas, self.config)
|
# Calendar manager is now initialized by DisplayController
|
||||||
|
|
||||||
def _setup_matrix(self):
|
def _setup_matrix(self):
|
||||||
"""Initialize the RGB matrix with configuration settings."""
|
"""Initialize the RGB matrix with configuration settings."""
|
||||||
|
|||||||
@@ -241,6 +241,27 @@ class BaseNHLManager:
|
|||||||
fonts['status'] = ImageFont.load_default()
|
fonts['status'] = ImageFont.load_default()
|
||||||
return fonts
|
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]:
|
def _load_and_resize_logo(self, team_abbrev: str) -> Optional[Image.Image]:
|
||||||
"""Load and resize a team logo, with caching."""
|
"""Load and resize a team logo, with caching."""
|
||||||
self.logger.debug(f"Loading logo for {team_abbrev}")
|
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_width = draw.textlength(status_text, font=self.fonts['status'])
|
||||||
status_x = (self.display_width - status_width) // 2
|
status_x = (self.display_width - status_width) // 2
|
||||||
status_y = 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")
|
# Calculate position for the date text (centered horizontally, below "Next Game")
|
||||||
date_width = draw.textlength(game_date, font=self.fonts['time'])
|
date_width = draw.textlength(game_date, font=self.fonts['time'])
|
||||||
date_x = (self.display_width - date_width) // 2
|
date_x = (self.display_width - date_width) // 2
|
||||||
date_y = center_y - 5 # Position in center
|
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)
|
# Calculate position for the time text (centered horizontally, in center)
|
||||||
time_width = draw.textlength(game_time, font=self.fonts['time'])
|
time_width = draw.textlength(game_time, font=self.fonts['time'])
|
||||||
time_x = (self.display_width - time_width) // 2
|
time_x = (self.display_width - time_width) // 2
|
||||||
time_y = date_y + 10 # Position below date
|
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:
|
else:
|
||||||
# For live/final games, show scores and period/time
|
# For live/final games, show scores and period/time
|
||||||
home_score = str(game.get("home_score", "0"))
|
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_width = draw.textlength(score_text, font=self.fonts['score'])
|
||||||
score_x = (self.display_width - score_width) // 2
|
score_x = (self.display_width - score_width) // 2
|
||||||
score_y = self.display_height - 15
|
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
|
# Draw period and time or Final
|
||||||
if game.get("is_final", False):
|
if game.get("is_final", False):
|
||||||
@@ -474,7 +495,7 @@ class BaseNHLManager:
|
|||||||
status_width = draw.textlength(status_text, font=self.fonts['time'])
|
status_width = draw.textlength(status_text, font=self.fonts['time'])
|
||||||
status_x = (self.display_width - status_width) // 2
|
status_x = (self.display_width - status_width) // 2
|
||||||
status_y = 5
|
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
|
# Display the image
|
||||||
self.display_manager.image.paste(main_img, (0, 0))
|
self.display_manager.image.paste(main_img, (0, 0))
|
||||||
|
|||||||
@@ -99,20 +99,33 @@ class WeatherManager:
|
|||||||
lat = geo_data[0]['lat']
|
lat = geo_data[0]['lat']
|
||||||
lon = geo_data[0]['lon']
|
lon = geo_data[0]['lon']
|
||||||
|
|
||||||
# Get current weather and forecast using coordinates
|
# Get current weather and daily forecast using One Call API
|
||||||
weather_url = f"https://api.openweathermap.org/data/2.5/weather?lat={lat}&lon={lon}&appid={api_key}&units={units}"
|
one_call_url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&exclude=minutely,alerts&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}"
|
|
||||||
|
|
||||||
# Fetch current weather
|
# Fetch current weather and daily forecast
|
||||||
response = requests.get(weather_url)
|
response = requests.get(one_call_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
self.weather_data = response.json()
|
one_call_data = response.json()
|
||||||
|
|
||||||
# Fetch forecast
|
# Store current weather data
|
||||||
response = requests.get(forecast_url)
|
self.weather_data = {
|
||||||
response.raise_for_status()
|
'main': {
|
||||||
self.forecast_data = response.json()
|
'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
|
# Process forecast data
|
||||||
self._process_forecast_data(self.forecast_data)
|
self._process_forecast_data(self.forecast_data)
|
||||||
|
|
||||||
@@ -144,12 +157,12 @@ class WeatherManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Process hourly forecast (next 5 hours)
|
# 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 = []
|
self.hourly_forecast = []
|
||||||
|
|
||||||
for hour_data in hourly_list:
|
for hour_data in hourly_list:
|
||||||
dt = datetime.fromtimestamp(hour_data['dt'])
|
dt = datetime.fromtimestamp(hour_data['dt'])
|
||||||
temp = round(hour_data['main']['temp'])
|
temp = round(hour_data['temp'])
|
||||||
condition = hour_data['weather'][0]['main']
|
condition = hour_data['weather'][0]['main']
|
||||||
self.hourly_forecast.append({
|
self.hourly_forecast.append({
|
||||||
'hour': dt.strftime('%I:00 %p').lstrip('0'), # Format as "2:00 PM"
|
'hour': dt.strftime('%I:00 %p').lstrip('0'), # Format as "2:00 PM"
|
||||||
@@ -158,38 +171,18 @@ class WeatherManager:
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Process daily forecast
|
# Process daily forecast
|
||||||
daily_data = {}
|
daily_list = forecast_data.get('daily', [])[1:4] # Skip today (index 0) and get next 3 days
|
||||||
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
|
|
||||||
self.daily_forecast = []
|
self.daily_forecast = []
|
||||||
today_str = datetime.now().strftime('%Y-%m-%d')
|
|
||||||
|
|
||||||
# Sort data by date to ensure chronological order
|
for day_data in daily_list:
|
||||||
sorted_daily_items = sorted(daily_data.items(), key=lambda item: item[1]['date'])
|
dt = datetime.fromtimestamp(day_data['dt'])
|
||||||
|
temp_high = round(day_data['temp']['max'])
|
||||||
# Filter out today's data and take the next 3 days
|
temp_low = round(day_data['temp']['min'])
|
||||||
future_days_data = [item for item in sorted_daily_items if item[0] != today_str][:3]
|
condition = day_data['weather'][0]['main']
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
self.daily_forecast.append({
|
self.daily_forecast.append({
|
||||||
'date': data['date'].strftime('%a'), # Day name (Mon, Tue, etc.)
|
'date': dt.strftime('%a'), # Day name (Mon, Tue, etc.)
|
||||||
'date_str': data['date'].strftime('%m/%d'), # Date (4/8, 4/9, etc.)
|
'date_str': dt.strftime('%m/%d'), # Date (4/8, 4/9, etc.)
|
||||||
'temp_high': temp_high,
|
'temp_high': temp_high,
|
||||||
'temp_low': temp_low,
|
'temp_low': temp_low,
|
||||||
'condition': condition
|
'condition': condition
|
||||||
@@ -340,7 +333,7 @@ class WeatherManager:
|
|||||||
|
|
||||||
# --- Wind (Section 3) ---
|
# --- Wind (Section 3) ---
|
||||||
wind_speed = weather_data['wind']['speed']
|
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_dir = self._get_wind_direction(wind_deg)
|
||||||
wind_text = f"W:{wind_speed:.0f}{wind_dir}"
|
wind_text = f"W:{wind_speed:.0f}{wind_dir}"
|
||||||
wind_width = draw.textlength(wind_text, font=font)
|
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