From d30ec921ae48bcd1fdd000374a2ad567b1b381a9 Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Mon, 21 Apr 2025 09:01:42 -0500 Subject: [PATCH] Add Google Calendar integration with display manager. Includes calendar manager implementation, configuration updates, and registration script. --- .gitignore | 1 + calendar_registration.py | 75 +++++++++++++++++++++++ config.example.json | 5 +- config/config.json | 11 +++- requirements.txt | 5 +- src/calendar_manager.py | 129 +++++++++++++++++++++++++++++++++++++++ src/display_manager.py | 4 ++ 7 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 calendar_registration.py create mode 100644 src/calendar_manager.py diff --git a/.gitignore b/.gitignore index 2a673364..fd1793c2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ # Secrets config/config_secrets.json credentials.json +token.pickle # Environment .env diff --git a/calendar_registration.py b/calendar_registration.py new file mode 100644 index 00000000..57f88836 --- /dev/null +++ b/calendar_registration.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +import os +import json +from google_auth_oauthlib.flow import InstalledAppFlow +from google.oauth2.credentials import Credentials +from google.auth.transport.requests import Request +import pickle + +# If modifying these scopes, delete the file token.pickle. +SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] + +def load_config(): + with open('config/config.json', 'r') as f: + return json.load(f) + +def save_credentials(creds, token_path): + # Save the credentials for the next run + with open(token_path, 'wb') as token: + pickle.dump(creds, token) + +def main(): + config = load_config() + calendar_config = config.get('calendar', {}) + + creds_file = calendar_config.get('credentials_file', 'credentials.json') + token_file = calendar_config.get('token_file', 'token.pickle') + + creds = None + # The file token.pickle stores the user's access and refresh tokens + if os.path.exists(token_file): + print("Existing token found, but you may continue to generate a new one.") + choice = input("Generate new token? (y/n): ") + if choice.lower() != 'y': + print("Keeping existing token. Exiting...") + return + + # If there are no (valid) credentials available, let the user log in. + if not os.path.exists(creds_file): + print(f"Error: No credentials file found at {creds_file}") + print("Please download the credentials file from Google Cloud Console") + print("1. Go to https://console.cloud.google.com") + print("2. Create a project or select existing project") + print("3. Enable the Google Calendar API") + print("4. Configure the OAuth consent screen (select TV and Limited Input Device)") + print("5. Create OAuth 2.0 credentials") + print("6. Download the credentials and save as credentials.json") + return + + # Create the flow using the client secrets file from the Google API Console + flow = InstalledAppFlow.from_client_secrets_file( + creds_file, SCOPES, + # Specify TV and Limited Input Device flow + redirect_uri='urn:ietf:wg:oauth:2.0:oob' + ) + + # Generate URL for authorization + auth_url, _ = flow.authorization_url(prompt='consent') + + print("\nPlease visit this URL to authorize this application:") + print(auth_url) + print("\nAfter authorizing, you will receive a code. Enter that code below:") + + code = input("Enter the authorization code: ") + + # Exchange the authorization code for credentials + flow.fetch_token(code=code) + creds = flow.credentials + + # Save the credentials + save_credentials(creds, token_file) + print(f"\nCredentials saved successfully to {token_file}") + print("You can now run the LED Matrix display with calendar integration!") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/config.example.json b/config.example.json index f0a749f8..cc33fe6f 100644 --- a/config.example.json +++ b/config.example.json @@ -43,9 +43,10 @@ "calendar": { "enabled": true, "credentials_file": "credentials.json", - "token_file": "token.json", + "token_file": "token.pickle", "update_interval": 300, - "max_events": 3 + "max_events": 3, + "calendars": ["primary"] }, "mqtt": { "broker": "homeassistant.local", diff --git a/config/config.json b/config/config.json index 4ed2eab8..17bb284d 100644 --- a/config/config.json +++ b/config/config.json @@ -37,7 +37,8 @@ "nhl_upcoming": 20, "nba_live": 30, "nba_recent": 20, - "nba_upcoming": 20 + "nba_upcoming": 20, + "calendar": 30 } }, "clock": { @@ -67,6 +68,14 @@ "max_headlines_per_symbol": 1, "headlines_per_rotation": 2 }, + "calendar": { + "enabled": true, + "credentials_file": "credentials.json", + "token_file": "token.pickle", + "update_interval": 300, + "max_events": 3, + "calendars": ["primary"] + }, "nhl_scoreboard": { "enabled": true, "test_mode": false, diff --git a/requirements.txt b/requirements.txt index 2077c8fc..329003a4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,7 @@ pytz==2023.3 requests==2.31.0 timezonefinder==6.2.0 geopy==2.4.1 -rgbmatrix \ No newline at end of file +rgbmatrix +google-auth-oauthlib==1.0.0 +google-auth-httplib2==0.1.0 +google-api-python-client==2.86.0 \ No newline at end of file diff --git a/src/calendar_manager.py b/src/calendar_manager.py new file mode 100644 index 00000000..3b012074 --- /dev/null +++ b/src/calendar_manager.py @@ -0,0 +1,129 @@ +from datetime import datetime, timedelta +import pickle +import os.path +from google.oauth2.credentials import Credentials +from google.auth.transport.requests import Request +from googleapiclient.discovery import build +from rgbmatrix import graphics +import pytz + +class CalendarManager: + def __init__(self, matrix, canvas, config): + self.matrix = matrix + self.canvas = canvas + self.config = config.get('calendar', {}) + self.enabled = self.config.get('enabled', False) + self.update_interval = self.config.get('update_interval', 300) + self.max_events = self.config.get('max_events', 3) + self.token_file = self.config.get('token_file', 'token.pickle') + + # Display properties + self.font = graphics.Font() + self.font.LoadFont("assets/fonts/7x13.bdf") + self.text_color = graphics.Color(255, 255, 255) + self.date_color = graphics.Color(0, 255, 0) + + # State management + self.last_update = None + self.events = [] + self.service = None + self.current_event_index = 0 + + # Initialize the calendar service + self._initialize_service() + + def _initialize_service(self): + """Initialize the Google Calendar service with stored credentials""" + if not os.path.exists(self.token_file): + print(f"No token file found at {self.token_file}") + print("Please run calendar_registration.py first") + self.enabled = False + return + + try: + with open(self.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()) + # Save the refreshed credentials + with open(self.token_file, 'wb') as token: + pickle.dump(creds, token) + else: + print("Invalid credentials. Please run calendar_registration.py") + self.enabled = False + return + + self.service = build('calendar', 'v3', credentials=creds) + except Exception as e: + print(f"Error initializing calendar service: {e}") + self.enabled = False + + def _fetch_events(self): + """Fetch upcoming events from Google Calendar""" + if 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() + return events_result.get('items', []) + except Exception as e: + print(f"Error fetching calendar events: {e}") + return [] + + def update(self): + """Update calendar events if needed""" + if not self.enabled: + return + + current_time = datetime.now() + if (self.last_update is None or + (current_time - self.last_update).seconds > self.update_interval): + self.events = self._fetch_events() + self.last_update = current_time + + def _format_event_time(self, event): + """Format event time for display""" + start = event['start'].get('dateTime', event['start'].get('date')) + if 'T' in start: # DateTime + dt = datetime.fromisoformat(start.replace('Z', '+00:00')) + local_dt = dt.astimezone(pytz.local) + return local_dt.strftime("%I:%M %p") + else: # All-day event + return "All Day" + + def display(self): + """Display calendar events on the matrix""" + if not self.enabled or not self.events: + return + + # Clear the canvas + self.canvas.Clear() + + # Get current event to display + if self.current_event_index >= len(self.events): + self.current_event_index = 0 + event = self.events[self.current_event_index] + + # Display event time + time_str = self._format_event_time(event) + graphics.DrawText(self.canvas, self.font, 1, 12, self.date_color, time_str) + + # Display event title (with scrolling if needed) + title = event['summary'] + if len(title) > 10: # Implement scrolling for long titles + # Add scrolling logic here + pass + else: + graphics.DrawText(self.canvas, self.font, 1, 25, self.text_color, title) + + # Increment event index for next display + self.current_event_index += 1 \ No newline at end of file diff --git a/src/display_manager.py b/src/display_manager.py index 2a1a74b9..357cedbb 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -5,6 +5,7 @@ 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 @@ -29,6 +30,9 @@ class DisplayManager: self._load_fonts() 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) + def _setup_matrix(self): """Initialize the RGB matrix with configuration settings.""" setup_start = time.time()