-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathgoogle_calendar.py
361 lines (336 loc) · 17.6 KB
/
google_calendar.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
###########################################################
# Calendar Module
#
# Exposes properties and methods for the Google Calendar
# This isn't a Singleton because the app will never try
# to load more than one instance of the class.
###########################################################
# This project's imports (local modules)
from settings import *
from status import Status
import unicorn_hat as unicorn
# other modules
from dateutil import parser
import json
import logging
import os
import pytz
import socket
import sys
import time
import traceback
# Google Calendar libraries
import datetime
import pickle
from googleapiclient.discovery import build
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
reboot_counter = 0
# Initialize the Google Calendar API stuff
# If modifying these scopes, delete the file `~/pi-remind-hd-notify/token.pickle`
SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']
class GoogleCalendar:
# Added to fix an issue when there's an error connecting to the
# Google Calendar API. The app needs to track whether there's an existing
# error through the process. If there is, then when checking again for entries
# the app will leave the light red while checking. Setting it to green if
# successful.
_has_error = False
def __init__(self):
# Populate the local properties
logging.info('Calendar Initialization')
settings = Settings.get_instance()
self._busy_only = settings.get_busy_only()
logging.info('Calendar: Busy Only: {}'.format(self._busy_only))
self._ignore_in_summary = settings.get_ignore_in_summary()
logging.info('Calendar: Ignore in Summary: {}'.format(self._ignore_in_summary))
self._reminder_only = settings.get_reminder_only()
logging.info('Calendar: Reminder Only: {}'.format(self._reminder_only))
self._use_reboot_counter = settings.get_use_reboot_counter()
logging.info('Calendar: Reboot Counter: {}'.format(self._use_reboot_counter))
if self._use_reboot_counter:
self._reboot_counter_limit = settings.get_reboot_counter_limit()
logging.info('Calendar: Reboot Counter Limit: {}'.format(self._reboot_counter_limit))
self._use_work_hours = settings.get_use_working_hours()
logging.info('Calendar: Use Work Hours: {}'.format(self._use_work_hours))
if self._use_work_hours:
self._work_start = settings.get_work_start()
self._work_end = settings.get_work_end()
logging.info('Work hours: {} to {}'.format(self._work_start, self._work_end))
# Turn off logging of specific warnings
logging.getLogger('googleapiclient.discovery_cache').setLevel(logging.ERROR)
creds = None
# The file token.pickle stores the user's access and refresh tokens, and is
# created automatically when the authorization flow completes for the first
# time.
if os.path.exists('token.pickle'):
# logging.debug('Token file exists')
with open('token.pickle', 'rb') as token:
creds = pickle.load(token)
# If there are no (valid) credentials available, let the user log in.
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)
creds = flow.run_local_server(port=0)
# Save the credentials for the next run
with open('token.pickle', 'wb') as token:
pickle.dump(creds, token)
logging.debug('Initializing calendar service')
self._service = build('calendar', 'v3', credentials=creds)
# Set the timeout for the rest of the Google API calls.
# need this at its default (infinity, i think) during the registration process.
socket.setdefaulttimeout(5) # seconds
@staticmethod
def _is_marked_busy(event):
logging.debug('_is_marked_busy(event)')
# event is busy if transparency is missing from the event object
try:
if event['transparency']:
logging.debug('Not busy')
return False
else:
logging.debug('Busy')
return True
except KeyError:
logging.debug('Busy')
return True
@staticmethod
def _has_reminder(event):
logging.debug('_has_reminder()')
# Return true if there's a reminder set for the event
# First, check to see if there is a default reminder set
# Yes, I know I could have done this and the next check without using variables
# this approach just makes the code easier to understand
has_default_reminder = event['reminders'].get('useDefault')
if has_default_reminder:
# if yes, then we're good
return True
else:
# are there overrides set for reminders?
# overrides = event['reminders'].get('overrides')
# if overrides:
if event['reminders'].get('overrides'):
# OK, then we have a reminder to use
return True
# if we got this far, then there must not be a reminder set
return False
def ignore_event(self, event_summary):
logging.debug('ignore_event()')
# Do we have any strings to ignore?
if len(self._ignore_in_summary) > 0:
# loop through the ignore list
for key in self._ignore_in_summary:
# see if the ignore keyword is in the lower case summary
if key in event_summary:
logging.debug('Ignoring this event')
return True
return False
else:
return False
def _is_working_hours(self, event):
logging.debug('_is_working_hours({})'.format(event))
event_time = event.time()
logging.debug('Event Time: {}'.format(event_time))
# is the current time within working hours?
return self._work_start < event_time < self._work_end
@staticmethod
def merge_status(current, new):
# Return the lowest status > 0 (1 busy, 2 tentative, 3 free)
if current < 1:
return new
else:
return min(current, new)
@staticmethod
def get_event_summary(event):
event_summary = event['summary'] if 'summary' in event else 'No Title'
# clockwise events have an icon in the start of the summary
# and that was causing an encoding error, so I added this to resolve it
event_summary = event_summary.encode('ascii', errors='ignore').strip()
return event_summary
@staticmethod
def _process_upcoming_event(event, start, time_delta):
logging.debug('_process_upcoming_event(event, {}, {})'.format(start, time_delta))
event_summary = GoogleCalendar.get_event_summary(event)
logging.info('Found event: {}'.format(event_summary))
logging.info('Event starts: {}'.format(start))
new_event = {
'summary': event_summary,
'minutes_to_start': time_delta.total_seconds() // 60}
return new_event
@staticmethod
def _process_upcoming_events(event_list, time_window):
logging.debug('_process_upcoming_events(event_list, {})'.format(time_window))
summary_list = []
nearest_time = time_window
for event in event_list:
summary_list.append(GoogleCalendar.get_event_summary(event))
# summary_list.append(event['summary'] if 'summary' in event else 'No Title')
# find the nearest (soonest) meeting time
nearest_time = min(nearest_time, event['minutes_to_start'])
return nearest_time, ', '.join(summary_list)
def get_status(self, time_window):
logging.debug('get_status({})'.format(time_window))
# get the status of the user's calendar
global reboot_counter
# get all of the events on the calendar from now through 10 minutes from now
logging.info('Getting next event')
# this 'now' is in a different format (UTC)
now = datetime.datetime.utcnow()
# Calculate a time search_limit from now
then = now + datetime.timedelta(minutes=time_window)
# if we don't have an error from the previous attempt, then change the LED color
# otherwise leave it alone (it should already be red, so it will stay that way).
if not self._has_error:
# turn on a sequential CHECKING_COLOR LED to show that you're requesting data from the Google Calendar API
unicorn.set_activity_light(unicorn.CHECKING_COLOR, True)
try:
# set our base calendar status, assume we're turning the Remote Notify status LED off
current_status = Status.OFF.value
# Now check to see whether the LED should be set to Green (free)
if self._use_work_hours:
# is the current time within working hours?
if self._is_working_hours(datetime.datetime.now()):
# Is it the weekend?
if datetime.datetime.today().weekday() < 5:
# No? Working hours on a weekday, so Free
logging.debug('Current time is within working hours')
current_status = Status.FREE.value
else:
# working hours, but Weekend, should be OFF
logging.debug('Skipping working hours, it\'s the weekend')
else:
# Not working hours, should be OFF
logging.debug('Current time is not within working hours')
else:
# not using work hours, always set status to FREE
logging.debug('Working hours disabled')
current_status = Status.FREE.value
# ask Google for the calendar entries
events_result = self._service.events().list(
# get all of them between now and 10 minutes from now
calendarId='primary',
timeMin=now.isoformat() + 'Z',
timeMax=then.isoformat() + 'Z',
singleEvents=True,
orderBy='startTime').execute()
# turn on the SUCCESS_COLOR LED so you'll know data was returned from the Google calendar API
unicorn.set_activity_light(unicorn.SUCCESS_COLOR, False)
# Get the event list
event_list = events_result.get('items', [])
# initialize this here, setting it to true later if we encounter an error
self._has_error = False
if reboot_counter > 0:
# reset the reboot counter, since everything worked so far
reboot_counter = 0
logging.info('Resetting the reboot counter ({})'.format(reboot_counter))
# Did we get any events back?
if not event_list:
# no? so nothing to do right now
logging.info('No calendar entries returned')
# Return values: num_minutes, summary_string, calendar_status
return 0, '', current_status
else:
# what time is it now?
current_time = pytz.utc.localize(datetime.datetime.utcnow())
# an empty list of upcoming events, will populate in the following loop
upcoming_events = []
logging.info('Events returned: {}'.format(len(event_list)))
# loop through the events in the list
for event in event_list:
# write the event to the console
logging.debug('Event: {}'.format(event))
# we only care about events that have a start time
start = event['start'].get('dateTime')
# we only want events that have a start time (skips all day events)
# do we have a start time for this event?
if start:
# get our event summary string
event_summary = GoogleCalendar.get_event_summary(event)
# is this one of the events we're support to just ignore?
if not self.ignore_event(event_summary.lower()):
# When does the appointment start?
# Convert the string it into a Python dateTime object so we can do math on it
event_start = parser.parse(start)
# does the event start in the future?
if current_time < event_start:
logging.info('Upcoming event: {}'.format(event_summary))
time_delta = event_start - current_time
new_event = self._process_upcoming_event(event, start, time_delta)
logging.debug('New Event: {}'.format(new_event))
# we have an upcoming event
if self._reminder_only:
# only use events that have a reminder set
if self._has_reminder(event):
upcoming_events.append(new_event)
else:
# add the event to our upcoming event list
upcoming_events.append(new_event)
else:
logging.info('Ongoing event: {}'.format(event_summary))
# we have an ongoing/current event
# Are we processing busy events only?
if self._busy_only:
# then is the user marked busy for this event?
if self._is_marked_busy(event):
logging.debug('Setting busy (1)')
# add the event to our current event list
current_status = Status.BUSY.value
# else use whatever the current status is
else:
if self._is_marked_busy(event):
logging.debug('Setting busy (2)')
# add the event to our current event list
current_status = Status.BUSY.value
else:
logging.debug('Merging tentative')
# set it equal to the highest status (lowest status value)
current_status = GoogleCalendar.merge_status(
current_status, Status.TENTATIVE.value)
else:
# We're ignoring the event because it contains some strings we don't care about
logging.info('Ignoring event: {}'.format(event_summary))
# start processing our lists
# do we have any upcoming events?
if len(upcoming_events) > 0:
# then process the list and figure out when the next one is
num_minutes, summary_string = self._process_upcoming_events(upcoming_events, time_window)
else:
# No? Then return an invalid number of minutes to the next appointment
num_minutes = -1
summary_string = ''
# Return values: num_minutes, summary_string, calendar_status
return num_minutes, summary_string, current_status
except Exception as e:
# Something went wrong, tell the user (just in case they have a monitor on the Pi)
logging.error('Exception type: {}'.format(type(e)))
# not much else we can do here except to skip this attempt and try again later
logging.error('Error: {}'.format(sys.exc_info()[0]))
# experimenting with a different way to output exception details
logging.info('print_exc()')
traceback.print_exc(file=sys.stdout)
# Another way to output exception details
logging.info('print_exc(1)')
traceback.print_exc(limit=1, file=sys.stdout)
# light up the array with FAILURE_COLOR LEDs to indicate a problem
unicorn.flash_all(1, 2, unicorn.FAILURE_COLOR)
# now set the current_activity_light to FAILURE_COLOR to indicate an error state
# with the last reading
unicorn.set_activity_light(unicorn.FAILURE_COLOR, False)
# we have an error, so make note of it
_has_error = True
# check to see if reboot is enabled
if self._use_reboot_counter:
# increment the counter
reboot_counter += 1
logging.info('Incrementing the reboot counter ({})'.format(reboot_counter))
# did we reach the reboot threshold?
if reboot_counter == self._reboot_counter_limit:
# Reboot the Pi
for i in range(1, 10):
logging.info('Rebooting in {} seconds'.format(i))
time.sleep(1)
os.system("sudo reboot")
# we have to return something here, so making some guesses
return -1, '', Status.OFF