mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 05:13:01 +00:00
269 lines
11 KiB
Python
269 lines
11 KiB
Python
import os
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from google.oauth2.credentials import Credentials
|
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
from google.auth.transport.requests import Request
|
|
from googleapiclient.discovery import build
|
|
import pickle
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import numpy as np
|
|
from rgbmatrix import graphics
|
|
import pytz
|
|
from src.config_manager import ConfigManager
|
|
|
|
class CalendarManager:
|
|
def __init__(self, matrix, canvas, config):
|
|
self.matrix = matrix
|
|
self.canvas = canvas
|
|
self.config = config
|
|
self.calendar_config = config.get('calendar', {})
|
|
self.enabled = self.calendar_config.get('enabled', False)
|
|
self.update_interval = self.calendar_config.get('update_interval', 300)
|
|
self.max_events = self.calendar_config.get('max_events', 3)
|
|
self.calendars = self.calendar_config.get('calendars', ['primary'])
|
|
self.last_update = 0
|
|
self.events = []
|
|
self.service = None
|
|
|
|
# Get display manager instance
|
|
from src.display_manager import DisplayManager
|
|
self.display_manager = DisplayManager._instance
|
|
|
|
# Get timezone from config
|
|
self.config_manager = ConfigManager()
|
|
timezone_str = self.config_manager.get_timezone()
|
|
try:
|
|
self.timezone = pytz.timezone(timezone_str)
|
|
except pytz.UnknownTimeZoneError:
|
|
logging.warning(f"Unknown timezone '{timezone_str}' in config, defaulting to UTC.")
|
|
self.timezone = pytz.utc
|
|
|
|
if self.enabled:
|
|
self.authenticate()
|
|
|
|
# Display properties
|
|
self.text_color = (255, 255, 255) # White
|
|
self.time_color = (0, 255, 0) # Green
|
|
self.date_color = (200, 200, 200) # Light Grey
|
|
|
|
# State management
|
|
self.current_event_index = 0
|
|
|
|
def authenticate(self):
|
|
"""Authenticate with Google Calendar API."""
|
|
creds = None
|
|
token_file = self.calendar_config.get('token_file', 'token.pickle')
|
|
|
|
if os.path.exists(token_file):
|
|
with open(token_file, 'rb') as token:
|
|
creds = pickle.load(token)
|
|
|
|
if not creds or not creds.valid:
|
|
if creds and creds.expired and creds.refresh_token:
|
|
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
|
|
|
|
def get_events(self):
|
|
"""Fetch upcoming calendar events."""
|
|
if not self.enabled or not self.service:
|
|
return []
|
|
|
|
try:
|
|
now = datetime.utcnow().isoformat() + 'Z'
|
|
events_result = self.service.events().list(
|
|
calendarId='primary',
|
|
timeMin=now,
|
|
maxResults=self.max_events,
|
|
singleEvents=True,
|
|
orderBy='startTime'
|
|
).execute()
|
|
|
|
events = events_result.get('items', [])
|
|
return events
|
|
except Exception as e:
|
|
logging.error(f"Error fetching calendar events: {str(e)}")
|
|
return []
|
|
|
|
def draw_event(self, event, y_start=1):
|
|
"""Draw a single calendar event on the canvas."""
|
|
y_pos_at_start = y_start # Store the starting position
|
|
try:
|
|
# Get event details
|
|
summary = event.get('summary', 'No Title')
|
|
time_str = self._format_event_time(event)
|
|
date_str = self._format_event_date(event)
|
|
|
|
# Use display manager's font for wrapping
|
|
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 )
|
|
|
|
# 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
|
|
|
|
# Draw 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)
|
|
y_pos += 8 + 2 # Move down for the next line, add 2px spacing
|
|
return y_pos # Return the final y position if successful
|
|
|
|
except Exception as e:
|
|
logging.error(f"Error drawing calendar event: {str(e)}", exc_info=True)
|
|
return y_pos_at_start # Return the original starting position on error
|
|
|
|
def _wrap_text(self, text, max_width, font):
|
|
"""Wrap text to fit within max_width using the provided font."""
|
|
if not text:
|
|
return [""]
|
|
|
|
lines = []
|
|
words = text.split()
|
|
current_line = []
|
|
|
|
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)
|
|
|
|
if text_width <= max_width:
|
|
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:
|
|
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))
|
|
|
|
return lines
|
|
|
|
def update(self, current_time):
|
|
"""Update calendar events if needed."""
|
|
if not self.enabled:
|
|
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...")
|
|
self.events = self.get_events()
|
|
self.last_update = current_time
|
|
if not self.events:
|
|
logging.info("No upcoming calendar events found.")
|
|
else:
|
|
logging.info(f"Fetched {len(self.events)} calendar events.")
|
|
# Reset index if events change
|
|
self.current_event_index = 0
|
|
|
|
def _format_event_date(self, event):
|
|
"""Format event date for display"""
|
|
start = event.get('start', {}).get('dateTime', event.get('start', {}).get('date'))
|
|
if not start:
|
|
return ""
|
|
|
|
try:
|
|
# Handle both date and dateTime formats
|
|
if 'T' in start:
|
|
dt = datetime.fromisoformat(start.replace('Z', '+00:00'))
|
|
else:
|
|
dt = datetime.strptime(start, '%Y-%m-%d')
|
|
# Make date object timezone-aware (assume UTC if no tz info)
|
|
dt = pytz.utc.localize(dt)
|
|
|
|
local_dt = dt.astimezone(self.timezone) # Use configured timezone
|
|
return local_dt.strftime("%a %-m/%-d") # e.g., "Mon 4/21"
|
|
except ValueError as e:
|
|
logging.error(f"Could not parse date string: {start} - {e}")
|
|
return ""
|
|
|
|
def _format_event_time(self, event):
|
|
"""Format event time for display"""
|
|
start = event.get('start', {}).get('dateTime', event.get('start', {}).get('date'))
|
|
if not start or 'T' not in start: # Only show time for dateTime events
|
|
return "All Day"
|
|
|
|
try:
|
|
dt = datetime.fromisoformat(start.replace('Z', '+00:00'))
|
|
local_dt = dt.astimezone(self.timezone) # Use configured timezone
|
|
return local_dt.strftime("%I:%M %p")
|
|
except ValueError as e:
|
|
logging.error(f"Could not parse time string: {start} - {e}")
|
|
return "Invalid Time"
|
|
|
|
def display(self):
|
|
"""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.")
|
|
return
|
|
|
|
if not self.events:
|
|
# Display "No Events" message if the list is empty
|
|
logging.debug("CalendarManager displaying 'No Events'.")
|
|
self.display_manager.clear()
|
|
self.display_manager.draw_text("No Events", small_font=True, color=self.text_color)
|
|
self.display_manager.update_display()
|
|
logging.debug("CalendarManager 'No Events' display updated.")
|
|
return
|
|
|
|
# Clear the display before drawing the current event
|
|
logging.debug("CalendarManager clearing display for event.")
|
|
self.display_manager.clear()
|
|
|
|
# Get current event to display
|
|
if self.current_event_index >= len(self.events):
|
|
logging.debug("Resetting calendar event index.")
|
|
self.current_event_index = 0
|
|
event = self.events[self.current_event_index]
|
|
|
|
# Draw the current event centered vertically
|
|
logging.debug(f"Displaying calendar event {self.current_event_index + 1}/{len(self.events)}: {event.get('summary')}")
|
|
self.draw_event(event)
|
|
|
|
# Update the display
|
|
logging.debug("CalendarManager updating display with event.")
|
|
self.display_manager.update_display()
|
|
|
|
# Increment event index for the *next* time display is called within its duration
|
|
# This logic might need adjustment depending on how often display() is called vs the mode duration
|
|
# For now, let's assume it cycles once per display call. If it needs to stay on one event
|
|
# for the full duration, this increment needs to move to the controller's mode switch logic.
|
|
self.current_event_index += 1 |