├── 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 | } --------------------------------------------------------------------------------