From 8abeac1f2716c31b89487c9b926a6af3db885abf Mon Sep 17 00:00:00 2001 From: Jo Rhett Date: Thu, 19 Mar 2020 04:15:47 -0700 Subject: [PATCH] Improve alert messages, add ignore list for events Handle error conditions with event socket status Add preference for slack in websocket timeout --- AbodeAlarm.groovy | 174 +++++++++++++++++++++++++++++++++------------- CHANGELOG.md | 12 ++++ 2 files changed, 139 insertions(+), 47 deletions(-) diff --git a/AbodeAlarm.groovy b/AbodeAlarm.groovy index 911a3a5..7e126c7 100644 --- a/AbodeAlarm.groovy +++ b/AbodeAlarm.groovy @@ -37,9 +37,10 @@ } } section('Behavior') { - input name: 'showLogin', type: 'bool', title: 'Show Login', 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: 'showLogin', type: 'bool', title: 'Show Login', 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', defaultValue: '30', description: 'seconds beyond timeout' } } } @@ -66,18 +67,21 @@ def updated() { if (logTrace) runIn(1800,disableTrace) if (logDebug) runIn(7200,disableDebug) - // Validate the session - if (state.token == null) { - if (logDebug) log.debug 'Not currently logged in.' - if (!username.isEmpty() && !password.isEmpty()) - login() - - // Clear the MFA token entry -- will be useless anyway - device.updateSetting('mfa_code', [value: '', type: 'text']) - } - else { + // 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() { @@ -89,7 +93,7 @@ def refresh() { } def uninstalled() { - clearState() + clearLoginState() if (logDebug) log.debug 'uninstalled' } @@ -132,23 +136,27 @@ private login() { ] reply = doHttpRequest('POST', '/api/auth2/login', input_values) if(reply.containsKey('mfa_type')) { - sendEvent(name: 'requiresMFA', value: reply.mfa_type, descriptionText: "Multi-Factor Authentication required: ${reply.mfa_type}", displayed: true) + updateDataValue('mfa_enabled', '1') + sendEvent(name: 'isLoggedIn', value: "false - requires ${reply.mfa_type}", descriptionText: "Multi-Factor Authentication required: ${reply.mfa_type}", displayed: true) } - if(reply.containsKey('token')) { + 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() - // may not want to force logout logged_in = user?.id ? true : false if(! logged_in) { - if (state.token) clearState() + if (state.token) { + sendEvent(name: 'lastResult', value: 'Not logged in', descriptionText: 'Attempted transaction when not logged in', displayed: true) + clearLoginState() + } } else { parseUser(user) @@ -164,13 +172,13 @@ def logout() { else { sendEvent(name: 'lastResult', value: 'Not logged in', descriptionText: 'Attempted logout when not logged in', displayed: true) } - clearState() - device.updateSetting('showLogin', [value: true, type: 'bool']) + clearLoginState() } -private clearState() { +private clearLoginState() { state.clear() unschedule() + device.updateSetting('showLogin', [value: true, type: 'bool']) sendEvent(name: 'isLoggedIn', value: false, displayed: true) } @@ -332,11 +340,11 @@ private doHttpRequest(String method, String path, Map body = [:]) { log.error error.toString() } } - sendEvent(name: 'lastResult', value: "${status} ${message}", descriptionText: message, displayed: true) + sendEvent(name: 'lastResult', value: "${status} ${message}", descriptionText: message, type: 'API call', displayed: true) return result } -// Abode websocket implementation +// Abode websocket handling private connectEventSocket() { if (!state.webSocketConnectAttempt) state.webSocketConnectAttempt = 0 if (logDebug) log.debug "Attempting WebSocket connection for Abode events (attempt ${state.webSocketConnectAttempt})" @@ -345,10 +353,11 @@ private connectEventSocket() { 'Origin': baseURL() + '/', 'Cookie': "SESSION=${state.cookies['SESSION']}", ]) - if (logDebug) log.debug 'Connection initiated' + if (logDebug) log.debug 'EventSocket connection initiated' + runEvery5Minutes(checkSocketTimeout) } catch(error) { - log.error 'WebSocket connection to Abode event socket failed: ' + error.message + log.error 'WebSocket connection to Abode event socket failed: ' + error.toString() } } @@ -358,13 +367,19 @@ private terminateEventSocket() { interfaces.webSocket.close() state.webSocketConnected = false state.webSocketConnectAttempt = 0 - if (logDebug) log.debug 'Connection terminated' + if (logDebug) log.debug 'EventSocket connection terminated' } catch(error) { - log.error 'Disconnect of WebSocket from Abode portal failed: ' + error.message + log.error 'Disconnect of WebSocket from Abode portal failed: ' + error.toString() } } +private restartEventSocket() { + terminateEventSocket() + refresh() + runInMillis(30000, connectEventSocket) // Try connect again in 30 seconds +} + def sendPing() { if (logTrace) log.trace 'Sending webSocket ping' interfaces.webSocket.sendMessage('2') @@ -375,16 +390,77 @@ def sendPong() { interfaces.webSocket.sendMessage('3') } +def receivePong() { + runInMillis(state.webSocketPingInterval, sendPing) +} + +def checkSocketTimeout() { + responseTimeout = state.lastMsgReceived + state.webSocketPingTimeout + (timeoutSlack*1000) + if (now() > responseTimeout) { + log.warn 'Socket ping timeout - Disconnecting Abode event socket' + restartEventSocket() + } +} + +// 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', + ] +} + +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 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) { + case 'null': + message = 'null' + 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 // Quoted text @@ -398,20 +474,19 @@ def parseEvent(String event_text) { break } switch(event_type) { + case eventsToIgnore: + break + case 'gateway.mode': updateMode(message) break case ~/^gateway\.timeline.*/: - // device type is not included in all events - device_type = details.device_type ? " (${details.device_type})" : '' - if (logDebug) log.debug "${event_type} -${device_type} ${details.event_name}" + if (logDebug) log.debug "${event_type} -${device_type} ${message}" - // Automation puts the rule name in device_name, which is backwards for our purposes - if (details.event_type == 'Automation') - sendEvent(name: 'Automation', value: details.device_name, descriptionText: details.event_name, displayed: true) - else - sendEvent(name: details.device_name, value: details.event_type, descriptionText: details.event_name + device_type, displayed: true) + // Devices we ignore events for + if (! devicesToIgnore().contains(details.device_name)) + sendEvent(name: alert_name, value: alert_value, descriptionText: message, type: alert_type, displayed: true) break default: @@ -425,6 +500,7 @@ def parseEvent(String 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 @@ -442,8 +518,8 @@ def parse(String message) { break case '1': - log.info 'webSocket session close received' + event_data - runIn(120, connectEventSocket) + log.info 'webSocket session close received' + restartEventSocket() break case '2': @@ -453,32 +529,36 @@ def parse(String message) { case '3': if (logTrace) log.trace 'webSocket Pong received' - runInMillis(state.webSocketPingInterval, sendPing) + receivePong() break case '4': // First character of the message indicates purpose - switch(event_data.substring(0,1)) { + message_type = event_data.substring(0,1) + message_data = event_data.substring(1) + switch(message_type) { case '0': - log.info 'webSocket message = Socket connected' + log.info 'webSocket message = Event socket connected' runInMillis(state.webSocketPingInterval, sendPing) break case '1': - log.info 'webSocket message = Socket disconnected' + log.info 'webSocket message = Event socket disconnected' break case '2': - if (logTrace) log.trace 'webSocket message = Event: ' + event_data.substring(1) - parseEvent(event_data.substring(1)) + if (logTrace) log.trace 'webSocket message = Event: ' + message_data + parseEvent(message_data) break case '4': - log.info 'webSocket message = Error: ' + event_data.substring(1) + log.info 'webSocket message = Error: ' + message_data + sendEvent(name: 'webSocket Message', value: message_data, descriptionText: message_data, type: 'Error', displayed: true) break default: - log.error "webSocket message = (unknown:${event_data.substring(0,1)}): " + event_data.substring(1) + log.error "webSocket message = (unknown:${message_type}): ${message_data}" + sendEvent(name: 'webSocket Message', value: message_data, descriptionText: message_data, type: 'Unknown type', displayed: true) break } break @@ -519,6 +599,6 @@ def webSocketStatus(String message) { state.webSocketConnectAttempt += 1 } - if (isLoggedIn && !state.webSocketConnected && state.webSocketConnectAttempt < 10) + if ((isLoggedIn == true) && !state.webSocketConnected && state.webSocketConnectAttempt < 10) runIn(120, 'connectEventSocket') } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc73f7..788489b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html) +## 2020-03-19 Release 0.5.0 + +### Added + +- Preference for how much slack to allow in socket timeout +- Ignore lists for events types and device names + +### Changed + +- Utilize type field in alerts to communicate data source +- Refactored session management of event socket to improve error handling + ## 2020-03-18 Release 0.4.0 ### Added