Files
LEDMatrix/src/calendar_manager.py
2025-07-23 16:46:30 -05:00

330 lines
14 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
import time
# Configure logger for this module
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) # Set to INFO level
class CalendarManager:
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)
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', ['birthdays'])
self.last_update = 0
self.last_display_log = 0 # Add timestamp for display message throttling
self.events = []
self.service = None
# Log font information during initialization
logger.info(f"Display Manager fonts:")
logger.info(f" Small font: {self.display_manager.small_font}")
logger.info(f" Calendar font: {self.display_manager.calendar_font}")
logger.info(f" Font types - Small: {type(self.display_manager.small_font)}, Calendar: {type(self.display_manager.calendar_font)}")
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()
timezone_str = self.config_manager.get_timezone()
logger.info(f"Loading timezone from config: {timezone_str}")
try:
self.timezone = pytz.timezone(timezone_str)
logger.info(f"Successfully loaded timezone: {self.timezone}")
except pytz.UnknownTimeZoneError:
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
self.time_color = (255, 255, 255) # White
self.date_color = (255, 255, 255) # White
# 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:
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."""
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', [])
# Log event details
if events:
logger.info(f"Found {len(events)} calendar events:")
for event in events:
summary = event.get('summary', 'No Title')
start = event.get('start', {}).get('dateTime', event.get('start', {}).get('date'))
end = event.get('end', {}).get('dateTime', event.get('end', {}).get('date'))
logger.info(f" Event: {summary}")
logger.info(f" Start: {start}")
logger.info(f" End: {end}")
else:
logger.info("No upcoming calendar events found")
return events
except Exception as e:
logging.error(f"Error fetching calendar events: {str(e)}")
return []
def draw_event(self, event, y_position=2):
"""Draw a single calendar event."""
try:
# Get date, time, and summary
date_text = self._format_event_date(event)
time_text = self._format_event_time(event)
datetime_text = f"{date_text} {time_text}"
summary = event.get('summary', 'No Title')
# Use regular font (PressStart2P) for date/time
datetime_width = self.display_manager.get_text_width(datetime_text, self.display_manager.regular_font)
datetime_x = (self.display_manager.matrix.width - datetime_width) // 2
# Draw centered date/time with regular font
self.display_manager.draw_text(datetime_text, datetime_x, y_position,
color=self.time_color,
font=self.display_manager.regular_font)
# Use calendar font (4x6) for summary
available_width = self.display_manager.matrix.width - 4 # 2 pixel margin on each side
title_lines = self._wrap_text(summary, available_width, self.display_manager.calendar_font, max_lines=2)
# Draw centered summary with calendar font
for i, line in enumerate(title_lines):
line_width = self.display_manager.get_text_width(line, self.display_manager.calendar_font)
line_x = (self.display_manager.matrix.width - line_width) // 2
# Adjust Y position for BDF font - subtract 4 pixels to compensate for the 6-pixel adjustment in _draw_bdf_text
adjusted_y = y_position + 8 + (i * 8)
self.display_manager.draw_text(line, line_x, adjusted_y,
color=self.text_color,
font=self.display_manager.calendar_font)
return True
except Exception as e:
logger.error(f"Error drawing calendar event: {e}", exc_info=True)
return False
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 = []
current_line = []
words = text.split()
for word in words:
# Try adding the word to the current line
test_line = ' '.join(current_line + [word]) if current_line else word
# Use display_manager's draw_text method to measure text width
text_width = self.display_manager.get_text_width(test_line, font)
if text_width <= max_width:
# Word fits, add it to current line
current_line.append(word)
else:
# Word doesn't fit, start a new line
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
else:
# Single word too long, truncate it
truncated = word
while len(truncated) > 0:
if self.display_manager.get_text_width(truncated + "...", font) <= max_width:
lines.append(truncated + "...")
break
truncated = truncated[:-1]
if not truncated:
lines.append(word[:10] + "...")
# 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:
if self.display_manager.get_text_width(remaining_text + "...", font) <= 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
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:
logger.info("No upcoming calendar events found.")
else:
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_display_log > 5:
logger.debug("Skipping calendar update - not enough time has passed")
self.last_display_log = current_time
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:
# The datetime string already includes timezone info (-05:00)
dt = datetime.fromisoformat(start)
else:
dt = datetime.strptime(start, '%Y-%m-%d')
# Make date object timezone-aware (assume UTC if no tz info)
dt = pytz.utc.localize(dt)
# No need to convert timezone since it's already in the correct one
return 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:
# The datetime string already includes timezone info (-05:00)
dt = datetime.fromisoformat(start)
# No need to convert timezone since it's already in the correct one
return 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, force_clear=False):
"""Display calendar events on the LED matrix."""
if not self.enabled or not self.events:
return
try:
if force_clear:
self.display_manager.clear()
self.force_clear = True
if self.current_event_index >= len(self.events):
self.current_event_index = 0
event = self.events[self.current_event_index]
# Log the event being displayed, but only every 5 seconds
current_time = time.time()
if current_time - self.last_display_log > 5:
summary = event.get('summary', 'No Title')
date_text = self._format_event_date(event)
time_text = self._format_event_time(event)
logger.info(f"Displaying calendar event: {summary}")
logger.info(f" Date: {date_text}")
logger.info(f" Time: {time_text}")
self.last_display_log = current_time
# Draw the event
self.draw_event(event)
# Update the display
self.display_manager.update_display()
except Exception as e:
logger.error(f"Error displaying calendar event: {e}", exc_info=True)
def advance_event(self):
"""Advance to the next event. Called by DisplayManager when calendar display time is up."""
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
logger.debug(f"CalendarManager advanced to event index {self.current_event_index}")