/** * Neato Botvac Connected Series * * Copyright 2016,2017,2018,2019,2020 Alex Lee Yuk Cheung * * 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. * * VERSION HISTORY * V1.0 Hubitat * V1.1 Hubitat * V1.2 Hubitat fixes and improvements * V1.3 General improvements and cleanup, added alerts and ability to clear them - 01/15/2022 * V1.4 Fix for retry attempt - 02/10/2023 */ definition( name: "Neato Botvac Connected Series", namespace: "alyc100", author: "Alex Lee Yuk Cheung", description: "Integration to Neato Robotics Connected Series robot vacuums", category: "", iconUrl: "", iconX2Url: "", iconX3Url: "", oauth: true, singleInstance: true) { } preferences { page(name: "auth", title: "
Neato Botvac
", nextPage:"", content:"authPage", uninstall: true, install:true) page(name: "selectDevicePAGE") } mappings { //path("/oauthInitialize") {action: [GET: "oauthInitUrl"]} path("/callback") {action: [GET: "oauthCallback"]} } def authPage() { logDebug ("authPage()") if(!atomicState.accessToken) { //this is to access token for 3rd party to make a call to connect app atomicState.accessToken = createAccessToken() } def description def uninstallAllowed = false def oauthTokenProvided = false if(atomicState.authToken) { description = "You are connected." uninstallAllowed = true oauthTokenProvided = true } else { description = "Click to enter Neato Credentials" } def redirectUrl = buildRedirectUrl logDebug ("RedirectUrl = ${redirectUrl}") // get rid of next button until the user is actually auth'd if (!oauthTokenProvided) { return dynamicPage(name: "auth", title: "
Login
", nextPage: "", uninstall:uninstallAllowed) { section { headerSECTION() } section{ input( name:"clientId", type:"string", title: "Your Client ID (optional)", multiple: false, required: false, submitOnChange: true ) input( name:"clientSecret", type:"string", title: "Your Client Secret (optional)", multiple: false, required: false, submitOnChange: true ) } section() { paragraph "Tap below to log in to the Neato service and authorize Hubitat access." href url:redirectUrl, style:"external", required:true, title:"Neato Account Authorization", description:description } } } else { updateDevices() //Disable push option if contact book is enabled if (location.contactBookEnabled) { settings.sendPush = false } dynamicPage(name: "auth", uninstall: true, install: true) { section { headerSECTION() } section ("Choose your Neato Botvacs:") { href("selectDevicePAGE", title: null, description: devicesSelected() ? "Devices:" + getDevicesSelectedString() : "Tap to select your Neato Botvacs", state: devicesSelected()) } if (devicesSelected() == "complete") { def botvacList = "" } section() { paragraph "Tap below to re-authenticate to the Neato service and reauthorize Hubitat access." href url:redirectUrl, style:"external", required:false, title:"Neato Account Authorization", description:description } section{ input( name:"logEnable", type:"bool", title: "Enable debug logging", required: true, defaultValue: true ) } } } } def selectDevicePAGE() { updateDevices() dynamicPage(name: "selectDevicePAGE", title: "Devices", uninstall: false, install: false) { section { headerSECTION() } section() { paragraph "Tap below to see the list of Neato Botvacs available in your Neato account and select the ones you want to connect to Hubitat." input "selectedBotvacs", "enum", image: "https://raw.githubusercontent.com/alyc100/SmartThingsPublic/master/devicetypes/alyc100/neato_botvac_image.png", required:false, title:"Select Neato Devices \n(${state.botvacDevices.size() ?: 0} found)", multiple:true, options:state.botvacDevices } } } def headerSECTION() { return paragraph ("${textVersion()}") } def oauthInitUrl() { logDebug ("oauthInitUrl with callback: ${callbackUrl}") atomicState.oauthInitState = buildStateUrl def oauthParams = [ response_type: "code", scope: "public_profile control_robots maps", client_id: clientId(), state: atomicState.oauthInitState, redirect_uri: callbackUrl ] return "${apiEndpoint}/oauth2/authorize?${toQueryString(oauthParams)}" } // The toQueryString implementation simply gathers everything in the passed in map and converts them to a string joined with the "&" character. String toQueryString(Map m) { return m.collect { k, v -> "${k}=${URLEncoder.encode(v.toString())}" }.sort().join("&") } def oauthCallback() { logDebug ("callback()>> params: $params, params.code ${params.code}") def code = params.code def oauthState = params.state if (oauthState == atomicState.oauthInitState) { def tokenParams = [ grant_type: "authorization_code", code : code, client_id : clientId(), client_secret: clientSecret(), redirect_uri: callbackUrl ] def tokenUrl = "https://beehive.neatocloud.com/oauth2/token?${toQueryString(tokenParams)}" httpPost(uri: tokenUrl) { resp -> atomicState.refreshToken = resp.data.refresh_token atomicState.authToken = resp.data.access_token } if (atomicState.authToken) { oauthSuccess() } else { oauthFailure() } } else { log.error "callback() failed oauthState != atomicState.oauthInitState" } } // Example success method def oauthSuccess() { def message = """

Your Neato Account is now connected to Hubitat!

Close this window to continue setup.

""" displayMessageAsHtml(message) } def oauthFailure() { def message = """

The connection could not be established!

Close this window to go back to the app.

""" displayMessageAsHtml(message) } def displayMessageAsHtml(message) { def redirectHtml = "" if (redirectUrl) { redirectHtml = """""" } def html = """ Hubitat & Neato connection
Hubitat logo neato icon ${message}
""" render contentType: 'text/html', data: html } private refreshAuthToken() { logDebug ("refreshing auth token") if(!atomicState.refreshToken) { log.warn "Can not refresh OAuth token since there is no refreshToken stored" } else { def refreshParams = [ method: 'POST', uri : "https://beehive.neatocloud.com", path : "/oauth2/token", query : [grant_type: 'refresh_token', refresh_token: "${atomicState.refreshToken}"], ] def notificationMessage = "Neato is disconnected from Hubitat, because the access credential changed or was lost. Please go to the Neato (Connect) SmartApp and re-enter your account login credentials." //changed to httpPost try { def jsonMap httpPost(refreshParams) { resp -> if(resp.status == 200) { logDebug ("Token refreshed...calling saved RestAction now!") saveTokenAndResumeAction(resp.data) } } } catch (groovyx.net.http.HttpResponseException e) { log.error "refreshAuthToken() >> Error: e.statusCode ${e.statusCode}" def reAttemptPeriod = 300 // in sec if (e.statusCode != 401) { // this issue might comes from exceed 20sec app execution, connectivity issue etc. runIn(reAttemptPeriod, "refreshAuthToken") } else if (e.statusCode == 401) { // unauthorized if (!atomicState.reAttempt) atomicState.reAttempt = 0 atomicState.reAttempt = atomicState.reAttempt + 1 log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}" if (atomicState.reAttempt <= 3) { runIn(reAttemptPeriod, "refreshAuthToken") } else { log.debug "$notificationMessage" atomicState.authToken = null atomicState.reAttempt = 0 } } } } } private void saveTokenAndResumeAction(json) { logDebug ("saveTokenAndResumeAction: token response json: $json") if (json) { atomicState.refreshToken = json?.refresh_token atomicState.authToken = json?.access_token if (atomicState.action) { logDebug ("got refresh token, executing next action: ${atomicState.action}") "${atomicState.action}"() } } else { log.warn "did not get response body from refresh token response" } atomicState.action = "" } void installed() { logDebug ("Installed with settings: ${settings}") initialize() } void updated() { logDebug ("Updated with settings: ${settings}") unsubscribe() initialize() } void initialize() { unschedule() addBotvacs() } void uninstalled() { log.info("Uninstalling, removing child devices...") unschedule() removeChildDevices(getChildDevices()) } def updateDevices() { logDebug ("Executing 'updateDevices'") if (!state.devices) { state.devices = [:] } def devices = devicesList() state.botvacDevices = [:] state.secretKeys = [:] def selectors = [] devices.each { device -> if (device.serial != null) { selectors.add("${device.serial}") state.secretKeys["${device.serial}"] = device.secret_key state.botvacDevices["${device.serial}"] = "Neato Botvac - " + device.name } } logDebug ("selectors: $selectors") //Remove devices if does not exist on the Neato platform getChildDevices().findAll { !selectors.contains("${it.deviceNetworkId}") }.each { log.info("Deleting ${it.deviceNetworkId}") try { deleteChildDevice(it.deviceNetworkId) } catch (hubitat.exception.NotFoundException e) { log.info("Could not find ${it.deviceNetworkId}. Assuming manually deleted.") } catch (hubitat.exception.ConflictException ce) { log.info("Device ${it.deviceNetworkId} in use. Please manually delete.") } } if (selectedBotvacs) { selectedBotvacs.retainAll(selectors as Object[]) } } def addBotvacs() { logDebug ("Executing 'addBotvacs'") updateDevices() selectedBotvacs.each { device -> def childDevice = getChildDevice(device) if (!childDevice) { log.info("Adding Neato Botvac device ${device}: ${state.botvacDevices[device]}") def data = [ name: state.botvacDevices[device], label: state.botvacDevices[device] ] childDevice = addChildDevice("alyc100","Neato Botvac Connected Series", device, null, data) childDevice.refresh() logDebug ("Created ${state.botvacDevices[device]} with id: ${device}") } else { logDebug ("found ${state.botvacDevices[device]} with id ${device} already exists") } getId(childDevice) } } def getId(childDevice){ childDevice.each { def neatoBotvac = "${it.deviceNetworkId}" logDebug("'${it.deviceNetworkId}'") state.neatoBotvac = neatoBotvac if(logEnable){ runIn(1800, logsOff) } } } def getSecretKey(deviceSerial) { return state.secretKeys[deviceSerial] } private removeChildDevices(devices) { devices.each { deleteChildDevice(it.deviceNetworkId) // 'it' is default } } def devicesList() { logErrors([]) { def reAttemptPeriod = 300 // in sec def resp = beehiveGET("/users/me/robots") def notificationMessage = "Neato is disconnected from Hubitat, because the access credential changed or was lost. Please go to the Neato (Connect) SmartApp and re-enter your account login credentials." if (resp.status == 200) { return resp.data } else if (resp.status == 401) { atomicState.action = "updateDevices" if (!atomicState.reAttempt) atomicState.reAttempt = 0 atomicState.reAttempt = atomicState.reAttempt + 1 log.warn "reAttempt refreshAuthToken to try = ${atomicState.reAttempt}" if (atomicState.reAttempt <= 3) { runIn(reAttemptPeriod, "refreshAuthToken") } else { log.debug "$notificationMessage" atomicState.authToken = null atomicState.reAttempt = 0 } } else { log.error("Non-200 from device list call. ${resp.status} ${resp.data}") runIn(reAttemptPeriod, "refreshAuthToken") return [] } } } def devicesSelected() { return (selectedBotvacs) ? "complete" : null } def getDevicesSelectedString() { updateDevices() def listString = "" selectedBotvacs.each { childDevice -> if (null != state.botvacDevices) { listString += "\n• " + state.botvacDevices[childDevice] } } return listString } //Beehive API Access def beehiveGET(path, body = [:]) { try { logDebug("Beginning API GET: ${beehiveURL(path)}, ${beehiveRequestHeaders()}") httpGet(uri: beehiveURL(path), contentType: 'application/json', headers: beehiveRequestHeaders()) {response -> logResponse(response) return response } } catch (groovyx.net.http.HttpResponseException e) { logResponse(e.response) return e.response } } Map beehiveRequestHeaders() { return [ 'Accept': 'application/vnd.neato.nucleo.v1', 'Content-Type': 'application/*+json', 'X-Agent': '0.11.3-142', 'Authorization': "Bearer ${atomicState.authToken}" ] } def logResponse(response){ def log = (response.data) def status = (response.status) logDebug ("${log}") logDebug ("${status}") } def logErrors(options = [errorReturn: null, logObject: log], Closure c) { try { return c() } catch (groovyx.net.http.HttpResponseException e) { log.error("got error: ${e}, body: ${e.getResponse().getData()}") return options.errorReturn } catch (java.net.SocketTimeoutException e) { log.warn "Connection timed out, not much we can do here" return options.errorReturn } } def getChildName() { return "Neato BotVac" } def getServerUrl() { return "https://cloud.hubitat.com" } def getShardUrl() { return getApiServerUrl() } def getCallbackUrl() { return "https://cloud.hubitat.com/oauth/stateredirect" } def getBuildRedirectUrl() { return oauthInitUrl() } def getBuildStateUrl() { return "${getHubUID()}/apps/${app.id}/callback?access_token=${state.accessToken}" } def getApiEndpoint() { return "https://apps.neatorobotics.com" } def getSmartThingsClientId() { return appSettings?.clientId } def beehiveURL(path = '/') { return "https://beehive.neatocloud.com${path}" } private def textVersion() { def text = "Neato Botvac Connected Series\nHubitat Version: 1.4" } private def textCopyright() { def text = "Copyright © 2016-2020 Alex Lee Yuk Cheung" } def clientId() { if(!settings.clientId) { return "4f21ab200ecacf56759e7b2654124f5945630e4249823dee6c0ae56bb7adc1de" } else { return settings.clientId } } def clientSecret() { if(!settings.clientSecret) { return "c4b91d782c86ff6ad714fc6176bf06e4f83aafbc5c68621a8d6c7d21403516e5" } else { return settings.clientSecret } } void logDebug(String msg){ if (settings?.logEnable != false){ log.debug "$msg" } } def logsOff(){ log.warn "debug logging disabled..." app.updateSetting("logEnable", [value:"false",type:"bool"]) }