├── README.md
├── devicetypes
└── info-fiend
│ ├── gcal-event-sensor.src
│ └── gcal-event-sensor.groovy
│ └── gcal-presence-sensor.src
│ └── gcal-presence-sensor.groovy
└── smartapps
└── mnestor
├── gcal-search-trigger.src
└── gcal-search-trigger.groovy
└── gcal-search.src
└── gcal-search.groovy
/README.md:
--------------------------------------------------------------------------------
1 | *GCal-Search*
2 |
3 | *Now with choice of virtual contact or virtual presence devices*
4 |
5 | Steps to set this up...
6 |
7 | 1) Create a Google Project - https://console.developers.google.com and enable OAuth2 - see https://support.google.com/googleapi/answer/6158849
8 |
9 | a) Give your project any name (perhaps "ST-GCal")
10 | b) Enable the Calendar API - https://console.developers.google.com/apis/library
11 | c) Setup new credentials - https://console.developers.google.com/apis/credentials
12 |
13 | d) Enable OAuth with the following redirect URI:
14 |
15 | https://graph.api.smartthings.com/oauth/callback
16 |
17 | e) Copy the Client ID and Client Secret from the Google credentials you just made. Paste them in a text editor, as you will need these later
18 |
19 | 2) Install the 2 SmartApps "GCal Search" and "GCal Search Trigger" via your IDE
20 | (go to https://graph.api.smartthings.com/ide/app/create)
21 |
22 | a) Once you have installed the "GCal Search" smartapp, enable OAuth
23 | b) Put the ClientID and Client Secret you copied from Step 1 into the Settings for "GCal Search"
24 | c) Publish the GCal Search (You DO NOT need to publish the GCal Search Trigger app)
25 |
26 | 3) Install and Publish the 2 DTHs: "GCal Event Sensor" and "GCal Presence Sensor"
27 | (go to https://graph.api.smartthings.com/ide/device/create)
28 |
29 | 4) Open the ST app on your phone and install the "GCal Search" app.
30 | -This will walk you through connecting to Google and selecting a calendar and search terms.
31 | - You can create multiple connections, and based on your selection of virtual device, the app will create a virtual Contact Sensor or a virtual Presence Sensor that are Open/Present when the event starts and Close/Not Present when the event ends.
32 |
33 |
34 | Donations always welcome...
35 | https://www.paypal.me/infofiend
36 |
--------------------------------------------------------------------------------
/devicetypes/info-fiend/gcal-event-sensor.src/gcal-event-sensor.groovy:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2017 Mike Nestor & Anthony Pastor
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 | * in compliance with the License. You may obtain a copy of the License at:
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11 | * for the specific language governing permissions and limitations under the License.
12 | *
13 | * Updates:
14 | *
15 | * 20170422.1 - added device Health capability
16 | * 20170419.1 - cleaned up tiles; added offsetNotify attribute - for additional CoRE flexibility - which turns on if startMsg / turns off at either endMsg event time or endTime (whichever occurs 1st).
17 | * 20170327.1 - restructured scheduling; bug fixes
18 | * 20170326.1 - bug fixes
19 | * 20170322.1 - startMsgTime, startMsg, endMsgTime, and endMsg are now attributes that CoRE should be able to use
20 | * - cleaned up variables and code.
21 | * 20170321.1 - Added notification offset times.
22 | * 20170306.1 - Scheduling updated.
23 | * Fixed Event Trigger with no search string.
24 | * Added AskAlexa Message Queue compatibility
25 | * 20170302.1 - Re-release version
26 | * 20170411.1 - Change schedule to happen in the child app instead of the device
27 | * 20170332.2 - Updated date parsing for non-fullday events
28 | * 20170331.1 - Fix for all day event attempt #2
29 | * 20170319.1 - Fix for all day events
30 | * 20170302.1 - Allow for polling of device version number
31 | * 20170301.1 - GUI fix for white space
32 | * 20170223.4 - Fix for Dates in UK
33 | * 20170223.3 - Fix for DateFormat, set the closeTime before we call open() on in progress event to avoid exception
34 | * 20170223.1 - Error checking - Force check for Device Handler so we can let the user have a more informative error
35 | * 20180312.1 - added location and locationForURL attributes; added location to event summary
36 | * 20180325.1 - added eventTime
37 | * 20180327.1 - eventTime now works for all-day events; added eventTitle; added Power capability (toggles between 0 and 1) - for use with webCoRE bc defined virtual device subscriptions can't use custom attributes
38 | * 20180327.2 - added startOffset and endOffset attributes; - these can be set by new commands below (and will then override the offSets from parent Trigger app)
39 | * - added setStartoffset() and setEndoffset()
40 | */
41 |
42 | preferences {
43 | input("primaryName", "test", title: "Name of your GCal primary calendar", description: "Google doesn't provide this info, so if you don't add it here, the device will list it as \"Primary Google Calendar\".")
44 | }
45 |
46 | metadata {
47 | // Automatically generated. Make future change here.
48 | definition (name: "GCal Event Sensor", namespace: "info_fiend", author: "anthony pastor") {
49 | capability "Contact Sensor"
50 | capability "Sensor"
51 | capability "Polling"
52 | capability "Refresh"
53 | capability "Switch"
54 | capability "Actuator"
55 | capability "Health Check"
56 | capability "Power Meter"
57 |
58 | command "open"
59 | command "close"
60 | command "offsetOn"
61 | command "offsetOff"
62 | command "childSummary"
63 | command "childLocation"
64 | command "setStartoffset"
65 | command "setEndoffset"
66 |
67 |
68 | attribute "calendar", "json_object"
69 | attribute "calName", "string"
70 | attribute "name", "string"
71 | attribute "eventSummary", "string"
72 | attribute "openTime", "number"
73 | attribute "closeTime", "number"
74 | attribute "startMsgTime", "number"
75 | attribute "endMsgTime", "number"
76 | attribute "startMsg", "string"
77 | attribute "endMsg", "string"
78 | attribute "offsetNotify", "string"
79 | attribute "deleteInfo", "string"
80 | attribute "location", "string"
81 | attribute "locationForURL", "string"
82 | attribute "eventTime", "string"
83 | attribute "eventTitle", "string"
84 | attribute "startOffset", "number"
85 | attribute "endOffset", "number"
86 | }
87 |
88 | simulator {
89 | status "open": "contact:open"
90 | status "closed": "contact:closed"
91 | }
92 |
93 | tiles (scale: 2) {
94 | standardTile("status", "device.contact", width: 2, height: 2) {
95 | state("closed", label:'', icon:"https://raw.githubusercontent.com/mnestor/GCal-Search/icons/icons/GCal-Off@2x.png", backgroundColor:"#ffffff")
96 | state("open", label:'', icon:"https://raw.githubusercontent.com/mnestor/GCal-Search/icons/icons/GCal-On@2x.png", backgroundColor:"#79b821")
97 | }
98 |
99 | //Open & Close Button Tiles (not used)
100 | standardTile("closeBtn", "device.fake", width: 3, height: 2, decoration: "flat") {
101 | state("default", label:'CLOSE', backgroundColor:"#CCCC00", action:"close")
102 | }
103 | standardTile("openBtn", "device.fake", width: 3, height: 2, decoration: "flat") {
104 | state("default", label:'OPEN', backgroundColor:"#53a7c0", action:"open")
105 | }
106 |
107 | //Refresh
108 | standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width:4, height: 2) {
109 | state "default", action:"refresh.refresh", icon:"st.secondary.refresh"
110 | }
111 |
112 | //Event Summary
113 | valueTile("summary", "device.eventSummary", inactiveLabel: false, decoration: "flat", width: 6, height: 6) {
114 | state "default", label:'${currentValue}'
115 | }
116 |
117 | //Event Info (not used)
118 | valueTile("calendar", "device.calendar", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
119 | state "default", label:'${currentValue}'
120 | }
121 | valueTile("calName", "device.calName", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
122 | state "default", label:'${currentValue}'
123 | }
124 | valueTile("name", "device.name", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
125 | state "default", label:'${currentValue}'
126 | }
127 | valueTile("location", "device.location", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
128 | state "default", label:'${currentValue}'
129 | }
130 |
131 | //Not used
132 | valueTile("startMsg", "device.startMsg", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
133 | state "default", label:'startMsg:\n ${currentValue}'
134 | }
135 | valueTile("startMsgTime", "device.startMsgTime", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
136 | state "default", label:'startMsgTime:\n ${currentValue}'
137 | }
138 | valueTile("endMsg", "device.endMsg", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
139 | state "default", label:'endMsg:\n ${currentValue}'
140 | }
141 | valueTile("endMsgTime", "device.endMsgTime", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
142 | state "default", label:'endMsgTime:\n ${currentValue}'
143 | }
144 | valueTile("offsetNotify", "device.offsetNotify", inactiveLabel: false, decoration: "flat", width: 3, height: 2) {
145 | state "off", label:'offsetNot: ${currentValue}', backgroundColor:"#ffffff"
146 | state "on", label:'offsetNot: ${currentValue}', backgroundColor:"#79b821"
147 | }
148 | valueTile("deleteInfo", "device.deleteInfo", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
149 | state "default", label:'To remove this device from ST - delete the corresponding GCal Search Trigger.'
150 | }
151 | valueTile("eventTime", "device.eventTime", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
152 | state "default", label:'eventTime:\n ${currentValue}'
153 | }
154 | valueTile("eventTitle", "device.eventTitle", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
155 | state "default", label:'${currentValue}'
156 | }
157 |
158 | htmlTile(name:"mapHTML",
159 | action: "getMapHTML",
160 | refreshInterval: 1,
161 | width: 6,
162 | height: 12,
163 | whitelist: ["www.google.com", "maps.googleapis.com"]
164 | )
165 |
166 | main "status"
167 | details(["eventTitle", "summary", "status", "refresh", "deleteInfo"])
168 | //"closeBtn", "openBtn", , "startMsgTime", "startMsg", "endMsgTime", "endMsg", , "offsetNotify", "eventTime", "mapHTML",
169 | }
170 | }
171 |
172 | mappings {
173 | path("/getMapHTML") {action: [GET: "getMapHTML"]}
174 | }
175 |
176 | def installed() {
177 | log.trace "GCalEventSensor: installed()"
178 | // sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\"}")
179 |
180 | sendEvent(name: "switch", value: "off")
181 | sendEvent(name: "offsetNotify", value: "off")
182 | sendEvent(name: "contact", value: "closed", isStateChange: true)
183 |
184 | initialize()
185 | }
186 |
187 | def updated() {
188 | log.trace "GCalEventSensor: updated()"
189 | initialize()
190 | }
191 |
192 | def initialize() {
193 | sendEvent(name: "power", value: "0", isStateChange: true)
194 | refresh()
195 |
196 |
197 | }
198 |
199 | def parse(String description) {
200 |
201 | }
202 |
203 | // refresh status
204 | def refresh() {
205 | log.trace "GCalEventSensor: refresh()"
206 |
207 | parent.refresh() // reschedule poll
208 | poll() // and do one now
209 |
210 | }
211 |
212 | def open() {
213 | log.trace "GCalEventSensor: open()"
214 |
215 | sendEvent(name: "switch", value: "on")
216 | sendEvent(name: "contact", value: "open", isStateChange: true)
217 |
218 | /** //Schedule Close
219 | def closeTime = device.currentValue("closeTime")
220 | log.debug "Device's closeTime = ${closeTime}"
221 |
222 | log.debug "SCHEDULING CLOSE: parent.scheduleEvent: (close, ${closeTime}, '[overwrite: true]' )."
223 | parent.scheduleEvent("close", closeTime, [overwrite: true])
224 |
225 |
226 | //Schedule endMsg
227 | def endMsgTime = device.currentValue("endMsgTime")
228 | log.debug "Device's endMsgTime = ${endMsgTime}"
229 | def endMsg = device.currentValue("endMsg") ?: "No End Message"
230 | log.debug "Device's endMsg = ${endMsg}"
231 |
232 | log.debug "SCHEDULING ENDMSG: parent.scheduleMsg(endMsg, ${endMsgTime}, ${endMsg}, '[overwrite: true]' )."
233 | parent.scheduleMsg("endMsg", endMsgTime, endMsg, [overwrite: true])
234 | **/
235 | }
236 |
237 |
238 | def close() {
239 | log.trace "GCalEventSensor: close()"
240 |
241 | sendEvent(name: "switch", value: "off")
242 | sendEvent(name: "offsetNotify", value: "off")
243 | sendEvent(name: "contact", value: "closed", isStateChange: true)
244 |
245 | }
246 |
247 | def offsetOn() {
248 | log.trace "GCalEventSensor: offsetOn()"
249 |
250 | sendEvent(name: "offsetNotify", value: "on", isStateChange: true)
251 |
252 | }
253 |
254 | def offsetOff() {
255 | log.trace "GCalEventSensor: offsetOff()"
256 |
257 | sendEvent(name: "offsetNotify", value: "off", isStateChange: true)
258 |
259 | }
260 |
261 | void poll() {
262 | log.trace "poll()"
263 | def items = parent.getNextEvents()
264 | try {
265 |
266 | def currentState = device.currentValue("contact") ?: "closed"
267 | def isOpen = currentState == "open"
268 | // log.debug "isOpen is currently: ${isOpen}"
269 |
270 | // EVENT FOUND **********
271 | if (items && items.items && items.items.size() > 0) {
272 |
273 | log.debug "GCalEventSensor: We Haz Eventz!"
274 |
275 | // Only process the next scheduled event
276 | def event = items.items[0]
277 | def title = event.summary
278 | sendEvent(name: "eventTitle", value: title)
279 |
280 |
281 | // Get Calendar Name
282 | def calName = "Primary Google Calendar"
283 | if (primaryName) { calName = primaryName }
284 | if ( event?.organizer?.displayName ) {
285 | calName = event.organizer.displayName
286 | }
287 | sendEvent("name":"calName", "value":calName, displayed: false)
288 |
289 | // Get Location, if available
290 | def evtLocation
291 | if ( event?.location ) {
292 | evtLocation = event.location
293 | }
294 | sendEvent("name":"location", "value":evtLocation, displayed: false)
295 |
296 | // Get event start and end times
297 | def startTime
298 | def endTime
299 | def type = "E"
300 |
301 | if (event.start.containsKey('date')) {
302 | // this is for all-day events
303 | type = "All-day e"
304 | def sdf = new java.text.SimpleDateFormat("yyyy-MM-dd")
305 | sdf.setTimeZone(TimeZone.getTimeZone(items.timeZone))
306 | startTime = sdf.parse(event.start.date)
307 | endTime = new Date(sdf.parse(event.end.date).time - 60)
308 | } else {
309 | // this is for timed events
310 | def sdf = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss")
311 | sdf.setTimeZone(TimeZone.getTimeZone(items.timeZone))
312 | startTime = sdf.parse(event.start.dateTime)
313 | endTime = sdf.parse(event.end.dateTime)
314 | }
315 | log.debug "From GCal: startTime = ${startTime} & endTime = ${endTime}"
316 | // log.debug "old power = ${device.currentValue("power")}"
317 | // Toggles power attribute when new event is detected
318 | if (startTime != device.currentValue("eventTime") ) {
319 | if (device.currentValue("power") == 0 ) {
320 | sendEvent(name: "power", value: "1", isStateChange: true)
321 | // log.debug "new power = ${device.currentValue("power")}"
322 | } else {
323 | sendEvent(name: "power", value: "0", displayed: true, isStateChange: true)
324 | // log.debug "new power = ${device.currentValue("power")}"
325 | }
326 | }
327 |
328 | sendEvent("name":"eventTime", "value":startTime, displayed: false, isStateChange: true)
329 |
330 | // Build Notification Times & Messages
331 | def startMsgWanted = parent.checkMsgWanted("startMsg")
332 | def startMsgTime = startTime
333 | if (startMsgWanted) {
334 | def startOffset = parent.getStartOffset() ?:0
335 | if ( device.currentValue("startOffset") > 0 ) {
336 | startOffset = device.currentValue("startOffset")
337 | }
338 |
339 | if (startOffset !=0) {
340 | startMsgTime = msgTimeOffset(startOffset, startMsgTime)
341 | log.debug "startOffset: ${startOffset} / startMsgTime = ${startMsgTime}"
342 | }
343 | }
344 |
345 | def endMsgWanted = parent.checkMsgWanted("endMsg")
346 | def endMsgTime = endTime
347 | if (endMsgWanted) {
348 | def endOffset = parent.getEndOffset() ?:0
349 | if ( device.currentValue("endOffset") > 0 ) {
350 | endOffset = device.currentValue("endOffset")
351 | }
352 | if (endOffset !=0) {
353 | endMsgTime = msgTimeOffset(endOffset, endMsgTime)
354 | log.debug "endOffset: ${endOffset} / endMsgTime = ${endMsgTime}}"
355 | }
356 | }
357 | log.debug "startMsgTime = ${startMsgTime} / endMsgTime = ${endMsgTime}"
358 |
359 | // Build Event Summary
360 | def eventSummary = "Next GCal Event: ${title}\n\n"
361 | eventSummary += "Calendar: ${calName}\n\n"
362 | if ( evtLocation ) { eventSummary += "Location: ${evtLocation}\n\n" }
363 |
364 | def startTimeHuman = startTime.format("EEE, MMM dd hh:mm a", location.timeZone)
365 | eventSummary += "Event Start: ${startTimeHuman}\n"
366 |
367 | def startMsg = "No Start Msg Wanted"
368 | if (startMsgWanted) {
369 | def sPart = "s"
370 | if (startOffset > 0) { sPart = "ed" }
371 | def startMsgTimeHuman = startMsgTime.format("hh:mm a", location.timeZone)
372 | startMsg = "${type}vent ${title} occur" + "${sPart} at " + startTimeHuman
373 | eventSummary += "Start Notfication at ${startMsgTimeHuman}.\n\n"
374 | }
375 |
376 | def endTimeHuman = endTime.format("EEE, MMM dd hh:mm a", location.timeZone)
377 | eventSummary += "Event End: ${endTimeHuman}\n"
378 |
379 | def endMsg = "No End Msg Wanted"
380 | if (endMsgWanted) {
381 | def ePart = "s"
382 | if (endOffset > 0) { ePart = "ed" }
383 | def endMsgTimeHuman = endMsgTime.format("hh:mm a", location.timeZone)
384 | endMsg = "${type}vent ${title} occur" + "${ePart} at " + endTimeHuman
385 | eventSummary += "End Notfication at ${endMsgTimeHuman}.\n\n"
386 | }
387 |
388 | // if (event.description) {
389 | // eventSummary += event.description ? event.description : ""
390 | // }
391 | sendEvent("name":"eventSummary", "value":eventSummary)
392 |
393 |
394 | // Then set the closeTime, endMsgTime, and endMsg before opening an event in progress
395 | sendEvent("name":"closeTime", "value":endTime, displayed: false)
396 | sendEvent("name":"endMsgTime", "value":endMsgTime, displayed: false)
397 | sendEvent("name":"endMsg", "value":"${endMsg}", displayed: false)
398 |
399 | // Then set the openTime, startMsgTime, and startMsg
400 | sendEvent("name":"openTime", "value":startTime, displayed: false)
401 | sendEvent("name":"startMsgTime", "value":startMsgTime, displayed: false)
402 | sendEvent("name":"startMsg", "value":"${startMsg}", displayed: false, isStateChange: true)
403 |
404 | // def eventTest = new Date()
405 | log.debug "eventTest = ${eventTest}"
406 | // ALREADY IN EVENT?
407 | // YES
408 | if ( startTime <= new Date() ) {
409 | // log.debug "startTime ${startTime} should be before eventTest = ${eventTest}"
410 | if ( new Date() < endTime ) {
411 | log.debug "Currently within ${type}vent ${title}."
412 | if (!isOpen) {
413 | log.debug "Contact currently closed, so opening."
414 | open()
415 |
416 | //Schedule Close & end event messaging
417 | log.debug "SCHEDULING CLOSE: parent.scheduleEvent: (close, ${endTime}, '[overwrite: true]' )."
418 | parent.scheduleEvent("close", endTime, [overwrite: true])
419 | log.debug "SCHEDULING ENDMSG: parent.scheduleMsg(endMsg, ${endMsgTime}, ${endMsg}, '[overwrite: true]' )."
420 | parent.scheduleMsg("endMsg", endMsgTime, endMsg, [overwrite: true])
421 | }
422 | } else {
423 | log.debug "Already past start of ${type}vent ${title}."
424 |
425 | if (isOpen) {
426 | log.debug "Contact incorrectly open, so close."
427 | close()
428 | offsetOff()
429 |
430 | // Unschedule All
431 | parent.unscheduleEvent("open")
432 | parent.unscheduleMsg("startMsg")
433 | parent.unscheduleEvent("close")
434 | parent.unscheduleMsg("endMsg")
435 |
436 | }
437 | }
438 | // NO
439 | } else {
440 | log.debug "${type}vent ${title} still in future."
441 | if (isOpen) {
442 | log.debug "Contact incorrectly open, so close."
443 | close()
444 | offsetOff()
445 | }
446 |
447 | // Schedule Open & start event messaging
448 | log.debug "SCHEDULING OPEN: parent.scheduleEvent(open, ${startTime}, '[overwrite: true]' )."
449 | parent.scheduleEvent("open", startTime, [overwrite: true])
450 | log.debug "SCHEDULING STARTMSG: parent.scheduleMsg(startMsg, ${startMsgTime}, ${startMsg}, '[overwrite: true]' )."
451 | parent.scheduleMsg("startMsg", startMsgTime, startMsg, [overwrite: true])
452 |
453 | //Schedule Close & end event messaging
454 | log.debug "SCHEDULING CLOSE: parent.scheduleEvent: (close, ${endTime}, '[overwrite: true]' )."
455 | parent.scheduleEvent("close", endTime, [overwrite: true])
456 | log.debug "SCHEDULING ENDMSG: parent.scheduleMsg(endMsg, ${endMsgTime}, ${endMsg}, '[overwrite: true]' )."
457 | parent.scheduleMsg("endMsg", endMsgTime, endMsg, [overwrite: true])
458 |
459 | }
460 |
461 | // END EVENT FOUND *******
462 |
463 |
464 | // START NO EVENT FOUND ******
465 | } else {
466 | log.trace "No events - set all attributes to null."
467 |
468 | sendEvent("name":"eventSummary", "value":"No events found", isStateChange: true)
469 |
470 | if (isOpen) {
471 | log.debug "Contact incorrectly open, so close."
472 | close()
473 | offsetOff()
474 | } else {
475 | // Unschedule All
476 | parent.unscheduleEvent("open")
477 | parent.unscheduleMsg("startMsg")
478 | parent.unscheduleEvent("close")
479 | parent.unscheduleMsg("endMsg")
480 | }
481 | }
482 | // END NO EVENT FOUND
483 |
484 | } catch (e) {
485 | log.warn "Failed to do poll: ${e}"
486 | }
487 | }
488 |
489 | private Date msgTimeOffset(int minutes, Date originalTime){
490 | log.trace "Gcal Event Sensor: msgTimeOffset()"
491 | final long ONE_MINUTE_IN_MILLISECONDS = 60000;
492 |
493 | long currentTimeInMs = originalTime.getTime()
494 | Date offsetTime = new Date(currentTimeInMs + (minutes * ONE_MINUTE_IN_MILLISECONDS))
495 |
496 | log.trace "offsetTime = ${offsetTime}"
497 | return offsetTime
498 | }
499 |
500 |
501 | def getMapHTML() {
502 | def html = """
503 |
504 |
505 |
506 |
507 |
508 | Directions service
509 |
559 |
560 |
561 |
562 |
563 |
596 |
599 |
600 |
601 | """
602 | render contentType: "text/html", data: html, status: 200
603 | }
604 |
605 | def childSummary() {
606 | def theSum
607 | theSum = device.currentValue("eventSummary") ?: "There is no event."
608 | log.debug "sending summary of ${theSum}"
609 | return "${theSum}"
610 | }
611 |
612 | def childLocation() {
613 | def theLoc
614 | theLoc = device.currentValue("location")
615 | // replace (theLoc, " ", "+")
616 | log.debug "sending location of ${theLoc}"
617 | return "${theLoc}"
618 | }
619 |
620 |
621 | def version() {
622 | def text = "20180327.2"
623 | }
624 |
--------------------------------------------------------------------------------
/devicetypes/info-fiend/gcal-presence-sensor.src/gcal-presence-sensor.groovy:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2017 Anthony Pastor
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 | * in compliance with the License. You may obtain a copy of the License at:
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11 | * for the specific language governing permissions and limitations under the License.
12 | *
13 | * Updates:
14 | *
15 | * 20170422.1 - added Health Check
16 | * 20170306.1 - Scheduling updated
17 | * Fixed Event Trigger with no search string.
18 | * Added AskAlexa Message Queue compatibility
19 | *
20 | * 20170302.1 - Initial release
21 | *
22 | */
23 |
24 | metadata {
25 | // Automatically generated. Make future change here.
26 | definition (name: "GCal Presence Sensor", namespace: "info_fiend", author: "anthony pastor") {
27 |
28 | capability "Presence Sensor"
29 | capability "Sensor"
30 | capability "Polling"
31 | capability "Refresh"
32 | capability "Switch"
33 | capability "Actuator"
34 | capability "Health Check"
35 |
36 | command "arrived"
37 | command "departed"
38 | command "present"
39 | command "away"
40 |
41 | attribute "calendar", "json_object"
42 | attribute "calName", "string"
43 | attribute "eventSummary", "string"
44 | attribute "arriveTime", "number"
45 | attribute "departTime", "number"
46 | attribute "startMsg", "string"
47 | attribute "endMsg", "string"
48 | attribute "deleteInfo", "string"
49 | }
50 |
51 | simulator {
52 | status "present": "presence: present"
53 | status "not present": "presence: not present"
54 | }
55 |
56 | tiles(scale: 2) {
57 | // You only get a presence tile view when the size is 3x3 otherwise it's a value tile
58 | standardTile("presence", "device.presence", width: 3, height: 3, canChangeBackground: true, inactiveLabel: false, canChangeIcon: true) {
59 | state("present", label:'${name}', icon:"st.presence.tile.mobile-present", action:"departed", backgroundColor:"#53a7c0")
60 | state("not present", label:'${name}', icon:"st.presence.tile.mobile-not-present", action:"arrived", backgroundColor:"#CCCC00")
61 | }
62 |
63 | standardTile("notPresentBtn", "device.fake", width: 3, height: 2, decoration: "flat") {
64 | state("default", label:'AWAY', backgroundColor:"#CCCC00", action:"departed")
65 | }
66 |
67 | standardTile("presentBtn", "device.fake", width: 3, height: 2, decoration: "flat") {
68 | state("default", label:'HERE', backgroundColor:"#53a7c0", action:"arrived")
69 | }
70 |
71 | standardTile("refresh", "device.refresh", inactiveLabel: false, decoration: "flat", width:3, height: 3) {
72 | state "default", action:"refresh.refresh", icon:"st.secondary.refresh"
73 | }
74 |
75 | valueTile("summary", "device.eventSummary", inactiveLabel: false, decoration: "flat", width: 6, height: 3) {
76 | state "default", label:'${currentValue}'
77 | }
78 |
79 | valueTile("deleteInfo", "device.deleteInfo", inactiveLabel: false, decoration: "flat", width: 6, height: 2) {
80 | state "default", label:'To remove this device from ST - delete the corresponding GCal Search Trigger.'
81 | }
82 |
83 |
84 | main("presence")
85 | details([
86 | "summary", "presence", "refresh", "deleteInfo" //"notPresentBtn", "presentBtn",
87 | ])
88 | }
89 |
90 |
91 |
92 | }
93 |
94 | def installed() {
95 | log.trace "GCalPresenceSensor: installed()"
96 | sendEvent(name: "DeviceWatch-Enroll", value: "{\"protocol\": \"LAN\", \"scheme\":\"untracked\", \"hubHardwareId\": \"${device.hub.hardwareID}\"}")
97 |
98 | sendEvent(name: "switch", value: "off")
99 | sendEvent(name: "presence", value: "not present", isStateChange: true)
100 |
101 | initialize()
102 | }
103 |
104 | def updated() {
105 | log.trace "GCalPresenceSensor: updated()"
106 | initialize()
107 | }
108 |
109 | def initialize() {
110 | log.trace "GCalPresenceSensor: initialize()"
111 | refresh()
112 | }
113 |
114 | def parse(String description) {
115 |
116 | }
117 |
118 | def arrived() {
119 | log.trace "arrived():"
120 | present()
121 | }
122 |
123 | def present() {
124 | log.trace "present()"
125 | sendEvent(name: "switch", value: "on")
126 | sendEvent(name: 'presence', value: 'present', isStateChange: true)
127 |
128 | def departTime = new Date( device.currentState("departTime").value )
129 | log.debug "Scheduling Close for: ${departTime}"
130 | sendEvent("name":"departTime", "value":departTime)
131 | parent.scheduleEvent("depart", departTime, [overwrite: true])
132 |
133 | //AskAlexaMsg
134 | def askAlexaMsg = device.currentValue("startMsg")
135 | parent.askAlexaStartMsgQueue(askAlexaMsg)
136 | }
137 |
138 |
139 | // refresh status
140 | def refresh() {
141 | log.trace "refresh()"
142 |
143 | parent.refresh() // reschedule poll
144 | poll() // and do one now
145 |
146 | }
147 |
148 | def departed() {
149 | log.trace "departed():"
150 | away()
151 | }
152 |
153 | def away() {
154 | log.trace "away():"
155 |
156 | sendEvent(name: "switch", value: "off")
157 | sendEvent(name: 'presence', value: 'not present', isStateChange: true)
158 |
159 | //AskAlexaMsg
160 | def askAlexaMsg = device.currentValue("endMsg")
161 | parent.askAlexaEndMsgQueue(askAlexaMsg)
162 |
163 | }
164 |
165 | def poll() {
166 | log.trace "poll()"
167 | def items = parent.getNextEvents()
168 | try {
169 |
170 | def currentState = device.currentValue("presence") ?: "not present"
171 | def isPresent = currentState == "present"
172 | log.debug "isPresent is currently: ${isPresent}"
173 |
174 | // START EVENT FOUND **********
175 | if (items && items.items && items.items.size() > 0) {
176 | // Only process the next scheduled event
177 | def event = items.items[0]
178 | def title = event.summary
179 |
180 | def calName = "GCal Primary"
181 | if ( event.organizer.displayName ) {
182 | calName = event.organizer.displayName
183 | }
184 |
185 | log.debug "We Haz Eventz! ${event}"
186 |
187 | def start
188 | def end
189 | def type = "E"
190 |
191 | if (event.start.containsKey('date')) {
192 | // this is for all-day events
193 | type = "All-day e"
194 | def sdf = new java.text.SimpleDateFormat("yyyy-MM-dd")
195 | sdf.setTimeZone(TimeZone.getTimeZone(items.timeZone))
196 | start = sdf.parse(event.start.date)
197 | end = new Date(sdf.parse(event.end.date).time - 60)
198 | } else {
199 | // this is for timed events
200 | def sdf = new java.text.SimpleDateFormat("yyyy-MM-dd'T'hh:mm:ss")
201 | sdf.setTimeZone(TimeZone.getTimeZone(items.timeZone))
202 | start = sdf.parse(event.start.dateTime)
203 | end = sdf.parse(event.end.dateTime)
204 | }
205 |
206 | def eventSummary = "Event: ${title}\n\n"
207 | eventSummary += "Calendar: ${state.calName}\n\n"
208 | def startHuman = start.format("EEE, hh:mm a", location.timeZone)
209 | eventSummary += "Arrives: ${startHuman}\n"
210 | def endHuman = end.format("EEE, hh:mm a", location.timeZone)
211 | eventSummary += "Departs: ${endHuman}\n\n"
212 |
213 | def startMsg = "${title} arrived at: " + startHuman
214 | def endMsg = "${title} departed at: " + endHuman
215 |
216 |
217 | if (event.description) {
218 | eventSummary += event.description ? event.description : ""
219 | }
220 |
221 | sendEvent("name":"eventSummary", "value":eventSummary, isStateChange: true)
222 |
223 | //Set the closeTime and endMeg before opening an event in progress
224 | //Then use in the open() call for scheduling close and askAlexaMsgQueue
225 |
226 | sendEvent("name":"departTime", "value":end)
227 | sendEvent("name":"endMsg", "value":endMsg)
228 |
229 | sendEvent("name":"arriveTime", "value":start)
230 | sendEvent("name":"startMsg", "value":startMsg)
231 |
232 | // ALREADY IN EVENT?
233 | // YES
234 | if ( start <= new Date() ) {
235 | log.debug "Already in event ${title}."
236 | if (!isPresent) {
237 | log.debug "Not Present, so arriving."
238 | open()
239 | }
240 |
241 | // NO
242 | } else {
243 | log.debug "Event ${title} still in future."
244 |
245 | if (isPresent) {
246 | log.debug "Presence incorrect, so departing."
247 | departed()
248 | }
249 |
250 | log.debug "SCHEDULING ARRIVAL: parent.scheduleEvent(arrive, ${start}, '[overwrite: true]' )."
251 | parent.scheduleEvent("arrive", start, [overwrite: true])
252 |
253 | }
254 | // END EVENT FOUND *******
255 |
256 |
257 | // START NO EVENT FOUND ******
258 | } else {
259 | log.trace "No events - set all atributes to null."
260 |
261 | sendEvent("name":"eventSummary", "value":"No events found", isStateChange: true)
262 |
263 | if (isPresent) {
264 |
265 | log.debug "Presence incorrect, so departing."
266 | departed()
267 | } else {
268 | parent.unscheduleEvent("open")
269 | }
270 | }
271 | // END NO EVENT FOUND
272 |
273 | } catch (e) {
274 | log.warn "Failed to do poll: ${e}"
275 | }
276 | }
277 |
278 | def version() {
279 | def text = "20170422.1"
280 | }
--------------------------------------------------------------------------------
/smartapps/mnestor/gcal-search-trigger.src/gcal-search-trigger.groovy:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2017 Mike Nestor & Anthony Pastor
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 | * in compliance with the License. You may obtain a copy of the License at:
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11 | * for the specific language governing permissions and limitations under the License.
12 | *
13 | */
14 |
15 | /**
16 | *
17 | * Updates:
18 | *
19 | * 20170327.2 - Added options to receive start and end event notifications via SMS or Push
20 | * 20170327.1 - Changed screen format; made search string & calendar name the default Trigger name
21 | * 20170322.1 - added checkMsgWanted(); made tips on screen hideable & hidden
22 | * 20170321.1 - Fixed OAuth issues; added notification times offset option
23 | * 20170306.1 - Bug fixes; No search string now working; schedules fixed
24 | * 20170303.1 - Re-release version. Added choice to make child device either contact or presence; conformed methods with updated DTH
25 | *
26 | * 20160411.1 - Change schedule to happen in the child app instead of the device
27 | * 20150304.1 - Revert back hub ID to previous method
28 | * 20160303.1 - Ensure switch is added to the currently used hub
29 | * 20160302.1 - Added device versioning
30 | * 20160223.4 - Fix for duplicating sensors, not having a clostTime at time of open when event is in progress
31 | * 20160223.2 - Don't make a quick change and forget to test
32 | * 20160223.1 - Error checking - Force check for Device Handler so we can let the user have a more informative error
33 | *
34 | */
35 |
36 | definition(
37 | name: "GCal Search Trigger",
38 | namespace: "mnestor",
39 | author: "Mike Nestor and Anthony Pastor",
40 | description: "Creates & Controls virtual contact (event) or presence sensors.",
41 | category: "My Apps",
42 | parent: "mnestor:GCal Search",
43 | iconUrl: "https://raw.githubusercontent.com/mnestor/GCal-Search/icons/icons/GCal.png",
44 | iconX2Url: "https://raw.githubusercontent.com/mnestor/GCal-Search/icons/icons/GCal%402x.png",
45 | iconX3Url: "https://raw.githubusercontent.com/mnestor/GCal-Search/icons/icons/GCal%402x.png",
46 | ) {}
47 |
48 | preferences {
49 | page(name: "selectCalendars")
50 | page(name: "notifications")
51 | page(name: "nameTrigger")
52 | }
53 |
54 | private version() {
55 | def text = "20170422.1"
56 | }
57 |
58 | def selectCalendars() {
59 | log.trace "selectCalendars()"
60 |
61 | def calendars = parent.getCalendarList()
62 | log.debug "Calendar list = ${calendars}"
63 |
64 | //force a check to make sure the device handler is available for use
65 | try {
66 | def device = getDevice()
67 | } catch (e) {
68 | return dynamicPage(name: "selectCalendars", title: "Missing Device", install: false, uninstall: false) {
69 | section ("Error") {
70 | paragraph "We can't seem to create a child device, did you install both associated device type handler?"
71 | }
72 | }
73 | }
74 |
75 | return dynamicPage(name: "selectCalendars", title: "Create new calendar search", install: false, uninstall: state.installed, nextPage: "notifications" ) {
76 | section("Required Info") {
77 | //we can't do multiple calendars because the api doesn't support it and it could potentially cause a lot of traffic to happen
78 | input name: "watchCalendars", title:"", type: "enum", required:true, multiple:false, description: "Which calendar do you want to search?", metadata:[values:calendars], submitOnChange: true
79 | input name: "eventOrPresence", title:"Type of Virtual Device to create? Contact (for events) or Presence?", type: "enum", required:true, multiple:false,
80 | description: "Do you want this gCal Search Trigger to control a virtual Contact Sensor (for Events) or a virtual presence sensor?", options:["Contact", "Presence"] //, defaultValue: "Contact"
81 |
82 | }
83 |
84 | section("Event Filter Tips", hideable:true, hidden:true) {
85 | paragraph "Leave search blank to match every event on the selected calendar(s)"
86 | paragraph "Searches for entries that have all terms\n\nTo search for an exact phrase, " +
87 | "enclose the phrase in quotation marks: \"exact phrase\"\n\nTo exclude entries that " +
88 | "match a given term, use the form -term\n\nExamples:\nHoliday (anything with Holiday)\n" +
89 | "\"#Holiday\" (anything with #Holiday)\n#Holiday (anything with Holiday, ignores the #)"
90 | }
91 |
92 | section("Optional - Event Filter") {
93 | input name: "search", type: "text", title: "Search String", required: false, submitOnChange: true
94 | }
95 |
96 | if ( state.installed ) {
97 | section ("Remove Trigger and Corresponding Device") {
98 | paragraph "ATTENTION: The only way to uninstall this trigger and the corresponding device is by clicking the button below.\n" +
99 | "Trying to uninstall the corresponding device from within that device's preferences will NOT work."
100 | }
101 | }
102 | }
103 | }
104 |
105 | def notifications(params) {
106 | log.trace "notifications()"
107 |
108 | def isSMS = false
109 | if (sendSmsMsg == "Yes") { isSMS = true }
110 | return dynamicPage(name: "notifications", title: "Notification Options", install: false, nextPage: "nameTrigger" ) {
111 |
112 | section("Optional - Receive Event Notifications?") {
113 | input name:"wantStartMsgs", type: "enum", title: "Send notification of event start?", required: true, multiple: false, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true
114 | if (wantStartMsgs == "Yes") {
115 | input name:"startOffset", type:"number", title:"Number of Minutes to Offset From Start of Calendar Event", required: false , range:"*..*"
116 | }
117 |
118 | input name:"wantEndMsgs", type: "enum", title: "Send notification of event end?", required: true, multiple: false, options: ["Yes", "No"], defaultValue: "No", submitOnChange: true
119 | if (wantEndMsgs == "Yes") {
120 | input name:"endOffset", type:"number", title:"Number of Minutes to Offset From End of Calendar Event", required: false , range:"*..*"
121 | }
122 | }
123 |
124 | section("Event Notification Time Tips", hideable:true, hidden:true) {
125 | paragraph "If you want the notification to occur BEFORE the start/end of the event, " +
126 | "then use a negative number for offset time. For example, to receive a " +
127 | "notification 5 minutes beforehand, use an an offset of -5. \n\n" +
128 | "If you want the notification to occur AFTER the start/end of the event, " +
129 | "then use positive number for offset time. For example, to receive a " +
130 | "notification 9 hours after event start, use an an offset of 540 (can be " +
131 | "helpful for all-day events, which start at midnight)."
132 | "of the calendar event, enter number of minutes to offset here."
133 | }
134 |
135 | if (wantStartMsgs == "Yes" || wantEndMsgs == "Yes") {
136 | section( "Optional - Receive Event Notifications using Ask Alexa" ) {
137 | input "sendAANotification", "enum", title: "Send Event Notifications to Ask Alexa Message Queue?", options: ["Yes", "No"], defaultValue: "No", required: false
138 | }
139 |
140 | section( "Optional - Receive Event Notifications via Push or SMS" ) {
141 | // input("recipients", "contact", title: "Send notifications to", required: false)
142 | input "sendPushMessage", "enum", title: "Send push notification?", options: ["Yes", "No"], required: false
143 | input "sendSmsMessage", "enum", title: "Send SMS notification?", options: ["Yes", "No"], required: false, submitOnChange: true
144 | if (sendSmsMessage == "Yes") {
145 | input "phone", "phone", title: "Enter Phone Number to receive SMS:", required: isSMS
146 | }
147 | }
148 | }
149 | }
150 | }
151 |
152 |
153 | def nameTrigger(params) {
154 | log.trace "nameTrigger()"
155 | log.debug "eventOrPresence = ${eventOrPresence}"
156 |
157 | //Populate default trigger name & corresponding device name
158 | def defName = ""
159 | if (search && eventOrPresence == "Contact") {
160 | defName = search - "\"" - "\"" //.replaceAll(" \" [^a-zA-Z0-9]+","")
161 | }
162 | log.debug "defName = ${defName}"
163 |
164 | def dName = defName
165 | if ( name ) {
166 | dName = name
167 | } else {
168 | dName = "[Name of Trigger] +"
169 | }
170 |
171 | if (eventOrPresence == "Contact") {
172 | dName = dName + " Events"
173 | } else {
174 | dName = dName + " Presence"
175 | }
176 |
177 |
178 | return dynamicPage(name: "nameTrigger", title: "Name of Trigger and Device", install: true, uninstall: false, nextPage: "" ) {
179 | section("Required - Trigger Name") {
180 | input name: "name", type: "text", title: "Trigger Name", required: true, multiple: false, defaultValue: "${defName}", submitOnChange: true
181 | }
182 | section("Name of the Corresponding Device will be") {
183 | paragraph "${dName}"
184 | }
185 | }
186 | }
187 |
188 | def installed() {
189 | log.trace "Installed with settings: ${settings}"
190 |
191 | initialize()
192 | }
193 |
194 | def updated() {
195 | log.trace "Updated with settings: ${settings}"
196 |
197 | //we have nothing to subscribe to yet
198 | //leave this just in case something crazy happens though
199 | unsubscribe()
200 |
201 | initialize()
202 | }
203 |
204 | def initialize() {
205 | log.trace "initialize()"
206 | state.installed = true
207 |
208 | // Sets Label of Trigger
209 | app.updateLabel(settings.name)
210 |
211 | // Sets Label of Corresponding Device
212 | def device = getDevice()
213 | if (eventOrPresence == "Contact") {
214 | device.label = "${settings.name} Events"
215 | } else if (eventOrPresence == "Presence") {
216 | device.label = "${settings.name} Presence"
217 | }
218 |
219 | //Currently deletes the queue at midnight
220 | schedule("0 0 0 * * ?", queueDeletionHandler)
221 | }
222 |
223 | def getDevice() {
224 | log.trace "GCalSearchTrigger: getDevice()"
225 | def device
226 | if (!childCreated()) {
227 | def calName = state.calName
228 | if (eventOrPresence == "Contact") {
229 | device = addChildDevice(getNamespace(), getEventDeviceHandler(), getDeviceID(), null, [label: "${settings.name}", calendar: watchCalendars, hub:hub, offsetNotify: "off", completedSetup: true])
230 |
231 | } else if (eventOrPresence == "Presence") {
232 | device = addChildDevice(getNamespace(), getPresenceDeviceHandler(), getDeviceID(), null, [label: "${settings.name}", calendar: watchCalendars, hub:hub, completedSetup: true])
233 |
234 | }
235 | } else {
236 | device = getChildDevice(getDeviceID())
237 |
238 | }
239 | return device
240 | }
241 |
242 | def getNextEvents() {
243 | log.trace "GCalSearchTrigger: getNextEvents() child"
244 | def search = (!settings.search) ? "" : settings.search
245 | return parent.getNextEvents(settings.watchCalendars, search)
246 | }
247 |
248 | def getStartOffset() {
249 | return (!settings.startOffset) ?"" : settings.startOffset
250 | }
251 |
252 | def getEndOffset() {
253 | return (!settings.endOffset) ?"" : settings.endOffset
254 | }
255 |
256 | private getPresenceDeviceHandler() { return "GCal Presence Sensor" }
257 | private getEventDeviceHandler() { return "GCal Event Sensor" }
258 |
259 |
260 | def refresh() {
261 | log.trace "GCalSearchTrigger::refresh()"
262 | try { unschedule(poll) } catch (e) { }
263 |
264 | runEvery15Minutes(poll)
265 | }
266 |
267 | def poll() {
268 | getDevice().poll()
269 | }
270 |
271 | private startMsg() {
272 | if (settings.wantStartMsgs == "Yes") {
273 | log.trace "startMsg():"
274 | def myApp = settings.name
275 | def msgText = state.startMsg ?: "Error finding start message"
276 |
277 | if (sendAANotification == "Yes") {
278 | log.debug( "Sending start event notification to AskAlexaMsgQueue." )
279 | sendLocationEvent(name: "AskAlexaMsgQueue", value: myApp, isStateChange: true, descriptionText: msgText, unit: myApp)
280 | }
281 |
282 | /** if ( recipients ) {
283 | log.debug( "Sending start event to selected contacts." )
284 | sendSms( recipients, msgText )
285 | }
286 | **/
287 |
288 | if ( sendPushMessage != "No" ) {
289 | log.debug( "Sending start event notification via push." )
290 | sendPush( msgText )
291 | }
292 |
293 | if ( phone ) {
294 | log.debug( "Sending start event notification via SMS." )
295 | sendSms( phone, msgText )
296 | }
297 |
298 | try {
299 | getDevice().offsetOn()
300 | } catch (e) {
301 | log.warn "Unable to find device's offsetOn function - device is using version ${dVersion()}."
302 | }
303 |
304 | } else {
305 | log.trace "No Start Msgs"
306 | }
307 | }
308 |
309 | private endMsg() {
310 | if (settings.wantEndMsgs == "Yes") {
311 | log.trace "endMsg():"
312 | def myApp = settings.name
313 | def msgText = state.endMsg ?: "Error finding end message"
314 |
315 | if (sendAANotification == "Yes") {
316 | log.debug( "Sending end event notification to AskAlexaMsgQueue." )
317 | sendLocationEvent(name: "AskAlexaMsgQueue", value: myApp, isStateChange: true, descriptionText: msgText, unit: myApp)
318 | }
319 |
320 | /** if ( recipients ) {
321 | log.debug( "Sending end event to selected contacts." )
322 | sendSms( recipients, msgText )
323 | }
324 | **/
325 | if ( sendPushMessage == "Yes" ) {
326 | log.debug( "Sending end event notification via push." )
327 | sendPush( msgText )
328 | }
329 |
330 | if ( phone ) {
331 | log.debug( "Sending end event notification via SMS." )
332 | sendSms( phone, msgText )
333 | }
334 |
335 | try {
336 | getDevice().offsetOff()
337 | } catch (e) {
338 | log.warn "Unable to find device's offsetOff function - device is using version ${dVersion()}."
339 | }
340 |
341 | } else {
342 | log.trace "No End Msgs"
343 | }
344 | }
345 |
346 |
347 | private queueDeletionHandler() {
348 | askAlexaMsgQueueDelete()
349 | }
350 |
351 | private askAlexaMsgQueueDelete() {
352 | log.trace "askAlexaMsgQueueDelete():"
353 | def myApp = settings.name
354 |
355 | sendLocationEvent(name: "AskAlexaMsgQueueDelete", value: myApp, isStateChange: true, unit: myApp)
356 |
357 | }
358 |
359 | def scheduleEvent(method, time, args) {
360 | def device = getDevice()
361 | log.trace "scheduleEvent( ${method}, ${time}, ${args} ) from ${device}."
362 | runOnce( time, method, args)
363 | }
364 |
365 | def scheduleMsg(method, time, msg, args) {
366 | def device = getDevice()
367 | if (method == "startMsg") {
368 | log.info "Saving ${msg} as state.startMsg ."
369 | state.startMsg = msg
370 | } else {
371 | log.info "Saving ${msg} as state.endMsg ."
372 | state.endMsg = msg
373 | }
374 | log.trace "scheduleMsg( ${method}, ${time}, ${args} ) from ${device}."
375 | runOnce( time, method, args)
376 | }
377 |
378 | def unscheduleEvent(method) {
379 | log.trace "unscheduleEvent( ${method} )"
380 | try {
381 | unschedule( "${method}" )
382 | } catch (e) {}
383 | }
384 |
385 | def unscheduleMsg(method) {
386 | log.trace "unscheduleMsg( ${method} )"
387 | try {
388 | unschedule( "${method}" )
389 | } catch (e) {}
390 | }
391 |
392 | def checkMsgWanted(type) {
393 | def isWanted = false
394 | if (type == "startMsg") {
395 | if (wantStartMsgs=="Yes") {isWanted = true}
396 | } else if (type == "endMsg") {
397 | if (wantEndMsgs=="Yes") {isWanted = true}
398 | }
399 |
400 | log.debug "${type} Msgs Wanted? = ${isWanted}"
401 | return isWanted
402 | }
403 |
404 | def open() {
405 | log.trace "${settings.name}.open():"
406 | getDevice().open()
407 | }
408 |
409 | def close() {
410 | log.trace "${settings.name}.close():"
411 | getDevice().close()
412 |
413 | }
414 |
415 | def arrive() {
416 | log.trace "${settings.name}.arrive():"
417 | getDevice().arrived()
418 |
419 | }
420 |
421 | def depart() {
422 | log.trace "${settings.name}.depart():"
423 | getDevice().departed()
424 | }
425 |
426 |
427 | private uninstalled() {
428 | log.trace "uninstalled():"
429 |
430 | log.info "Delete any existing messages in AskAlexa message queue."
431 | askAlexaMsgQueueDelete()
432 | log.info "Delete all child devices."
433 | deleteAllChildren()
434 | }
435 |
436 | private deleteAllChildren() {
437 | log.trace "deleteAllChildren():"
438 |
439 | getChildDevices().each {
440 | log.debug "Delete $it.deviceNetworkId"
441 | try {
442 | deleteChildDevice(it.deviceNetworkId)
443 | } catch (Exception e) {
444 | log.debug "Fatal exception? $e"
445 | }
446 | }
447 | }
448 |
449 | private childCreated() {
450 | def isChild = getChildDevice(getDeviceID())
451 | log.debug "childCreated? ${isChild}"
452 | return isChild
453 | }
454 |
455 | private getDeviceID() {
456 | return "GCal_${app.id}"
457 | }
458 |
459 | private getNamespace() { return "info_fiend" }
460 |
461 | private textVersion() {
462 | def text = "Trigger Version: ${ version() }"
463 | }
464 | private dVersion(){
465 | def text = "Device Version: ${getChildDevices()[0].version()}"
466 | }
467 |
468 |
469 |
--------------------------------------------------------------------------------
/smartapps/mnestor/gcal-search.src/gcal-search.groovy:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2017 Mike Nestor & Anthony Pastor
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 | * in compliance with the License. You may obtain a copy of the License at:
6 | *
7 | * http://www.apache.org/licenses/LICENSE-2.0
8 | *
9 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
10 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
11 | * for the specific language governing permissions and limitations under the License.
12 | *
13 | */
14 |
15 | definition (
16 | name: "GCal Search",
17 | namespace: "mnestor",
18 | author: "Mike Nestor & Anthony Pastor",
19 | description: "Integrates SmartThings with Google Calendar events to trigger virtual event using contact sensor (or a virtual presence sensor).",
20 | category: "My Apps",
21 | iconUrl: "https://raw.githubusercontent.com/mnestor/GCal-Search/icons/icons/GCal.png",
22 | iconX2Url: "https://raw.githubusercontent.com/mnestor/GCal-Search/icons/icons/GCal%402x.png",
23 | iconX3Url: "https://raw.githubusercontent.com/mnestor/GCal-Search/icons/icons/GCal%402x.png",
24 | singleInstance: false,
25 | ) {
26 | appSetting "clientId"
27 | appSetting "clientSecret"
28 | }
29 |
30 | preferences {
31 | page(name: "authentication", title: "Google Calendar Triggers", content: "mainPage", submitOnChange: true, uninstall: false, install: true)
32 | page name: "pageAbout"
33 | }
34 |
35 | mappings {
36 | path("/oauth/initialize") {action: [GET: "oauthInitUrl"]}
37 | path("/oauth/callback") {action: [GET: "callback"]}
38 | }
39 |
40 | private version() {
41 | def text = "20170326.1"
42 | }
43 |
44 | def mainPage() {
45 | log.trace "mainPage(): appId = ${app.id}, apiServerUrl = ${ getApiServerUrl() }"
46 | log.info "state.refreshToken = ${state.refreshToken}"
47 |
48 | if (!atomicState.accessToken && !state.refreshToken && !atomicState.refreshToken) {
49 | log.debug "No access or refresh tokens found - calling createAccessToken()"
50 | atomicState.authToken = null
51 | atomicState.accessToken = createAccessToken()
52 | } else {
53 | log.debug "Access token ${atomicState.accessToken} found - saving list of calendars."
54 | if (!atomicState.refreshToken && !state.refreshToken) {
55 | log.debug "BUT...No refresh token found."
56 | } else {
57 | if (state.refreshToken) {
58 | log.debug "state.refreshToken ${atomicState.refreshToken} found"
59 | } else if (atomicState.refreshToken) {
60 | log.debug "atomicState.refreshToken ${atomicState.refreshToken} found"
61 | }
62 |
63 | state.myCals = getCalendarList()
64 | }
65 | }
66 |
67 |
68 | return dynamicPage(name: "authentication", uninstall: false) {
69 | if (!atomicState.authToken) {
70 | log.debug "No authToken found."
71 | def redirectUrl = "https://graph.api.smartthings.com/oauth/initialize?appId=${app.id}&access_token=${atomicState.accessToken}&apiServerUrl=${getApiServerUrl()}"
72 | log.debug "RedirectUrl = ${redirectUrl}"
73 |
74 | section("Google Authentication"){
75 | paragraph "Tap below to log in to Google and authorize access for GCal Search."
76 | href url:redirectUrl, style:"external", required:true, title:"", description:"Click to enter credentials"
77 | }
78 | } else {
79 | log.debug "authToken ${atomicState.authToken} found."
80 | section(){
81 | app(name: "childApps", appName: "GCal Search Trigger", namespace: "mnestor", title: "New Trigger...", multiple: true)
82 | }
83 | section("Options"){
84 | href "pageAbout", title: "About ${textAppName()}", description: "Tap to get application version, license, instructions or remove the application"
85 | }
86 | }
87 | }
88 | }
89 |
90 | def pageAbout() {
91 | dynamicPage(name: "pageAbout", title: "About ${textAppName()}", uninstall: true) {
92 | section {
93 | paragraph "${textVersion()}\n${textCopyright()}\n\n${textContributors()}\n\n${textLicense()}\n"
94 | }
95 | section("Instructions") {
96 | paragraph textHelp()
97 | }
98 | section("Tap button below to remove all GCal Searches, triggers and switches"){
99 | }
100 | }
101 | }
102 |
103 |
104 |
105 | def installed() {
106 | log.trace "Installed with settings: ${settings}"
107 | initialize()
108 | }
109 |
110 | def updated() {
111 | log.trace "Updated with settings: ${settings}"
112 | unsubscribe()
113 | initialize()
114 | }
115 |
116 | def initialize() {
117 | log.trace "GCalSearch: initialize()"
118 |
119 | log.debug "There are ${childApps.size()} GCal Search Triggers"
120 | childApps.each {child ->
121 | log.debug "child app: ${child.label}"
122 | }
123 | // log.info "clientId = ${clientId}"
124 | // log.info "clientSecret = ${clientSecret}"
125 | log.info "initialize: state.refreshToken = ${state.refreshToken}"
126 |
127 | state.setup = true
128 |
129 | /** getCalendarList()
130 |
131 | def cals = state.calendars
132 | log.debug "Calendars are ${cals}"
133 | **/
134 | }
135 |
136 |
137 |
138 | def getCalendarList() {
139 | log.trace "getCalendarList()"
140 | isTokenExpired("getCalendarList")
141 |
142 | def path = "/calendar/v3/users/me/calendarList"
143 | def calendarListParams = [
144 | uri: "https://www.googleapis.com",
145 | path: path,
146 | headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"],
147 | query: [format: 'json', body: requestBody]
148 | ]
149 |
150 | log.debug "calendar params: $calendarListParams"
151 |
152 | def stats = [:]
153 |
154 | try {
155 | httpGet(calendarListParams) { resp ->
156 | resp.data.items.each { stat ->
157 | stats[stat.id] = stat.summary
158 | }
159 |
160 | }
161 | } catch (e) {
162 | log.debug "error: ${path}"
163 | log.debug e
164 | if (refreshAuthToken()) {
165 | return getCalendarList()
166 | } else {
167 | log.debug "fatality"
168 | log.error e.getResponse().getData()
169 | }
170 | }
171 |
172 | // def myCals = stats
173 | def i=1
174 | def calList = ""
175 | def calCount = stats.size()
176 | calList = calList + "\nYou have ${calCount} available Gcal calendars (Calendar Name - calendarId): \n\n"
177 | stats.each {
178 | calList = calList + "(${i}) ${it.value} - ${it.key} \n"
179 | i = i+1
180 | }
181 |
182 | log.info calList
183 |
184 | state.calendars = stats
185 | return stats
186 | }
187 |
188 | def getNextEvents(watchCalendars, search) {
189 | log.trace "getNextEvents()"
190 | isTokenExpired("getNextEvents")
191 |
192 | def pathParams = [
193 | maxResults: 1,
194 | orderBy: "startTime",
195 | singleEvents: true,
196 | timeMin: getCurrentTime()
197 | ]
198 | if (search != "") {
199 | pathParams['q'] = "${search}"
200 | }
201 | log.debug "pathParams: ${pathParams}"
202 |
203 | def path = "/calendar/v3/calendars/${watchCalendars}/events"
204 | def eventListParams = [
205 | uri: "https://www.googleapis.com",
206 | path: path,
207 | headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"],
208 | query: pathParams
209 | ]
210 |
211 | log.debug "event params: $eventListParams"
212 |
213 | def evs = []
214 | try {
215 | httpGet(eventListParams) { resp ->
216 | evs = resp.data
217 | }
218 | } catch (e) {
219 | log.debug "error: ${path}"
220 | log.debug e
221 | log.error e.getResponse().getData()
222 | if (refreshAuthToken()) {
223 | return getNextEvents(watchCalendars, search)
224 | } else {
225 | log.debug "fatality"
226 | log.error e.getResponse().getData()
227 | }
228 | }
229 |
230 | log.debug evs
231 | return evs
232 | }
233 |
234 | def oauthInitUrl() {
235 | log.trace "GCalSearch: oauthInitUrl()"
236 |
237 | atomicState.oauthInitState = UUID.randomUUID().toString()
238 | def cid = getAppClientId()
239 |
240 | def oauthParams = [
241 | response_type: "code",
242 | scope: "https://www.googleapis.com/auth/calendar",
243 | client_id: cid,
244 | state: atomicState.oauthInitState,
245 | include_granted_scopes: "true",
246 | access_type: "offline",
247 | redirect_uri: "https://graph.api.smartthings.com/oauth/callback"
248 | ]
249 |
250 | redirect(location: "https://accounts.google.com/o/oauth2/v2/auth?" + toQueryString(oauthParams))
251 | }
252 |
253 | def callback() {
254 |
255 | log.trace "GCalSearch: callback()"
256 |
257 | log.debug "atomicState.oauthInitState ${atomicState.oauthInitState}"
258 | log.debug "params.state ${params.state}"
259 | log.debug "callback() >> params: $params, params.code ${params.code}"
260 |
261 | log.debug "token request: $params.code"
262 |
263 | def postParams = [
264 | uri: "https://www.googleapis.com",
265 |
266 | path: "/oauth2/v4/token",
267 | requestContentType: "application/x-www-form-urlencoded; charset=utf-8",
268 | body: [
269 | code: params.code,
270 | client_secret: getAppClientSecret(),
271 | client_id: getAppClientId(),
272 | grant_type: "authorization_code",
273 | redirect_uri: "https://graph.api.smartthings.com/oauth/callback"
274 | ]
275 | ]
276 |
277 | log.debug "postParams: ${postParams}"
278 |
279 | def jsonMap
280 | try {
281 | httpPost(postParams) { resp ->
282 | log.debug "resp callback"
283 | log.debug resp.data
284 | if (!atomicState.refreshToken && resp.data.refresh_token) {
285 | atomicState.refreshToken = resp.data.refresh_token
286 | }
287 | atomicState.authToken = resp.data.access_token
288 | atomicState.last_use = now()
289 | jsonMap = resp.data
290 | }
291 | log.trace "After Callback: atomicState.refreshToken = ${atomicState.refreshToken}"
292 | log.debug "After Callback: atomicState.authToken = ${atomicState.authToken}"
293 | if (!state.refreshToken && atomicState.refreshToken) {
294 | state.refreshToken = atomicState.refreshToken
295 | }
296 | } catch (e) {
297 | log.error "something went wrong: $e"
298 | log.error e.getResponse().getData()
299 | return
300 | }
301 |
302 | if (atomicState.authToken && atomicState.refreshToken ) {
303 | // call some method that will render the successfully connected message
304 | success()
305 | } else {
306 | // gracefully handle failures
307 | fail()
308 | }
309 | }
310 |
311 | def isTokenExpired(whatcalled) {
312 | log.trace "isTokenExpired() called by ${whatcalled}"
313 |
314 | if (atomicState.last_use == null || now() - atomicState.last_use > 3000) {
315 | log.debug "authToken null or old (>3000) - calling refreshAuthToken()"
316 | return refreshAuthToken()
317 | } else {
318 | log.debug "authToken good"
319 | return false
320 | }
321 | }
322 |
323 | def success() {
324 |
325 | def message = """
326 | Your account is now connected to GCal Search!
327 | Now return to the SmartThings App and then
328 | Click 'Done' to finish setup of GCal Search.
329 |
330 | authToken
331 | ${atomicState.authToken}
332 | refreshToken
333 | ${atomicState.refreshToken}
334 | """
335 | displayMessageAsHtml(message)
336 | }
337 |
338 | def fail() {
339 | def message = """
340 | There was an error authorizing GCal Search with
341 | your Google account. Please try again.
342 | """
343 | displayMessageAsHtml(message)
344 | }
345 |
346 | def displayMessageAsHtml(message) {
347 | def html = """
348 |
349 |
350 |
351 |
352 |
353 |
354 | ${message}
355 |
356 |
357 |
358 | """
359 | render contentType: 'text/html', data: html
360 | }
361 |
362 | private refreshAuthToken() {
363 | log.trace "GCalSearch: refreshAuthToken()"
364 | if(!atomicState.refreshToken && !state.refreshToken) {
365 | log.warn "Can not refresh OAuth token since there is no refreshToken stored"
366 | log.debug state
367 | } else {
368 | def refTok
369 | if (state.refreshToken) {
370 | refTok = state.refreshToken
371 | log.debug "Existing state.refreshToken = ${refTok}"
372 | } else if ( atomicState.refreshToken ) {
373 | refTok = atomicState.refreshToken
374 | log.debug "Existing atomicState.refreshToken = ${refTok}"
375 | }
376 | def stcid = getAppClientId()
377 | log.debug "ClientId = ${stcid}"
378 | def stcs = getAppClientSecret()
379 | log.debug "ClientSecret = ${stcs}"
380 |
381 | def refreshParams = [
382 | method: 'POST',
383 | uri : "https://www.googleapis.com",
384 | path : "/oauth2/v3/token",
385 | body : [
386 | refresh_token: "${refTok}",
387 | client_secret: stcs,
388 | grant_type: 'refresh_token',
389 | client_id: stcid
390 | ],
391 | ]
392 |
393 | log.debug refreshParams
394 |
395 | //changed to httpPost
396 | try {
397 | httpPost(refreshParams) { resp ->
398 | log.debug "Token refreshed...calling saved RestAction now!"
399 |
400 | if(resp.data) {
401 | log.debug resp.data
402 | atomicState.authToken = resp?.data?.access_token
403 | atomicState.last_use = now()
404 |
405 | return true
406 | }
407 | }
408 | }
409 | catch(Exception e) {
410 | log.debug "caught exception refreshing auth token: " + e
411 | log.error e.getResponse().getData()
412 | }
413 | }
414 | return false
415 | }
416 |
417 | def toQueryString(Map m) {
418 | return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&")
419 | }
420 |
421 | def getCurrentTime() {
422 | //RFC 3339 format
423 | //2015-06-20T11:39:45.0Z
424 | def d = new Date().format("yyyy-MM-dd'T'HH:mm:ss.SSSZ", location.timeZone)
425 | return d
426 | }
427 |
428 | def getAppClientId() { appSettings.clientId }
429 | def getAppClientSecret() { appSettings.clientSecret }
430 |
431 | def uninstalled() {
432 | //curl https://accounts.google.com/o/oauth2/revoke?token={token}
433 | revokeAccess()
434 | }
435 |
436 | def childUninstalled() {
437 |
438 | }
439 |
440 | def revokeAccess() {
441 |
442 | log.trace "GCalSearch: revokeAccess()"
443 |
444 | refreshAuthToken()
445 |
446 | if (!atomicState.authToken) {
447 | return
448 | }
449 |
450 | try {
451 | def uri = "https://accounts.google.com/o/oauth2/revoke?token=${atomicState.authToken}"
452 | log.debug "Revoke: ${uri}"
453 | httpGet(uri) { resp ->
454 | log.debug "resp"
455 | log.debug resp.data
456 | revokeAccessToken()
457 | atomicState.accessToken = atomicState.refreshToken = atomicState.authToken = state.refreshToken = null
458 | }
459 | } catch (e) {
460 | log.debug "something went wrong: $e"
461 | log.debug e.getResponse().getData()
462 | }
463 | }
464 |
465 | //Version/Copyright/Information/Help
466 | private def textAppName() {
467 | def text = "GCal Search"
468 | }
469 | private def textVersion() {
470 | def version = "Main App Version: ${version()}"
471 | def childCount = childApps.size()
472 | def childVersion = childCount ? childApps[0].textVersion() : "No GCal Triggers installed"
473 | def deviceVersion = childCount ? "\n${childApps[0].dVersion()}" : ""
474 | return "${version}\n${childVersion}${deviceVersion}"
475 | }
476 | private def textCopyright() {
477 | def text = "Copyright © 2017 Mike Nestor & Anthony Pastor"
478 | }
479 | private def textLicense() {
480 | def text =
481 | "Licensed under the Apache License, Version 2.0 (the 'License'); "+
482 | "you may not use this file except in compliance with the License. "+
483 | "You may obtain a copy of the License at"+
484 | "\n\n"+
485 | " http://www.apache.org/licenses/LICENSE-2.0"+
486 | "\n\n"+
487 | "Unless required by applicable law or agreed to in writing, software "+
488 | "distributed under the License is distributed on an 'AS IS' BASIS, "+
489 | "WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. "+
490 | "See the License for the specific language governing permissions and "+
491 | "limitations under the License."
492 | }
493 |
494 | private def textHelp() {
495 | def text =
496 | "Once you associate your Google Calendar with this application, you can set up "+
497 | "different seaches for different events that will trigger the corresponding GCal "+
498 | "switch to go on or off.\n\nWhen searching for events, if you leave the search "+
499 | "string blank it will trigger for each event in your calendar.\n\nTo search an exact phrase, "+
500 | "enclose the phrase in quotation marks: \"exact phrase\"\n\nTo exclude entries "+
501 | "that match a given term, use the form -term\n\nExamples:\nHoliday (anything with Holiday)\n" +
502 | "\"#Holiday\" (anything with #Holiday)\n#Holiday (anything with Holiday, ignores the #)"
503 | }
504 |
505 | private def textContributors() {
506 | def text = "Contributors:\nUI/UX: Michael Struck \nOAuth: Gary Spender"
507 | }
--------------------------------------------------------------------------------