Merge branch 'working' of https://github.com/ChuckBuilds/LEDMatrix into working

This commit is contained in:
Chuck
2025-04-22 17:42:43 -05:00
9 changed files with 454 additions and 145 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -1,5 +1,9 @@
{
"weather": {
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
},
"youtube": {
"api_key": "YOUR_YOUTUBE_API_KEY",
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
}
}

View File

@@ -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}")

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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))

View File

@@ -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
View 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()