-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
alarmpy.py
416 lines (377 loc) · 15.7 KB
/
alarmpy.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
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Requires google api account, google stuff: gflags, ouath2client, apiclient
# pytz, pygame
# TODO: replace pygame with other, cross platform audio library
import ConfigParser
import sys
import argparse
import httplib2
import dateutil.parser
import datetime
import pytz
import pygame
import os
import random
import Queue
try:
import RPi.GPIO as GPIO
button_available = True
except ImportError:
button_available = False
from Queue import PriorityQueue
from time import sleep
import apiclient
import gflags
from apiclient.discovery import build
from oauth2client.file import Storage
from oauth2client.client import OAuth2WebServerFlow
from oauth2client.tools import run
class AlarmError(Exception):
pass
class AlarmPy(object):
"""song_dir is the directory holding the mp3 files you want to pull
alarm tunes from.
interval is the interval in seconds with which we poll Google Calendar for any
changes, e.g. new or removed alarms.
"""
def __init__(self, api_id, api_secret, api_scope,
timezone, calendar_id, song_dir='songs', interval=45):
self.tz = pytz.timezone(timezone)
self.now = datetime.datetime.now
utc = self.now(self.tz).strftime('%z')
self.utc = utc[0:3] + ":" + utc[3:]
self.alarms = None
self.debug = True
if os.path.isdir(song_dir):
self.song_dir = song_dir
else:
raise AlarmException("Song directory doesn't exist.")
self.calendar_id = calendar_id
self.interval = datetime.timedelta(seconds=interval)
if button_available:
# setup the gpio button
self.button_pin = 17
GPIO.setmode(GPIO.BCM)
GPIO.setup(self.button_pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
pygame.mixer.init()
self.gcal_init(api_id, api_secret, api_scope)
self.update_alarms()
def gcal_init(self, client_id, client_secret, api_scope):
"""Initialize the service object, through which we
interact with the Google calendar storing our alarms.
"""
print "Initializing connection with Google Calendar..."
FLAGS = gflags.FLAGS
# Set up a Flow object to be used if we need to authenticate.
# The client_id and client_secret are copied from the
# API Access tab on the Google APIs Console.
# https://code.google.com/apis/console/
self.FLOW = OAuth2WebServerFlow(
client_id = client_id,
client_secret = client_secret,
scope = api_scope)
# Credentials will get written back to a file.
self.storage = Storage('calendar.dat')
self.update_gcal_tokens()
print "Done!"
def update_gcal_tokens(self):
"""Update access token if token_expiry is due."""
# update last_update
self.last_update = self.now(self.tz)
credentials = self.storage.get()
if credentials is None or credentials.invalid == True:
credentials = run(self.FLOW, self.storage)
# Create an httplib2.Http object to handle our HTTP requests
# and authorize it with our good Credentials.
http = httplib2.Http()
http = credentials.authorize(http)
# Build a service object for interacting with the API. Visit
# the google APIs Console to get a developerKey for your own
# application.
self.service = build(serviceName='calendar',
version='v3', http=http)
def listen(self):
"""Listen for alarms and trigger them if necessary."""
print "Listening after alarms..."
while True:
# wait in get_alarm until we have an alarm
self.get_alarm()
# loop until an alarm goes off
self.start = self.now()
while self.now(self.tz) < self.alarm:
self.check_alarms()
# sleep to prevent eating the cpu
sleep(1)
print "[{}]: Alarm went off!".format(self.now(self.tz))
self.play_some_beats()
self.purge_alarms()
def update_end(self):
"""Update the right endpoint of the update interval."""
# used to tell google how far into the future you want to
# check your alarms
self.end = self.now() + datetime.timedelta(days=365)
self.end = self.end.strftime("%Y-%m-%dT%H:%M:%S%z") + self.utc
def get_alarm(self):
"""Retrieve the earliest alarm.
If we don't have any alarms we wait until we get one.
"""
while True:
try:
self.update_alarms()
# if we don't have any alarms, we wait <interval> seconds and
# then attempt to get alarms again
self.alarm = self.alarms.get(timeout=self.interval.total_seconds())
self.alarms.put(self.alarm)
print self.alarm
return
except Queue.Empty:
# Block until we have an alarm
pass
def check_alarms(self):
"""Check if the newest alarm is the one we're monitoring.
If it isn't then update self.alarm to reflect the newest one.
"""
if self.now() - self.start >= self.interval:
self.get_alarm()
self.start = self.now()
def update_alarms(self):
"""Update self.alarms with new alarms.
Range is from self.now(self.tz) until self.end.
"""
count = 0
interval = datetime.timedelta(days=1)
# while True allows us to recover from 503 backend errors
while True:
try:
# make sure our access token is valid
# only update our token if it has been more than a day
if self.last_update - self.now(self.tz) > interval:
self.update_gcal_tokens()
# update the the right endpoint of the interval, we pull events from [now, end]
self.update_end()
self.alarms = PriorityQueue()
# get all events from self.now until self.end
cal_events = self.service.events().list(
calendarId=self.calendar_id,
timeMin=self.now(self.tz).strftime('%Y-%m-%dT%H:%M:%S.%f%z'),
timeMax=self.end
).execute()
break
except:
e = sys.exc_info()[0]
count += 1
if self.debug:
print "recovering from backend error", count
print e
self.update_gcal_tokens()
sleep(1)
# grab the starting times for all events in cal_events
for event in cal_events.get('items', []):
# don't grab events without starting times
if u"dateTime" in event["start"]:
datetime_string = event['start'][u'dateTime']
datetime_object = dateutil.parser.parse(datetime_string)
# only store the times that start after the current time
if datetime_object > self.now(self.tz):
self.alarms.put(datetime_object)
def set_alarm(self, datetime_obj, name='alarm', days=None):
"""Set new alarms in self.calendar."""
# parse user string
dt_object = dateutil.parser.parse(datetime_obj)
if dt_object < self.now(self.tz):
raise AlarmError("Invalid input. Alarm is in the past.")
valid_days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
if days:
# remove any whitespaces
days = "".join(days.split())
for day in days.upper().split(','):
# check input
if day not in valid_days:
raise AlarmException(
"Recurrence range invalid.\nFormat as: {}".format(
", ".join(valid_days).lower()))
recurrence_rule = ['RRULE:FREQ=DAILY;BYDAY=' + days.upper()]
else:
recurrence_rule = []
event = {
'summary': name,
'start': {
'dateTime': datetime_obj,
'timeZone': self.tz.zone
},
'end' : {
'dateTime': datetime_obj,
'timeZone': self.tz.zone
},
'recurrence': recurrence_rule
}
# fire away the event to Google!
self.service.events().insert(
calendarId=self.calendar_id,
body=event).execute()
def purge_alarms(self):
"""Go through self.alarms removing alarms in the past."""
# go through the alarms, removing expired ones
while not self.alarms.empty():
# remove expired alarms
self.alarm = self.alarms.get()
if self.alarm < self.now(self.tz):
print "Removing: {}".format(self.alarm)
# if the alarm hasn't expired, put it back
else:
self.alarms.put(self.alarm)
break
def play_some_beats(self):
"""Play some funky alarm beats.
Beats are randomly selected from self.song_dir, and looped
until the alarm is silenced, or until 5 minutes have passed.
"""
# randomly choose a song from the song dir
# do this every time we run an alarm
self.extensions = ("mp3", "ogg", "wav" )
# only grab the files that we can listen to
songs = [f for f in os.listdir(self.song_dir) if
f.lower().endswith(self.extensions)]
song = songs[random.randint(0, len(songs)-1)]
print song
start = self.now()
duration = datetime.timedelta(minutes=5)
pygame.mixer.music.load(os.path.join(self.song_dir, song))
pygame.mixer.music.play(-1)
clock = pygame.time.Clock()
while pygame.mixer.music.get_busy():
# check if playback has finished
clock.tick(50)
if button_available:
if not GPIO.input(self.button_pin) or self.now() - start > duration:
pygame.mixer.music.fadeout(250)
else:
try:
if self.now() - start > duration:
raise KeyboardInterrupt
except KeyboardInterrupt:
pygame.mixer.music.fadeout(250)
print "You stopped the music!"
def exit(self):
pygame.mixer.quit()
parser = argparse.ArgumentParser("AlarmPy")
group = parser.add_mutually_exclusive_group()
group.add_argument("-s", "--setalarm", nargs=2, metavar=("YYYY-mm-dd","HH:MM"),
help="Set an alarm. Format as: YYYY-mm-dd HH:MM, unless -p specified.")
group.add_argument("-t", "--today", nargs=1, metavar="MM:SS",
help="Only takes a time parameter; assumes alarm is for today.")
group.add_argument("--tomorrow", nargs=1, metavar="HH:MM",
help="Specifies an alarm to go off at HH:MM tomorrow.")
group.add_argument("--timer", nargs=1, metavar="MM, or HH:MM",
help="Specifies an alarm to go off <minutes>/<hours:minutes> from now.")
parser.add_argument("-p", "--precise", action="store_true",
help="Allows alarm times with seconds. e.g. YYYY-mm-dd HH:MM:SS")
parser.add_argument("-r", "--recurring", metavar="<comma delim list>",
help="Sets a recurring alarm in the specified interval. e.g: fr, sa, su")
parser.add_argument("-n", "--name", nargs="+", metavar="desired name",
help="Names an alarm. If not specified, the name will be \"alarm\"")
def main():
args = parser.parse_args()
config = ConfigParser.ConfigParser()
try:
with open("settings.cfg") as f:
config.readfp(f)
except (ConfigParser.NoSectionError,
ConfigParser.NoOptionError) as e:
print "Invalid config file: "
print e
print "Exiting."
sys.exit(1)
# Grabs all the settings in settings.cfg and feeds them to AlarmPy's init
try:
alarm = AlarmPy(**dict(config.items("Settings")))
except TypeError as e:
print "Error: settings.cfg is improperly formatted."
print "Error message: {}".format(sys.exc_info()[1])
sys.exit(1)
except pytz.exceptions.UnknownTimeZoneError as e:
print "Unknown timezone: "
print "timezone = " + str(e).replace("'", "")
sys.exit(1)
except:
print "uncaught exception"
print sys.exc_info()
sys.exit(1)
if args.setalarm or args.today or args.tomorrow or args.timer:
if args.name:
name = " ".join(args.name).decode("iso-8859-1")
else:
name = "alarm"
error_msg = "Invalid input.\nPlease format as: YYYY-MM-DD HH:MM"
dtstrings = []
# if today, prepend today's date and format dtstring
if args.timer:
error_msg ="Invalid input.\nPlease input the offset in minutes, or as HH:MM."
# allows for multiple timers to be set in one command
times = args.timer[0].split(",")
for time in times:
# check if the input is formatted as HH:MM
try:
h, m = time.split(":")
offset = datetime.timedelta(hours=int(h), minutes=int(m))
# nope, treat it as minutes
except ValueError:
offset = datetime.timedelta(minutes=int(time))
dtobj = alarm.now(alarm.tz) + offset
dtstrings.append(dtobj.strftime("%Y-%m-%dT%H:%M:%S"))
elif args.today:
# allows for multiple timers to be set in one command
times = args.today[0].split(",")
for time in times:
error_msg = "Invalid input.\nPlease format as: HH:MM"
date = alarm.now(alarm.tz).strftime('%Y-%m-%dT')
dtstrings.append(date + time)
elif args.tomorrow:
times = args.tomorrow[0].split(",")
for time in times:
error_msg = "Invalid input.\nPlease format as: HH:MM"
date = alarm.now(alarm.tz) + datetime.timedelta(days=1)
dtstrings.append(date.strftime('%Y-%m-%dT') + time)
else:
times = args.setalarm[1].split(",")
for time in times:
# join date and time with T inbetween
dtstrings.append("T".join([args.setalarm[0], time]))
if args.precise:
error_msg += ":SS"
elif args.timer:
# we've already formatted the seconds for the timer
pass
else:
for index, dtstring in enumerate(dtstrings):
seconds =":00"
dtstrings[index] = dtstring + seconds
# process all the alarms we set
for dtstring in dtstrings:
# get the user input, format it correctly and cat with utc offset
dtstring = "{}{}".format(dtstring, alarm.utc)
print dtstring
try:
if args.recurring:
alarm.set_alarm(dtstring, name=name, days=args.recurring)
else:
alarm.set_alarm(dtstring, name=name)
except (ValueError, apiclient.errors.HttpError) as e:
print "Error: " + str(e).capitalize()
print error_msg
sys.exit(1)
except:
print "{}\n{}".format(*sys.exc_info()[0:2])
sys.exit(1)
else:
wait_time = 60
while True:
try:
alarm.listen()
except (Exception) as e:
print "Connection Error: " + str(e).capitalize()
print "Reattempting to connection in {} seconds.".format(wait_time)
sleep(wait_time)
if __name__ == '__main__':
main()