/* * Abode Alarm * * Copyright 2020 Jo Rhett. All Rights Reserved * Started from Hubitat example driver code https://github.com/hubitat/HubitatPublic/tree/master/examples/drivers * Implementation inspired by https://github.com/MisterWil/abodepy * * Licensed under the Apache License, Version 2.0 -- details in the LICENSE file in this repo * */ metadata { definition ( name: 'Abode Alarm', namespace: 'jorhett', author: 'Jo Rhett', importUrl: 'https://raw.githubusercontent.com/jorhett/hubitat-abode/master/AbodeAlarm.groovy', ) { capability 'Actuator' capability 'Refresh' command 'armAway' command 'armHome' command 'disarm' command 'logout' attribute 'isLoggedIn', 'String' attribute 'gatewayMode', 'String' } preferences { if(showLogin != false) { section('Abode API') { input name: 'username', type: 'text', title: 'Abode username', required: true, displayDuringSetup: true, description: 'Abode username' input name: 'password', type: 'text', title: 'Abode password', required: true, displayDuringSetup: true, description: 'Abode password' input name: 'mfa_code', type: 'text', title: 'Current MFA Code', required: false, displayDuringSetup: true, description: 'Not stored -- used one time' } } section('Behavior') { input name: 'targetModeAway', type: 'enum', title: 'Hubitat Mode when Abode Away', options: location.getModes().collect { it.toString() } input name: 'targetModeHome', type: 'enum', title: 'Hubitat Mode when Abode Home', options: location.getModes().collect { it.toString() } input name: 'syncArming', type: 'bool', title: 'Sync Exit Delay start', defaultValue: false, description: 'Enable concurrent exit delays' input name: 'saveContacts', type: 'bool', title: 'Save Abode contact events', defaultValue: false, description: '...to Hubitat Events' input name: 'saveGeofence', type: 'bool', title: 'Save Abode geofence events', defaultValue: false, description: '...to Hubitat Events' input name: 'saveAutomation', type: 'bool', title: 'Save CUE Automation actions', defaultValue: false, description: '...to Hubitat Events' input name: 'showLogin', type: 'bool', title: 'Show login fields', defaultValue: true, description: 'Show login fields', submitOnChange: true input name: 'logDebug', type: 'bool', title: 'Enable debug logging', defaultValue: true, description: 'for 2 hours' input name: 'logTrace', type: 'bool', title: 'Enable trace logging', defaultValue: false, description: 'for 30 minutes' input name: 'timeoutSlack', type: 'number', title: 'Timeout slack in seconds', defaultValue: '30', description: '+ for resilience, - reconnect faster' } } } // Hubitat standard methods def installed() { log.debug 'installed' device.updateSetting('showLogin', [value: true, type: 'bool']) initialize() if (!childDevices) createIsArmedSwitch() } private initialize() { state.uuid = UUID.randomUUID() state.cookies = [:] } def updated() { log.info 'Preferences saved.' log.info 'debug logging is: ' + logDebug log.info 'description logging is: ' + logDetails log.info 'Abode username: ' + username if (!childDevices) createIsArmedSwitch() // Disable high levels of logging after time if (logTrace) runIn(1800,disableTrace) if (logDebug) runIn(7200,disableDebug) // Reasons we should attempt login again if ( // If they supplied mfa code they want to login again (!username.isEmpty() && !password.isEmpty() && mfa_code) || // If we aren't logged in, attempt login (!username.isEmpty() && !password.isEmpty() && (state.token == null)) || // If they changed the username, attempt login (!username.isEmpty() && !password.isEmpty() && (username != getDataValue('abodeID'))) ) login() else validateSession() // Clear the MFA token entry -- will be useless anyway device.updateSetting('mfa_code', [value: '', type: 'text']) } def refresh() { if (validateSession()) { parsePanel(getPanel()) if (state.webSocketConnected != true) connectEventSocket() } } def uninstalled() { clearLoginState() if (logDebug) log.debug 'uninstalled' } def disarm() { changeMode('standby') } def armHome() { changeMode('home') } def armAway() { changeMode('away') } def disableDebug(String level) { log.info "Timed elapsed, disabling debug logging" device.updateSetting("logDebug", [value: 'false', type: 'bool']) } def disableTrace(String level) { log.info "Timed elapsed, disabling trace logging" device.updateSetting("logTrace", [value: 'false', type: 'bool']) } // isArmed Child Switch def createIsArmedSwitch() { addChildDevice('hubitat', 'Virtual Switch', device.id + '-isArmed', [name: device.name + '-isArmed', isComponent: true]) } // Abode actions private baseURL() { return 'https://my.goabode.com' } private driverUserAgent() { return 'AbodeAlarm/0.4.0 Hubitat Elevation driver' } private login() { if(state.uuid == null) initialize() input_values = [ id: username, password: password, mfa_code: mfa_code, uuid: state.uuid, remember_me: 1, ] reply = doHttpRequest('POST', '/api/auth2/login', input_values) if(reply.containsKey('mfa_type')) { updateDataValue('mfa_enabled', '1') sendEvent(name: 'isLoggedIn', value: "false - requires ${reply.mfa_type}", descriptionText: "Multi-Factor Authentication required: ${reply.mfa_type}", displayed: true) } else if(reply.containsKey('token')) { sendEvent(name: 'isLoggedIn', value: true, displayed: true) device.updateSetting('showLogin', [value: false, type: 'bool']) parseLogin(reply) parsePanel(getPanel()) connectEventSocket() } } // Make sure we're still authenticated private validateSession() { user = getUser() logged_in = user?.id ? true : false if(! logged_in) { if (state.token) { sendEvent(name: 'lastResult', value: 'Not logged in', descriptionText: 'Attempted transaction when not logged in', displayed: true) clearLoginState() } } else { parseUser(user) } return logged_in } def logout() { if(state.token && validateSession()) { reply = doHttpRequest('POST', '/api/v1/logout') terminateEventSocket() } else { sendEvent(name: 'lastResult', value: 'Not logged in', descriptionText: 'Attempted logout when not logged in', displayed: true) } clearLoginState() } private clearLoginState() { state.clear() unschedule() device.updateSetting('showLogin', [value: true, type: 'bool']) sendEvent(name: 'isLoggedIn', value: false, displayed: true) } // Send a request to change mode to Abode private changeMode(String new_mode) { if(new_mode != device.currentValue('gatewayMode')) { // Only update area 1 since area is not returned in event messages reply = doHttpRequest('PUT','/api/v1/panel/mode/1/' + new_mode) if (reply['area'] == '1') { log.info "Sent request to change Abode gateway mode to ${new_mode}" state.localModeChange = new_mode } } else { if (logDebug) log.debug "Gateway is already in mode ${new_mode}" } } // Process an update from Abode that the mode has changed private updateMode(String new_mode) { log.info 'Abode gateway mode has changed to ' + new_mode sendEvent(name: "gatewayMode", value: new_mode, descriptionText: 'Gateway mode has changed to ' + new_mode, displayed: true) // Set isArmed? isArmed = getChildDevice(device.id + '-isArmed') if (new_mode == 'standby') isArmed.off() else { isArmed.on() // Avoid changing the mode if it's a rebound from a local action if (new_mode == state.localModeChange) { state.remove('localModeChange') } else { if (targetModeAway && new_mode == 'away') { log.info 'Changing Hubitat mode to ' + new_mode location.setMode(targetModeAway) } else if (targetModeHome) { log.info 'Changing Hubitat mode to ' + new_mode location.setMode(targetModeHome) } } } } // Abode types private getAccessToken() { reply = doHttpRequest('GET','/api/auth2/claims') return reply?.access_token } private getPanel() { doHttpRequest('GET','/api/v1/panel') } private getUser() { doHttpRequest('GET','/api/v1/user') } private parseLogin(Map data) { state.token = data.token state.access_token = getAccessToken() state.loginExpires = data.expired_at // Login contains a panel hash which is different enough we can't reuse parsePanel() device.data.remove('loginExpires') ['ip','mac','model','online'].each() { field -> updateDataValue(field, data.panel[field]) } } private parseUser(Map user) { // Store these for use by Apps updateDataValue('abodeID', user.id) ['plan','mfa_enabled'].each() { field -> updateDataValue(field, user[field]) } // ignore everything else for now return user } private parsePanel(Map panel) { // Update these for use by Apps ['ip','online'].each() { field -> updateDataValue(field, panel[field]) } areas = parseAreas(panel['areas']) ?: [] parseMode(panel['mode'], areas) ?: {} return panel } private parseAreas(Map areas) { // Haven't found anything useful other than list of area keys areas.keySet() } private parseMode(Map mode, Set areas) { modeMap = [:] // Collect mode for each area areas.each() { number -> modeMap[number] = mode["area_${number}"] } // Status is based on area 1 only if (device.currentValue('gatewayMode') != modeMap['1']) sendEvent(name: "gatewayMode", value: modeMap['1'], descriptionText: "Gateway mode is ${modeMap['1']}", displayed: true) state.modes = modeMap } // HTTP methods tuned for Abode private storeCookies(String cookies) { // Cookies are comma separated, colon-delimited pairs cookies.split(',').each { namevalue = it.split(';')[0].split('=') state.cookies[namevalue[0]] = namevalue[1] } } private doHttpRequest(String method, String path, Map body = [:]) { result = [:] status = '' message = '' params = [ uri: baseURL(), path: path, headers: ['User-Agent': driverUserAgent()], ] if (method == 'POST' && body.isEmpty() == false) params.body = body if (state.token) params.headers['ABODE-API-KEY'] = state.token if (state.access_token) params.headers['Authorization'] = "Bearer ${state.access_token}" if (state.cookies) params.headers['Cookie'] = state.cookies.collect { key, value -> "${key}=${value}" }.join('; ') Closure $parseResponse = { response -> if (logTrace) log.trace response.data if (logDebug) log.debug "HTTPS ${method} ${path} results: ${response.status}" status = response.status.toString() result = response.data message = result?.message ?: "${method} ${path} successful" if (response.headers.'Set-Cookie') storeCookies(response.headers.'Set-Cookie') } try { switch(method) { case 'PATCH': httpPatch(params, $parseResponse) break case 'POST': httpPostJson(params, $parseResponse) break case 'PUT': httpPut(params, $parseResponse) break default: httpGet(params, $parseResponse) break } } catch(error) { // Is this an HTTP error or a different exception? if (error.metaClass.respondsTo(error, 'response')) { if (logTrace) log.trace error.response.data status = error.response.status?.toString() result = error.response.data message = error.response.data?.message ?: "${method} ${path} failed" log.error "HTTPS ${method} ${path} result: ${error.response.status} ${error.response.data?.message}" error.response.data?.errors?.each() { errormsg -> log.warn errormsg.toString() } } else { status = 'Exception' log.error error.toString() } } sendEvent(name: 'lastResult', value: "${status} ${message}", descriptionText: message, type: 'API call', displayed: true) return result } // Abode event websocket handling def connectEventSocket() { if (!state.webSocketConnectAttempt) state.webSocketConnectAttempt = 0 if (logDebug) log.debug "Attempting WebSocket connection for Abode events (attempt ${state.webSocketConnectAttempt})" try { interfaces.webSocket.connect('wss://my.goabode.com/socket.io/?EIO=3&transport=websocket', headers: [ 'Origin': baseURL() + '/', 'Cookie': "SESSION=${state.cookies['SESSION']}", ]) if (logDebug) log.debug 'EventSocket connection initiated' runEvery5Minutes(checkSocketTimeout) } catch(error) { log.error 'WebSocket connection to Abode event socket failed: ' + error.toString() } } private terminateEventSocket() { if (logDebug) log.debug 'Disconnecting Abode event socket' try { interfaces.webSocket.close() state.webSocketConnected = false state.webSocketConnectAttempt = 0 if (logDebug) log.debug 'EventSocket connection terminated' } catch(error) { log.error 'Disconnect of WebSocket from Abode portal failed: ' + error.toString() } } // failure handler: validate state and reconnect in 5 seconds private restartEventSocket() { terminateEventSocket() runInMillis(5000, refresh) } def sendPing() { if (logTrace) log.trace 'Sending webSocket ping' interfaces.webSocket.sendMessage('2') } def sendPong() { if (logTrace) log.trace 'Sending webSocket pong' interfaces.webSocket.sendMessage('3') } def receivePong() { runInMillis(state.webSocketPingInterval, sendPing) } // This is called every 5 minutes whether we are connected or not def checkSocketTimeout() { if (state.webSocketConnected) { responseTimeout = state.lastMsgReceived + state.webSocketPingTimeout + (timeoutSlack*1000) if (now() > responseTimeout) { log.warn 'Socket ping timeout - Disconnecting Abode event socket' restartEventSocket() } } else { connectEventSocket() } } // Websocket message parsing private devicesToIgnore() { return [ // Don't need to log what the camera captured 'Iota Cam' ] } // These events have corresponding timeline and don't appear actionable private eventsToIgnore() { return [ // Internal alarm tracking events used by Abode responders 'alarm.add', 'alarm.del', // Nest integration events 'nest.refresh.true', ] } String formatEventUser(HashMap jsondata) { userdata = '' if (jsondata.user_name) { userdata += ' by ' + jsondata.user_name } if (jsondata.mobile_name) { userdata += ' using ' + jsondata.mobile_name } return userdata } def syncArmingEvents(String event_type) { switch(event_type) { case ~/Arming .* Away.*/: if (targetModeAway) location.setMode(targetModeAway) break case ~/Arming .* Home.*/: if (targetModeHome) location.setMode(targetModeHome) break default: // ignore it break } } def sendEnabledEvents( String alert_name, String alert_value, String message, String alert_type ) { switch(alert_type) { // Ignore camera events case ~/.* Cam/: break // User choice to log case ~/.* Contact/: // or event code 5100 open, 5101 closed, 5110 unlocked, 5111 locked if (saveContacts) sendEvent(name: alert_name, value: alert_value, descriptionText: message, type: alert_type, displayed: true) break case ~/CUE Automation/: // or event code 520x if (saveAutomation) sendEvent(name: alert_name, value: alert_value, descriptionText: message, type: alert_type, displayed: true) break default: sendEvent(name: alert_name, value: alert_value, descriptionText: message, type: alert_type, displayed: true) break } } def parseEvent(String event_text) { twovalue = event_text =~ /^\["com\.goabode\.([\w+\.]+)",(.*)\]$/ if (twovalue.find()) { event_type = twovalue[0][1] event_data = twovalue[0][2] switch(event_data) { // Quoted text case ~/^".*"$/: message = event_data[1..-2] break // Unquoted text case ~/^\w+$/: message = event_data break // JSON format case ~/^\{.*\}$/: details = parseJson(event_data) message = details.event_name user_info = formatEventUser(details) device_type = details.device_type ?: '' alert_name = details.device_name ?: 'unknown' alert_value = details.event_type if (details.event_type == 'Automation') { alert_type = 'CUE Automation' // Automation puts the rule name in device_name, which is backwards for our purposes alert_name = 'Automation' alert_value = details.device_name } else if (user_info) alert_type = user_info else if (device_type != '') alert_type = device_type else alert_type = '' break default: log.warn "Event ${event_type} has unknown data format: ${event_data}" message = event_data break } switch(event_type) { case eventsToIgnore: break case 'gateway.mode': updateMode(message) break case ~/^gateway\.timeline.*/: if (logDebug) log.debug "${event_type} -${device_type} ${message}" // Devices we ignore events for if (! devicesToIgnore().contains(details.device_name)) { if (syncArming) syncArmingEvents(details.event_type) sendEnabledEvents(alert_name, alert_value, message, alert_type) } break // Presence/Geofence updates case ~/fence.update.*/: if (saveGeofence) sendEvent(name: details.name, value: details.location, descriptionText: details.message, type: 'Geofence', displayed: true) break default: if (logDebug) log.debug "Ignoring Event ${event_type} ${message}" break } } else { log.warn "Unparseable Event message: ${event_text}" } } // Hubitat required method: This method is called with any incoming messages from the web socket server def parse(String message) { state.lastMsgReceived = now() if (logTrace) log.trace 'webSocket event raw: ' + message // First character is the event type event_type = message.substring(0,1) // remainder is the data (optional) event_data = message.substring(1) switch(event_type) { case '0': log.info 'webSocket session open received' jsondata = parseJson(event_data) if (jsondata.containsKey('pingInterval')) state.webSocketPingInterval = jsondata['pingInterval'] if (jsondata.containsKey('pingTimeout')) state.webSocketPingTimeout = jsondata['pingTimeout'] if (jsondata.containsKey('sid')) state.webSocketSid = jsondata['sid'] break case '1': log.info 'webSocket session close received' restartEventSocket() break case '2': if (logTrace) log.trace 'webSocket Ping received, sending reply' sendPong() break case '3': if (logTrace) log.trace 'webSocket Pong received' receivePong() break case '4': // First character of the message indicates purpose message_type = event_data.substring(0,1) message_data = event_data.substring(1) switch(message_type) { case '0': log.info 'webSocket message = Event socket connected' runInMillis(state.webSocketPingInterval, sendPing) break case '1': log.info 'webSocket message = Event socket disconnected' break case '2': parseEvent(message_data) break case '4': log.info 'webSocket message = Error: ' + message_data sendEvent(name: 'webSocket Message', value: message_data, descriptionText: message_data, type: 'Error', displayed: true) break default: log.warn "webSocket message = (unknown:${message_type}): ${message_data}" sendEvent(name: 'webSocket Message', value: message_data, descriptionText: message_data, type: 'Unknown type', displayed: true) break } break default: log.warn "Unknown webSocket event (${event_type}) received: " + event_data break } } // Hubitat required method: This method is called with any status messages from the web socket client connection def webSocketStatus(String message) { if (logTrace) log.trace 'webSocketStatus ' + message switch(message) { case ~/^status: open.*$/: log.info 'Connected to Abode event socket' sendEvent([name: 'eventSocket', value: 'connected']) state.webSocketConnected = true state.webSocketConnectAttempt = 0 break case ~/^status: closing.*$/: log.info 'Closing connection to Abode event socket' sendEvent([name: 'eventSocket', value: 'disconnected']) state.webSocketConnected = false state.webSocketConnectAttempt = 0 break case ~/^failure:(.*)$/: log.warn 'Event socket connection: ' + message state.webSocketConnected = false state.webSocketConnectAttempt += 1 break default: log.warn 'Event socket sent unexpected message: ' + message state.webSocketConnected = false state.webSocketConnectAttempt += 1 } if ((isLoggedIn == true) && !state.webSocketConnected && state.webSocketConnectAttempt < 10) runIn(120, 'connectEventSocket') }