Compare commits

...

1 Commits

Author SHA1 Message Date
CHRIS WILLIAMS
b4aaa62959 MSA-2707: Sensi Thermostat 2018-03-19 00:55:17 -07:00
2 changed files with 1941 additions and 0 deletions

View File

@@ -0,0 +1,643 @@
/**
* Copyright 2016 Kirk Brown
*
* 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.
*
* Emerson Sensi Community Created Service Manager
*
* Author: Kirk Brown
* Date: 2016-12-24
* Date: 2017-01-02 The Sensi Connect App is near fully functional
* Date: 2017-01-07 The Sensi Connect App has been changed to allow individual device polling.
* It also polls a thermostat immediately after sending a command
*
* Date: 2017-05-02 Changed the frequency of subscription/unsubscribe. Any time a new poll or before command subscribe new.
*
* Place the Sensi (Connect) code under the My SmartApps section. Be certain you publish the app for you.
* Place the Sensi Thermostat Device Type Handler under My Device Handlers section.
* Be careful that if you change the Name and Namespace you that additionally change it in the addChildDevice() function
*
*
* The Program Flow is as follows:
* 1. SmartApp gets user credentials in the install process.
* 2. The SmartApp gets the users thermostats and lists them for subscription in the SmartApp.
* a. The smartApp uses the users credentials to get authorized, get a connection token, and then list the thermostats
* 3. The User then selects the desired thermostats to add to SmartThings
* 4. The SmartApp schedules a refresh/poll of the thermostats every so often. Default is 5 minutes for now but can be changed in the install configurations. The interface is not official, so polling to often could get noticed.
* XXXXXX Not true any more -> 5. If any thermostat device is refreshed, then they all get polled from the Sensi API. YOU SHOULD NOT add polling to devices ie dont use pollster for more than 1 thermostat device -> if you do then all devices will get updated each time.
* 6. The devices can be polled by a pollster type SmartApp now and update seperately. However, all of them are still polled at the interval chosen during setup.
* There are a large number of debug statements that will turn on if you uncomment the statement inside the TRACE function at the bottom of the code
*/
definition(
name: "Sensi (Connect)",
namespace: "kirkbrownOK/SensiThermostat",
author: "Kirk Brown",
description: "Connect your Sensi thermostats to SmartThings.",
category: "SmartThings Labs",
iconUrl: "http://i.imgur.com/QVbsCpu.jpg",
iconX2Url: "http://i.imgur.com/4BfQn6I.jpg",
singleInstance: true
)
preferences {
page(name: "auth", title: "Sensi", nextPage:"", content:"authPage", uninstall: true)
page(name: "getDevicesPage", title: "Sensi Devices", nextPage:"", content:"getDevicesPage", uninstall: true, install: true)
}
def authPage() {
def description
def uninstallAllowed = false
if(state.connectionToken) {
description = "You are connected."
uninstallAllowed = true
} else {
description = "Click to enter Sensi Credentials"
}
return dynamicPage(name: "auth", title: "Login", nextPage: "getDevicesPage", uninstall:uninstallAllowed) {
section() {
paragraph "Enter your Username and Password for Sensi Connect. Your username and password will be saved in SmartThings in whatever secure/insecure manner SmartThings saves them."
input("userName", "string", title:"Sensi Email Address", required:true, displayDuringSetup: true)
input("userPassword", "password", title:"Sensi account password", required:true, displayDuringSetup:true)
input("pollInput", "number", title: "How often should ST poll Sensi Thermostat? (minutes)", required: false, displayDureingSetup: true)
}
}
}
def getDevicesPage() {
getConnected()
def stats = getSensiThermostats()
return dynamicPage(name: "getDevicesPage", title: "Select Your Thermostats", uninstall: true) {
section("") {
paragraph "Tap below to see the list of sensi thermostats available in your sensi account and select the ones you want to connect to SmartThings."
input(name: "thermostats", title:"", type: "enum", required:false, multiple:true, description: "Tap to choose", metadata:[values:stats])
}
}
}
def getThermostatDisplayName(stat) {
if(stat?.DeviceName) {
return stat.DeviceName.toString()
}
return "Unknown"
}
def installed() {
log.info "Installed with settings: ${settings}"
initialize()
}
def updated() {
log.info "Updated with settings: ${settings}"
unsubscribe()
unschedule()
initialize()
}
def initialize() {
getAuthorized()
getToken()
def devices = thermostats.collect { dni ->
def d = getChildDevice(dni)
if(!d) {
TRACE( "addChildDevice($app.namespace, ${getChildName()}, $dni, null, [\"label\":\"${state.thermostats[dni]}\" : \"Sensi Thermostat\"])")
d = addChildDevice(app.namespace, getChildName(), dni, null, ["label":"${state.thermostats[dni]}" ?: "Sensi Thermostat"])
log.info "created ${d.displayName} with id $dni"
} else {
log.info "found ${d.displayName} with id $dni already exists"
}
return d
}
TRACE( "created ${devices.size()} thermostats.")
def delete // Delete any that are no longer in settings
if(!thermostats) {
log.info "delete thermostats ands sensors"
delete = getAllChildDevices() //inherits from SmartApp (data-management)
} else { //delete only thermostat
log.info "delete individual thermostat"
delete = getChildDevices().findAll { !thermostats.contains(it.deviceNetworkId) }
}
log.warn "delete: ${delete}, deleting ${delete.size()} thermostats"
delete.each { deleteChildDevice(it.deviceNetworkId) } //inherits from SmartApp (data-management)
//send activity feeds to tell that device is connected
def notificationMessage = "is connected to SmartThings"
sendActivityFeeds(notificationMessage)
state.timeSendPush = null
state.reAttempt = 0
try{
poll() //first time polling data data from thermostat
} catch (e) {
log.warn "Error in first time polling. Could mean something is wrong."
}
//automatically update devices status every 5 mins
def pollRate = pollInput == null ? 5 : pollInput
if(pollRate > 59 || pollRate < 1) {
pollRate = 5
log.warn "You picked an invalid pollRate: $pollInput minutes. Changed to 5 minutes."
}
schedule("0 0/${pollRate} * * * ?","poll")
}
def getAuthorized() {
def bodyParams = [ Password: "${userPassword}", UserName: "${userName}" ]
state.RBCounter = 2
state.sendCounter= 0
state.GroupsToken = null
def deviceListParams = [
uri: getApiEndpoint(),
path: "/api/authorize",
headers: ["Content-Type": "application/json", "Accept": "application/json; version=1, */*; q=0.01", "X-Requested-With":"XMLHttpRequest"],
body: [ Password: userPassword, UserName: userName ]
]
try {
httpPostJson(deviceListParams) { resp ->
//log.debug "Resp Headers: ${resp.headers}"
if (resp.status == 200) {
resp.headers.each {
//log.debug "${it.name} : ${it.value}"
if (it.name == "Set-Cookie") {
//log.debug "Its SETCOOKIE ${it.value}"
//state.myCookie = it.value
def tempC = it.value.split(";")
tempC = tempC[0].trim()
if(tempC == state.myCookie) {
//log.debug "Cookie didn't change"
} else {
state.myCookie = tempC
//log.debug "My Cookie: ${state.myCookie}"
}
}
}
} else {
TRACE( "http status: ${resp.status}")
}
}
} catch (e) {
log.warn "Exception trying to authenticate $e"
}
}
def getToken() {
//log.debug "GetToken"
def params = [
uri: getApiEndpoint(),
path: '/realtime/negotiate',
requestContentType: 'application/json',
contentType: 'application/json',
headers: [
'Cookie':state.myCookie,
'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip'
]
]
try {
httpGet(params) { resp ->
state.connectionToken = resp.data.ConnectionToken
state.connectionId = resp.data.ConnectionId
}
} catch (e) {
log.error "Connection Token error $e"
}
}
def getConnected() {
getAuthorized()
getToken()
log.info "GetConnected"
def params = [
uri: getApiEndpoint(),
path: '/realtime/connect',
query: [
transport:'longPolling',
connectionToken:state.connectionToken,
connectionData:"[{\"name\": \"thermostat-v1\"}]",
connectionId:state.connectionId,
tid:state.RBCounter,"_":now()
],
contentType: 'application/json',
headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip']
]
try {
httpGet(params) { resp ->
if(resp.data.C) {
state.messageId= resp.data.C
//log.debug "MessageID: ${state.messageId}"
}
state.connected = true
state.RBCounter = state.RBCounter + 1
state.lastSubscribedDNI = null
}
} catch (e) {
log.error "Get Connected went wrong: $e"
state.connected = false
}
}
def getSensiThermostats() {
TRACE("getting device list")
state.sensiSensors = []
def deviceListParams = [
uri: apiEndpoint,
path: "/api/thermostats",
requestContentType: 'application/json',
contentType: 'application/json',
headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip']
]
//log.debug "Get Stats: ${deviceListParams}"
def stats = [:]
try {
httpGet(deviceListParams) { resp ->
if (resp.status == 200) {
TRACE ("resp.data.DeviceName: ${resp.data.DeviceName}")
resp.data.each { stat ->
state.sensiSensors = state.sensiSensors == null ? stat.DeviceName : state.sensiSensors << stat.DeviceName
def dni = stat.ICD
stats[dni] = getThermostatDisplayName(stat)
}
} else {
log.warn "Failed to get thermostat list in getSensiThermostats: ${resp.status}"
}
}
} catch (e) {
log.trace "Exception getting thermostats: " + e
state.connected = false
}
state.thermostats = stats
state.thermostatResponse = stats
//log.debug "State Thermostats: ${state.thermostats}"
return stats
}
def pollHandler() {
//log.debug "pollHandler()"
pollChildren(null) // Hit the sensi API for update on all thermostats
}
def pollChildren() {
def devices = getChildDevices()
devices.each { child ->
TRACE("pollChild($child.device.deviceNetworkId)")
try{
if(pollChild(child.device.deviceNetworkId)) {
TRACE("pollChildren successful")
} else {
log.warn "pollChildren FAILED for $child.device.label"
state.connected = false
runIn(30, poll)
}
} catch (e) {
log.error "Error $e in pollChildren() for $child.device.label"
}
}
return true
}
def getSubscribed(thermostatIdsString) {
/*
if(state.lastSubscribedDNI == thermostatIdsString) {
TRACE("Thermostat already subscribed")
return true
} else */
if(state.lastSubscribedDNI != null) {
TRACE("Unsubscribing from: $state.lastSubscribedDNI")
getUnsubscribed(state.lastSubscribedDNI)
}
TRACE("Getting subscribed to $thermostatIdsString")
if(!state.connected) { getConnected() }
if( state.RBCounter > 50) {
state.RBCounter = 0
}
def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"Subscribe\",\"A\":[\"${thermostatIdsString}\"],\"I\":$state.RBCounter}"]
state.RBCounter = state.RBCounter + 1
def params = [
uri: getApiEndpoint(),
path: '/realtime/send',
query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId],
headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip','Content-Type':'application/x-www-form-urlencoded',"X-Requested-With":"XMLHttpRequest"],
body: requestBody
]
try {
httpPost(params) { resp ->
TRACE( "Subscribe response: ${resp.data} Expected Response: [I:${state.RBCounter - 1}]")
if(resp?.data.I?.toInteger() == (state.RBCounter - 1)) {
state.lastSubscribedDNI = thermostatIdsString
TRACE("Subscribe successfully")
} else {
TRACE("Failed to subscribe")
state.connected = false
}
}
} catch (e) {
log.error "getSubscribed failed: $e"
state.connected = false
runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite
}
}
def getUnsubscribed(thermostatIdsString) {
//Unsubscribe from this device
def requestBody3 = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"Unsubscribe\",\"A\":[\"${thermostatIdsString}\"],\"I\":$state.RBCounter}"]
def params = [
uri: getApiEndpoint(),
path: '/realtime/send',
query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId],
headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip','Content-Type':'application/x-www-form-urlencoded',"X-Requested-With":"XMLHttpRequest"],
body: requestBody3
]
state.RBCounter = state.RBCounter + 1
try {
httpPost(params) { resp ->
TRACE( "resp 3: ${resp.data}")
}
}
catch (e) {
log.trace "Exception unsubscribing " + e
state.connected = false
runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite
}
state.lastSubscribedDNI = null
}
def pollChildData(data) {
def device = getChildDevice(data.value)
log.info "Scheduled re-poll of $device.deviceLabel $data.value $device.label"
pollChild(data.value)
}
// Poll Child is invoked from the Child Device itself as part of the Poll Capability
// If no dni is passed it will call pollChildren and poll all devices
def pollChild(dni = null) {
if(dni == null) {
TRACE("dni in pollChild is $dni")
pollChildren()
return
}
def thermostatIdsString = dni
def params = []
def result = false
if(!state.connected || (state.messageId == null)) {
getConnected()
}
getSubscribed(thermostatIdsString)
params = [
uri: getApiEndpoint(),
path: '/realtime/poll',
query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]"
,connectionId:state.connectionId,messageId:state.messageId,tid:state.RBCounter,'_':now()],
headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip','Content-Type':'application/x-www-form-urlencoded',"X-Requested-With":"XMLHttpRequest"]
]
if(state.GroupsToken) {
params.query = [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]"
,connectionId:state.connectionId,messageId:state.messageId,GroupsToken:state.GroupsToken,tid:state.RBCounter,'_':now()]
}
try{
httpGet(params) { resp ->
def httpResp = resp.data.M[0].A[1] == null ? " " : resp.data.M[0].A[1]
if(httpResp && (httpResp != true)) {
state.thermostatResponse[thermostatIdsString] = httpResp
TRACE("child.generateEvent=$httpResp")
def myChild = getChildDevice(dni)
myChild.generateEvent(httpResp)
result = true
} else {
httpResp = resp.data.M[0].M == null ? " " : resp.data.M[0].M
log.warn "Unexpected final resp in pollChild: ${resp.data} likely offline: $httpResp"
}
if(resp.data.C) {
state.messageId = resp.data.C
}
if(resp.data.G) {
state.GroupsToken = resp.data.G
}
}
state.RBCounter = state.RBCounter + 1
} catch (e) {
log.error "Exception in pollChild: $e data: $resp.data"
log.error "repoll in 30 seconds. Re-poll: $thermostatIdsString"
state.connected = false //This will trigger new authentication next time the poll occurs
runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite
}
return result
}
void poll() {
pollChildren()
}
def availableModes(child) {
def modes = ["off", "heat", "cool", "aux", "auto"]
return modes
}
def currentMode(child) {
debugEvent ("state.Thermos = ${state.thermostats}")
debugEvent ("Child DNI = ${child.device.deviceNetworkId}")
def tData = state.thermostatResponse[child.device.deviceNetworkId]?.EnvironmentControls
//debugEvent("Data = ${tData}")
if(!tData) {
log.error "ERROR: Device connection removed? no data for ${child.device.deviceNetworkId} after polling"
return null
}
def mode = tData?.SystemMode
return mode
}
/**
* Executes the cmdString and cmdVal
* @param deviceId - the ID of the device
* @cmdString is passed directly to Sensi Web
* @cmdVal is the value to send on.
*
* @retrun true if the command was successful, false otherwise.
*/
boolean setStringCmd(deviceId, cmdString, cmdVal) {
//getConnected()
getSubscribed(deviceId)
def result = sendDniStringCmd(deviceId,cmdString,cmdVal)
TRACE( "Setstring ${result}")
//The sensi web app immediately polls the thermostat for updates after send before unsubscribe
pollChild(deviceId)
getUnsubscribed(deviceId)
return result
}
boolean setSettingsStringCmd(deviceId,cmdSettings, cmdString, cmdVal) {
//getConnected()
getSubscribed(deviceId)
def result = sendDniSettingsStringCmd(deviceId,cmdSettings,cmdString,cmdVal)
TRACE( "Setstring ${result}")
//The sensi web app immediately polls the thermostat for updates after send before unsubscribe
pollChild(deviceId)
getUnsubscribed(deviceId)
return result
}
boolean setTempCmd(deviceId, cmdString, cmdVal) {
//getConnected()
getSubscribed(deviceId)
def result = sendDniValue(deviceId,cmdString,cmdVal)
TRACE( "Setstring ${result}")
//The sensi web app immediately polls the thermostat for updates after send before unsubscribe
pollChild(deviceId)
getUnsubscribed
return result
}
boolean sendDniValue(thermostatIdsString,cmdString,cmdVal) {
def result = false
def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdString\",\"A\":[\"${thermostatIdsString}\",$cmdVal,\"$location.temperatureScale\"],\"I\":$state.RBCounter}"]
TRACE( "sendDNIValue body: ${requestBody}")
def params = [
uri: getApiEndpoint(),
path: '/realtime/send',
query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId],
headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip','Content-Type':'application/x-www-form-urlencoded',"X-Requested-With":"XMLHttpRequest"],
body: requestBody
]
try {
httpPost(params) { resp ->
if (resp.data.I.toInteger() == state.RBCounter.toInteger()) {
result = true
}
state.RBCounter = state.RBCounter + 1
}
} catch (e) {
log.warn "Send DNI Command went wrong: $e"
state.connected = false
state.RBCounter = state.RBCounter + 1
}
return result
}
boolean sendDniStringCmd(thermostatIdsString,cmdString,cmdVal) {
def result = false
def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdString\",\"A\":[\"${thermostatIdsString}\",\"$cmdVal\"],\"I\":$state.RBCounter}"]
def params = [
uri: getApiEndpoint(),
path: '/realtime/send',
query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId],
headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip','Content-Type':'application/x-www-form-urlencoded',"X-Requested-With":"XMLHttpRequest"],
body: requestBody
]
try {
httpPost(params) { resp ->
if (resp.data.I.toInteger() == state.RBCounter.toInteger()) {
result = true
}
state.RBCounter = state.RBCounter + 1
}
} catch (e) {
log.warn "Send DNI String Command went wrong: $e"
state.connected = false
state.RBCounter = state.RBCounter + 1
runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite
}
TRACE( "Send Function : $result")
return result
}
/* {"H":"thermostat-v1","M":"ChangeSetting","A":["thermostatid is here","KeypadLockout","Off"],"I":8} */
boolean sendDniSettingsStringCmd(thermostatIdsString,cmdSettings,cmdString,cmdVal) {
def result = false
def requestBody = ['data':"{\"H\":\"thermostat-v1\",\"M\":\"$cmdSettings\",\"A\":[\"${thermostatIdsString}\",\"$cmdString\",\"$cmdVal\"],\"I\":$state.RBCounter}"]
def params = [
uri: getApiEndpoint(),
path: '/realtime/send',
query: [transport:'longPolling',connectionToken:state.connectionToken,connectionData:"[{\"name\": \"thermostat-v1\"}]",connectionId:state.connectionId],
headers: ['Cookie':state.myCookie,'Accept':'application/json; version=1, */*; q=0.01', 'Accept-Encoding':'gzip','Content-Type':'application/x-www-form-urlencoded',"X-Requested-With":"XMLHttpRequest"],
body: requestBody
]
try {
httpPost(params) { resp ->
if (resp.data.I.toInteger() == state.RBCounter.toInteger()) {
result = true
}
state.RBCounter = state.RBCounter + 1
}
} catch (e) {
log.warn "Send DNI Setting String Command went wrong: $e"
state.connected = false
state.RBCounter = state.RBCounter + 1
runIn(30, pollChildData,[data: [value: thermostatIdsString], overwrite: true]) //when user click button this runIn will be overwrite
}
TRACE( "Send Function : $result")
return result
}
def getChildName() { return "Sensi Thermostat" }
def getServerUrl() { return "https://graph.api.smartthings.com" }
def getApiEndpoint() { return "https://bus-serv.sensicomfort.com" }
def debugEvent(message, displayEvent = false) {
def results = [
name: "appdebug",
descriptionText: message,
displayed: displayEvent
]
log.debug "Generating AppDebug Event: ${results}"
sendEvent (results)
}
def sendActivityFeeds(notificationMessage) {
def devices = getChildDevices()
devices.each { child ->
child.generateActivityFeedsEvent(notificationMessage) //parse received message from parent
}
}
private def TRACE(message) {
//log.trace message
}