def appVersion() { return "4.7.4" } /** * GCal Search * https://raw.githubusercontent.com/HubitatCommunity/Google_Calendar_Search/main/Apps/GCal_Search.groovy * * Credits: * Originally posted on the SmartThings Community in 2017:https://community.smartthings.com/t/updated-3-27-18-gcal-search/80042 * Special thanks to Mike Nestor & Anthony Pastor for creating the original SmartApp and DTH * UI/UX contributions made by Michael Struck and OAuth improvements by Gary Spender * Code was ported for use on Hubitat Elevation by cometfish in 2019: https://github.com/cometfish/hubitat_app_gcalsearch * Further improvements made by ritchierich and posted to the HubitatCommunity GitHub Repository so other community members can continue to improve this application * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * */ import groovy.json.JsonSlurper import groovy.json.JsonOutput private getClientId() { return settings.gaClientID } private getClientSecret() { return settings.gaClientSecret } private getRedirectURL() { "https://cloud.hubitat.com/oauth/stateredirect" } private oauthInitState() { "${getHubUID()}/apps/${app.id}/callback?access_token=${state.accessToken}" } definition( name: "GCal Search", namespace: "HubitatCommunity", author: "Mike Nestor & Anthony Pastor, cometfish, ritchierich", description: "Integrates Hubitat with Google Calendar events to toggle virtual switch.", category: "Convenience", documentationLink: "https://community.hubitat.com/t/release-google-calendar-search/71397", importUrl: "https://raw.githubusercontent.com/HubitatCommunity/Google_Calendar_Search/main/Apps/GCal_Search.groovy", iconUrl: "", iconX2Url: "", iconX3Url: "", ) preferences { page(name: "mainPage") page(name: "addNotificationDevice") page(name: "authenticationPage") page(name: "utilitiesPage") page name: "authenticationReset" page(name: "removePage") } mappings { path("/callback") {action: [GET: "callback"]} } def mainPage() { dynamicPage(name: "mainPage", title: "${getFormat("title", "GCal Search Version " + appVersion())}", uninstall: false, install: true) { def isAuthorized = authTokenValid("mainPage") logDebug("mainPage - isAuthorized: ${isAuthorized}") if (isAuthorized) { section("${getFormat("box", "Search Triggers")}") { app(name: "childApps", appName: "GCal Search Trigger", namespace: "HubitatCommunity", title: "New Search...", multiple: true) paragraph "${getFormat("line")}" } section("${getFormat("box", "Gmail Notification Devices")}") { if (state.scopesAuthorized.indexOf("mail.google.com") > -1) { clearNotificationDeviceSettings() paragraph notificationDeviceInstructions() /*input name: "security", type: "bool", title: "Do you plan to send local files from File Manager and have hub security enabled? Credentials are required to get the local file.", defaultValue: false, submitOnChange: true if (settings.security == true) { input name: "username", type: "string", title: "Hub Security Username", required: true input name: "password", type: "password", title: "Hub Security Password", required: true }*/ paragraph getNotificationDevices() paragraph "${getFormat("line")}" } else { paragraph "${getFormat("text", "This app is capable of creating Gmail Notification devices to send email notifications from rules. In order to leverage this feature:\n1. Enable the Gmail API in the Google Console\n2. Click Google API Authorization below and then Reset Google Authentication. Leave your existing credentials alone; you just need to reauthorize the APIs including Gmail\n3. Follow steps to complete the Google authentication process again and be sure to allow Hubitat access to Gmail when prompted.")}" } } } section("${getFormat("box", "Authentication")}") { if (!isAuthorized) { paragraph "${getFormat("warning", "Authentication Problem! Please click the button below to setup Google API Authorization.")}" } href ("authenticationPage", title: "Google API Authorization", description: "Click for Google Authentication") paragraph "${getFormat("line")}" } section("${getFormat("box", "Options")}") { input name: "appName", type: "text", title: "Name this parent app", required: true, defaultValue: "GCal Search", submitOnChange: true input name: "isDebugEnabled", type: "bool", title: "Enable debug logging?", defaultValue: false, required: false, submitOnChange: true, width: 4 if (settings.isDebugEnabled == true) { input name: "debugAuth", type: "bool", title: "Debug authentication?", defaultValue: false, required: false, width: 4 } href "utilitiesPage", title: "Utilities", description: "Tap to access utilities" paragraph "${getFormat("line")}" } section("${getFormat("box", "Removal")}") { href ("removePage", description: "Click to remove ${app.label?:app.name}", title: "Remove GCal Search") } } } def authenticationPage() { def isOAuthEnabled = oauthEnabled() def readyToInstall = false def isAuthorized = false if (isOAuthEnabled) { isAuthorized = authTokenValid("authenticationPage") if (isAuthorized && !atomicState.version) { readyToInstall = true } } dynamicPage(name: "authenticationPage", install: readyToInstall, uninstall: false, nextPage: "mainPage") { section("${getFormat("box", "Google Authentication")}") { if (isOAuthEnabled) { // Make sure no leading or trailing spaces on gaClientID and gaClientSecret if (settings.gaClientID && settings.gaClientID != settings.gaClientID.trim()) { app.updateSetting("gaClientID",[type: "text", value: settings.gaClientID.trim()]) } if (settings.gaClientSecret && settings.gaClientSecret != settings.gaClientSecret.trim()) { app.updateSetting("gaClientSecret",[type: "text", value: settings.gaClientSecret.trim()]) } if (!atomicState.authToken && !isAuthorized) { paragraph "${getFormat("text", "Enter your Google API credentials below. Instructions to setup these credentials can be found in HubitatCommunity GitHub.")}" input "gaClientID", "text", title: "Google API Client ID", required: true, submitOnChange: true input "gaClientSecret", "text", title: "Google API Client Secret", required: true, submitOnChange: true } else if (!isAuthorized) { paragraph "${getFormat("warning", "Authentication Problem! Please click Reset Google Authentication and try the setup again.")}" } else if (readyToInstall) { paragraph "${getFormat("text", "Authentication process complete!")}" paragraph "${getFormat("warning", "Click Done to complete the installation of this app. Open the GCal Search app again to setup Google search triggers.")}" } else { paragraph "${getFormat("text", "Authentication process complete! Click Next to continue setup.")}" } if (gaClientID && gaClientSecret) { if (!atomicState.authToken) { paragraph "${authenticationInstructions()}" href url: getOAuthInitUrl(), style: "external", required: true, title: "Authenticate GCal Search", description: "Tap to start the authentication process" } paragraph "${getFormat("text", "At any time click the button below to restart the authentication process.")}" href "authenticationReset", title: "Reset Google Authentication", description: "Tap to reset Google API Authentication and start over" paragraph "${getFormat("text", "Use the browser back button or click Next to exit.")}" } } else { paragraph "${getFormat("warning", "OAuth must be enabled on the GCal Search app.")}" paragraph "${oAuthInstructions()}" } } } } def oAuthInstructions() { def text = "

Steps to enable OAuth:

" text += "
    " text += "
  1. Please click this link to open another browser tab to enable this setting in Apps Code.
  2. " text += "" text += "
  3. After completing Step 1 is refresh this page (browser refresh) to continue setup.
  4. " text += "
" return text } def authenticationInstructions() { def text = "

Steps required to complete the Google authentication process:

" text += "" return text } def authenticationReset() { revokeAccess() atomicState.authToken = null atomicState.oauthInitState = null atomicState.refreshToken = null atomicState.tokenExpires = null atomicState.scopesAuthorized = null authenticationPage() } def utilitiesPage() { if (settings.resyncNow == true) { runIn(10, resyncChildApps) app.updateSetting("resyncNow",[type: "bool", value: false]) } dynamicPage(name: "utilitiesPage", title: "${getFormat("box", "App Utilities")}", uninstall: false, install: false, nextPage: "mainPage") { section() { paragraph "${getFormat("text", "All commands take effect immediately!")}" input "resyncNow", "bool", title: "Sync all calendar searches now. FYI You can sync individual calendar searches by clicking the Poll button within the child switch.", required: false, defaultValue: false, submitOnChange: true } } } def addNotificationDevice() { dynamicPage(name: "addNotificationDevice", title: "${getFormat("box", "Add Gmail Notification Device")}", uninstall: false, install: false, nextPage: "mainPage") { section() { if (state.missingDriver == null) { paragraph "${getFormat("text", "Fill in the following details and click anywhere on the screen to expose the 'Create Notification Device' button. Click this to add a new Gmail notification device and repeat steps to add additional Gmail notification devices. Click Next to return to the main menu.")}" input "notifLabel", "text", title: "Notification device name", required: false, submitOnChange: true input "notifTo", "text", title: "Default email address to send notification (if one is not passed in the notification)", required: false, submitOnChange: true input "notifSubject", "text", title: "Default email subject (if one is not passed in the notification)", defaultValue: "${location.name} Notification", required: false, submitOnChange: true input "notifDisplayName", "text", title: "Default display name for email sender (if one is not passed in the notification)", required: false, submitOnChange: true if (settings.notifLabel && settings.notifTo) { input name: "createChild", type: "button", title: "Create Notification Device", backgroundColor: "Green", textColor: "white", width: 4, submitOnChange: true } paragraph "${getFormat("line")}" paragraph "${getFormat("text", "Existing Gmail Notification Devices:\n${getNotificationDevices(false)}")}" } else { paragraph "${getFormat("text", "Gmail Notification Device driver is missing and a notification device cannot be created.\n1. Please download the Gmail Notification Device driver from GitHub\n2. Navigate to Drivers code and install this driver\n3. Once installed click the 'Driver Installed' button to continue adding Gmail notification devices")}" input name: "driverInstalled", type: "button", title: "Driver Installed", backgroundColor: "Green", textColor: "white", width: 4, submitOnChange: true } } } } def notificationDeviceInstructions() { def text = "

Email message settings can dynamically get set via notification message. Optionally include the following keys separated by commas at the beginning of the message, followed by the email body. Keys are case sensitive.

" text += "" return text } def getNotificationDevices(showAdd=true) { def childDevices = getAllChildDevices() if (childDevices.size() == 0 && showAdd == false) return "None" String str = "" str += "
" + "" + "" + "" + "" childDevices.sort{it.displayName.toLowerCase()}.each {dev -> def devPrefs = dev.getPreferenceValues() String devLink = "$dev" str += "" if (dev.isDisabled()) { str += "" + "" } else { str += "" + "" } } str += "
Device NameEmail AddressEmail Subject
$devLinkDevice is DisabledDevice is Disabled
${devPrefs.toEmail}${devPrefs.toSubject}
" if (showAdd) { String newNotificationDevice = buttonLink("createNewDevice", "", "#007009", "25px") str += "" + //"" + "" + "" + "
$newNotificationDevice$newNotificationDevice Create New Gmail Notification Device
" } str += "
" return str } String buttonLink(String btnName, String linkText, color = "#1A77C9", font = "15px") { "
$linkText
" } def clearNotificationDeviceSettings() { app.updateSetting("notifLabel", [value:"", type:"text"]) app.updateSetting("notifTo", [value:"", type:"text"]) app.updateSetting("notifSubject", [value:"", type:"text"]) app.updateSetting("notifDisplayName", [value:"", type:"text"]) } def appButtonHandler(btn) { switch(btn) { case "createChild": createDevice() clearNotificationDeviceSettings() break case "driverInstalled": atomicState.missingDriver = null return } } def createDevice(){ try{ state.vsIndex = (state.vsIndex) ? state.vsIndex + 1 : 1 //increment even on invalid device type def deviceLabel = settings.notifLabel.toString().trim() def deviceID = deviceLabel.toLowerCase().replace(" ", "_") deviceID += "-${state.vsIndex}" logDebug "Attempting to create Virtual Device: Label: ${deviceLabel}, deviceID: ${deviceID}" childDevice = addChildDevice("HubitatCommunity", "Gmail Notification Device", "${deviceID}", [label: "${deviceLabel}", isComponent: false]) logDebug "createDevice Success" childDevice.updateSetting("toEmail",[value:"${settings.notifTo}",type:"text"]) childDevice.updateSetting("toSubject",[value:"${settings.notifSubject}",type:"text"]) childDevice.updateSetting("fromDisplayName",[value:"${settings.notifDisplayName}",type:"text"]) logDebug "toEmail Update Success" app.removeSetting("missingDriver") } catch (Exception e) { if (e.toString().indexOf("Device type 'Gmail Notification Device' in namespace 'HubitatCommunity' not found") > -1) { log.error "Gmail Notification Device driver is missing. Please navigate to Drivers code and install this driver.\\nInstructions can be found in the Hubitat Documentation: https://docs.hubitat.com/index.php?title=How_to_Install_Custom_Drivers\\nDriver can be found here: https://raw.githubusercontent.com/HubitatCommunity/Google_Calendar_Search/main/Driver/Gmail_Notification_Device.groovy" state.missingDriver = true } else { log.error "Unable to create device. Error: ${e}" } } } def resyncChildApps() { childApps.each { child -> child.poll() logDebug "Syncing ${child.label}" } } def removePage() { dynamicPage(name: "removePage", title: "${getFormat("box", "Remove GCal Search and its Children")}", install: false, uninstall: true) { section () { paragraph("${getFormat("text", "Removing GCal Search will revoke its access to your Google Account, removes all child search triggers, and also removes all child devices! This may impact existing rules you have in place. Please note that you will need to manually delete the project in the Google Console.")}") } } } def installed() { initialize() } def updated() { unsubscribe() unschedule() initialize() } def initialize() { // Make sure no leading or trailing spaces on gaClientID and gaClientSecret if (settings.gaClientID && settings.gaClientID != settings.gaClientID.trim()) { app.updateSetting("gaClientID",[type: "text", value: settings.gaClientID.trim()]) } if (settings.gaClientSecret && settings.gaClientSecret != settings.gaClientSecret.trim()) { app.updateSetting("gaClientSecret",[type: "text", value: settings.gaClientSecret.trim()]) } updateAppLabel() upgradeSettings() } def uninstalled() { revokeAccess() } def childUninstalled() { } def oauthEnabled() { def answer = false if (state.accessToken) { answer = true } else { def accessToken try { accessToken = createAccessToken() } catch (e) { if (e.toString().indexOf("OAuth is not enabled for this App") > -1) { log.error "OAuth must be enabled on the GCal Search app. Please navigate to Apps code and enable OAuth. Instructions can be found in the Hubitat Documentation: https://docs.hubitat.com/index.php?title=How_to_Install_Custom_Apps" } else { log.error "${e}" } answer = false } if (accessToken) { state.accessToken = accessToken state.oauthInitState = "${getHubUID()}/apps/${app.id}/callback?access_token=${accessToken}" answer = true logDebug("Access token is : ${state.accessToken}, oauthInitState: ${state.oauthInitState}") } } return answer } /* ============================= Start Google APIs ============================= */ def getOAuthInitUrl() { if (!state.accessToken) { initialize() } def OAuthInitUrl = "https://accounts.google.com/o/oauth2/v2/auth" def oauthParams = [ response_type: "code", access_type: "offline", prompt: "consent", client_id: getClientId(), state: state.oauthInitState, redirect_uri: getRedirectURL(), scope: "https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/tasks https://mail.google.com/" ] OAuthInitUrl += "?" + toQueryString(oauthParams) //logDebug("OAuthInitUrl: ${OAuthInitUrl}") return OAuthInitUrl } def callback() { def code = params.code def oauthState = params.state def logMsg = ["callback - params: $params, code: ${code}, oauthState: ${oauthState}"] if (oauthState == state.oauthInitState) { def tokenParams = [ code: code, client_id : getClientId(), client_secret: getClientSecret(), redirect_uri: getRedirectURL(), grant_type: "authorization_code" ] def tokenUrl = "https://oauth2.googleapis.com/token" def params = [ uri: tokenUrl, contentType: 'application/x-www-form-urlencoded', body: tokenParams ] logMsg.push("params: ${params}") try { httpPost(params) { resp -> logMsg.push("Resp Status: ${resp.status}, Data: ${resp.data}") def slurper = new JsonSlurper() resp.data.each { key, value -> def data = slurper.parseText(key) state.refreshToken = data.refresh_token state.authToken = data.access_token state.tokenExpires = now() + (data.expires_in * 1000) state.scopesAuthorized = data.scope } } } catch (e) { log.error "callback - ${e}, ${e.getResponse().getData()}" } // Handle success and failure here, and render stuff accordingly def message = "" if (state.authToken) { logMsg.push("OAuth flow succeeded") message = """

Your Google Account has been successfully authorized.

Close this page to continue with the setup.

""" } else { logMsg.push("OAuth flow failed") message = """

The connection could not be established!

Close this page and click Reset Google Authentication to try again.

""" } logDebug("${logMsg}") connectionStatus(message) } else { log.error "callback() failed oauthState != state.oauthInitState" } } private refreshAuthToken() { def answer def logMsg = ["refreshAuthToken - state.refreshToken: ${state.refreshToken}"] if(!atomicState.refreshToken && !state.refreshToken) { answer = false logMsg.push("Can not refresh OAuth token since there is no refreshToken stored, ${state}") } else { def refTok if (state.refreshToken) { refTok = state.refreshToken logMsg.push("Existing state.refreshToken = ${refTok}") } else if (atomicState.refreshToken) { refTok = atomicState.refreshToken logMsg.push("Existing state.refreshToken = ${refTok}") } def refreshParams = [ uri : "https://www.googleapis.com", path : "/oauth2/v4/token", body : [ refresh_token: "${refTok}", client_secret: getClientSecret(), grant_type: 'refresh_token', client_id: getClientId() ], ] logMsg.push("refreshParams: ${refreshParams}") try { httpPost(refreshParams) { resp -> if(resp.data) { logMsg.push("resp callback ${resp.data}") atomicState.authToken = resp.data.access_token atomicState.tokenExpires = now() + (resp.data.expires_in * 1000) answer = true } } } catch (e) { log.error "refreshAuthToken - caught exception refreshing auth token: ${e}" answer = false } } logMsg.push("returning ${answer}") logDebug("${logMsg}", "auth") return answer } def authTokenValid(fromFunction) { //Upgrade check if (state.scopesAuthorized == null && ["mainPage", "authenticationPage"].indexOf(fromFunction) > -1) { return false } def answer def logMsg = ["authTokenValid - fromFunction: ${fromFunction}"] if (atomicState.tokenExpires >= now()) { logMsg.push("authToken good expires ${new Date(atomicState.tokenExpires)}") answer = true } else { def refreshAuthToken = refreshAuthToken() logMsg.push("authToken ${(atomicState.tokenExpires == null) ? "null" : "expired (" + new Date(atomicState.tokenExpires) + ")"} - calling refreshAuthToken: ${refreshAuthToken}") answer = refreshAuthToken } logMsg.push("returning ${answer}") logDebug("${logMsg}", "auth") return answer } def revokeAccess() { logDebug "GCalSearch: revokeAccess()" revokeAccessToken() refreshAuthToken() if (!atomicState.authToken) { return } try { def uri = "https://accounts.google.com/o/oauth2/revoke?token=${atomicState.authToken}" logDebug "Revoke: ${uri}" httpGet(uri) { resp -> logDebug "Resp Status: ${resp.status}, Data: ${resp.data}" } } catch (e) { log.error "revokeAccess - something went wrong: ${e}" } } def apiGet(fromFunction, uri, path, queryParams) { def logMsg = [] def apiResponse def isAuthorized = authTokenValid(fromFunction) logMsg.push("apiGet - fromFunction: ${fromFunction}, isAuthorized: ${isAuthorized}") if (isAuthorized == true) { def output = new JsonOutput() def apiParams = [ uri: uri, path: path, headers: ["Content-Type": "text/json", "Authorization": "Bearer ${atomicState.authToken}"], query: queryParams ] logMsg.push("apiParams: ${apiParams}") try { httpGet(apiParams) { resp -> apiResponse = resp.data logMsg.push("Resp Status: ${resp.status}") } } catch (e) { if (e.toString().indexOf("HttpResponseException") > -1) { if (e.response.status == 401 && refreshAuthToken() == true) { return apiGet(fromFunction, uri, path, queryParams) } else if (e.response.status == 403) { log.error "apiGet - fromFunction: ${fromFunction}, status: ${e.response.status}, path: ${path}, error: ${e}, data: ${e.getResponse().getData()}" apiResponse = "error" } } else { log.error "apiGet - fromFunction: ${fromFunction}, path: ${path}, error: ${e}" } } } else { logMsg.push("Authentication Problem") } logMsg.push("apiResponse: ${apiResponse}") logDebug("${logMsg}", "auth") return apiResponse } def apiPut(fromFunction, uri, path, bodyParams) { def logMsg = [] def apiResponse def isAuthorized = authTokenValid(fromFunction) logMsg.push("apiPut - fromFunction: ${fromFunction}, isAuthorized: ${isAuthorized}") if (isAuthorized == true) { def output = new JsonOutput() def apiParams = [ uri: uri, path: path, contentType: "application/json", headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], body: output.toJson(bodyParams) ] logMsg.push("apiParams: ${apiParams}") try { httpPut(apiParams) { resp -> apiResponse = resp.data logDebug "Resp Status: ${resp.status}, apiResponse: ${apiResponse}" } } catch (e) { if (e.toString().indexOf("HttpResponseException") > -1 && e.response.status == 401 && refreshAuthToken() == true) { return apiPut(fromFunction, uri, path, bodyParams) } else { log.error "apiPut - fromFunction: ${fromFunction}, path: ${path}, error: ${e}" } } } else { logMsg.push("Authentication Problem") } logDebug("${logMsg}", "auth") return apiResponse } def apiPatch(fromFunction, uri, path, bodyParams) { def logMsg = [] def apiResponse def isAuthorized = authTokenValid(fromFunction) logMsg.push("apiPatch - fromFunction: ${fromFunction}, isAuthorized: ${isAuthorized}") if (isAuthorized == true) { def output = new JsonOutput() def apiParams = [ uri: uri, path: path, contentType: "application/json", headers: ["Content-Type": "application/json", "Authorization": "Bearer ${atomicState.authToken}"], body: output.toJson(bodyParams) ] logMsg.push("apiParams: ${apiParams}") try { httpPatch(apiParams) { resp -> apiResponse = resp.data logDebug "Resp Status: ${resp.status}, apiResponse: ${apiResponse}" } } catch (e) { if (e.toString().indexOf("HttpResponseException") > -1 && e.response.status == 401 && refreshAuthToken() == true) { return apiPatch(fromFunction, uri, path, bodyParams) } else { log.error "apiPatch - fromFunction: ${fromFunction}, path: ${path}, error: ${e}" } } } else { logMsg.push("Authentication Problem") } logDebug("${logMsg}", "auth") return apiResponse } def apiPost(fromFunction, apiPrefs, bodyParams) { def logMsg = [] def apiResponse def isAuthorized = authTokenValid(fromFunction) logMsg.push("apiPost - fromFunction: ${fromFunction}, isAuthorized: ${isAuthorized}, apiPrefs: ${apiPrefs}") if (isAuthorized == true) { def apiParams = [ uri: apiPrefs.uri, path: (apiPrefs.containsKey("path")) ? apiPrefs.path : null, contentType: "application/json", headers: ["Content-Type": apiPrefs.contentType, "Authorization": "Bearer ${atomicState.authToken}"] ] if (bodyParams) { def output = new JsonOutput() apiParams.body = (apiPrefs.jsonBody == true) ? output.toJson(bodyParams) : bodyParams } def trimFilefromLog = false //Remove file contents from logging, if trying to troubleshoot the API, comment the following line so it gets logged. Performance issues will arise if this is left on. trimFilefromLog = true logMsg.push("apiParams: ${(trimFilefromLog && apiParams.toString().indexOf("filename=") > -1) ? apiParams.toString().substring(0, apiParams.toString().indexOf("filename=")) : apiParams}") try { httpPost(apiParams) { resp -> apiResponse = [:] apiResponse.status = resp.status apiResponse.data = resp.data logDebug "apiResponse: ${apiResponse}" } } catch (e) { if (e.toString().indexOf("HttpResponseException") > -1 && e.response.status == 401 && refreshAuthToken() == true) { return apiPost(fromFunction, apiPrefs, bodyParams) } else { log.error "apiPost - fromFunction: ${fromFunction}, path: ${path}, error: ${e}" } } } else { logMsg.push("Authentication Problem") } logDebug("${logMsg}", "auth") return apiResponse } /* ============================= End Google APIs ============================= */ /* ============================= Start Google Calendar ============================= */ def getCalendarList() { def logMsg = [] def calendarList = [:] def uri = "https://www.googleapis.com" def path = "/calendar/v3/users/me/calendarList" def queryParams = [ format: 'json' ] def calendars = apiGet("getCalendarList", uri, path, queryParams) logMsg.push("getCalendarList - path: ${path}, queryParams: ${queryParams}, calendars: ${calendars}") if (calendars instanceof Map && calendars.size() > 0) { calendars.items.each { calendarItem -> calendarList[calendarItem.id] = (calendarItem.summaryOverride) ? calendarItem.summaryOverride : calendarItem.summary } logMsg.push("calendarList: ${calendarList}") } else { calendarList = calendars } //logDebug("${logMsg}") return calendarList } def getNextEvents(watchCalendar, GoogleMatching, search, endTimePreference, offsetEnd, dateFormat, appName=null, timeZoneQuery) { endTimePreference = translateEndTimePref(endTimePreference) def logMsg = ["getNextEvents - appName: ${appName}, watchCalendar: ${watchCalendar}, search: ${search}, endTimePreference: ${endTimePreference}"] def eventList = [] def uri = "https://www.googleapis.com" def path = "/calendar/v3/calendars/${watchCalendar}/events" def queryParams = [ //maxResults: 1, orderBy: "startTime", singleEvents: true, //timeMin: getCurrentTime(), timeMin: getStartTime(offsetEnd), timeMax: getEndDate(endTimePreference) ] if (GoogleMatching == true && search != "") { queryParams['q'] = "${search}" } if (timeZoneQuery == true) { queryParams['timeZone'] = location.timeZone.getID() } def events = apiGet("${appName}-getNextEvents", uri, path, queryParams) logMsg.push("queryParams: ${queryParams}, events: ${events}") if (events == null || !events instanceof ArrayList) { eventList = events } else if (events && events.items && events.items.size() > 0) { def defaultReminder = (events.containsKey("defaultReminders") && events.defaultReminders.size() > 0) ? events.defaultReminders[0] : [method:"popup", minutes:15] for (int i = 0; i < events.items.size(); i++) { def event = events.items[i] def reminderMinutes if (event.containsKey("reminders") && event.reminders.containsKey("overrides")) { def reminders = event.reminders.overrides if (reminders.size() == 1) { reminderMinutes = reminders[0].minutes } else { reminderMinutes = reminders.find{it.method == defaultReminder.method} reminderMinutes = (reminderMinutes) ? reminderMinutes.minutes : defaultReminder.minutes } } else { reminderMinutes = defaultReminder.minutes } def eventDetails = [:] eventDetails.kind = event.kind //eventDetails.timeZone = events.timeZone eventDetails.eventID = event.id eventDetails.eventTitle = event.summary ? event.summary.trim() : "none" eventDetails.eventLocation = event.location ? event.location : "none" eventDetails.eventReminderMin = reminderMinutes if (event.description && event.description != null && event.description.trim() != "") { eventDetails.eventDescription = event.description //Description is an HTML field, remove html tags, special characters, and spaces eventDetails.eventDescription = eventDetails.eventDescription.trim().replaceAll("
", "\n") eventDetails.eventDescription = eventDetails.eventDescription.replaceAll("\\<.*?\\>", " ") eventDetails.eventDescription = eventDetails.eventDescription.replaceAll("\\&.*?\\;", " ") eventDetails.eventDescription = eventDetails.eventDescription.trim().replaceAll("\r", "\n") eventDetails.eventDescription = eventDetails.eventDescription.trim().replaceAll(" +", " ") eventDetails.eventDescriptionRaw = eventDetails.eventDescription eventDetails.eventDescription = eventDetails.eventDescription.replaceAll("\n"," ") } else { eventDetails.eventDescription = "none" } def eventAllDay def eventStartTime def eventEndTime if (event.start.containsKey('date')) { eventAllDay = true def sdf = new java.text.SimpleDateFormat("yyyy-MM-dd") sdf.setTimeZone(location.timeZone) eventStartTime = sdf.parse(event.start.date) eventEndTime = new Date(sdf.parse(event.end.date).time - 60) } else { eventAllDay = false def sdf = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss") sdf.setTimeZone(TimeZone.getTimeZone(events.timeZone)) eventStartTime = sdf.parse(event.start.dateTime) eventEndTime = sdf.parse(event.end.dateTime) } eventDetails.eventAllDay = eventAllDay eventDetails.eventStartTime = eventStartTime eventDetails.eventEndTime = eventEndTime eventList.push(eventDetails) } logMsg.push("eventList:\n${eventList.join("\n")}") } logDebug("${logMsg}") return eventList } /* ============================= End Google Calendar ============================= */ /* ============================= Start Google Task ============================= */ def getTaskList() { def logMsg = [] def taskList = [:] def uri = "https://www.googleapis.com" def path = "/tasks/v1/users/@me/lists" def queryParams = [ format: 'json' ] def taskLists = apiGet("getTaskList", uri, path, queryParams) logMsg.push("getTaskList - path: ${path}, queryParams: ${queryParams}, taskLists: ${taskLists}") if (taskLists instanceof Map && taskLists.size() > 0) { taskLists.items.each { taskListItem -> taskList[taskListItem.id] = taskListItem.title } logMsg.push("taskLists: ${taskLists}") } else { taskList = taskLists } logDebug("${logMsg}") return taskList } def getNextTasks(taskList, search, endTimePreference, appName=null) { endTimePreference = translateEndTimePref(endTimePreference) def logMsg = ["getNextTasks - appName: ${appName}, taskList: ${taskList}, search: ${search}, endTimePreference: ${endTimePreference}"] def tasksList = [] def uri = "https://www.googleapis.com" def path = "/tasks/v1/lists/${taskList}/tasks" def queryParams = [ //maxResults: 1, showCompleted: false, dueMax: getEndDate(endTimePreference) ] def tasks = apiGet("${appName}-getNextTasks", uri, path, queryParams) logMsg.push("queryParams: ${queryParams}, tasks: ${tasks}") if (tasks == null || !tasks instanceof ArrayList) { tasksList = tasks } else if (tasks && tasks.items && tasks.items.size() > 0) { for (int i = 0; i < tasks.items.size(); i++) { def task = tasks.items[i] def taskDetails = [:] taskDetails.kind = task.kind taskDetails.taskTitle = task.title ? task.title.trim() : "none" taskDetails.taskID = task.id def sdf = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss") taskDetails.taskDueDate = sdf.parse(task.due) tasksList.push(taskDetails) } logMsg.push("tasksList:\n${tasksList.join("\n")}") } logDebug("${logMsg}") return tasksList } def completeTask(watchTaskList, taskID) { def logMsg = ["completeTask - watchTaskList: ${watchTaskList}, taskID: ${taskID} - "] def uri = "https://tasks.googleapis.com" def path = "/tasks/v1/lists/${watchTaskList}/tasks/${taskID}" def bodyParams = [ id: taskID, status: "completed" ] def task = apiPatch("completeTask", uri, path, bodyParams) logMsg.push("bodyParams: ${bodyParams}, task: ${task}") logDebug("${logMsg}") return task } /* ============================= End Google Task ============================= */ /* ============================= Start Gmail ============================= */ def getUserLabels() { def logMsg = [] def userLabelList = ["none":"NONE"] def uri = "https://gmail.googleapis.com" def path = "/gmail/v1/users/me/labels" def queryParams = [:] def userLabels = apiGet("getUserLabels", uri, path, queryParams) logMsg.push("getUserLabels - path: ${path}, queryParams: ${queryParams}, userLabels: ${userLabels}") if (userLabels == null || !userLabels instanceof Map) { userLabelList = userLabels } else if (userLabels instanceof Map && userLabels.labels.size() > 0) { def includeSystemLabels = ["INBOX", "IMPORTANT", "STARRED", "TRASH", "UNREAD"] for (int i = 0; i < userLabels.labels.size(); i++) { def userLabelItem = userLabels.labels[i] //if (userLabelItem.containsKey("labelListVisibility") || ignoreLabels.indexOf(userLabelItem.id) > -1) continue if (userLabelItem.type == "system" && includeSystemLabels.indexOf(userLabelItem.id) == -1) continue userLabelList[userLabelItem.id] = userLabelItem.name } logMsg.push("userLabelList: ${userLabelList}") } else { userLabelList = userLabels } logDebug("${logMsg}") return userLabelList } def getNextMessages(search, setlabelList=null) { def logMsg = ["getNextMessages - search: ${search}, setlabelList: ${setlabelList}"] def messageList = [] def uri = "https://gmail.googleapis.com" def path = "/gmail/v1/users/me/messages" def queryParams = [ //maxResults: 1, q: "${search}" ] if (labelList != null) { //queryParams['labelIds'] = "${labelList}" } def messages = apiGet("getNextMessages", uri, path, queryParams) logMsg.push("queryParams: ${queryParams}, messages: ${messages}") def messageIDs = [] if (messages == null || !messages instanceof Map) { return messages } else if (messages.resultSizeEstimate > 0) { for (int i = 0; i < messages.messages.size(); i++) { def message = messages.messages[i] def messageID = message.id messageIDs.push(messageID) def messageDetails = getMessage(messageID) messageDetails.kind = "message" messageList.push(messageDetails) } if (setlabelList != null) { batchModifyMessages(messageIDs, setlabelList.add, setlabelList.remove) } } messageList.sort{it.messageReceived} logMsg.push("messageList:\n${messageList.join("\n")}") logDebug("${logMsg}") return messageList } def getMessage(messageID) { def logMsg = ["getMessage - messageID: ${messageID}"] def uri = "https://gmail.googleapis.com" def path = "/gmail/v1/users/me/messages/${messageID}" def queryParams = [:] def message = apiGet("getMessage", uri, path, queryParams) logMsg.push("queryParams: ${queryParams}, message: ${message}") def messageDetails = [:] if (message && message.id) { messageDetails.messageID = message.id messageDetails.threadID = message.threadId messageDetails.labelIDs = message.labelIds def messageBody = message.snippet if (messageBody) { messageDetails.messageBody = messageBody if (message.payload && message.payload.parts && message.payload.parts.size() > 0) { try { byte[] messageBodyRawBytes = message.payload.parts[0].body.data.decodeBase64() messageDetails.messageBodyRaw = new String(messageBodyRawBytes) } catch (e) { messageDetails.messageBodyRaw = messageBody } } } messageDetails.messageReceived = new Date(message.internalDate.toLong()) def payloadHeaders = message.payload.headers def messageTitle = payloadHeaders.find{it.name == "Subject"}.value messageDetails.messageTitle = messageTitle ? messageTitle : "none" messageDetails.messageFrom = payloadHeaders.find{it.name == "From"}.value.replace("\u003c", "").replace("\u003e", "") messageDetails.messageTo = payloadHeaders.find{it.name == "To"}.value.replace("\u003c", "").replace("\u003e", "") } logMsg.push("messageDetails: ${messageDetails}") logDebug("${logMsg}") return messageDetails } def batchModifyMessages(messageIDs, addLabels, removeLabels) { def logMsg = ["batchModifyMessages - messageIDs: ${messageIDs}, addLabels: ${addLabels}, removeLabels: ${removeLabels} - "] def bodyParams = [ ids: messageIDs, addLabelIds: addLabels, removeLabelIds: removeLabels ] def apiPrefs = [ uri: "https://gmail.googleapis.com", path: "/gmail/v1/users/me/messages/batchModify", contentType: "application/json", jsonBody: true ] def messages = apiPost("batchModifyMessages", apiPrefs, bodyParams) logMsg.push("bodyParams: ${bodyParams}, apiPrefs: ${apiPrefs}") logDebug("${logMsg}") return messages } def sendMessage(toEmail, fromDisplayName, subject, message) { def fromEmail = getUserProfile() def logMsg = ["sendMessage - toEmail: ${toEmail}, fromDisplayName: ${fromDisplayName}, fromEmail: ${fromEmail}, subject: ${subject}, message: ${message} - "] def keyWords = ["To", "From", "Subject", "File"] def foundKeywords = [:] for (int k = 0; k < keyWords.size(); k++) { def keyWord = keyWords[k] def keyWordIndex = message.indexOf(keyWord + ":") def commaIndex = message.indexOf(",", keyWordIndex) if (keyWordIndex > -1 && commaIndex > -1 && keyWordIndex < commaIndex) { def word = message.substring(keyWordIndex + keyWord.length() +1, commaIndex) foundKeywords[keyWord] = word message = message.replace(keyWord + ":" + word + ",", "").trim() } } logMsg.push("foundKeywords: ${foundKeywords}") toEmail = (foundKeywords.containsKey("To")) ? foundKeywords.To : toEmail subject = (foundKeywords.containsKey("Subject"))? foundKeywords.Subject : subject if (foundKeywords.containsKey("From")) { fromEmail = encodeString(foundKeywords.From) + "<" + fromEmail + ">" } else if (fromDisplayName != null && fromDisplayName != "") { fromEmail = encodeString(fromDisplayName) + "<" + fromEmail + ">" } if (message.indexOf("\\n") > -1) { message = message.replace("\\n", "
") } def bodyParams = [ from: "${fromEmail}", to: "${toEmail}", subject: "${encodeString(subject)}", body: "${message}" ] if (foundKeywords.containsKey("File") && foundKeywords.File.indexOf(".") > -1) { def file = getFile(foundKeywords.File) if (file.startsWith("File Error")) { bodyParams.body += "

" + file } else { bodyParams.file = [ name: foundKeywords.File, type: "application/" + foundKeywords.File.substring(foundKeywords.File.indexOf(".") +1), bytes: getFile(foundKeywords.File) ] } } def apiPrefs = [ uri: "https://www.googleapis.com/upload/gmail/v1/users/me/messages/send?uploadType=media", contentType: "message/rfc822", jsonBody: false ] def messages = apiPost("sendMessage", apiPrefs, createMimeMessage(bodyParams)) //Remove file contents from logging, Comment the following line to troubleshoot the file so it gets logged if (bodyParams.containsKey("file")) bodyParams.file.bytes = "" logMsg.push("bodyParams: ${bodyParams}, messages: ${messages}") logDebug("${logMsg}") return messages } def encodeString(msg) { return '=?utf-8?B?' + msg.encodeAsBase64() + '?='; } def getUserProfile() { def logMsg = [] def fromEmail = atomicState.fromEmail logMsg.push("getUserProfile - fromEmail: ${fromEmail}") if (fromEmail == null) { def uri = "https://gmail.googleapis.com" def path = "/gmail/v1/users/me/profile" def queryParams = [ format: 'json' ] def userProfile = apiGet("getUserProfile", uri, path, queryParams) logMsg.push("path: ${path}, queryParams: ${queryParams}, userProfile: ${userProfile}") fromEmail = userProfile.emailAddress atomicState.fromEmail = fromEmail } logDebug("${logMsg}") return fromEmail } def createMimeMessage(msg) { def nl = '\n'; def boundary = 'hubitat_attachment'; def mimeBody = [ 'MIME-Version: 1.0', 'To: ' + msg.to, 'From: ' + msg.from, 'Subject: ' + msg.subject, 'Content-Type: multipart/mixed; boundary=' + boundary + nl, '--' + boundary, 'Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64' + nl, msg.body.encodeAsBase64() + nl ]; if (msg.containsKey("file")) { def attachment = [ '--' + boundary, 'Content-Type: ' + msg.file.type, 'MIME-Version: 1.0', 'Content-Transfer-Encoding: base64', 'Content-Disposition: attachment; filename="' + msg.file.name + '"' + nl, msg.file.bytes, ] mimeBody.push(attachment.join(nl)) mimeBody.push('--' + boundary); } return mimeBody.join(nl); } //Thanks to community members @thebearmay and @younes and @bbacon19 for example code to get and send files def getFile(fileName) { def logMsg = ["getFile - fileName: ${fileName}"] def file try { byte[] fileData = downloadHubFile(fileName) file = fileData.encodeAsBase64() logMsg.push("file found") } catch (exception) { logMsg.push("File Error Exception: ${fileName} could not be found within File Manager, exception: ${exception}") logDebug("${logMsg}") file = "File Error: ${fileName} could not be found within File Manager" } logDebug("${logMsg}") return file } //Thanks to community member @thebearmay for example code to get login security cookie HashMap securityLogin() { def result = false try { httpPost( [ uri: "http://127.0.0.1:8080", path: "/login", query: [ loginRedirect: "/" ], body: [ username: username, password: password, submit: "Login" ], textParser: true, ignoreSSLIssues: true ] ) { resp -> //log.debug resp.data?.text if (resp.data?.text?.contains("The login information you supplied was incorrect.")) { result = false } else { cookie = resp?.headers?.'Set-Cookie'?.split(';')?.getAt(0) result = true } } } catch (e) { log.error "Error logging in: ${e}" result = false cookie = null } return [result: result, cookie: cookie] } /* ============================= End Gmail ============================= */ def displayMessageAsHtml(message) { def html = """
${message}
""" render contentType: 'text/html', data: html } def toQueryString(Map m) { return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") } def getCurrentTime() { //RFC 3339 format //2015-06-20T11:39:45.0Z def d = new Date().format("yyyy-MM-dd'T'HH:mm:ssZ", location.timeZone) return d } def getStartTime(offsetEnd) { //RFC 3339 format //2015-06-20T11:39:45.0Z def startDate = new Date() if (offsetEnd != null && !offsetEnd.toString().startsWith("-")) { def tempStartTime = startDate.getTime() tempStartTime = tempStartTime - offsetEnd startDate.setTime(tempStartTime) } def d = startDate.format("yyyy-MM-dd'T'HH:mm:ssZ", location.timeZone) return d } def getEndDate(endTimePreference, format=true) { //RFC 3339 format //2015-06-20T11:39:45.0Z def endDate = new Date() long numberOfHours if (["endOfToday", "endOfTomorrow"].indexOf(endTimePreference) > -1) { endDate.setHours(23); endDate.setMinutes(59); endDate.setSeconds(59); if (endTimePreference == "endOfTomorrow") { numberOfHours = 24 } } else if (endTimePreference instanceof Number) { numberOfHours = endTimePreference } if (numberOfHours != null) { def tempEndTime = endDate.getTime() tempEndTime = tempEndTime + (numberOfHours * 1000 * 60 * 60) endDate.setTime(tempEndTime) } def returnDate if (format) { returnDate = endDate.format("yyyy-MM-dd'T'HH:mm:ssZ", location.timeZone) } else { returnDate = endDate } return returnDate } def translateEndTimePref(endTimePref) { def endTimePreference switch (endTimePref) { case "End of Current Day": endTimePreference = "endOfToday" break case "End of Next Day": endTimePreference = "endOfTomorrow" break //case "Number of Hours from Current Time": //endTimePreference = settings.endTimeHours //break default: endTimePreference = endTimePref } return endTimePreference } def getFormat(type, displayText=""){ // Modified from @Stephack and @dman2306 Code def color = "#1A77C9" if(type == "title") return "

${displayText}

" if(type == "box") return "

${displayText}

" if(type == "text") return "${displayText}" if(type == "warning") return "${displayText}" if(type == "line") return "
" if(type == "code") return "" } def getScopesAuthorized() { def answer = [] def scopesAuthorized = state.scopesAuthorized if (scopesAuthorized.indexOf("auth/calendar") > -1) { answer.push("Calendar Event") } if (scopesAuthorized.indexOf("auth/tasks") > -1) { answer.push("Task") } if (scopesAuthorized.indexOf("mail.google.com") > -1) { answer.push("Gmail") } return answer } def connectionStatus(message, redirectUrl = null) { def redirectHtml = "" if (redirectUrl) { redirectHtml = """ """ } def html = """ Google Connection
Hubitat logo ${message}
""" render contentType: 'text/html', data: html } def updateAppLabel() { String appName = settings.appName app.updateLabel(appName) } private logDebug(msg, type=null) { if (isDebugEnabled != null && isDebugEnabled != false && ((type == "auth" && debugAuth == true) || type == null)) { if (msg instanceof List && msg.size() > 0) { msg = msg.join(", ") } log.debug "$msg" } } def versionToInt(version=null) { version = (version == null) ? appVersion() : version return version.replace(".", "").toInteger() } def upgradeSettings() { if (state.version == null || state.version != appVersion()) { childApps.each { child -> child.upgradeSettings() } int currentVersionInt = versionToInt() if (currentVersionInt < versionToInt("3.0.0")) { // Remove old states from previous version that are no longer utilized. This code will be removed in the future state.remove("authCode") state.remove("calendars") state.remove("deviceCode") state.remove("events") state.remove("isScheduled") state.remove("last_use") state.remove("setup") state.remove("userCode") state.remove("verificationUrl") // Remove old settings from previous version that are no longer utilized. app.removeSetting("cacheThreshold") app.removeSetting("clearCache") app.removeSetting("resyncNow") } if (currentVersionInt < versionToInt("4.5.0")) { def scopesAuthorized = state.scopesAuthorized if (scopesAuthorized.indexOf("auth/reminders") > -1) { //Previous: https://mail.google.com/ https://www.googleapis.com/auth/reminders https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/calendar.readonly atomicState.scopesAuthorized = scopesAuthorized.replace("https://www.googleapis.com/auth/reminders", "") } } atomicState.version = appVersion() log.info "Upgraded GCal Search settings" } }