diff --git a/README.md b/README.md index 5655ee85..bbd9e277 100644 --- a/README.md +++ b/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 diff --git a/config/config.json b/config/config.json index e100938f..cdcfdc66 100644 --- a/config/config.json +++ b/config/config.json @@ -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 } } \ No newline at end of file diff --git a/config/config_secrets.template.json b/config/config_secrets.template.json index 6153736f..0a454c14 100644 --- a/config/config_secrets.template.json +++ b/config/config_secrets.template.json @@ -1,5 +1,9 @@ { "weather": { "api_key": "YOUR_OPENWEATHERMAP_API_KEY" + }, + "youtube": { + "api_key": "YOUR_YOUTUBE_API_KEY", + "channel_id": "YOUR_YOUTUBE_CHANNEL_ID" } } \ No newline at end of file diff --git a/src/calendar_manager.py b/src/calendar_manager.py index 9479a27f..816f657e 100644 --- a/src/calendar_manager.py +++ b/src/calendar_manager.py @@ -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}") \ No newline at end of file + logger.debug(f"CalendarManager advanced to event index {self.current_event_index}") \ No newline at end of file diff --git a/src/display_controller.py b/src/display_controller.py index 863e2e59..ac46001a 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -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: diff --git a/src/display_manager.py b/src/display_manager.py index 357cedbb..a39a6fda 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -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.""" diff --git a/src/nhl_managers.py b/src/nhl_managers.py index acc077bb..ee6650ee 100644 --- a/src/nhl_managers.py +++ b/src/nhl_managers.py @@ -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)) diff --git a/src/weather_manager.py b/src/weather_manager.py index b3baee37..0409d488 100644 --- a/src/weather_manager.py +++ b/src/weather_manager.py @@ -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) diff --git a/src/youtube_display.py b/src/youtube_display.py new file mode 100644 index 00000000..10915666 --- /dev/null +++ b/src/youtube_display.py @@ -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() \ No newline at end of file